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::install::DESTRUCTIVE_CLEANUP;
13
14const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target";
15const STATUS_PATH_UNIT: &str = "bootc-status-updated.path";
16const CLEANUP_UNIT: &str = "bootc-destructive-cleanup.service";
17const MULTI_USER_TARGET: &str = "multi-user.target";
18const EDIT_UNIT: &str = "bootc-fstab-edit.service";
19const FSTAB_ANACONDA_STAMP: &str = "Created by anaconda";
20pub(crate) const BOOTC_EDITED_STAMP: &str = "Updated by bootc-fstab-edit.service";
21const TRANSIENT_RELABEL_UNIT: &str = "bootc-early-overlay-relabel.service";
22const SYSINIT_TARGET: &str = "sysinit.target";
23const SHADOW_SYNC_UNIT: &str = "bootc-sysusers-shadow-sync.service";
24
25#[context("bootc generator")]
27pub(crate) fn fstab_generator_impl(root: &Dir, unit_dir: &Dir) -> Result<bool> {
28 if !is_ostree_booted_in(root)? {
30 return Ok(false);
31 }
32
33 if let Some(fd) = root
34 .open_optional("etc/fstab")
35 .context("Opening /etc/fstab")?
36 .map(std::io::BufReader::new)
37 {
38 let mut from_anaconda = false;
39 for line in fd.lines() {
40 let line = line.context("Reading /etc/fstab")?;
41 if line.contains(BOOTC_EDITED_STAMP) {
42 return Ok(false);
44 }
45 if line.contains(FSTAB_ANACONDA_STAMP) {
46 from_anaconda = true;
47 }
48 }
49 if !from_anaconda {
50 return Ok(false);
51 }
52 tracing::debug!("/etc/fstab from anaconda: {from_anaconda}");
53 if from_anaconda {
54 generate_fstab_editor(unit_dir)?;
55 return Ok(true);
56 }
57 }
58 Ok(false)
59}
60
61pub(crate) fn enable_unit(unitdir: &Dir, name: &str, target: &str) -> Result<()> {
62 let wants = Utf8PathBuf::from(format!("{target}.wants"));
63 unitdir
64 .create_dir_all(&wants)
65 .with_context(|| format!("Creating {wants}"))?;
66 let source = format!("/usr/lib/systemd/system/{name}");
67 let target = wants.join(name);
68 unitdir.remove_file_optional(&target)?;
69 unitdir
70 .symlink_contents(&source, &target)
71 .with_context(|| format!("Writing {name}"))?;
72 Ok(())
73}
74
75pub(crate) fn unit_enablement_impl(sysroot: &Dir, unit_dir: &Dir) -> Result<()> {
77 for unit in [STATUS_ONBOOT_UNIT, STATUS_PATH_UNIT] {
78 enable_unit(unit_dir, unit, MULTI_USER_TARGET)?;
79 }
80
81 if sysroot.try_exists(DESTRUCTIVE_CLEANUP)? {
82 tracing::debug!("Found {DESTRUCTIVE_CLEANUP}");
83 enable_unit(unit_dir, CLEANUP_UNIT, MULTI_USER_TARGET)?;
84 } else {
85 tracing::debug!("Didn't find {DESTRUCTIVE_CLEANUP}");
86 }
87
88 Ok(())
89}
90
91pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> {
93 {
114 let st = rustix::fs::fstatfs(root.as_fd())?;
115 if st.f_type == libc::OVERLAYFS_SUPER_MAGIC {
116 let root_is_transient =
117 match bootc_mount::inspect_filesystem(camino::Utf8Path::new("/")) {
118 Ok(fs) => fs.source.starts_with("transient:composefs="),
119 Err(e) => {
120 tracing::debug!("Could not inspect root filesystem: {e:#}");
121 false
122 }
123 };
124 let submounts_are_transient = bootc_initramfs_setup::config_has_transient_submounts(
125 std::path::Path::new(bootc_initramfs_setup::SETUP_ROOT_CONF_PATH),
126 );
127 if root_is_transient || submounts_are_transient {
128 tracing::debug!(
129 root_is_transient,
130 submounts_are_transient,
131 "Transient overlay detected; generating relabel unit"
132 );
133 generate_transient_overlay_relabel(unit_dir)?;
134 }
135 }
136 }
137
138 {
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
160 if !root.try_exists(OSTREE_BOOTED)? {
163 return Ok(());
164 }
165
166 let Some(ref sysroot) = root.open_dir_optional("sysroot")? else {
167 return Ok(());
168 };
169
170 unit_enablement_impl(sysroot, unit_dir)?;
171
172 let st = rustix::fs::fstatfs(root.as_fd())?;
174 if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
175 tracing::trace!("Root is not overlayfs");
176 return Ok(());
177 }
178
179 let st = rustix::fs::fstatvfs(root.as_fd())?;
181 if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
182 tracing::trace!("Root is writable, skipping fstab generator");
183 return Ok(());
184 }
185
186 let updated = fstab_generator_impl(root, unit_dir)?;
187 tracing::trace!("Generated fstab: {updated}");
188
189 Ok(())
190}
191
192#[context("shadow sync generator")]
206pub(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
217fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> {
220 unit_dir.atomic_write(
221 EDIT_UNIT,
222 "[Unit]\n\
223DefaultDependencies=no\n\
224After=systemd-fsck-root.service\n\
225Before=local-fs-pre.target local-fs.target shutdown.target systemd-remount-fs.service\n\
226\n\
227[Service]\n\
228Type=oneshot\n\
229RemainAfterExit=yes\n\
230ExecStart=bootc internals fixup-etc-fstab\n\
231",
232 )?;
233 let target = "local-fs-pre.target.wants";
234 unit_dir.create_dir_all(target)?;
235 unit_dir.symlink(&format!("../{EDIT_UNIT}"), &format!("{target}/{EDIT_UNIT}"))?;
236 Ok(())
237}
238
239fn generate_transient_overlay_relabel(unit_dir: &Dir) -> Result<()> {
243 unit_dir.atomic_write(
244 TRANSIENT_RELABEL_UNIT,
245 include_str!("units/bootc-early-overlay-relabel.service"),
246 )?;
247 let wants = format!("{SYSINIT_TARGET}.wants");
248 unit_dir.create_dir_all(&wants)?;
249 unit_dir.symlink(
250 &format!("../{TRANSIENT_RELABEL_UNIT}"),
251 &format!("{wants}/{TRANSIENT_RELABEL_UNIT}"),
252 )?;
253 Ok(())
254}
255
256pub(crate) fn relabel_overlay_mountpoints() -> Result<()> {
262 let policy = ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)
263 .context("Loading SELinux policy")?;
264 for path in ["/", "/etc", "/var"] {
265 let dir = Dir::open_ambient_dir(path, cap_std::ambient_authority())
266 .with_context(|| format!("Opening {path}"))?;
267 let st = rustix::fs::fstatfs(dir.as_fd())?;
268 if st.f_type != libc::OVERLAYFS_SUPER_MAGIC {
269 tracing::trace!("{path} is not an overlayfs mount, skipping relabel");
270 continue;
271 }
272 let stv = rustix::fs::fstatvfs(dir.as_fd())?;
273 if stv.f_flag.contains(StatVfsMountFlags::RDONLY) {
274 tracing::trace!("{path} is a read-only overlayfs (composefs), skipping relabel");
275 continue;
276 }
277 let metadata = dir.metadata(".").with_context(|| format!("stat {path}"))?;
278 crate::lsm::relabel(
279 &dir,
280 &metadata,
281 Utf8Path::new("."),
282 Some(Utf8Path::new(path)),
283 &policy,
284 )
285 .with_context(|| format!("Relabelling {path}"))?;
286 tracing::debug!("Relabelled {path}");
287 }
288 Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293 use camino::Utf8Path;
294
295 use super::*;
296
297 fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
298 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
299 tempdir.create_dir("etc")?;
300 tempdir.create_dir("run")?;
301 tempdir.create_dir("sysroot")?;
302 tempdir.create_dir_all("run/systemd/system")?;
303 Ok(tempdir)
304 }
305
306 #[test]
307 fn test_generator_no_fstab() -> Result<()> {
308 let tempdir = fixture()?;
309 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
310 fstab_generator_impl(&tempdir, &unit_dir).unwrap();
311
312 assert_eq!(unit_dir.entries()?.count(), 0);
313 Ok(())
314 }
315
316 #[test]
317 fn test_units() -> Result<()> {
318 let tempdir = &fixture()?;
319 let sysroot = &tempdir.open_dir("sysroot").unwrap();
320 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
321
322 let verify = |wantsdir: &Dir, n: u32| -> Result<()> {
323 assert_eq!(unit_dir.entries()?.count(), 1);
324 let r = wantsdir.read_link_contents(STATUS_ONBOOT_UNIT)?;
325 let r: Utf8PathBuf = r.try_into().unwrap();
326 assert_eq!(r, format!("/usr/lib/systemd/system/{STATUS_ONBOOT_UNIT}"));
327 assert_eq!(wantsdir.entries()?.count(), n as usize);
328 anyhow::Ok(())
329 };
330
331 unit_enablement_impl(sysroot, &unit_dir).unwrap();
334 unit_enablement_impl(sysroot, &unit_dir).unwrap();
335 let wantsdir = &unit_dir.open_dir("multi-user.target.wants")?;
336 verify(wantsdir, 2)?;
337 assert!(
338 wantsdir
339 .symlink_metadata_optional(CLEANUP_UNIT)
340 .unwrap()
341 .is_none()
342 );
343
344 unit_enablement_impl(sysroot, &unit_dir).unwrap();
346 verify(wantsdir, 2)?;
347
348 sysroot
350 .create_dir_all(Utf8Path::new(DESTRUCTIVE_CLEANUP).parent().unwrap())
351 .unwrap();
352 sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"").unwrap();
353 unit_enablement_impl(sysroot, unit_dir).unwrap();
354 verify(wantsdir, 3)?;
355
356 assert!(
358 wantsdir
359 .symlink_metadata(CLEANUP_UNIT)
360 .unwrap()
361 .is_symlink()
362 );
363
364 Ok(())
365 }
366
367 #[cfg(test)]
368 mod test {
369 use super::*;
370
371 use ostree_ext::container_utils::OSTREE_BOOTED;
372
373 #[test]
374 fn test_generator_fstab() -> Result<()> {
375 let tempdir = fixture()?;
376 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
377 tempdir.atomic_write("etc/fstab", "# Some dummy fstab")?;
379 fstab_generator_impl(&tempdir, &unit_dir).unwrap();
380 assert_eq!(unit_dir.entries()?.count(), 0);
381
382 tempdir.atomic_write("etc/fstab", &format!("# {FSTAB_ANACONDA_STAMP}"))?;
384 fstab_generator_impl(&tempdir, &unit_dir).unwrap();
385 assert_eq!(unit_dir.entries()?.count(), 0);
386
387 tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
389 fstab_generator_impl(&tempdir, &unit_dir).unwrap();
390 assert_eq!(unit_dir.entries()?.count(), 2);
391
392 Ok(())
393 }
394
395 #[test]
396 fn test_transient_overlay_relabel_generated() -> Result<()> {
397 let tempdir = fixture()?;
398 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
399
400 generate_transient_overlay_relabel(unit_dir)?;
402
403 assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
405 let wants = format!("{SYSINIT_TARGET}.wants");
407 let link = unit_dir.read_link_contents(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
408 let link: camino::Utf8PathBuf = link.try_into().unwrap();
409 assert_eq!(link, format!("../{TRANSIENT_RELABEL_UNIT}"));
410 let content = unit_dir.read_to_string(TRANSIENT_RELABEL_UNIT)?;
412 assert!(
413 content.contains("ExecStart=bootc internals relabel-overlay-mountpoints"),
414 "unexpected unit content: {content}"
415 );
416
417 Ok(())
418 }
419
420 #[test]
421 fn test_transient_overlay_relabel_idempotent() -> Result<()> {
422 let tempdir = fixture()?;
423 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
424
425 generate_transient_overlay_relabel(unit_dir)?;
427 let wants = format!("{SYSINIT_TARGET}.wants");
432 unit_dir.remove_file_optional(format!("{wants}/{TRANSIENT_RELABEL_UNIT}"))?;
433 generate_transient_overlay_relabel(unit_dir)?;
434
435 assert!(unit_dir.try_exists(TRANSIENT_RELABEL_UNIT)?);
436
437 Ok(())
438 }
439
440 #[test]
441 fn test_generator_fstab_idempotent() -> Result<()> {
442 let anaconda_fstab = indoc::indoc! { "
443#
444# /etc/fstab
445# Created by anaconda on Tue Mar 19 12:24:29 2024
446#
447# Accessible filesystems, by reference, are maintained under '/dev/disk/'.
448# See man pages fstab(5), findfs(8), mount(8) and/or blkid(8) for more info.
449#
450# After editing this file, run 'systemctl daemon-reload' to update systemd
451# units generated from this file.
452#
453# Updated by bootc-fstab-edit.service
454UUID=715be2b7-c458-49f2-acec-b2fdb53d9089 / xfs ro 0 0
455UUID=341c4712-54e8-4839-8020-d94073b1dc8b /boot xfs defaults 0 0
456" };
457 let tempdir = fixture()?;
458 let unit_dir = &tempdir.open_dir("run/systemd/system")?;
459
460 tempdir.atomic_write("etc/fstab", anaconda_fstab)?;
461 tempdir.atomic_write(OSTREE_BOOTED, "ostree booted")?;
462 let updated = fstab_generator_impl(&tempdir, &unit_dir).unwrap();
463 assert!(!updated);
464 assert_eq!(unit_dir.entries()?.count(), 0);
465
466 Ok(())
467 }
468
469 #[test]
470 fn test_shadow_sync_no_shadow() -> Result<()> {
471 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 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 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 #[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 }
514 }
515}