Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,12 @@ pub(crate) async fn setup_composefs_boot(
.or(root_setup.rootfs_uuid.as_deref())
.ok_or_else(|| anyhow!("No uuid for boot/root"))?;

if cfg!(target_arch = "s390x") {
// If bootloader is disabled, we skip the installer calls
let skip_bootloader = state.config_opts.no_bootloader;

if skip_bootloader {
tracing::info!("Skipping bootloader installation (--no-bootloader requested)");
} else if cfg!(target_arch = "s390x") {
// TODO: Integrate s390x support into install_via_bootupd
crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?;
} else if postfetch.detected_bootloader == Bootloader::Grub {
Expand All @@ -1257,20 +1262,26 @@ pub(crate) async fn setup_composefs_boot(

let boot_type = BootType::from(entry);

let boot_digest = match boot_type {
BootType::Bls => setup_composefs_bls_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
repo,
&id,
entry,
&mounted_fs,
)?,
BootType::Uki => setup_composefs_uki_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
repo,
&id,
entries,
)?,
// We calculate the digest only if we are actually setting up the boot files.
// If we skip, we use a placeholder digest for the state file.
let boot_digest = if skip_bootloader {
"skipped".to_string()
} else {
match boot_type {
BootType::Bls => setup_composefs_bls_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
repo,
&id,
entry,
&mounted_fs,
)?,
BootType::Uki => setup_composefs_uki_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
repo,
&id,
entries,
)?,
}
};

write_composefs_state(
Expand Down
105 changes: 74 additions & 31 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ pub(crate) struct InstallConfigOpts {
#[clap(long)]
#[serde(default)]
pub(crate) bootupd_skip_boot_uuid: bool,

/// Disable all bootloader integration and requirements
// This allows installing to a filesystem without boot and ESP partitions.
#[clap(long)]
#[serde(default)]
pub(crate) no_bootloader: bool,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The no_bootloader flag is not respected in the composefs installation path. While it is used to skip bootloader setup in the ostree path, the setup_composefs_boot function (called when composefs_backend is enabled) does not check this flag and proceeds to install the bootloader via bootupd or systemd-boot. This can lead to unexpected modification of the system's bootloader, potentially overwriting a custom or secure bootloader that the user intended to preserve.

Copy link
Author

@martinezjavier martinezjavier Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also skipping the crate::bootloader::install_* calls in setup_composefs_boot() if the --no-bootloader option is used.

}

#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -1737,30 +1743,35 @@ async fn install_with_sysroot(
.physical_root
.open_dir(&deployment_path)
.context("Opening deployment dir")?;
let postfetch = PostFetchState::new(state, &deployment_dir)?;

if cfg!(target_arch = "s390x") {
// TODO: Integrate s390x support into install_via_bootupd
crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?;
if state.config_opts.no_bootloader {
tracing::debug!("Skipping bootloader installation (--no-bootloader requested)");
} else {
match postfetch.detected_bootloader {
Bootloader::Grub => {
crate::bootloader::install_via_bootupd(
&rootfs.device_info,
&rootfs
.target_root_path
.clone()
.unwrap_or(rootfs.physical_root_path.clone()),
&state.config_opts,
Some(&deployment_path.as_str()),
)?;
}
Bootloader::Systemd => {
anyhow::bail!("bootupd is required for ostree-based installs");
let postfetch = PostFetchState::new(state, &deployment_dir)?;

if cfg!(target_arch = "s390x") {
// TODO: Integrate s390x support into install_via_bootupd
crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?;
} else {
match postfetch.detected_bootloader {
Bootloader::Grub => {
crate::bootloader::install_via_bootupd(
&rootfs.device_info,
&rootfs
.target_root_path
.clone()
.unwrap_or(rootfs.physical_root_path.clone()),
&state.config_opts,
Some(&deployment_path.as_str()),
)?;
}
Bootloader::Systemd => {
anyhow::bail!("bootupd is required for ostree-based installs");
}
}
}
tracing::debug!("Installed bootloader");
}
tracing::debug!("Installed bootloader");

tracing::debug!("Performing post-deployment operations");

Expand Down Expand Up @@ -1890,7 +1901,11 @@ async fn install_to_filesystem_impl(
let (id, verity) = initialize_composefs_repository(state, rootfs).await?;
tracing::info!("id: {id}, verity: {}", verity.to_hex());

setup_composefs_boot(rootfs, state, &id).await?;
if state.config_opts.no_bootloader {
tracing::debug!("Skipping setup composefs boot (--no-bootloader requested)");
} else {
setup_composefs_boot(rootfs, state, &id).await?;
}
} else {
ostree_install(state, rootfs, cleanup).await?;
}
Expand Down Expand Up @@ -2138,7 +2153,16 @@ fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> {
}

#[context("Removing boot directory content")]
fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) -> Result<()> {
fn clean_boot_directories(
rootfs: &Dir,
rootfs_path: &Utf8Path,
is_ostree: bool,
skip: bool,
) -> Result<()> {
if skip {
return Ok(());
}

let bootdir =
crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?;

Expand Down Expand Up @@ -2359,7 +2383,13 @@ pub(crate) async fn install_to_filesystem(
.await??;
}
Some(ReplaceMode::Alongside) => {
clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)?
let skip_cleaning = state.config_opts.no_bootloader;
clean_boot_directories(
&target_rootfs_fd,
&target_root_path,
is_already_ostree,
skip_cleaning,
)?
}
None => require_empty_rootdir(&rootfs_fd)?,
}
Expand Down Expand Up @@ -2410,14 +2440,14 @@ pub(crate) async fn install_to_filesystem(

let boot_is_mount = {
let root_dev = rootfs_fd.dir_metadata()?.dev();
let boot_dev = target_rootfs_fd
.symlink_metadata_optional(BOOT)?
.ok_or_else(|| {
anyhow!("No /{BOOT} directory found in root; this is is currently required")
})?
.dev();
tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}");
root_dev != boot_dev
if let Some(boot_metadata) = target_rootfs_fd.symlink_metadata_optional(BOOT)? {
let boot_dev = boot_metadata.dev();
tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}");
root_dev != boot_dev
} else {
tracing::debug!("No /{BOOT} directory found");
false
}
};
// Find the UUID of /boot because we need it for GRUB.
let boot_uuid = if boot_is_mount {
Expand Down Expand Up @@ -2461,7 +2491,10 @@ pub(crate) async fn install_to_filesystem(
.install_config
.as_ref()
.and_then(|c| c.boot_mount_spec.as_ref());
let mut boot = if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) {

let mut boot = if state.config_opts.no_bootloader {
None
} else if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) {
// An empty boot mount spec signals to omit the mountspec kargs
// See https://github.com/bootc-dev/bootc/issues/1441
if spec.is_empty() {
Expand Down Expand Up @@ -2963,4 +2996,14 @@ UUID=boot-uuid /boot ext4 defaults 0 0

Ok(())
}

#[test]
fn test_clean_boot_directories_explicit_skip() -> Result<()> {
let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
let root_path = Utf8Path::new("/target");
// Case: Explicit Skip should succeed even if /boot doesn't exist
let res = clean_boot_directories(&td, root_path, false, true);
assert!(res.is_ok());
Ok(())
}
}
5 changes: 5 additions & 0 deletions docs/src/man/bootc-install-to-filesystem.8.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ is currently expected to be empty by default.

The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar operations, and finally mounting it readonly. This option skips those operations. It is then the responsibility of the invoking code to perform those operations

**--no-bootloader**

Disable all bootloader integration and requirements. This allows installing to a
filesystem without boot and ESP partitions

**--source-imgref**=*SOURCE_IMGREF*

Install the system from an explicitly given source
Expand Down