@@ -20,6 +20,7 @@ const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
2020pub ( crate ) const BOOTC_EDITED_STAMP : & str = "Updated by bootc-fstab-edit.service" ;
2121const TRANSIENT_RELABEL_UNIT : & str = "bootc-early-overlay-relabel.service" ;
2222const SYSINIT_TARGET : & str = "sysinit.target" ;
23+ const SHADOW_SYNC_UNIT : & str = "bootc-sysusers-shadow-sync.service" ;
2324
2425/// Called when the root is read-only composefs to reconcile /etc/fstab
2526#[ context( "bootc generator" ) ]
@@ -134,6 +135,28 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
134135 }
135136 }
136137
138+ // === Shadow sync unit: runs for ALL bootc systems (native composefs or ostree) ===
139+ // Must be before the ostree-booted guard because native composefs boots do
140+ // not write /run/ostree-booted, but still need the shadow sync to clean up
141+ // stale shadow/gshadow entries (the rechunk scenario). Gate on either a
142+ // composefs mount source (native composefs boot) or the ostree-booted marker.
143+ {
144+ let is_composefs = match bootc_mount:: inspect_filesystem ( camino:: Utf8Path :: new ( "/" ) ) {
145+ Ok ( fs) => {
146+ fs. source . starts_with ( "composefs:" ) || fs. source . starts_with ( "transient:composefs=" )
147+ }
148+ Err ( e) => {
149+ tracing:: debug!( "Could not inspect root filesystem: {e:#}" ) ;
150+ false
151+ }
152+ } ;
153+ let is_ostree = root. try_exists ( OSTREE_BOOTED ) ?;
154+ if is_composefs || is_ostree {
155+ let updated = shadow_sync_generator_impl ( root, unit_dir) ?;
156+ tracing:: trace!( "Enabled shadow sync: {updated}" ) ;
157+ }
158+ }
159+
137160 // === Ostree-specific generator logic ===
138161 // Only run on ostree systems (native composefs boots skip below).
139162 if !root. try_exists ( OSTREE_BOOTED ) ? {
@@ -166,6 +189,31 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
166189 Ok ( ( ) )
167190}
168191
192+ /// Enable the statically-installed shadow sync unit by symlinking it into
193+ /// `sysinit.target.wants/` in the generator output directory.
194+ ///
195+ /// The unit file itself lives at `/usr/lib/systemd/system/bootc-sysusers-shadow-sync.service`
196+ /// and is shipped with bootc; the generator only performs conditional enablement.
197+ ///
198+ /// We check existence of `/etc/shadow` rather than writability because systemd
199+ /// sandboxes generators in a private read-only mount namespace (see
200+ /// `systemd.generator(7)`), so any writability check would always fail even
201+ /// though `/etc` will be on its own writable mount by the time the service
202+ /// actually runs. The caller already gates on the ostree-booted marker,
203+ /// which guarantees we are on a bootc system where `/etc` is writable at
204+ /// service-run time.
205+ #[ context( "shadow sync generator" ) ]
206+ pub ( crate ) fn shadow_sync_generator_impl ( root : & Dir , unit_dir : & Dir ) -> Result < bool > {
207+ if !root. try_exists ( "etc/shadow" ) ? {
208+ tracing:: trace!( "/etc/shadow not found, skipping shadow sync" ) ;
209+ return Ok ( false ) ;
210+ }
211+
212+ tracing:: debug!( "/etc/shadow found, enabling {SHADOW_SYNC_UNIT}" ) ;
213+ enable_unit ( unit_dir, SHADOW_SYNC_UNIT , "sysinit.target" ) ?;
214+ Ok ( true )
215+ }
216+
169217/// Parse /etc/fstab and check if the root mount is out of sync with the composefs
170218/// state, and if so, fix it.
171219fn generate_fstab_editor ( unit_dir : & Dir ) -> Result < ( ) > {
@@ -417,5 +465,51 @@ UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaul
417465
418466 Ok ( ( ) )
419467 }
468+
469+ #[ test]
470+ fn test_shadow_sync_no_shadow ( ) -> Result < ( ) > {
471+ // No /etc/shadow => should not enable (boot detection is caller's job)
472+ let tempdir = fixture ( ) ?;
473+ let unit_dir = & tempdir. open_dir ( "run/systemd/system" ) ?;
474+ let generated = shadow_sync_generator_impl ( & tempdir, unit_dir) ?;
475+ assert ! ( !generated) ;
476+ assert_eq ! ( unit_dir. entries( ) ?. count( ) , 0 ) ;
477+ Ok ( ( ) )
478+ }
479+
480+ #[ test]
481+ fn test_shadow_sync_enables_when_shadow_present ( ) -> Result < ( ) > {
482+ // /etc/shadow present => enables the static unit
483+ let tempdir = fixture ( ) ?;
484+ tempdir. atomic_write ( "etc/shadow" , "root:*:18912:0:99999:7:::\n " ) ?;
485+ let unit_dir = & tempdir. open_dir ( "run/systemd/system" ) ?;
486+ let generated = shadow_sync_generator_impl ( & tempdir, unit_dir) ?;
487+ assert ! ( generated) ;
488+ // The generator creates a symlink in sysinit.target.wants/; check the
489+ // directory entry exists (symlink_contents creates an absolute-path symlink
490+ // that cap-std won't follow in a tempdir, so we check metadata directly).
491+ let wants = unit_dir. open_dir ( "sysinit.target.wants" ) ?;
492+ let meta = wants. symlink_metadata ( SHADOW_SYNC_UNIT ) ?;
493+ assert ! ( meta. is_symlink( ) , "expected symlink for {SHADOW_SYNC_UNIT}" ) ;
494+ Ok ( ( ) )
495+ }
496+
497+ /// Verify that generator() enables the shadow sync unit on traditional
498+ /// ostree boots (tmpfs root, OSTREE_BOOTED marker present).
499+ #[ test]
500+ fn test_generator_shadow_sync_on_non_composefs ( ) -> Result < ( ) > {
501+ let tempdir = fixture ( ) ?;
502+ tempdir. atomic_write ( OSTREE_BOOTED , "" ) ?;
503+ tempdir. atomic_write ( "etc/shadow" , "root:*:18912:0:99999:7:::\n " ) ?;
504+ let unit_dir = & tempdir. open_dir ( "run/systemd/system" ) ?;
505+ generator ( & tempdir, unit_dir) ?;
506+ let wants = unit_dir. open_dir ( "sysinit.target.wants" ) ?;
507+ let meta = wants. symlink_metadata ( SHADOW_SYNC_UNIT ) ?;
508+ assert ! (
509+ meta. is_symlink( ) ,
510+ "shadow sync unit must be enabled on non-composefs ostree systems"
511+ ) ;
512+ Ok ( ( ) )
513+ }
420514 }
421515}
0 commit comments