Skip to content
Draft
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
1 change: 1 addition & 0 deletions crates/etc-merge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ impl From<(&cap_std::fs::Metadata, Xattrs)> for MyStat {
st_uid: value.0.uid(),
st_gid: value.0.gid(),
st_mtim_sec: value.0.mtime(),
st_mtim_nsec: value.0.mtime_nsec() as u32,
xattrs: value.1,
})
}
Expand Down
7 changes: 5 additions & 2 deletions crates/initramfs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use composefs::{
mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount},
repository::Repository,
};
use composefs_boot::cmdline::get_cmdline_composefs;
use composefs_boot::cmdline::ComposefsCmdline;
use composefs_ctl::composefs;
use composefs_ctl::composefs_boot;

Expand Down Expand Up @@ -463,7 +463,10 @@ pub fn setup_root(args: Args) -> Result<()> {
config
};

let (image, insecure) = get_cmdline_composefs::<Sha512HashValue>(&cmdline)?;
let composefs = ComposefsCmdline::<Sha512HashValue>::from_cmdline(&cmdline)?
.context("No composefs= or composefs.digest= karg found")?;
let image = composefs.digest();
let insecure = composefs.is_insecure();

let new_root = match &args.root_fs {
Some(path) => open_root_fs(path).context("Failed to clone specified root fs")?,
Expand Down
95 changes: 74 additions & 21 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ use composefs_boot::bootloader::{
BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType,
UsrLibModulesVmlinuz, get_boot_resources,
};
use composefs_boot::{cmdline::get_cmdline_composefs, os_release::OsReleaseInfo, uki};
use composefs_boot::{
cmdline::ComposefsCmdline as BootComposefsCmdline, os_release::OsReleaseInfo, uki,
};
use composefs_ctl::composefs;
use composefs_ctl::composefs_boot;
use composefs_ctl::composefs_oci;
Expand Down Expand Up @@ -783,6 +785,12 @@ struct UKIInfo {
version: Option<String>,
os_id: Option<String>,
boot_digest: String,
/// The composefs image digest parsed from (and validated against) the UKI's
/// own cmdline. This is the authoritative deployment key for UKI boots:
/// setup-root opens `state/deploy/<this>` using the karg baked into the UKI,
/// so the deploy directory must be named after exactly this value regardless
/// of which EROFS format (V1 or V2) was sealed.
composefs_cmdline: Sha512HashValue,
}

/// Writes a PortableExecutable to ESP along with any PE specific or Global addons
Expand All @@ -793,6 +801,7 @@ fn write_pe_to_esp(
file_path: &Utf8Path,
pe_type: PEType,
uki_id: &Sha512HashValue,
boot_ids: &[&Sha512HashValue],
missing_fsverity_allowed: bool,
mounted_efi: impl AsRef<Path>,
) -> Result<Option<UKIInfo>> {
Expand All @@ -811,8 +820,11 @@ fn write_pe_to_esp(
if matches!(pe_type, PEType::Uki) {
let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?;

let (composefs_cmdline, missing_verity_allowed_cmdline) =
get_cmdline_composefs::<Sha512HashValue>(&cmdline).context("Parsing composefs=")?;
let parsed_cmdline = BootComposefsCmdline::<Sha512HashValue>::from_cmdline(&cmdline)
.context("Parsing composefs=")?
.context("No composefs= or composefs.digest= karg found in UKI cmdline")?;
let composefs_cmdline = parsed_cmdline.digest().clone();
let missing_verity_allowed_cmdline = parsed_cmdline.is_insecure();

// If the UKI cmdline does not match what the user has passed as cmdline option
// NOTE: This will only be checked for new installs and now upgrades/switches
Expand All @@ -830,11 +842,9 @@ fn write_pe_to_esp(
_ => { /* no-op */ }
}

if composefs_cmdline != *uki_id {
anyhow::bail!(
"The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})"
);
}
parsed_cmdline
.validate_digest(boot_ids.iter().copied())
.context("Validating UKI composefs digest")?;

uki_reader.seek(SeekFrom::Start(0))?;
let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?;
Expand All @@ -851,6 +861,7 @@ fn write_pe_to_esp(
version: parsed_osrel.get_version(),
os_id: parsed_osrel.get_value(&["ID"]),
boot_digest,
composefs_cmdline,
});
}

Expand Down Expand Up @@ -1066,8 +1077,9 @@ pub(crate) fn setup_composefs_uki_boot(
setup_type: BootSetupType,
repo: crate::store::ComposefsRepository,
id: &Sha512HashValue,
boot_ids: &[&Sha512HashValue],
entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
) -> Result<String> {
) -> Result<(String, Sha512HashValue)> {
let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type
{
BootSetupType::Setup((root_setup, state, postfetch)) => {
Expand Down Expand Up @@ -1147,7 +1159,8 @@ pub(crate) fn setup_composefs_uki_boot(
&entry.file,
utf8_file_path,
entry.pe_type,
&id,
id,
boot_ids,
missing_fsverity_allowed,
esp_mount.dir.path(),
)?;
Expand All @@ -1164,6 +1177,13 @@ pub(crate) fn setup_composefs_uki_boot(

let boot_digest = uki_info.boot_digest.clone();

// The deploy key for a UKI boot is the composefs digest baked into the UKI
// cmdline (already validated against `boot_ids` in `write_pe_to_esp`).
// setup-root opens `state/deploy/<this>` using that same karg, so we must
// key the deployment off exactly this value -- whether the UKI was sealed
// with the V2 (default) or V1 EROFS digest.
let deploy_id = uki_info.composefs_cmdline.clone();

match bootloader {
Bootloader::Grub => {
write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)?
Expand All @@ -1174,7 +1194,7 @@ pub(crate) fn setup_composefs_uki_boot(
Bootloader::None => unreachable!("Checked at install time"),
};

Ok(boot_digest)
Ok((boot_digest, deploy_id))
}

/// A composefs image attached to a temporary directory with the ESP and a
Expand Down Expand Up @@ -1345,6 +1365,15 @@ pub(crate) async fn setup_composefs_boot(
let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest)
.context("Generating bootable EROFS image")?;

// Open the OCI image to read both stored boot EROFS digests. The UKI may
// have been sealed with either the V1 or V2 boot image digest, so we need
// both for verification.
let oci_img =
composefs_oci::oci_image::OciImage::open(&*repo, &pull_result.manifest_digest, None)
.context("Opening OCI image to read boot image refs")?;
let boot_id_v1 = oci_img.boot_image_ref_v1().cloned();
let boot_id_v2 = oci_img.boot_image_ref_v2().cloned();

// Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.).
let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None)
.context("Creating composefs filesystem for boot entry discovery")?;
Expand Down Expand Up @@ -1397,25 +1426,49 @@ pub(crate) async fn setup_composefs_boot(
)
})?;

let boot_digest = match boot_type {
BootType::Bls => setup_composefs_bls_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch)),
repo,
&id,
entry,
mounted_root.dir(),
)?,
// The deployment key is the hash that setup-root looks for in
// state/deploy/<hash>, derived from the composefs karg. The two boot types
// establish that karg differently:
//
// * BLS: bootc writes the karg itself, so we are free to choose the key.
// Prefer the V2 digest (matching the default EROFS format), falling back
// to the primary id for legacy repos with no separate V2 boot ref.
//
// * UKI: the karg is baked into the UKI at seal time, so the key is
// whatever digest the UKI carries. `setup_composefs_uki_boot` parses and
// validates that against the boot refs and returns it, so we override the
// provisional value below. This keeps us correct whether the UKI was
// sealed with the V2 (default) or V1 EROFS digest.
let provisional_deploy_id = boot_id_v2.as_ref().cloned().unwrap_or_else(|| id.clone());

// Collect whichever boot image refs exist; the UKI cmdline may carry either.
let boot_ids_owned: Vec<Sha512HashValue> =
[boot_id_v1, boot_id_v2].into_iter().flatten().collect();
let boot_ids: Vec<&Sha512HashValue> = boot_ids_owned.iter().collect();

let (boot_digest, deploy_id) = match boot_type {
BootType::Bls => (
setup_composefs_bls_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch)),
repo,
&provisional_deploy_id,
entry,
mounted_root.dir(),
)?,
provisional_deploy_id,
),
BootType::Uki => setup_composefs_uki_boot(
BootSetupType::Setup((&root_setup, &state, &postfetch)),
repo,
&id,
&provisional_deploy_id,
&boot_ids,
entries,
)?,
};

