From 9b2961261c5500329f023e255d83a409ebfae729 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Mon, 12 Jan 2026 10:51:59 +0530 Subject: [PATCH 1/6] composefs: Add option to reset soft reboot state Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/soft_reboot.rs | 47 ++++++++++++++++++- crates/lib/src/bootc_composefs/update.rs | 2 +- crates/lib/src/cli.rs | 24 ++++++++-- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e4509da1..3e0bcf8c2 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -10,25 +10,70 @@ use bootc_initramfs_setup::setup_root; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::{PID1, bind_mount_from_pidns}; use camino::Utf8Path; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; +use rustix::mount::{unmount, UnmountFlags}; use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, process::Command}; const NEXTROOT: &str = "/run/nextroot"; +#[context("Resetting soft reboot state")] +fn reset_soft_reboot() -> Result<()> { + let run = Utf8Path::new("/run"); + bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; + + let run_dir = Dir::open_ambient_dir("/run", ambient_authority()).context("Opening run")?; + + let nextroot = run_dir + .open_dir_optional("nextroot") + .context("Opening nextroot")?; + + let Some(nextroot) = nextroot else { + tracing::debug!("Nextroot is not a directory"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + }; + + let nextroot_mounted = nextroot + .is_mountpoint(".")? + .ok_or_else(|| anyhow::anyhow!("Failed to get mount info"))?; + + if !nextroot_mounted { + tracing::debug!("Nextroot is not a mountpoint"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + } + + unmount(NEXTROOT, UnmountFlags::DETACH).context("Unmounting nextroot")?; + + println!("Soft reboot state cleared successfully"); + + Ok(()) +} + /// Checks if the provided deployment is soft reboot capable, and soft reboots the system if /// argument `reboot` is true #[context("Soft rebooting")] pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, - deployment_id: &String, + deployment_id: Option<&String>, reboot: bool, + reset: bool, ) -> Result<()> { if !systemd_has_soft_reboot() { anyhow::bail!("System does not support soft reboots") } + if reset { + return reset_soft_reboot(); + } + + let deployment_id = deployment_id.ok_or_else(|| anyhow::anyhow!("Expected deployment id"))?; + if *deployment_id == *booted_cfs.cmdline.digest { anyhow::bail!("Cannot soft-reboot to currently booted deployment"); } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index f6252a65f..206bb7335 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -267,7 +267,7 @@ pub(crate) async fn do_upgrade( } if opts.soft_reboot.is_some() { - prepare_soft_reboot_composefs(storage, booted_cfs, &id.to_hex(), true).await?; + prepare_soft_reboot_composefs(storage, booted_cfs, Some(&id.to_hex()), true, false).await?; } Ok(()) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 567ba8ddc..4ae6e69bf 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -618,8 +618,12 @@ pub(crate) enum InternalsOpts { /// Dump CLI structure as JSON for documentation generation DumpCliJson, PrepSoftReboot { - deployment: String, - #[clap(long)] + #[clap(required_unless_present = "reset")] + deployment: Option, + #[clap(long, conflicts_with = "reset")] + reboot: bool, + #[clap(long, conflicts_with = "reboot")] + reset: bool, reboot: bool, }, } @@ -1836,7 +1840,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { Ok(()) } - InternalsOpts::PrepSoftReboot { deployment, reboot } => { + InternalsOpts::PrepSoftReboot { + deployment, + reboot, + reset, + } => { let storage = &get_storage().await?; match storage.kind()? { @@ -1845,8 +1853,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { anyhow::bail!("soft-reboot only implemented for composefs") } BootedStorageKind::Composefs(booted_cfs) => { - prepare_soft_reboot_composefs(&storage, &booted_cfs, &deployment, reboot) - .await + prepare_soft_reboot_composefs( + &storage, + &booted_cfs, + deployment.as_ref(), + reboot, + reset, + ) + .await } } } From 92dab16e21d5de7dfb11bfd2d8374e700732e82b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Jan 2026 11:36:49 +0530 Subject: [PATCH 2/6] composefs: Don't soft-reboot automatically Aligning with ostree API, now we only initiate soft-reboot if `--apply` is passed to `bootc update`, `bootc switch`, else we only prepare the soft reboot Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/soft_reboot.rs | 21 ++++++++++++------- crates/lib/src/bootc_composefs/update.rs | 15 +++++++++---- crates/lib/src/cli.rs | 10 ++++++--- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e0bcf8c2..a24965bfe 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -2,6 +2,7 @@ use crate::{ bootc_composefs::{ service::start_finalize_stated_svc, status::composefs_deployment_status_from, }, + cli::SoftRebootMode, composefs_consts::COMPOSEFS_CMDLINE, store::{BootedComposefs, Storage}, }; @@ -21,7 +22,7 @@ use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, proc const NEXTROOT: &str = "/run/nextroot"; #[context("Resetting soft reboot state")] -fn reset_soft_reboot() -> Result<()> { +pub(crate) fn reset_soft_reboot() -> Result<()> { let run = Utf8Path::new("/run"); bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; @@ -49,7 +50,7 @@ fn reset_soft_reboot() -> Result<()> { unmount(NEXTROOT, UnmountFlags::DETACH).context("Unmounting nextroot")?; - println!("Soft reboot state cleared successfully"); + println!("Cleared soft reboot queued state"); Ok(()) } @@ -61,17 +62,13 @@ pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, deployment_id: Option<&String>, + soft_reboot_mode: SoftRebootMode, reboot: bool, - reset: bool, ) -> Result<()> { if !systemd_has_soft_reboot() { anyhow::bail!("System does not support soft reboots") } - if reset { - return reset_soft_reboot(); - } - let deployment_id = deployment_id.ok_or_else(|| anyhow::anyhow!("Expected deployment id"))?; if *deployment_id == *booted_cfs.cmdline.digest { @@ -89,7 +86,13 @@ pub(crate) async fn prepare_soft_reboot_composefs( .ok_or_else(|| anyhow::anyhow!("Deployment '{deployment_id}' not found"))?; if !requred_deployment.soft_reboot_capable { - anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state"); + match soft_reboot_mode { + SoftRebootMode::Required => { + anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state") + } + + SoftRebootMode::Auto => return Ok(()), + } } start_finalize_stated_svc()?; @@ -113,6 +116,8 @@ pub(crate) async fn prepare_soft_reboot_composefs( setup_root(args)?; + println!("Soft reboot setup complete"); + if reboot { // Replacing the current process should be fine as we restart userspace anyway let err = Command::new("systemctl").arg("soft-reboot").exec(); diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 206bb7335..667105cf5 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -262,14 +262,21 @@ pub(crate) async fn do_upgrade( ) .await?; + if let Some(soft_reboot_mode) = opts.soft_reboot { + return prepare_soft_reboot_composefs( + storage, + booted_cfs, + Some(&id.to_hex()), + soft_reboot_mode, + opts.apply, + ) + .await; + }; + if opts.apply { return crate::reboot::reboot(); } - if opts.soft_reboot.is_some() { - prepare_soft_reboot_composefs(storage, booted_cfs, Some(&id.to_hex()), true, false).await?; - } - Ok(()) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4ae6e69bf..47cddcc53 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -34,7 +34,7 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::delete::delete_composefs_deployment; -use crate::bootc_composefs::soft_reboot::prepare_soft_reboot_composefs; +use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -624,7 +624,6 @@ pub(crate) enum InternalsOpts { reboot: bool, #[clap(long, conflicts_with = "reboot")] reset: bool, - reboot: bool, }, } @@ -1852,13 +1851,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> { // TODO: Call ostree implementation? anyhow::bail!("soft-reboot only implemented for composefs") } + BootedStorageKind::Composefs(booted_cfs) => { + if reset { + return reset_soft_reboot(); + } + prepare_soft_reboot_composefs( &storage, &booted_cfs, deployment.as_ref(), + SoftRebootMode::Required, reboot, - reset, ) .await } From 46a5aa490a9fa7d95197d4688517e2e5a86354e6 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Jan 2026 18:20:20 +0530 Subject: [PATCH 3/6] composefs/export: Update image digest query format After bootc/commit/49d753f996747a9b1f531abf35ba4e207cf4f020, composefs-rs saves config in the format `oci-config-sha256:`. Update to match the same Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/export.rs | 5 +++-- crates/lib/src/bootc_composefs/soft_reboot.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index b8abd4b4c..f86392421 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -50,7 +50,8 @@ pub async fn export_repo_to_image( let imginfo = get_imginfo(storage, &depl_verity, None).await?; - let config_digest = imginfo.manifest.config().digest().digest(); + // We want the digest in the form of "sha256:abc123" + let config_digest = format!("{}", imginfo.manifest.config().digest()); let var_tmp = Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; @@ -60,7 +61,7 @@ pub async fn export_repo_to_image( // Use composefs_oci::open_config to get the config and layer map let (config, layer_map) = - open_config(&*booted_cfs.repo, config_digest, None).context("Opening config")?; + open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; // We can't guarantee that we'll get the same tar stream as the container image // So we create new config and manifest diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index a24965bfe..a3120dabf 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -16,7 +16,7 @@ use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; -use rustix::mount::{unmount, UnmountFlags}; +use rustix::mount::{UnmountFlags, unmount}; use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, process::Command}; const NEXTROOT: &str = "/run/nextroot"; From 235818ab7658735c09e870f1d5eb428687742f9d Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 16 Jan 2026 16:01:46 +0530 Subject: [PATCH 4/6] composefs/update: Handle --download-only flag When `--download-only` is passed, only download the image into the composefs repository but don't finalize it. Conver the /run/composefs/staged-deployment to a JSON file and Add a finalization_locked field depending upon which the finalize service will either finalize the staged deployment or leave it as is for garbage collection (even though GC is not fully implemented right now). Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/bootc_composefs/finalize.rs | 5 + crates/lib/src/bootc_composefs/state.rs | 20 +++- crates/lib/src/bootc_composefs/status.rs | 17 ++- crates/lib/src/bootc_composefs/switch.rs | 1 + crates/lib/src/bootc_composefs/update.rs | 123 +++++++++++++++------ 6 files changed, 125 insertions(+), 43 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1a295d071..801a019bd 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1277,7 +1277,7 @@ pub(crate) async fn setup_composefs_boot( &root_setup.physical_root_path, &id, &crate::spec::ImageReference::from(state.target_imgref.clone()), - false, + None, boot_type, boot_digest, &get_container_manifest_and_config(&get_imgref( diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 03b62e2ea..0f8ffab08 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -57,6 +57,11 @@ pub(crate) async fn composefs_backend_finalize( return Ok(()); }; + if staged_depl.download_only { + tracing::debug!("Staged deployment is marked download only. Won't finalize"); + return Ok(()); + } + let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!( "Staged deployment is not a composefs deployment" ))?; diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 0dc20f8be..517281be0 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -9,6 +9,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::tempmount::TempMount; use bootc_utils::CommandRunExt; use camino::Utf8PathBuf; +use canon_json::CanonJsonSerialize; use cap_std_ext::cap_std::ambient_authority; use cap_std_ext::cap_std::fs::{Dir, Permissions, PermissionsExt}; use cap_std_ext::dirext::CapStdExtDirExt; @@ -23,7 +24,9 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::repo::get_imgref; -use crate::bootc_composefs::status::{ImgConfigManifest, get_sorted_type1_boot_entries}; +use crate::bootc_composefs::status::{ + ImgConfigManifest, StagedDeployment, get_sorted_type1_boot_entries, +}; use crate::parsers::bls_config::BLSConfigType; use crate::store::{BootedComposefs, Storage}; use crate::{ @@ -227,7 +230,7 @@ pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, - staged: bool, + staged: Option, boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, @@ -248,7 +251,12 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to create symlink for /var")?; - initialize_state(&root_path, &deployment_id.to_hex(), &state_path, !staged)?; + initialize_state( + &root_path, + &deployment_id.to_hex(), + &state_path, + staged.is_none(), + )?; let ImageReference { image: image_name, @@ -291,7 +299,7 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to write to .origin file")?; - if staged { + if let Some(staged) = staged { std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; @@ -302,7 +310,9 @@ pub(crate) async fn write_composefs_state( staged_depl_dir .atomic_write( COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - deployment_id.to_hex().as_bytes(), + staged + .to_canon_json_vec() + .context("Failed to serialize staged deployment JSON")?, ) .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 386e2470a..c5da0d20c 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -79,6 +79,12 @@ impl std::fmt::Display for ComposefsCmdline { } } +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct StagedDeployment { + pub(crate) depl_id: String, + pub(crate) finalization_locked: bool, +} + /// Detect if we have composefs= in /proc/cmdline pub(crate) fn composefs_booted() -> Result> { static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); @@ -554,7 +560,7 @@ pub(crate) async fn composefs_deployment_status_from( let mut host = Host::new(host_spec); - let staged_deployment_id = match std::fs::File::open(format!( + let staged_deployment = match std::fs::File::open(format!( "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" )) { Ok(mut f) => { @@ -590,7 +596,7 @@ pub(crate) async fn composefs_deployment_status_from( let ini = tini::Ini::from_string(&config) .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; - let boot_entry = + let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?; // SAFETY: boot_entry.composefs will always be present @@ -614,8 +620,11 @@ pub(crate) async fn composefs_deployment_status_from( continue; } - if let Some(staged_deployment_id) = &staged_deployment_id { - if depl_file_name == staged_deployment_id.trim() { + if let Some(staged_deployment) = &staged_deployment { + let staged_depl = serde_json::from_str::(&staged_deployment)?; + + if depl_file_name == staged_depl.depl_id { + boot_entry.download_only = staged_depl.finalization_locked; host.status.staged = Some(boot_entry); continue; } diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 4f12b4790..944e166c3 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -45,6 +45,7 @@ pub(crate) async fn switch_composefs( let do_upgrade_opts = DoUpgradeOpts { soft_reboot: opts.soft_reboot, apply: opts.apply, + download_only: false, }; if let Some(cfg_verity) = image { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 667105cf5..8b74f4c03 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,10 +1,14 @@ +use std::io::Write; + use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::cap_std::fs::Dir; +use canon_json::CanonJsonSerialize; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; use composefs_oci::image::create_filesystem; use fn_error_context::context; +use ocidir::cap_std::ambient_authority; use ostree_ext::container::ManifestDiff; use crate::{ @@ -15,12 +19,15 @@ use crate::{ soft_reboot::prepare_soft_reboot_composefs, state::write_composefs_state, status::{ - ImgConfigManifest, get_bootloader, get_composefs_status, + ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo, }, }, cli::{SoftRebootMode, UpgradeOpts}, - composefs_consts::{STATE_DIR_RELATIVE, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED}, + composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, + TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, + }, spec::{Bootloader, Host, ImageReference}, store::{BootedComposefs, ComposefsRepository, Storage}, }; @@ -206,6 +213,31 @@ pub(crate) fn validate_update( pub(crate) struct DoUpgradeOpts { pub(crate) apply: bool, pub(crate) soft_reboot: Option, + pub(crate) download_only: bool, +} + +async fn apply_upgrade( + storage: &Storage, + booted_cfs: &BootedComposefs, + depl_id: &String, + opts: &DoUpgradeOpts, +) -> Result<()> { + if let Some(soft_reboot_mode) = opts.soft_reboot { + return prepare_soft_reboot_composefs( + storage, + booted_cfs, + Some(depl_id), + soft_reboot_mode, + opts.apply, + ) + .await; + }; + + if opts.apply { + return crate::reboot::reboot(); + } + + Ok(()) } /// Performs the Update or Switch operation @@ -255,29 +287,17 @@ pub(crate) async fn do_upgrade( &Utf8PathBuf::from("/sysroot"), &id, imgref, - true, + Some(StagedDeployment { + depl_id: id.to_hex(), + finalization_locked: opts.download_only, + }), boot_type, boot_digest, img_manifest_config, ) .await?; - if let Some(soft_reboot_mode) = opts.soft_reboot { - return prepare_soft_reboot_composefs( - storage, - booted_cfs, - Some(&id.to_hex()), - soft_reboot_mode, - opts.apply, - ) - .await; - }; - - if opts.apply { - return crate::reboot::reboot(); - } - - Ok(()) + apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } #[context("Upgrading composefs")] @@ -286,18 +306,60 @@ pub(crate) async fn upgrade_composefs( storage: &Storage, composefs: &BootedComposefs, ) -> Result<()> { - // Download-only mode is not yet supported for composefs backend - if opts.download_only { - anyhow::bail!("--download-only is not yet supported for composefs backend"); - } - if opts.from_downloaded { - anyhow::bail!("--from-downloaded is not yet supported for composefs backend"); - } - let host = get_composefs_status(storage, composefs) .await .context("Getting composefs deployment status")?; + let do_upgrade_opts = DoUpgradeOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + download_only: opts.download_only, + }; + + if opts.from_downloaded { + let staged = host + .status + .staged + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; + + // Staged deployment exists, but it will be finalized + if !staged.download_only { + println!("Staged deployment is present and not in download only mode."); + println!("Use `bootc update --apply` to apply the update."); + return Ok(()); + } + + start_finalize_stated_svc()?; + + // Make the staged deployment not download_only + let new_staged = StagedDeployment { + depl_id: staged.require_composefs()?.verity.clone(), + finalization_locked: false, + }; + + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + + staged_depl_dir + .atomic_replace_with( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + |f| -> std::io::Result<()> { + f.write_all(new_staged.to_canon_json_string()?.as_bytes()) + }, + ) + .context("Writing staged file")?; + + return apply_upgrade( + storage, + composefs, + &staged.require_composefs()?.verity, + &do_upgrade_opts, + ) + .await; + } + let mut booted_imgref = host .spec .image @@ -313,11 +375,6 @@ pub(crate) async fn upgrade_composefs( // Or if we have another staged deployment with a different image let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref()); - let do_upgrade_opts = DoUpgradeOpts { - soft_reboot: opts.soft_reboot, - apply: opts.apply, - }; - if let Some(staged_image) = staged_image { // We have a staged image and it has the same digest as the currently booted image's latest // digest From 9e8a3ef1af3e7a7ddec8d8cdded2bd2c9ad175f7 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 22 Jan 2026 14:15:36 +0530 Subject: [PATCH 5/6] composefs/soft-reboot: Check for SELinux policy divergence Until now while checking if a deployment is capable of being soft rebooted, we were not taking into account any differences in SELinux policies between the two deployments. This commit adds such a check We only check for policy diff if SELinux is enabled Signed-off-by: Pragyan Poudyal composefs: Refactor Add doc comments for StagedDeployment struct Use `serde_json::to_writer` to prevent intermediate string allocation Signed-off-by: Pragyan Poudyal composefs/selinux: More refactor Move SELinux realted oprations to a separate module Minor refactoring and add some comments Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/selinux.rs | 136 ++++++++++++++++++ crates/lib/src/bootc_composefs/soft_reboot.rs | 9 +- crates/lib/src/bootc_composefs/status.rs | 115 ++++++++++----- crates/lib/src/bootc_composefs/update.rs | 5 +- crates/lib/src/cli.rs | 2 +- crates/lib/src/status.rs | 4 + 7 files changed, 230 insertions(+), 42 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/selinux.rs diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index d3dda0f5f..0660cdcd7 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod finalize; pub(crate) mod gc; pub(crate) mod repo; pub(crate) mod rollback; +pub(crate) mod selinux; pub(crate) mod service; pub(crate) mod soft_reboot; pub(crate) mod state; diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs new file mode 100644 index 000000000..5ec7438eb --- /dev/null +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -0,0 +1,136 @@ +use anyhow::{Context, Result}; +use bootc_initramfs_setup::mount_composefs_image; +use bootc_mount::tempmount::TempMount; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; + +use crate::bootc_composefs::status::ComposefsCmdline; +use crate::lsm::selinux_enabled; +use crate::store::Storage; + +const SELINUX_CONFIG_PATH: &str = "etc/selinux/config"; +const SELINUX_TYPE: &str = "SELINUXTYPE="; +const POLICY_FILE_PREFIX: &str = "policy."; + +#[context("Getting SELinux policy for deployment {depl_id}")] +fn get_selinux_policy_for_deployment( + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + depl_id: &str, +) -> Result> { + let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; + + // Booted deployment. We want to get the policy from "/etc" as it might have been modified + let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id { + (Dir::open_ambient_dir("/", ambient_authority())?, None) + } else { + let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, false)?; + let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?; + + (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt)) + }; + + if !deployment_root.exists(SELINUX_CONFIG_PATH) { + return Ok(None); + } + + let selinux_config = deployment_root + .read_to_string(SELINUX_CONFIG_PATH) + .context("Reading selinux config")?; + + let type_ = selinux_config + .lines() + .find(|l| l.starts_with(SELINUX_TYPE)) + .ok_or_else(|| anyhow::anyhow!("Falied to find SELINUXTYPE"))? + .split("=") + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Failed to parse SELINUXTYPE"))? + .trim(); + + let policy_dir_path = format!("etc/selinux/{type_}/policy"); + + let mut highest_policy_version = -1; + let mut latest_policy_name = None; + + let policy_dir = deployment_root + .open_dir(&policy_dir_path) + .context("Opening selinux policy dir")?; + + for entry in policy_dir + .entries_utf8() + .context("Getting policy dir entries")? + { + let entry = entry?; + + if !entry.file_type()?.is_file() { + // We don't want symlinks, another directory etc + continue; + } + + let filename = entry.file_name()?; + + match filename.strip_prefix(POLICY_FILE_PREFIX) { + Some(version) => { + let v_int = version + .parse::() + .with_context(|| anyhow::anyhow!("Parsing {version} as int"))?; + + if v_int < highest_policy_version { + continue; + } + + highest_policy_version = v_int; + latest_policy_name = Some(filename.to_string()); + } + + None => continue, + }; + } + + let policy_name = + latest_policy_name.ok_or_else(|| anyhow::anyhow!("Failed to get latest SELinux policy"))?; + + let full_path = format!("{policy_dir_path}/{policy_name}"); + + let mut file = deployment_root + .open(full_path) + .context("Opening policy file")?; + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + std::io::copy(&mut file, &mut hasher)?; + + let hash = hex::encode(hasher.finish().context("Computing hash")?); + + Ok(Some(hash)) +} + +#[context("Checking SELinux policy compatibility")] +pub(crate) fn are_selinux_policies_compatible( + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + depl_id: &str, +) -> Result { + if !selinux_enabled()? { + return Ok(true); + } + + let booted_policy_hash = + get_selinux_policy_for_deployment(storage, booted_cmdline, &booted_cmdline.digest)?; + + let depl_policy_hash = get_selinux_policy_for_deployment(storage, booted_cmdline, depl_id)?; + + let sl_policy_match = match (booted_policy_hash, depl_policy_hash) { + // both have policies, compare them + (Some(booted_csum), Some(target_csum)) => booted_csum == target_csum, + // one depl has policy while the other doesn't + (Some(_), None) | (None, Some(_)) => false, + // no policy in either + (None, None) => true, + }; + + if !sl_policy_match { + tracing::debug!("Soft rebooting not allowed due to differing SELinux policies"); + } + + Ok(sl_policy_match) +} diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index a3120dabf..0a71d15b9 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -23,6 +23,11 @@ const NEXTROOT: &str = "/run/nextroot"; #[context("Resetting soft reboot state")] pub(crate) fn reset_soft_reboot() -> Result<()> { + // NOTE: By default bootc runs in an unshared mount namespace; + // this sets up our /runto actually be the same as the host/run + // so the umount (at the end of this function) actually affects the host + // + // Similar operation is performed in `prepare_soft_reboot_composefs` let run = Utf8Path::new("/run"); bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; @@ -33,7 +38,7 @@ pub(crate) fn reset_soft_reboot() -> Result<()> { .context("Opening nextroot")?; let Some(nextroot) = nextroot else { - tracing::debug!("Nextroot is not a directory"); + tracing::debug!("Nextroot does not exist"); println!("No deployment staged for soft rebooting"); return Ok(()); }; @@ -61,7 +66,7 @@ pub(crate) fn reset_soft_reboot() -> Result<()> { pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, - deployment_id: Option<&String>, + deployment_id: Option<&str>, soft_reboot_mode: SoftRebootMode, reboot: bool, ) -> Result<()> { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index c5da0d20c..d46bffce5 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -10,6 +10,7 @@ use crate::{ bootc_composefs::{ boot::BootType, repo::get_imgref, + selinux::are_selinux_policies_compatible, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ @@ -59,6 +60,13 @@ pub(crate) struct ComposefsCmdline { pub digest: Box, } +/// Information about a deployment for soft reboot comparison +struct DeploymentBootInfo<'a> { + boot_digest: &'a str, + full_cmdline: &'a Cmdline<'a>, + verity: &'a str, +} + impl ComposefsCmdline { pub(crate) fn new(s: &str) -> Self { let (insecure, digest_str) = s @@ -79,9 +87,14 @@ impl std::fmt::Display for ComposefsCmdline { } } +/// The JSON schema for staged deployment information +/// stored in /run/composefs/staged-deployment #[derive(Debug, Serialize, Deserialize)] pub(crate) struct StagedDeployment { + /// The id (verity hash of the EROFS image) of the staged deployment pub(crate) depl_id: String, + /// Whether to finalize this staged deployment on reboot or not + /// This also maps to `download_only` field in `BootEntry` pub(crate) finalization_locked: bool, } @@ -371,7 +384,7 @@ fn set_soft_reboot_capability( storage: &Storage, host: &mut Host, bls_entries: Option>, - cmdline: &ComposefsCmdline, + booted_cmdline: &ComposefsCmdline, ) -> Result<()> { let booted = host.require_composefs_booted()?; @@ -387,10 +400,10 @@ fn set_soft_reboot_capability( // vector to check for existence of an entry bls_entries.extend(staged_entries); - set_reboot_capable_type1_deployments(cmdline, host, bls_entries) + set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries) } - BootType::Uki => set_reboot_capable_uki_deployments(storage, cmdline, host), + BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), } } @@ -430,6 +443,7 @@ fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool { #[context("Setting soft reboot capability for Type1 entries")] fn set_reboot_capable_type1_deployments( + storage: &Storage, booted_cmdline: &ComposefsCmdline, host: &mut Host, bls_entries: Vec, @@ -445,7 +459,13 @@ fn set_reboot_capable_type1_deployments( let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)? .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?; - let booted_cmdline = booted_bls_entry.get_cmdline()?; + let booted_full_cmdline = booted_bls_entry.get_cmdline()?; + + let booted_info = DeploymentBootInfo { + boot_digest: booted_boot_digest, + full_cmdline: booted_full_cmdline, + verity: &booted_cmdline.digest, + }; for depl in host .status @@ -454,46 +474,64 @@ fn set_reboot_capable_type1_deployments( .chain(host.status.rollback.iter_mut()) .chain(host.status.other_deployments.iter_mut()) { - let entry = find_bls_entry(&depl.require_composefs()?.verity, &bls_entries)? + let depl_verity = &depl.require_composefs()?.verity; + + let entry = find_bls_entry(&depl_verity, &bls_entries)? .ok_or_else(|| anyhow::anyhow!("Entry not found"))?; let depl_cmdline = entry.get_cmdline()?; - depl.soft_reboot_capable = is_soft_rebootable( - depl.composefs_boot_digest()?, - booted_boot_digest, - depl_cmdline, - booted_cmdline, - ); + let target_info = DeploymentBootInfo { + boot_digest: depl.composefs_boot_digest()?, + full_cmdline: depl_cmdline, + verity: &depl_verity, + }; + + depl.soft_reboot_capable = + is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?; } Ok(()) } +/// Determines whether a soft reboot can be performed between the currently booted +/// deployment and a target deployment. +/// +/// # Arguments +/// +/// * `storage` - The bootc storage backend +/// * `booted_cmdline` - The composefs command line parameters of the currently booted deployment +/// * `booted` - Boot information for the currently booted deployment +/// * `target` - Boot information for the target deployment fn is_soft_rebootable( - depl_boot_digest: &str, - booted_boot_digest: &str, - depl_cmdline: &Cmdline, - booted_cmdline: &Cmdline, -) -> bool { - if depl_boot_digest != booted_boot_digest { + storage: &Storage, + booted_cmdline: &ComposefsCmdline, + booted: &DeploymentBootInfo, + target: &DeploymentBootInfo, +) -> Result { + if target.boot_digest != booted.boot_digest { tracing::debug!("Soft reboot not allowed due to kernel skew"); - return false; + return Ok(false); } - if depl_cmdline.as_bytes().len() != booted_cmdline.as_bytes().len() { + if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() { tracing::debug!("Soft reboot not allowed due to differing cmdline"); - return false; + return Ok(false); } - return compare_cmdline_skip_cfs(depl_cmdline, booted_cmdline) - && compare_cmdline_skip_cfs(booted_cmdline, depl_cmdline); + let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline) + && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline); + + let selinux_compatible = + are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?; + + return Ok(cmdline_eq && selinux_compatible); } #[context("Setting soft reboot capability for UKI deployments")] fn set_reboot_capable_uki_deployments( storage: &Storage, - cmdline: &ComposefsCmdline, + booted_cmdline: &ComposefsCmdline, host: &mut Host, ) -> Result<()> { let booted = host @@ -505,10 +543,16 @@ fn set_reboot_capable_uki_deployments( // Since older booted systems won't have the boot digest for UKIs let booted_boot_digest = match booted.composefs_boot_digest() { Ok(d) => d, - Err(_) => &compute_store_boot_digest_for_uki(storage, &cmdline.digest)?, + Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?, }; - let booted_cmdline = get_uki_cmdline(storage, &booted.require_composefs()?.verity)?; + let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?; + + let booted_info = DeploymentBootInfo { + boot_digest: booted_boot_digest, + full_cmdline: &booted_full_cmdline, + verity: &booted_cmdline.digest, + }; for deployment in host .status @@ -517,23 +561,24 @@ fn set_reboot_capable_uki_deployments( .chain(host.status.rollback.iter_mut()) .chain(host.status.other_deployments.iter_mut()) { + let depl_verity = &deployment.require_composefs()?.verity; + // Since older booted systems won't have the boot digest for UKIs let depl_boot_digest = match deployment.composefs_boot_digest() { Ok(d) => d, - Err(_) => &compute_store_boot_digest_for_uki( - storage, - &deployment.require_composefs()?.verity, - )?, + Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?, }; let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?; - deployment.soft_reboot_capable = is_soft_rebootable( - depl_boot_digest, - booted_boot_digest, - &depl_cmdline, - &booted_cmdline, - ); + let target_info = DeploymentBootInfo { + boot_digest: depl_boot_digest, + full_cmdline: &depl_cmdline, + verity: depl_verity, + }; + + deployment.soft_reboot_capable = + is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?; } Ok(()) diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 8b74f4c03..978a45360 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,8 +1,5 @@ -use std::io::Write; - use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use canon_json::CanonJsonSerialize; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; @@ -346,7 +343,7 @@ pub(crate) async fn upgrade_composefs( .atomic_replace_with( COMPOSEFS_STAGED_DEPLOYMENT_FNAME, |f| -> std::io::Result<()> { - f.write_all(new_staged.to_canon_json_string()?.as_bytes()) + serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) }, ) .context("Writing staged file")?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 47cddcc53..0464e42aa 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1860,7 +1860,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { prepare_soft_reboot_composefs( &storage, &booted_cfs, - deployment.as_ref(), + deployment.as_deref(), SoftRebootMode::Required, reboot, ) diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index f0ce7cc94..26220c5c3 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -311,6 +311,10 @@ impl BootEntry { )) } + /// Get the boot digest for this deployment + /// This is the + /// - SHA256SUM of kernel + initrd for Type1 booted deployments + /// - SHA256SUM of UKI for Type2 booted deployments pub(crate) fn composefs_boot_digest(&self) -> Result<&String> { self.require_composefs()? .boot_digest From 087d072bb1400194259448d3ad8b2f38a2b52432 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 23 Jan 2026 13:49:04 +0530 Subject: [PATCH 6/6] composefs/selinux: Add tests Tests added by Claude Code Assisted-by: Claude Code (Sonnet 4) Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/selinux.rs | 265 ++++++++++++++++++---- 1 file changed, 222 insertions(+), 43 deletions(-) diff --git a/crates/lib/src/bootc_composefs/selinux.rs b/crates/lib/src/bootc_composefs/selinux.rs index 5ec7438eb..700275264 100644 --- a/crates/lib/src/bootc_composefs/selinux.rs +++ b/crates/lib/src/bootc_composefs/selinux.rs @@ -13,6 +13,57 @@ const SELINUX_CONFIG_PATH: &str = "etc/selinux/config"; const SELINUX_TYPE: &str = "SELINUXTYPE="; const POLICY_FILE_PREFIX: &str = "policy."; +/// Find the highest versioned policy file in the given directory +fn find_latest_policy_file(policy_dir: &Dir) -> Result { + let mut highest_policy_version = -1; + let mut latest_policy_name = None; + + for entry in policy_dir + .entries_utf8() + .context("Getting policy dir entries")? + { + let entry = entry?; + + if !entry.file_type()?.is_file() { + // We don't want symlinks, another directory etc + continue; + } + + let filename = entry.file_name()?; + + match filename.strip_prefix(POLICY_FILE_PREFIX) { + Some(version) => { + let v_int = version + .parse::() + .with_context(|| anyhow::anyhow!("Parsing {version} as int"))?; + + if v_int < highest_policy_version { + continue; + } + + highest_policy_version = v_int; + latest_policy_name = Some(filename.to_string()); + } + + None => continue, + }; + } + + latest_policy_name.ok_or_else(|| anyhow::anyhow!("Failed to get latest SELinux policy")) +} + +/// Compute SHA256 hash of a policy file +fn compute_policy_file_hash(deployment_root: &Dir, full_path: &str) -> Result { + let mut file = deployment_root + .open(full_path) + .context("Opening policy file")?; + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; + std::io::copy(&mut file, &mut hasher)?; + + let hash = hex::encode(hasher.finish().context("Computing hash")?); + Ok(hash) +} + #[context("Getting SELinux policy for deployment {depl_id}")] fn get_selinux_policy_for_deployment( storage: &Storage, @@ -50,56 +101,15 @@ fn get_selinux_policy_for_deployment( let policy_dir_path = format!("etc/selinux/{type_}/policy"); - let mut highest_policy_version = -1; - let mut latest_policy_name = None; - let policy_dir = deployment_root .open_dir(&policy_dir_path) .context("Opening selinux policy dir")?; - for entry in policy_dir - .entries_utf8() - .context("Getting policy dir entries")? - { - let entry = entry?; - - if !entry.file_type()?.is_file() { - // We don't want symlinks, another directory etc - continue; - } - - let filename = entry.file_name()?; - - match filename.strip_prefix(POLICY_FILE_PREFIX) { - Some(version) => { - let v_int = version - .parse::() - .with_context(|| anyhow::anyhow!("Parsing {version} as int"))?; - - if v_int < highest_policy_version { - continue; - } - - highest_policy_version = v_int; - latest_policy_name = Some(filename.to_string()); - } - - None => continue, - }; - } - - let policy_name = - latest_policy_name.ok_or_else(|| anyhow::anyhow!("Failed to get latest SELinux policy"))?; + let policy_name = find_latest_policy_file(&policy_dir)?; let full_path = format!("{policy_dir_path}/{policy_name}"); - let mut file = deployment_root - .open(full_path) - .context("Opening policy file")?; - let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?; - std::io::copy(&mut file, &mut hasher)?; - - let hash = hex::encode(hasher.finish().context("Computing hash")?); + let hash = compute_policy_file_hash(&deployment_root, &full_path)?; Ok(Some(hash)) } @@ -134,3 +144,172 @@ pub(crate) fn are_selinux_policies_compatible( Ok(sl_policy_match) } + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std::ambient_authority; + use cap_std_ext::dirext::CapStdExtDirExt; + + #[test] + fn test_find_latest_policy_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + // Create policy files with different versions + tempdir.atomic_write("policy.30", "policy content 30")?; + tempdir.atomic_write("policy.31", "policy content 31")?; + tempdir.atomic_write("policy.29", "policy content 29")?; + tempdir.atomic_write("not_policy.32", "not a policy file")?; + tempdir.atomic_write("other_policy.txt", "invalid policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.31"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_with_single_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy.25", "single policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.25"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_no_policy_files() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + tempdir + .atomic_write("not_policy.txt", "not a policy file") + .unwrap(); + tempdir.atomic_write("other.txt", "invalid format").unwrap(); + + let result = find_latest_policy_file(&tempdir); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to get latest SELinux policy") + ); + } + + #[test] + fn test_find_latest_policy_file_invalid_version() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + tempdir + .atomic_write("policy.abc", "invalid version") + .unwrap(); + + let result = find_latest_policy_file(&tempdir); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Parsing abc as int") + ); + } + + #[test] + fn test_find_latest_policy_file_negative_version() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy.5", "positive version")?; + tempdir.atomic_write("policy.-1", "negative version")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.5"); + + Ok(()) + } + + #[test] + fn test_find_latest_policy_file_skips_directories() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.create_dir("policy.99")?; // This should be skipped + tempdir.atomic_write("policy.5", "actual policy file")?; + + let result = find_latest_policy_file(&tempdir)?; + assert_eq!(result, "policy.5"); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + let test_content = "test policy content for hashing"; + tempdir.atomic_write("test_policy.30", test_content)?; + + let hash = compute_policy_file_hash(&tempdir, "test_policy.30")?; + + // Verify the hash is a valid SHA256 hash (64 hex characters) + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + + // Verify consistent hashing + let hash2 = compute_policy_file_hash(&tempdir, "test_policy.30")?; + assert_eq!(hash, hash2); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash_different_content() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("policy1.30", "content 1")?; + tempdir.atomic_write("policy2.30", "content 2")?; + + let hash1 = compute_policy_file_hash(&tempdir, "policy1.30")?; + let hash2 = compute_policy_file_hash(&tempdir, "policy2.30")?; + + assert_ne!(hash1, hash2); + + Ok(()) + } + + #[test] + fn test_compute_policy_file_hash_nonexistent_file() { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap(); + + let result = compute_policy_file_hash(&tempdir, "nonexistent.30"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Opening policy file") + ); + } + + #[test] + fn test_compute_policy_file_hash_empty_file() -> Result<()> { + let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + + tempdir.atomic_write("empty_policy.30", "")?; + + let hash = compute_policy_file_hash(&tempdir, "empty_policy.30")?; + + // Should produce a valid hash even for empty file + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + + // SHA256 of empty string + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + + Ok(()) + } +}