write_composefs_state(
&root_setup.physical_root_path,
&id,
&deploy_id,
&crate::spec::ImageReference::from(state.target_imgref.clone()),
None,
boot_type,
Expand Down
36 changes: 24 additions & 12 deletions crates/lib/src/bootc_composefs/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use camino::Utf8Path;
use cap_std_ext::cap_std;
use cap_std_ext::cap_std::fs::Dir;
use composefs::dumpfile;
use composefs::fsverity::{Algorithm, FsVerityHashValue};
use composefs::erofs::format::FormatVersion;
use composefs::fsverity::FsVerityHashValue;
use composefs::repository::RepositoryConfig;
use composefs_boot::BootOps as _;
use composefs_ctl::composefs;
use composefs_ctl::composefs_boot;
Expand All @@ -20,21 +22,26 @@ use crate::store::ComposefsRepository;

/// Creates a temporary composefs repository for computing digests.
///
/// The `erofs_version` controls which EROFS format the digest is computed for:
/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha256-12:<hex>` karg (V1 EROFS,
/// C-tool compatible) or `FormatVersion::V2` for the legacy `composefs=<hex>` karg.
///
/// Returns the TempDir guard (must be kept alive for the repo to remain valid)
/// and the repository wrapped in Arc.
#[fn_error_context::context("Creating new temp composefs repo")]
pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc<ComposefsRepository>)> {
pub(crate) fn new_temp_composefs_repo(
erofs_version: FormatVersion,
) -> Result<(TempDir, Arc<ComposefsRepository>)> {
let td_guard = tempfile::tempdir_in("/var/tmp")?;
let td_path = td_guard.path();
let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?;

td_dir.create_dir("repo")?;
let repo_dir = td_dir.open_dir("repo")?;
let (mut repo, _created) =
ComposefsRepository::init_path(&repo_dir, ".", Algorithm::SHA512, false)
.context("Init cfs repo")?;
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
repo.set_insecure();
let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure();
config.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
let (repo, _created) =
ComposefsRepository::init_path(&repo_dir, ".", config).context("Init cfs repo")?;
Ok((td_guard, Arc::new(repo)))
}

Expand All @@ -58,13 +65,14 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc<ComposefsReposit
#[fn_error_context::context("Computing composefs digest")]
pub(crate) async fn compute_composefs_digest(
path: &Utf8Path,
erofs_version: FormatVersion,
write_dumpfile_to: Option<&Utf8Path>,
) -> Result<String> {
if path.as_str() == "/" {
anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead");
}

let (_td_guard, repo) = new_temp_composefs_repo()?;
let (_td_guard, repo) = new_temp_composefs_repo(erofs_version)?;

// Read filesystem from path, transform for boot, compute digest
let dirfd: OwnedFd = rustix::fs::open(
Expand All @@ -81,7 +89,7 @@ pub(crate) async fn compute_composefs_digest(
.await
.context("Reading container root")?;
fs.transform_for_boot(&repo).context("Preparing for boot")?;
let id = fs.compute_image_id();
let id = fs.compute_image_id(erofs_version);
let digest = id.to_hex();

if let Some(dumpfile_path) = write_dumpfile_to {
Expand Down Expand Up @@ -135,7 +143,9 @@ mod tests {

// Compute the digest
let path = Utf8Path::from_path(td.path()).unwrap();
let digest = compute_composefs_digest(path, None).await.unwrap();
let digest = compute_composefs_digest(path, FormatVersion::V2, None)
.await
.unwrap();

// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
assert_eq!(
Expand All @@ -150,7 +160,9 @@ mod tests {
);

// Verify consistency - computing twice on the same filesystem produces the same result
let digest2 = compute_composefs_digest(path, None).await.unwrap();
let digest2 = compute_composefs_digest(path, FormatVersion::V2, None)
.await
.unwrap();
assert_eq!(
digest, digest2,
"Digest should be consistent across multiple computations"
Expand All @@ -159,7 +171,7 @@ mod tests {

#[tokio::test]
async fn test_compute_composefs_digest_rejects_root() {
let result = compute_composefs_digest(Utf8Path::new("/"), None).await;
let result = compute_composefs_digest(Utf8Path::new("/"), FormatVersion::V2, None).await;
assert!(result.is_err());
let err = result.unwrap_err();
let found = err.chain().any(|e| {
Expand Down
24 changes: 14 additions & 10 deletions crates/lib/src/bootc_composefs/gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,16 +402,20 @@ pub(crate) async fn composefs_gc(
ref_digest,
None,
) {
if let Some(img_ref) = img.image_ref() {
if img_ref.to_hex() == *verity {
tracing::info!(
"Deployment {verity} has no manifest_digest in origin; \
found matching manifest {ref_digest} via image_ref"
);
live_manifest_digests.push(ref_digest.clone());
found_manifest = true;
break;
}
// Check both V1 and V2 slots: the deployment verity
// may have been produced under either format.
let img_ref_hex = img
.image_ref_v1()
.or_else(|| img.image_ref_v2())
.map(|id| id.to_hex());
if img_ref_hex.as_deref() == Some(verity.as_str()) {
tracing::info!(
"Deployment {verity} has no manifest_digest in origin; \
found matching manifest {ref_digest} via image_ref"
);
live_manifest_digests.push(ref_digest.clone());
found_manifest = true;
break;
}
}
}
Expand Down
29 changes: 19 additions & 10 deletions crates/lib/src/bootc_composefs/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,25 @@ pub(crate) async fn initialize_composefs_repository(

crate::store::ensure_composefs_dir(rootfs_dir)?;

let (mut repo, _created) = crate::store::ComposefsRepository::init_path(
rootfs_dir,
"composefs",
composefs::fsverity::Algorithm::SHA512,
!allow_missing_fsverity,
)
.context("Failed to initialize composefs repository")?;
if allow_missing_fsverity {
repo.set_insecure();
}
let mut config = {
let c =
composefs::repository::RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
if allow_missing_fsverity {
c.set_insecure()
} else {
c
}
};
// Generate both V1 and V2 EROFS images so a deployment can be booted via
// either the composefs= legacy shorthand (V2) or composefs.digest= (V1/V2)
// karg. This makes both digests available for any boot path.
config.erofs_formats = composefs::erofs::format::FormatConfig {
default: composefs::erofs::format::FormatVersion::V1,
extra: [composefs::erofs::format::FormatVersion::V2].into(),
};
let (repo, _created) =
crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config)
.context("Failed to initialize composefs repository")?;

let imgref: containers_image_proxy::ImageReference = state
.source
Expand Down
Loading
Loading