diff --git a/.github/workflows/bootc-revdep.yml b/.github/workflows/bootc-revdep.yml index 1ca8cec7..cf9dce61 100644 --- a/.github/workflows/bootc-revdep.yml +++ b/.github/workflows/bootc-revdep.yml @@ -48,4 +48,4 @@ jobs: # Use bootc branch adapted to composefs-rs API changes # TODO: revert to main once bootc-dev/bootc merges these adaptations COMPOSEFS_BOOTC_REPO: https://github.com/cgwalters/bootc - COMPOSEFS_BOOTC_REF: adapt-composefs-rs-api + COMPOSEFS_BOOTC_REF: adapt-composefs-rs-v1-erofs-2 diff --git a/crates/composefs-boot/src/bootloader.rs b/crates/composefs-boot/src/bootloader.rs index f8ce30d8..3bab573f 100644 --- a/crates/composefs-boot/src/bootloader.rs +++ b/crates/composefs-boot/src/bootloader.rs @@ -19,7 +19,7 @@ use composefs::{ tree::{DirectoryRef, FileSystem, ImageError, Inode, LeafContent, RegularFile}, }; -use crate::cmdline::{make_cmdline_composefs, split_cmdline}; +use crate::cmdline::split_cmdline; /// Strips the key (if it matches) plus the following whitespace from a single line in a "Type #1 /// Boot Loader Specification Entry" file. @@ -139,11 +139,15 @@ impl BootLoaderEntryFile { self.lines.push(format!("options {arg}")); } - /// Adjusts the kernel command-line arguments by adding a composefs= parameter (if appropriate) - /// and adding additional arguments, as requested. - pub fn adjust_cmdline(&mut self, composefs: Option<&str>, insecure: bool, extra: &[&str]) { - if let Some(id) = composefs { - self.add_cmdline(&make_cmdline_composefs(id, insecure)); + /// Adjusts the kernel command-line arguments by adding a composefs karg (if provided) + /// and adding additional arguments. + /// + /// `karg` should be a complete kernel argument string such as + /// `"composefs.digest=v1-sha256-12:abc123"` or `"composefs=abc123"` as produced by + /// [`composefs_boot::cmdline::ComposefsCmdline::to_cmdline_arg`]. + pub fn adjust_cmdline(&mut self, karg: Option<&str>, extra: &[&str]) { + if let Some(k) = karg { + self.add_cmdline(k); } for item in extra { @@ -776,7 +780,7 @@ mod tests { #[test] fn test_adjust_cmdline_with_composefs() { let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n"); - entry.adjust_cmdline(Some("abc123"), false, &["quiet", "splash"]); + entry.adjust_cmdline(Some("composefs=abc123"), &["quiet", "splash"]); assert_eq!(entry.lines.len(), 3); assert_eq!(entry.lines[2], "options composefs=abc123 quiet splash"); @@ -785,17 +789,16 @@ mod tests { #[test] fn test_adjust_cmdline_with_composefs_insecure() { let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n"); - entry.adjust_cmdline(Some("abc123"), true, &[]); + entry.adjust_cmdline(Some("composefs=?abc123"), &[]); assert_eq!(entry.lines.len(), 3); - // Assuming make_cmdline_composefs adds digest=off for insecure mode - assert!(entry.lines[2].contains("abc123")); + assert_eq!(entry.lines[2], "options composefs=?abc123"); } #[test] fn test_adjust_cmdline_no_composefs() { let mut entry = BootLoaderEntryFile::new("title Test Entry\nlinux /vmlinuz\n"); - entry.adjust_cmdline(None, false, &["quiet", "splash"]); + entry.adjust_cmdline(None, &["quiet", "splash"]); assert_eq!(entry.lines.len(), 3); assert_eq!(entry.lines[2], "options quiet splash"); @@ -804,7 +807,7 @@ mod tests { #[test] fn test_adjust_cmdline_existing_options() { let mut entry = BootLoaderEntryFile::new("title Test Entry\noptions root=/dev/sda1\n"); - entry.adjust_cmdline(Some("abc123"), false, &["quiet"]); + entry.adjust_cmdline(Some("composefs=abc123"), &["quiet"]); assert_eq!(entry.lines.len(), 2); assert!(entry.lines[1].contains("root=/dev/sda1")); diff --git a/crates/composefs-boot/src/cmdline.rs b/crates/composefs-boot/src/cmdline.rs index f65dd303..f19e2415 100644 --- a/crates/composefs-boot/src/cmdline.rs +++ b/crates/composefs-boot/src/cmdline.rs @@ -6,14 +6,245 @@ //! insecure mode indicators. use anyhow::{Context, Result}; -use composefs::fsverity::FsVerityHashValue; +use composefs::fsverity::{Algorithm, FsVerityHashValue}; + +/// Legacy kernel argument for V2 EROFS: `composefs=`. +/// +/// Shorthand for `composefs.digest=v2--12:`. Used in existing +/// sealed UKIs. The initramfs checks for [`KARG_COMPOSEFS_DIGEST`] first, +/// then falls back to this. +pub const KARG_V2: &str = "composefs"; + +/// Self-describing kernel argument: `composefs.digest=--:`. +/// +/// The value encodes the EROFS format version, hash algorithm, and block size, +/// e.g. `composefs.digest=v1-sha256-12:` or `composefs.digest=v2-sha512-12:`. +/// Both `v1` and `v2` are accepted; `composefs=` is a legacy alias for +/// the `v2` form. +/// +/// Multiple entries may appear on the cmdline with different format/algorithm +/// combinations; the initramfs tries each in order, mounting the first image +/// that exists in the repository. +pub const KARG_COMPOSEFS_DIGEST: &str = "composefs.digest"; + +/// A composefs kernel argument identifying which EROFS image to mount at boot. +/// +/// Two variants exist to distinguish EROFS format versions: +/// - [`ComposefsCmdline::V2`]: V2 EROFS — either `composefs=` (legacy shorthand) +/// or `composefs.digest=v2--:` (explicit form) +/// - [`ComposefsCmdline::V1`]: V1 EROFS — `composefs.digest=v1--:` +/// +/// The initramfs checks for `composefs.digest=` first (accepting both `v1` and `v2` +/// descriptors), then falls back to the legacy `composefs=` shorthand. +/// Multiple `composefs.digest=` entries may appear on the cmdline (different +/// format/algorithm combinations); the initramfs tries each in order, mounting +/// the first image that exists. +/// +/// NOTE: The equivalent parsing logic in bootc's `crates/initramfs/src/lib.rs` must be +/// kept in sync with this file manually, since bootc does not yet depend on composefs-boot +/// directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ComposefsCmdline { + /// V2 EROFS image: embedded as `composefs=` in the UKI cmdline. + /// + /// The `insecure` flag, when `true`, means the digest is prefixed with `?` + /// (e.g. `composefs=?`), making fs-verity verification optional. + V2 { + /// The fs-verity hash of the EROFS image. + digest: ObjectID, + /// If `true`, a `?` prefix is added to the digest, making fs-verity + /// verification optional at boot. + insecure: bool, + }, + /// V1 EROFS image: embedded as `composefs.digest=v1--:` in the UKI cmdline. + /// + /// The value encodes the algorithm, e.g. `composefs.digest=v1-sha256-12:` + /// or `composefs.digest=v1-sha512-12:`. + /// + /// The `insecure` flag, when `true`, means the value is prefixed with `?` + /// (e.g. `composefs.digest=?v1-sha512-12:`), making fs-verity verification optional. + V1 { + /// The fs-verity hash of the EROFS image. + digest: ObjectID, + /// If `true`, a `?` prefix is added before the format descriptor in the value, + /// making fs-verity verification optional at boot. + insecure: bool, + }, +} + +impl ComposefsCmdline { + /// Returns a reference to the hex digest, regardless of variant. + /// + /// Useful for looking up the image in `composefs/images/`. + pub fn digest(&self) -> &ObjectID { + match self { + ComposefsCmdline::V2 { digest, .. } | ComposefsCmdline::V1 { digest, .. } => digest, + } + } + + /// Validates that this UKI cmdline's digest matches one of the acceptable + /// boot image digests. + /// + /// With dual V1+V2 EROFS, a single composefs image is stored as two boot + /// EROFS serializations with distinct digests; a UKI is sealed carrying + /// exactly one of them. This accepts the UKI if its digest matches ANY of + /// `acceptable`. Returns the matched (UKI's own) digest on success. + pub fn validate_digest<'a>( + &self, + acceptable: impl IntoIterator, + ) -> Result<&ObjectID> + where + ObjectID: 'a, + { + let acceptable: Vec<&ObjectID> = acceptable.into_iter().collect(); + let uki_digest = self.digest(); + if acceptable.contains(&uki_digest) { + return Ok(uki_digest); + } + let expected = acceptable + .iter() + .map(|id| format!("{id:?}")) + .collect::>() + .join(", "); + anyhow::bail!( + "The UKI has the wrong composefs digest (is '{uki_digest:?}', should be one of [{expected}])" + ) + } + + /// Returns whether this karg is in insecure mode (fs-verity verification skipped). + pub fn is_insecure(&self) -> bool { + match self { + ComposefsCmdline::V1 { insecure, .. } | ComposefsCmdline::V2 { insecure, .. } => { + *insecure + } + } + } + + /// Constructs a V2 cmdline value (`composefs=`). + pub fn new_v2(digest: ObjectID, insecure: bool) -> Self { + ComposefsCmdline::V2 { digest, insecure } + } + + /// Constructs a V1 cmdline value (`composefs.digest=v1--:`). + pub fn new_v1(digest: ObjectID, insecure: bool) -> Self { + ComposefsCmdline::V1 { digest, insecure } + } + + /// Parses a [`ComposefsCmdline`] from a kernel command line string. + /// + /// Scans for `composefs.digest=` tokens first (→ [`ComposefsCmdline::V1`]). Multiple + /// such tokens may appear on the cmdline (different algorithms); the first one whose + /// format descriptor matches the `ObjectID` algorithm is returned. Then falls back to + /// `composefs=` (→ [`ComposefsCmdline::V2`]). Returns `None` if no matching token is + /// present. + /// + /// # Errors + /// + /// Returns an error if a matching karg is found but the hex digest cannot be parsed + /// for the given `ObjectID` type. + pub fn from_cmdline(cmdline: &str) -> Result> { + let expected_hex_len = size_of::() * 2; + + // V1: composefs.digest=v1--: + // Optional '?' insecure marker directly after '=': composefs.digest=?v1-sha256-12: + // There may be multiple composefs.digest= tokens with different algorithms; find the + // first one whose format descriptor matches this ObjectID type. + let v1_key_prefix = format!("{KARG_COMPOSEFS_DIGEST}="); + for token in split_cmdline(cmdline) { + let Some(val) = token.strip_prefix(&v1_key_prefix) else { + continue; + }; + let (val_no_q, insecure) = if let Some(s) = val.strip_prefix('?') { + (s, true) + } else { + (val, false) + }; + let (desc, hex) = parse_digest_value(val_no_q) + .with_context(|| format!("parsing {KARG_COMPOSEFS_DIGEST}= value: {val}"))?; + if !desc.algorithm.is_compatible::() { + // Different algorithm (e.g. sha512 when we're sha256) — skip. + continue; + } + let digest = ObjectID::from_hex(hex).with_context(|| { + format!( + "parsing {KARG_COMPOSEFS_DIGEST}= hash: got {} hex chars, expected {} for {}", + hex.len(), + expected_hex_len, + ObjectID::ALGORITHM, + ) + })?; + return Ok(Some(match desc.version { + 1 => ComposefsCmdline::V1 { digest, insecure }, + _ => ComposefsCmdline::V2 { digest, insecure }, + })); + } + + // V2: composefs= (optional '?' prefix for insecure mode) + if let Some(val) = get_cmdline_value(cmdline, &format!("{KARG_V2}=")) { + let (hex, insecure) = if let Some(stripped) = val.strip_prefix('?') { + (stripped, true) + } else { + (val, false) + }; + let digest = ObjectID::from_hex(hex).with_context(|| { + format!( + "parsing {KARG_V2}= hash: got {} hex chars, expected {} for {}", + hex.len(), + expected_hex_len, + ObjectID::ALGORITHM, + ) + })?; + return Ok(Some(ComposefsCmdline::V2 { digest, insecure })); + } + + Ok(None) + } + + /// Renders this value as a kernel command line fragment. + /// + /// - [`ComposefsCmdline::V1`] (secure) → `"composefs.digest=v1--:"` + /// - [`ComposefsCmdline::V1`] (insecure) → `"composefs.digest=?v1--:"` + /// - [`ComposefsCmdline::V2`] (secure) → `"composefs="` + /// - [`ComposefsCmdline::V2`] (insecure) → `"composefs=?"` + pub fn to_cmdline_arg(&self) -> String { + let verity_suffix = ObjectID::ALGORITHM.verity_suffix(); + match self { + ComposefsCmdline::V1 { + digest, + insecure: false, + } => format!( + "{KARG_COMPOSEFS_DIGEST}=v1-{verity_suffix}:{}", + digest.to_hex() + ), + ComposefsCmdline::V1 { + digest, + insecure: true, + } => format!( + "{KARG_COMPOSEFS_DIGEST}=?v1-{verity_suffix}:{}", + digest.to_hex() + ), + ComposefsCmdline::V2 { + digest, + insecure: false, + } => { + format!("{KARG_V2}={}", digest.to_hex()) + } + ComposefsCmdline::V2 { + digest, + insecure: true, + } => { + format!("{KARG_V2}=?{}", digest.to_hex()) + } + } + } +} /// Perform kernel command line splitting. /// /// The way this works in the kernel is to split on whitespace with an extremely simple quoting /// mechanism: whitespace inside of double quotes is literal, but there is no escaping mechanism. /// That means that having a literal double quote in the cmdline is effectively impossible. -pub(crate) fn split_cmdline(cmdline: &str) -> impl Iterator { +pub fn split_cmdline(cmdline: &str) -> impl Iterator { let mut in_quotes = false; cmdline.split(move |c: char| { @@ -35,61 +266,414 @@ pub fn get_cmdline_value<'a>(cmdline: &'a str, prefix: &str) -> Option<&'a str> split_cmdline(cmdline).find_map(|item| item.strip_prefix(prefix)) } -/// Extracts and parses the composefs= parameter from a kernel command line. -/// -/// # Arguments -/// -/// * `cmdline` - The kernel command line string -/// -/// # Returns +/// Parsed format descriptor from a `composefs.digest=` value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DigestDescriptor { + /// The EROFS format version (`v1` or `v2`). + pub version: u32, + /// The fs-verity algorithm (hash + blocksize). + pub algorithm: Algorithm, +} + +/// Parse a `composefs.digest=` value like `v1-sha256-12:` or `v2-sha512-12:`. /// -/// A tuple of (hash, insecure_flag) where the hash is the composefs object ID -/// and insecure_flag indicates whether the '?' prefix was present (making verification optional) -pub fn get_cmdline_composefs( - cmdline: &str, -) -> Result<(ObjectID, bool)> { - let id = get_cmdline_value(cmdline, "composefs=").context("composefs= value not found")?; - let expected_hex_len = size_of::() * 2; - if let Some(stripped) = id.strip_prefix('?') { - Ok(( - ObjectID::from_hex(stripped).with_context(|| { - format!( - "parsing composefs= hash: got {} hex chars, expected {} for {}", - stripped.len(), - expected_hex_len, - ObjectID::ALGORITHM, - ) - })?, - true, - )) - } else { - Ok(( - ObjectID::from_hex(id).with_context(|| { - format!( - "parsing composefs= hash: got {} hex chars, expected {} for {}", - id.len(), - expected_hex_len, - ObjectID::ALGORITHM, - ) - })?, - false, - )) - } +/// Returns `(descriptor, hex_digest)`. Errors on malformed or unsupported +/// format descriptors (unknown version, bad hash name, unsupported blocksize). +pub fn parse_digest_value(s: &str) -> Result<(DigestDescriptor, &str)> { + // Split "v1-sha256-12:" into descriptor "v1-sha256-12" and hex. + let (descriptor, hex) = s + .split_once(':') + .with_context(|| format!("expected '--:', got: {s}"))?; + + // Split "v1-sha256-12" → version "v1", remainder "sha256-12". + let (version_str, hash_and_bs) = descriptor + .split_once('-') + .with_context(|| format!("expected 'v--', got: {descriptor}"))?; + + let version = match version_str { + "v1" => 1, + "v2" => 2, + _ => anyhow::bail!("unsupported format version '{version_str}'"), + }; + + // Reuse Algorithm's parser by prepending the expected "fsverity-" prefix. + let algorithm: Algorithm = format!("fsverity-{hash_and_bs}") + .parse() + .with_context(|| format!("parsing algorithm from '{hash_and_bs}'"))?; + + Ok((DigestDescriptor { version, algorithm }, hex)) } -/// Creates a composefs= kernel command line argument. +/// Creates a composefs kernel command line argument string. /// /// # Arguments /// /// * `id` - The composefs object ID as a hex string /// * `insecure` - If true, prepends '?' to make fs-verity verification optional +/// * `version` - Which EROFS format version karg to emit +/// * `algorithm` - The fs-verity algorithm (used to build the V1 value prefix) /// /// # Returns /// -/// A string like "composefs=abc123" or "composefs=?abc123" (if insecure) -pub fn make_cmdline_composefs(id: &str, insecure: bool) -> String { - match insecure { - true => format!("composefs=?{id}"), - false => format!("composefs={id}"), +/// A string like `"composefs.digest=v1-sha512-12:abc123"` (V1) or `"composefs=abc123"` (V2), +/// with optional `?` insecure marker for V1 (`composefs.digest=?v1-sha512-12:abc123`). +pub fn make_cmdline_composefs( + id: &str, + insecure: bool, + version: composefs::erofs::format::FormatVersion, + algorithm: composefs::fsverity::Algorithm, +) -> String { + use composefs::erofs::format::FormatVersion; + match version { + // V0 and V1 both use the C-compatible compact-inode layout; same karg key. + FormatVersion::V0 | FormatVersion::V1 => { + let fmt_desc = format!("v1-{}", algorithm.verity_suffix()); + if insecure { + format!("{KARG_COMPOSEFS_DIGEST}=?{fmt_desc}:{id}") + } else { + format!("{KARG_COMPOSEFS_DIGEST}={fmt_desc}:{id}") + } + } + FormatVersion::V2 => { + if insecure { + format!("{KARG_V2}=?{id}") + } else { + format!("{KARG_V2}={id}") + } + } + } +} + +#[cfg(test)] +mod tests { + use composefs::fsverity::{Algorithm, Sha256HashValue, Sha512HashValue}; + + use super::*; + + const SHA256_HEX: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + const SHA512_HEX: &str = "6f06b5e82420abec546d6e6d3ddd612c50cfa9b707c129345b7ec16f456b92fe\ + 35df68999b042e1a6a70dfe75f2fed8cf9f67afd0bf08d2374678d75e2f65a02"; + + #[test] + fn test_composefs_cmdline_v2_round_trip() { + let digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let karg = ComposefsCmdline::new_v2(digest.clone(), false); + assert_eq!(karg.to_cmdline_arg(), format!("composefs={SHA256_HEX}")); + + let parsed = ComposefsCmdline::::from_cmdline(&karg.to_cmdline_arg()) + .unwrap() + .unwrap(); + assert_eq!( + parsed, + ComposefsCmdline::V2 { + digest, + insecure: false + } + ); + } + + #[test] + fn test_composefs_cmdline_v2_insecure_round_trip() { + let digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let karg = ComposefsCmdline::new_v2(digest.clone(), true); + assert_eq!(karg.to_cmdline_arg(), format!("composefs=?{SHA256_HEX}")); + + let parsed = ComposefsCmdline::::from_cmdline(&karg.to_cmdline_arg()) + .unwrap() + .unwrap(); + assert_eq!( + parsed, + ComposefsCmdline::V2 { + digest, + insecure: true + } + ); + } + + #[test] + fn test_composefs_cmdline_v1_round_trip_sha256() { + let digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let karg = ComposefsCmdline::new_v1(digest.clone(), false); + assert_eq!( + karg.to_cmdline_arg(), + format!("composefs.digest=v1-sha256-12:{SHA256_HEX}") + ); + + let parsed = ComposefsCmdline::::from_cmdline(&karg.to_cmdline_arg()) + .unwrap() + .unwrap(); + assert_eq!( + parsed, + ComposefsCmdline::V1 { + digest, + insecure: false + } + ); + } + + #[test] + fn test_composefs_cmdline_v1_round_trip_sha512() { + let digest = Sha512HashValue::from_hex(SHA512_HEX).unwrap(); + let karg = ComposefsCmdline::new_v1(digest.clone(), false); + assert_eq!( + karg.to_cmdline_arg(), + format!("composefs.digest=v1-sha512-12:{SHA512_HEX}") + ); + + let parsed = ComposefsCmdline::::from_cmdline(&karg.to_cmdline_arg()) + .unwrap() + .unwrap(); + assert_eq!( + parsed, + ComposefsCmdline::V1 { + digest, + insecure: false + } + ); + } + + #[test] + fn test_composefs_cmdline_v1_insecure_round_trip() { + let digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let karg = ComposefsCmdline::new_v1(digest.clone(), true); + assert_eq!( + karg.to_cmdline_arg(), + format!("composefs.digest=?v1-sha256-12:{SHA256_HEX}") + ); + + let parsed = ComposefsCmdline::::from_cmdline(&karg.to_cmdline_arg()) + .unwrap() + .unwrap(); + assert_eq!( + parsed, + ComposefsCmdline::V1 { + digest, + insecure: true + } + ); + assert!(parsed.is_insecure()); + } + + #[test] + fn test_composefs_cmdline_v1_takes_priority_over_v2() { + // When both kargs are present, V1 (composefs.digest=) should win. + let hex_v1 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let hex_v2 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let cmdline = format!("composefs={hex_v2} composefs.digest=v1-sha256-12:{hex_v1}"); + + let parsed = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!( + matches!(&parsed, ComposefsCmdline::V1 { digest, .. } if digest.to_hex() == hex_v1), + "expected V1 variant with hex_v1, got {parsed:?}" + ); + } + + #[test] + fn test_composefs_cmdline_v1_cross_type_rejection() { + // A sha512 V1 karg should NOT be parsed by the sha256 variant (returns None). + let cmdline = format!("composefs.digest=v1-sha512-12:{SHA512_HEX}"); + let result = ComposefsCmdline::::from_cmdline(&cmdline).unwrap(); + assert!( + result.is_none(), + "sha256 parser should not match sha512 karg, got {result:?}" + ); + + // And vice versa: sha256 V1 karg should not be parsed by the sha512 variant. + let cmdline256 = format!("composefs.digest=v1-sha256-12:{SHA256_HEX}"); + let result512 = ComposefsCmdline::::from_cmdline(&cmdline256).unwrap(); + assert!( + result512.is_none(), + "sha512 parser should not match sha256 karg, got {result512:?}" + ); + } + + #[test] + fn test_composefs_cmdline_absent_returns_none() { + assert!( + ComposefsCmdline::::from_cmdline("quiet splash rw") + .unwrap() + .is_none() + ); + assert!( + ComposefsCmdline::::from_cmdline("") + .unwrap() + .is_none() + ); + } + + #[test] + fn test_composefs_cmdline_invalid_hex_errors() { + // Valid key present but digest is garbage. + let err = ComposefsCmdline::::from_cmdline( + "composefs.digest=v1-sha256-12:notahex", + ) + .unwrap_err(); + assert!(err.to_string().contains("composefs.digest=")); + + let err = + ComposefsCmdline::::from_cmdline("composefs=notahex").unwrap_err(); + assert!(err.to_string().contains("composefs=")); + } + + #[test] + fn test_composefs_cmdline_unsupported_blocksize_errors() { + // Right hash, wrong blocksize → error (not silently skipped) + let err = ComposefsCmdline::::from_cmdline(&format!( + "composefs.digest=v1-sha256-8:{SHA256_HEX}" + )) + .unwrap_err(); + // The root cause is AlgorithmParseError::UnsupportedBlockSize, wrapped by anyhow context. + let chain = format!("{err:#}"); + assert!( + chain.contains("unsupported"), + "expected 'unsupported' in error chain, got: {chain}" + ); + + // Right hash (sha512), wrong blocksize + let err = ComposefsCmdline::::from_cmdline(&format!( + "composefs.digest=v1-sha512-99:{SHA512_HEX}" + )) + .unwrap_err(); + let chain = format!("{err:#}"); + assert!( + chain.contains("unsupported"), + "expected 'unsupported' in error chain, got: {chain}" + ); + + // Unknown version → error + let err = ComposefsCmdline::::from_cmdline(&format!( + "composefs.digest=v3-sha256-12:{SHA256_HEX}" + )) + .unwrap_err(); + let chain = format!("{err:#}"); + assert!( + chain.contains("unsupported format version"), + "expected version error, got: {chain}" + ); + } + + #[test] + fn test_composefs_digest_v2_parsed_as_v2() { + // composefs.digest=v2-sha256-12: should parse as V2, same as composefs= + let cmdline = format!("composefs.digest=v2-sha256-12:{SHA256_HEX}"); + let parsed = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!( + matches!(parsed, ComposefsCmdline::V2 { .. }), + "expected V2 variant, got {parsed:?}" + ); + assert_eq!(parsed.digest().to_hex(), SHA256_HEX); + assert!(!parsed.is_insecure()); + + // Insecure variant + let cmdline = format!("composefs.digest=?v2-sha512-12:{SHA512_HEX}"); + let parsed = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!(matches!( + parsed, + ComposefsCmdline::V2 { insecure: true, .. } + )); + } + + #[test] + fn test_digest_accessor() { + let digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let v1 = ComposefsCmdline::new_v1(digest.clone(), false); + let v2 = ComposefsCmdline::new_v2(digest.clone(), false); + assert_eq!(v1.digest(), &digest); + assert_eq!(v2.digest(), &digest); + } + + #[test] + fn test_from_cmdline_v1() { + let cmdline = format!("root=UUID=abc composefs.digest=v1-sha256-12:{SHA256_HEX} rw"); + let result = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!(matches!(result, ComposefsCmdline::V1 { .. })); + assert_eq!(result.digest().to_hex(), SHA256_HEX); + assert!(!result.is_insecure()); + } + + #[test] + fn test_from_cmdline_v2_fallback() { + let cmdline = format!("root=UUID=abc composefs={SHA256_HEX} rw"); + let result = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!(matches!(result, ComposefsCmdline::V2 { .. })); + assert_eq!(result.digest().to_hex(), SHA256_HEX); + assert!(!result.is_insecure()); + } + + #[test] + fn test_from_cmdline_missing_returns_none() { + let result = ComposefsCmdline::::from_cmdline("root=UUID=abc rw").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_from_cmdline_insecure_prefix() { + let cmdline = format!("composefs=?{SHA256_HEX}"); + let result = ComposefsCmdline::::from_cmdline(&cmdline) + .unwrap() + .unwrap(); + assert!(result.is_insecure()); + assert_eq!(result.digest().to_hex(), SHA256_HEX); + } + + #[test] + fn test_make_cmdline_composefs_v1() { + use composefs::erofs::format::FormatVersion; + let result = + make_cmdline_composefs(SHA256_HEX, false, FormatVersion::V1, Algorithm::SHA256); + assert_eq!( + result, + format!("composefs.digest=v1-sha256-12:{SHA256_HEX}") + ); + } + + #[test] + fn test_make_cmdline_composefs_v1_sha512() { + use composefs::erofs::format::FormatVersion; + let result = + make_cmdline_composefs(SHA512_HEX, false, FormatVersion::V1, Algorithm::SHA512); + assert_eq!( + result, + format!("composefs.digest=v1-sha512-12:{SHA512_HEX}") + ); + } + + #[test] + fn test_validate_digest() { + let v1_digest = Sha256HashValue::from_hex(SHA256_HEX).unwrap(); + let other = Sha256HashValue::from_hex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + let karg = ComposefsCmdline::new_v2(v1_digest.clone(), false); + + // Digest present in a multi-element acceptable set → Ok with the match. + let acceptable = [&other, &v1_digest]; + let matched = karg.validate_digest(acceptable.iter().copied()).unwrap(); + assert_eq!(matched, &v1_digest); + + // Digest absent → Err mentioning "should be one of". + let err = karg.validate_digest(std::iter::once(&other)).unwrap_err(); + assert!( + err.to_string().contains("should be one of"), + "unexpected error message: {err}" + ); + } + + #[test] + fn test_make_cmdline_composefs_v2_insecure() { + use composefs::erofs::format::FormatVersion; + let result = make_cmdline_composefs(SHA256_HEX, true, FormatVersion::V2, Algorithm::SHA256); + assert_eq!(result, format!("composefs=?{SHA256_HEX}")); } } diff --git a/crates/composefs-boot/src/write_boot.rs b/crates/composefs-boot/src/write_boot.rs index fe0e2481..f9620ac7 100644 --- a/crates/composefs-boot/src/write_boot.rs +++ b/crates/composefs-boot/src/write_boot.rs @@ -16,7 +16,7 @@ use composefs::{fsverity::FsVerityHashValue, repository::Repository}; use crate::{ bootloader::{BootEntry, Type1Entry, Type2Entry}, - cmdline::get_cmdline_composefs, + cmdline::ComposefsCmdline, uki, }; @@ -27,16 +27,14 @@ use crate::{ /// * `t1` - The Type 1 entry to write /// * `bootdir` - Path to the boot directory /// * `boot_subdir` - Optional subdirectory to prepend to paths -/// * `root_id` - The composefs root object ID -/// * `insecure` - Whether to allow optional fs-verity verification +/// * `karg` - The composefs kernel argument (encodes format version, digest, and insecure flag) /// * `cmdline_extra` - Additional kernel command line arguments /// * `repo` - The composefs repository pub fn write_t1_simple( mut t1: Type1Entry, bootdir: &Path, boot_subdir: Option<&str>, - root_id: &ObjectID, - insecure: bool, + karg: &ComposefsCmdline, cmdline_extra: &[&str], repo: &Repository, ) -> Result<()> { @@ -47,8 +45,8 @@ pub fn write_t1_simple( bootdir.to_path_buf() }; - t1.entry - .adjust_cmdline(Some(&root_id.to_hex()), insecure, cmdline_extra); + let karg_str = karg.to_cmdline_arg(); + t1.entry.adjust_cmdline(Some(&karg_str), cmdline_extra); // Write the content before we write the loader entry for (filename, file) in &t1.files { @@ -70,18 +68,19 @@ pub fn write_t1_simple( /// Writes a Type 2 boot entry (UKI) to the boot directory. /// -/// Validates that the UKI's embedded composefs= parameter matches the expected root_id. +/// Validates that the UKI's embedded composefs karg (`composefs=` or `composefs.digest=`) +/// matches one of the expected `acceptable_digests`. /// /// # Arguments /// /// * `t2` - The Type 2 entry to write /// * `bootdir` - Path to the boot directory -/// * `root_id` - The expected composefs root object ID +/// * `acceptable_digests` - The composefs root object IDs the UKI may carry /// * `repo` - The composefs repository pub fn write_t2_simple( t2: Type2Entry, bootdir: &Path, - root_id: &ObjectID, + acceptable_digests: &[&ObjectID], repo: &Repository, ) -> Result<()> { let efi_linux = bootdir.join("EFI/Linux"); @@ -89,13 +88,15 @@ pub fn write_t2_simple( let filename = efi_linux.join(t2.file_path); let content = composefs::fs::read_file(&t2.file, repo)?; let cmdline = uki::get_cmdline(&content)?; - let (composefs, _) = get_cmdline_composefs::(cmdline) - .with_context(|| format!("parsing UKI .cmdline section: {cmdline:?}"))?; + let parsed = ComposefsCmdline::::from_cmdline(cmdline) + .with_context(|| format!("parsing UKI .cmdline section: {cmdline:?}"))? + .ok_or_else(|| { + anyhow::anyhow!( + "UKI .cmdline has no composefs karg (composefs= or composefs.digest=): {cmdline:?}" + ) + })?; - ensure!( - &composefs == root_id, - "The UKI has the wrong composefs= parameter (is '{composefs:?}', should be {root_id:?})" - ); + parsed.validate_digest(acceptable_digests.iter().copied())?; write(filename, content)?; Ok(()) } @@ -106,9 +107,8 @@ pub fn write_t2_simple( /// /// * repo - The composefs repository /// * entry - Boot entry variant to be written -/// * root_id - The content hash of the generated EROFS image id -/// * insecure - Make fs-verity validation optional in case the filesystem doesn't support -/// it, indicated by `composefs=?hash` cmdline argument +/// * karg - The composefs kernel argument (encodes format version, digest, and insecure +/// flag); used to build the `composefs=` or `composefs.digest=` cmdline argument /// * boot_partition - Path to the boot partition/directory /// * boot_subdir - If `Some(path)`, the path is prepended to `initrd` and `linux` keys in the BLS entry /// @@ -130,12 +130,10 @@ pub fn write_t2_simple( /// * entry_id - In case of a BLS entry, the name of file to be generated in `loader/entries` /// * cmdline_extra - Extra kernel command line arguments /// -#[allow(clippy::too_many_arguments)] pub fn write_boot_simple( repo: &Repository, entry: BootEntry, - root_id: &ObjectID, - insecure: bool, + karg: &ComposefsCmdline, boot_partition: &Path, boot_subdir: Option<&str>, entry_id: Option<&str>, @@ -146,37 +144,21 @@ pub fn write_boot_simple( if let Some(name) = entry_id { t1.relocate(boot_subdir, name); } - write_t1_simple( - t1, - boot_partition, - boot_subdir, - root_id, - insecure, - cmdline_extra, - repo, - )?; + write_t1_simple(t1, boot_partition, boot_subdir, karg, cmdline_extra, repo)?; } BootEntry::Type2(mut t2) => { if let Some(name) = entry_id { t2.rename(name); } ensure!(cmdline_extra.is_empty(), "Can't add --cmdline args to UKIs"); - write_t2_simple(t2, boot_partition, root_id, repo)?; + write_t2_simple(t2, boot_partition, &[karg.digest()], repo)?; } BootEntry::UsrLibModulesVmLinuz(entry) => { let mut t1 = entry.into_type1(entry_id)?; if let Some(name) = entry_id { t1.relocate(boot_subdir, name); } - write_t1_simple( - t1, - boot_partition, - boot_subdir, - root_id, - insecure, - cmdline_extra, - repo, - )?; + write_t1_simple(t1, boot_partition, boot_subdir, karg, cmdline_extra, repo)?; } }; diff --git a/crates/composefs-ctl/src/lib.rs b/crates/composefs-ctl/src/lib.rs index 548ae23c..bbad52c3 100644 --- a/crates/composefs-ctl/src/lib.rs +++ b/crates/composefs-ctl/src/lib.rs @@ -53,9 +53,11 @@ use composefs::progress::{ ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter, }; use composefs_boot::BootOps; +use composefs_boot::cmdline::ComposefsCmdline; #[cfg(feature = "oci")] use composefs_boot::write_boot; +use composefs::erofs::format::FormatVersion; #[cfg(feature = "oci")] use composefs::shared_internals::IO_BUF_CAPACITY; use composefs::{ @@ -599,6 +601,28 @@ enum Command { #[clap(flatten)] fs_opts: FsReadOptions, }, + /// Read rootfs located at a path and compute the composefs kernel argument string. + /// + /// Like compute-id but outputs the full kernel argument rather than the bare digest, + /// choosing the argument name based on the EROFS format version: + /// + /// V1: composefs.digest=v1-sha256-12: + /// V2: composefs= + /// + /// Use --erofs-version to select the format. + /// The boot transformation (SELinux relabeling, empty /boot and /sysroot) is + /// always applied — this command produces a karg for a sealed boot image. + /// + /// Example (in a Containerfile): + /// cfsctl --erofs-version 1 compute-karg /mnt/base > /etc/kernel/cmdline + #[clap(name = "compute-karg")] + ComputeKarg { + /// The path to the filesystem + path: PathBuf, + /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata) + #[clap(long)] + no_propagate_usr_to_root: bool, + }, /// Read rootfs located at a path and dump full content of the rootfs to a composefs dumpfile, /// writing to stdout. Does not store any file objects in the repository. CreateDumpfile { @@ -906,7 +930,9 @@ pub async fn run_app(args: App) -> Result<()> { if args.no_repo || matches!( args.cmd, - Command::ComputeId { .. } | Command::CreateDumpfile { .. } + Command::ComputeId { .. } + | Command::ComputeKarg { .. } + | Command::CreateDumpfile { .. } ) { // If a repo path is available and --no-repo wasn't passed, @@ -1200,12 +1226,37 @@ pub async fn run_cmd_without_repo(args: App) -> Res let version = erofs_version.unwrap_or_default(); let id = composefs::fsverity::compute_verity::( &composefs::erofs::writer::mkfs_erofs_versioned( - &mut composefs::erofs::writer::ValidatedFileSystem::new(fs)?, + &composefs::erofs::writer::ValidatedFileSystem::new(fs)?, version, ), ); println!("{}", id.to_hex()); } + Command::ComputeKarg { + path, + no_propagate_usr_to_root, + } => { + let fs_opts = FsReadOptions { + path, + bootable: true, + no_propagate_usr_to_root, + }; + let fs = load_filesystem_from_ondisk_fs::(&fs_opts, None).await?; + let version = erofs_version.unwrap_or_default(); + let id = composefs::fsverity::compute_verity::( + &composefs::erofs::writer::mkfs_erofs_versioned( + &composefs::erofs::writer::ValidatedFileSystem::new(fs)?, + version, + ), + ); + let karg = match version { + FormatVersion::V0 | FormatVersion::V1 => { + ComposefsCmdline::new_v1(id, args.insecure) + } + FormatVersion::V2 => ComposefsCmdline::new_v2(id, args.insecure), + }; + println!("{}", karg.to_cmdline_arg()); + } Command::CreateDumpfile { fs_opts } => { let fs = load_filesystem_from_ondisk_fs::(&fs_opts, None).await?; fs.print_dumpfile()?; @@ -1275,14 +1326,14 @@ where composefs_oci::oci_image::OciImage::open_ref(&repo, image)? }; let erofs_id = if bootable { - match img.boot_image_ref() { + match img.boot_image_ref(repo.erofs_version()) { Some(id) => id, None => anyhow::bail!( "No boot EROFS image linked — try pulling with --bootable" ), } } else { - match img.image_ref() { + match img.image_ref(repo.erofs_version()) { Some(id) => id, None => anyhow::bail!( "No composefs EROFS image linked — try re-pulling the image" @@ -1292,7 +1343,7 @@ where repo.mount_at(&erofs_id.to_hex(), mountpoint.as_str(), &mount_options)?; } OciCommand::ComputeId { config_opts } => { - let mut fs = load_filesystem_from_oci_image(&repo, config_opts)?; + let fs = load_filesystem_from_oci_image(&repo, config_opts)?; let id = fs.compute_image_id(repo.erofs_version()); println!("{}", id.to_hex()); } @@ -1447,7 +1498,25 @@ where config_verity.as_ref(), )?; let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; + let ids = fs.commit_images(&repo, None)?; + let fmt_config = repo.default_format_config(); + // Prefer V1 digest; fall back to V2. + let id = ids + .get(&FormatVersion::V1) + .or_else(|| ids.get(&FormatVersion::V2)) + .ok_or_else(|| anyhow::anyhow!("commit_images produced no images"))? + .clone(); + + let insecure = repo.is_insecure(); + let karg = if fmt_config.default == FormatVersion::V1 + && !fmt_config.extra.contains(&FormatVersion::V2) + { + // V1-only repo → composefs.digest=v1-...: (with optional ? for insecure) + ComposefsCmdline::new_v1(id, insecure) + } else { + // BOTH or V2-only repo → composefs= (with optional ? for insecure) + ComposefsCmdline::new_v2(id, insecure) + }; let Some(entry) = entries.into_iter().next() else { anyhow::bail!("No boot entries!"); @@ -1457,8 +1526,7 @@ where write_boot::write_boot_simple( &repo, entry, - &id, - repo.is_insecure(), + &karg, bootdir, None, entry_id.as_deref(), @@ -1471,7 +1539,7 @@ where .map(|p: &PathBuf| p.parent().unwrap()) .unwrap_or(Path::new("/sysroot")) .join("state/deploy") - .join(id.to_hex()); + .join(karg.digest().to_hex()); create_dir_all(state.join("var"))?; create_dir_all(state.join("etc/upper"))?; @@ -1502,13 +1570,17 @@ where fs_opts, ref image_name, } => { - let mut fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?; + let fs = load_filesystem_from_ondisk_fs(&fs_opts, Some(Arc::clone(&repo))).await?; let id = fs.commit_image(&repo, image_name.as_deref())?; println!("{}", id.to_id()); } - Command::ComputeId { .. } | Command::CreateDumpfile { .. } => { + Command::ComputeId { .. } + | Command::ComputeKarg { .. } + | Command::CreateDumpfile { .. } => { // Handled in run_app before opening the repo - unreachable!("compute-id and create-dumpfile are dispatched without a repo"); + unreachable!( + "compute-id, compute-karg, and create-dumpfile are dispatched without a repo" + ); } Command::Mount { name, diff --git a/crates/composefs-ctl/src/mkcomposefs.rs b/crates/composefs-ctl/src/mkcomposefs.rs index 72278c97..c49c299f 100644 --- a/crates/composefs-ctl/src/mkcomposefs.rs +++ b/crates/composefs-ctl/src/mkcomposefs.rs @@ -216,7 +216,7 @@ fn run_with_args(args: Args) -> Result<()> { apply_transformations(&mut fs, &args)?; // Generate EROFS image - let image = mkfs_erofs_versioned(&mut ValidatedFileSystem::new(fs)?, format_version); + let image = mkfs_erofs_versioned(&ValidatedFileSystem::new(fs)?, format_version); // Write image (skipped when only the digest is requested) if !args.print_digest_only { diff --git a/crates/composefs-ctl/src/varlink.rs b/crates/composefs-ctl/src/varlink.rs index dc003fcb..fedff358 100644 --- a/crates/composefs-ctl/src/varlink.rs +++ b/crates/composefs-ctl/src/varlink.rs @@ -500,9 +500,9 @@ fn run_oci_mount( })?; let erofs_id = if bootable { - img.boot_image_ref() + img.boot_image_ref(repo.erofs_version()) } else { - img.image_ref() + img.image_ref(repo.erofs_version()) } .ok_or_else(|| oci::OciError::InternalError { message: if bootable { @@ -1427,8 +1427,10 @@ pub mod oci { manifest, config, referrers, - composefs_erofs: img.image_ref().map(|id| id.to_hex()), - composefs_boot_erofs: img.boot_image_ref().map(|id| id.to_hex()), + composefs_erofs: img.image_ref(repo.erofs_version()).map(|id| id.to_hex()), + composefs_boot_erofs: img + .boot_image_ref(repo.erofs_version()) + .map(|id| id.to_hex()), }) } } diff --git a/crates/composefs-integration-tests/src/tests/cli.rs b/crates/composefs-integration-tests/src/tests/cli.rs index 08288162..33260c9a 100644 --- a/crates/composefs-integration-tests/src/tests/cli.rs +++ b/crates/composefs-integration-tests/src/tests/cli.rs @@ -12,10 +12,12 @@ use xshell::{Shell, cmd}; use crate::{cfsctl, create_test_rootfs, integration_test}; // Pinned composefs image ID for the deterministic OCI layout built by -// create_oci_layout() (single layer with usr/ dir + hello.txt + hello-link.txt hardlink, mtime=1234567890). -const OCI_LAYOUT_COMPOSEFS_ID: &str = "f7684c21050615c02cec250e0c7d4118fb0ff6340af5039e06f2f372a7614fff\ - 25dfd38cf7f28ad67d8b86e86371b7deafa9ae4bf543630322aee6196417de92"; - +// create_oci_layout() (single layer with usr/ dir + hello.txt, mtime=1234567890). +const OCI_LAYOUT_COMPOSEFS_ID: &str = "f7684c21050615c02cec250e0c7d4118fb0ff6340af5039e06f2f372a7614fff25dfd38cf7f28ad67\ + d8b86e86371b7deafa9ae4bf543630322aee6196417de92"; +// Pinned V1 composefs image ID for the same OCI layout (V1 writer: compact inodes, BFS). +const OCI_LAYOUT_COMPOSEFS_V1_ID: &str = "d06f1f6a73f62ed48e05ce9442ff045cdf2a9f5ba1ec623795de3d6177a253d9\ + 1076459795d94f55d8c3d71dab458bb3fc0071255a50c0a4896d86ffdc870b9a"; /// Create a fresh initialized insecure repository in a tempdir. /// /// Returns the tempdir (for lifetime) and the path to the repo. @@ -1877,3 +1879,248 @@ fn test_mkcomposefs_vs_c_mkcomposefs() -> Result<()> { Ok(()) } integration_test!(test_mkcomposefs_vs_c_mkcomposefs); + +/// Test that --erofs-version 1 and 2 produce different deterministic digests, +/// for both `compute-id` (no-repo) and `create-image` (repo-based). +fn test_erofs_versions() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + + // V1 digest via compute-id (no repo) + let id1 = cmd!( + sh, + "{cfsctl} --no-repo --erofs-version 1 compute-id --no-propagate-usr-to-root {rootfs}" + ) + .read()?; + + // V2 digest via compute-id (no repo) + let id2 = cmd!( + sh, + "{cfsctl} --no-repo --erofs-version 2 compute-id --no-propagate-usr-to-root {rootfs}" + ) + .read()?; + + // Default digest (should be V2) + let id_default = cmd!( + sh, + "{cfsctl} --no-repo compute-id --no-propagate-usr-to-root {rootfs}" + ) + .read()?; + + assert_ne!( + id1.trim(), + id2.trim(), + "V1 and V2 should produce different digests" + ); + assert_eq!(id2.trim(), id_default.trim(), "Default should be V2"); + + // Also verify via create-image in a real repo + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; + let repo = repo_dir.path(); + + let img_v1 = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} create-image {rootfs}" + ) + .read()?; + let img_v2 = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 2 --repo {repo} create-image {rootfs}" + ) + .read()?; + let img_default = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} create-image {rootfs}" + ) + .read()?; + + assert_ne!( + img_v1.trim(), + img_v2.trim(), + "create-image: V1 and V2 should produce different image IDs" + ); + assert_eq!( + img_v2.trim(), + img_default.trim(), + "create-image: default should match V2" + ); + + Ok(()) +} +integration_test!(test_erofs_versions); + +/// Verify that `create-image --erofs-version 1` is idempotent and differs from V2. +fn test_create_image_v1_idempotent() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; + let repo = repo_dir.path(); + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + + let v1_id_a = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} create-image {rootfs}" + ) + .read()?; + let v1_id_b = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} create-image {rootfs}" + ) + .read()?; + let v2_id = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} create-image {rootfs}" + ) + .read()?; + + assert_eq!( + v1_id_a.trim(), + v1_id_b.trim(), + "create-image V1 must be idempotent" + ); + assert_ne!( + v1_id_a.trim(), + v2_id.trim(), + "create-image V1 and V2 must produce different image IDs" + ); + + Ok(()) +} +integration_test!(test_create_image_v1_idempotent); + +/// Verify that a repository initialized with `--erofs-version 1` produces V1 images +/// by default, without needing `--erofs-version` on every subsequent command. +fn test_v1_repo_uses_v1_by_default() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + + // Init a V1-only repo using the init subcommand's --erofs flag + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + cmd!( + sh, + "{cfsctl} --repo {repo} init --insecure --erofs-version 1" + ) + .read()?; + + // Verify meta.json records v1_erofs in ro_compat (V1-only mode) + let meta_json = std::fs::read_to_string(repo.join("meta.json"))?; + assert!( + meta_json.contains("v1_erofs"), + "meta.json should contain v1_erofs in ro_compat for a V1-only repo, got: {meta_json}" + ); + + // create-image WITHOUT --erofs-version flag — should use the repo's default (V1) + let id_default = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} create-image {rootfs}" + ) + .read()?; + let id_default = id_default.trim(); + + // create-image WITH explicit --erofs-version 1 — should be identical + let id_explicit_v1 = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} create-image {rootfs}" + ) + .read()?; + let id_explicit_v1 = id_explicit_v1.trim(); + + assert_eq!( + id_default, id_explicit_v1, + "repo initialized as V1 must produce V1 images by default (no flag needed)" + ); + + // create-image with explicit --erofs-version 2 — should differ + let id_v2 = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 2 --repo {repo} create-image {rootfs}" + ) + .read()?; + let id_v2 = id_v2.trim(); + + assert_ne!( + id_default, id_v2, + "V1 repo default must not equal explicit V2 output" + ); + + Ok(()) +} +integration_test!(test_v1_repo_uses_v1_by_default); + +/// Verify `oci compute-id --erofs-version 1` is idempotent, differs from V2, +/// and matches the pinned V1 digest for the deterministic test OCI layout. +fn test_oci_pull_v1_digest_stability() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; + let repo = repo_dir.path(); + let fixture_dir = tempfile::tempdir()?; + let oci_layout = create_oci_layout(fixture_dir.path())?; + + // Pull the OCI layout + let pull_output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-v1-image" + ) + .read()?; + + // Extract config digest from pull output (e.g. "config sha256:abc...") + let config_digest = pull_output + .lines() + .find_map(|l| l.strip_prefix("config").map(|s| s.trim().to_string())) + .expect("config digest in pull output"); + let at_config_digest = format!("@{config_digest}"); + + // Compute V1 digest twice — must be identical (idempotency) + let v1_id_a = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} oci compute-id {at_config_digest}" + ) + .read()?; + let v1_id_b = cmd!( + sh, + "{cfsctl} --insecure --erofs-version 1 --repo {repo} oci compute-id {at_config_digest}" + ) + .read()?; + assert_eq!( + v1_id_a.trim(), + v1_id_b.trim(), + "V1 oci compute-id must be idempotent" + ); + + // V2 (default) must differ from V1 + let v2_id = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} oci compute-id {at_config_digest}" + ) + .read()?; + assert_ne!( + v1_id_a.trim(), + v2_id.trim(), + "V1 and V2 oci compute-id must produce different digests" + ); + + // V2 must still match the existing pinned constant + assert_eq!( + v2_id.trim(), + OCI_LAYOUT_COMPOSEFS_ID, + "V2 OCI layout composefs image ID changed" + ); + + // V1 must match the pinned V1 constant (stability across code changes) + assert_eq!( + v1_id_a.trim(), + OCI_LAYOUT_COMPOSEFS_V1_ID, + "V1 OCI layout composefs image ID changed — \ + the V1 EROFS writer produced different output for the same deterministic OCI image" + ); + + Ok(()) +} +integration_test!(test_oci_pull_v1_digest_stability); diff --git a/crates/composefs-integration-tests/src/tests/mod.rs b/crates/composefs-integration-tests/src/tests/mod.rs index 91a0da1a..a9b180db 100644 --- a/crates/composefs-integration-tests/src/tests/mod.rs +++ b/crates/composefs-integration-tests/src/tests/mod.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod cstor; pub mod digest_stability; +pub mod oci_compat; pub mod old_format; pub mod privileged; pub mod varlink; diff --git a/crates/composefs-integration-tests/src/tests/oci_compat.rs b/crates/composefs-integration-tests/src/tests/oci_compat.rs new file mode 100644 index 00000000..42a37cc3 --- /dev/null +++ b/crates/composefs-integration-tests/src/tests/oci_compat.rs @@ -0,0 +1,515 @@ +//! Real filesystem compatibility tests. +//! +//! These tests create realistic filesystem structures (similar to what you'd find +//! in container images) and verify bit-for-bit compatibility between the Rust +//! mkfs_erofs and C mkcomposefs implementations. +//! +//! Requirements: +//! - C mkcomposefs binary (/usr/bin/mkcomposefs or set C_MKCOMPOSEFS_PATH) +//! - cfsctl binary (built from this project; invoked as "mkcomposefs" via symlink) +//! +//! Install the C mkcomposefs with: `sudo apt install composefs` + +use std::fs; +use std::io::Write; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +use anyhow::{Context, Result, bail}; +use xshell::{Shell, cmd}; + +use crate::{cfsctl, integration_test}; + +/// Cached path to C mkcomposefs binary, computed once. +/// `None` means the binary is not available and compat tests should be skipped. +static C_MKCOMPOSEFS_PATH: OnceLock> = OnceLock::new(); + +/// Get the path to C mkcomposefs binary, or `None` if not available. +/// +/// Priority: +/// 1. C_MKCOMPOSEFS_PATH environment variable (if set; error if it doesn't exist) +/// 2. /usr/bin/mkcomposefs (system installation) +/// 3. None — caller should skip the test +fn c_mkcomposefs_path() -> Option<&'static PathBuf> { + C_MKCOMPOSEFS_PATH + .get_or_init(|| { + // Check env var first + if let Ok(path) = std::env::var("C_MKCOMPOSEFS_PATH") { + let path = PathBuf::from(path); + if path.exists() { + return Some(path); + } + panic!( + "C_MKCOMPOSEFS_PATH is set to '{}' but the file does not exist", + path.display() + ); + } + + // Check system location + let system_path = PathBuf::from("/usr/bin/mkcomposefs"); + if system_path.exists() { + return Some(system_path); + } + + None + }) + .as_ref() +} + +/// Cached symlink to cfsctl named "mkcomposefs" for multi-call dispatch. +static RUST_MKCOMPOSEFS_PATH: OnceLock> = OnceLock::new(); + +/// Get the path to the Rust mkcomposefs binary (a symlink to cfsctl). +/// +/// cfsctl is a multi-call binary that dispatches based on argv[0]. We create +/// a symlink named "mkcomposefs" pointing to cfsctl so that it runs in +/// mkcomposefs mode. +fn rust_mkcomposefs_path() -> Result { + let result = RUST_MKCOMPOSEFS_PATH.get_or_init(|| { + let cfsctl_path = cfsctl().map_err(|e| format!("{e:#}"))?; + + // Create a symlink in the same directory as cfsctl + let parent = cfsctl_path.parent().unwrap_or(std::path::Path::new(".")); + let symlink_path = parent.join("mkcomposefs"); + + // Remove any existing symlink/file (idempotent) + let _ = std::fs::remove_file(&symlink_path); + + std::os::unix::fs::symlink(&cfsctl_path, &symlink_path) + .map_err(|e| format!("Failed to create mkcomposefs symlink: {e}"))?; + + Ok(symlink_path) + }); + + match result { + Ok(path) => Ok(path.clone()), + Err(e) => bail!("{e}"), + } +} + +/// Compare Rust and C mkcomposefs output for a given dumpfile. +/// +/// Returns `Ok(true)` if the outputs are bit-for-bit identical, `Ok(false)` if +/// the C mkcomposefs binary is not available (test should be skipped). +fn compare_mkcomposefs_output(dumpfile: &str) -> Result { + let Some(c_mkcomposefs) = c_mkcomposefs_path() else { + eprintln!( + "Skipping: C mkcomposefs not found (install composefs or set C_MKCOMPOSEFS_PATH)" + ); + return Ok(false); + }; + let rust_mkcomposefs = rust_mkcomposefs_path()?; + + // Run Rust mkcomposefs + let mut rust_cmd = Command::new(&rust_mkcomposefs) + .args(["--from-file", "-", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn Rust mkcomposefs")?; + + { + let stdin = rust_cmd.stdin.as_mut().unwrap(); + stdin + .write_all(dumpfile.as_bytes()) + .context("Failed to write to Rust mkcomposefs stdin")?; + } + + let rust_output = rust_cmd + .wait_with_output() + .context("Failed to wait for Rust mkcomposefs")?; + + if !rust_output.status.success() { + bail!( + "Rust mkcomposefs failed: {}", + String::from_utf8_lossy(&rust_output.stderr) + ); + } + + // Run C mkcomposefs + let mut c_cmd = Command::new(c_mkcomposefs) + .args(["--min-version=0", "--from-file", "-", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn C mkcomposefs")?; + + { + let stdin = c_cmd.stdin.as_mut().unwrap(); + stdin + .write_all(dumpfile.as_bytes()) + .context("Failed to write to C mkcomposefs stdin")?; + } + + let c_output = c_cmd + .wait_with_output() + .context("Failed to wait for C mkcomposefs")?; + + if !c_output.status.success() { + bail!( + "C mkcomposefs failed: {}", + String::from_utf8_lossy(&c_output.stderr) + ); + } + + // Compare outputs + let rust_image = rust_output.stdout; + let c_image = c_output.stdout; + + if rust_image != c_image { + // Find first difference for debugging + let first_diff = rust_image + .iter() + .zip(c_image.iter()) + .position(|(a, b)| a != b) + .unwrap_or(std::cmp::min(rust_image.len(), c_image.len())); + + bail!( + "Images differ! Rust: {} bytes, C: {} bytes. First difference at byte {}.\n\ + Dumpfile has {} lines.", + rust_image.len(), + c_image.len(), + first_diff, + dumpfile.lines().count() + ); + } + + Ok(true) +} + +/// Create a realistic test filesystem with container-like structure. +/// +/// This creates a directory structure similar to what you'd find in a container: +/// - Nested directories (/usr/bin, /usr/lib, /etc, /var/log) +/// - Symlinks (absolute and relative) +/// - Large files (for external content) +/// - Various file permissions +fn create_container_like_rootfs(root: &std::path::Path) -> Result<()> { + // Create directory structure + fs::create_dir_all(root.join("usr/bin"))?; + fs::create_dir_all(root.join("usr/lib/x86_64-linux-gnu"))?; + fs::create_dir_all(root.join("usr/share/doc/test"))?; + fs::create_dir_all(root.join("etc/default"))?; + fs::create_dir_all(root.join("var/log"))?; + fs::create_dir_all(root.join("var/cache"))?; + fs::create_dir_all(root.join("tmp"))?; + fs::create_dir_all(root.join("home/user"))?; + + // Create various files + fs::write(root.join("usr/bin/hello"), "#!/bin/sh\necho Hello\n")?; + fs::write(root.join("usr/bin/world"), "#!/bin/sh\necho World\n")?; + + // Create a large file (128KB) that won't be inlined + let large_content = "x".repeat(128 * 1024); + fs::write(root.join("usr/lib/libtest.so"), &large_content)?; + + // Create files in nested directories + fs::write( + root.join("usr/lib/x86_64-linux-gnu/libc.so.6"), + &large_content, + )?; + fs::write( + root.join("usr/share/doc/test/README"), + "Test documentation\n", + )?; + fs::write( + root.join("usr/share/doc/test/LICENSE"), + "MIT License\n...\n", + )?; + + // Create config files + fs::write(root.join("etc/hostname"), "container\n")?; + fs::write(root.join("etc/passwd"), "root:x:0:0:root:/root:/bin/sh\n")?; + fs::write(root.join("etc/default/locale"), "LANG=en_US.UTF-8\n")?; + + // Create log files + fs::write(root.join("var/log/messages"), "")?; + fs::write(root.join("var/log/auth.log"), "")?; + + // Create symlinks + symlink("/usr/bin/hello", root.join("usr/bin/hi"))?; + symlink("../lib/libtest.so", root.join("usr/bin/libtest-link"))?; + symlink("/etc/hostname", root.join("etc/HOSTNAME"))?; + + // Create home directory files + fs::write(root.join("home/user/.bashrc"), "# Bash config\n")?; + fs::write(root.join("home/user/.profile"), "# Profile\n")?; + + Ok(()) +} + +/// Create a dumpfile from a directory using cfsctl. +fn create_dumpfile_from_dir(sh: &Shell, root: &std::path::Path) -> Result { + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Use cfsctl to create a dumpfile from the directory. + // Use --no-propagate-usr-to-root because test directories may not have /usr. + let dumpfile = cmd!( + sh, + "{cfsctl} --insecure --hash sha256 --repo {repo} create-dumpfile --no-propagate-usr-to-root {root}" + ) + .read() + .with_context(|| format!("Failed to create dumpfile from {:?}", root))?; + + Ok(dumpfile) +} + +/// Test bit-for-bit compatibility with a container-like filesystem. +/// +/// Creates a realistic filesystem structure and verifies that both +/// Rust and C mkcomposefs produce identical output. +fn test_container_rootfs_compat() -> Result<()> { + let sh = Shell::new()?; + let rootfs_dir = tempfile::tempdir()?; + let rootfs = rootfs_dir.path().join("rootfs"); + fs::create_dir_all(&rootfs)?; + + // Create the test filesystem + create_container_like_rootfs(&rootfs)?; + + // Generate dumpfile + let dumpfile = create_dumpfile_from_dir(&sh, &rootfs)?; + + eprintln!( + "Container rootfs dumpfile: {} lines, {} bytes", + dumpfile.lines().count(), + dumpfile.len() + ); + + if compare_mkcomposefs_output(&dumpfile)? { + eprintln!("Container rootfs: bit-for-bit match!"); + } + Ok(()) +} +integration_test!(test_container_rootfs_compat); + +/// Test with deeply nested directory structure. +/// +/// This exercises the BFS inode ordering with many levels of nesting. +fn test_deep_nesting_compat() -> Result<()> { + let sh = Shell::new()?; + let rootfs_dir = tempfile::tempdir()?; + let rootfs = rootfs_dir.path().join("rootfs"); + + // Create deeply nested structure: /a/b/c/d/e/f/g/h/file + let deep_path = rootfs.join("a/b/c/d/e/f/g/h"); + fs::create_dir_all(&deep_path)?; + fs::write(deep_path.join("file"), "deep content")?; + + // Add files at various levels + fs::write(rootfs.join("a/file1"), "level 1")?; + fs::write(rootfs.join("a/b/file2"), "level 2")?; + fs::write(rootfs.join("a/b/c/file3"), "level 3")?; + fs::write(rootfs.join("a/b/c/d/file4"), "level 4")?; + + // Add parallel directory trees + fs::create_dir_all(rootfs.join("x/y/z"))?; + fs::write(rootfs.join("x/file"), "x tree")?; + fs::write(rootfs.join("x/y/file"), "y tree")?; + fs::write(rootfs.join("x/y/z/file"), "z tree")?; + + let dumpfile = create_dumpfile_from_dir(&sh, &rootfs)?; + + eprintln!( + "Deep nesting dumpfile: {} lines, {} bytes", + dumpfile.lines().count(), + dumpfile.len() + ); + + if compare_mkcomposefs_output(&dumpfile)? { + eprintln!("Deep nesting: bit-for-bit match!"); + } + Ok(()) +} +integration_test!(test_deep_nesting_compat); + +/// Test with many files in a single directory. +/// +/// This exercises the directory entry handling with many entries. +fn test_wide_directory_compat() -> Result<()> { + let sh = Shell::new()?; + let rootfs_dir = tempfile::tempdir()?; + let rootfs = rootfs_dir.path().join("rootfs"); + fs::create_dir_all(&rootfs)?; + + // Create many files in a single directory + for i in 0..100 { + fs::write(rootfs.join(format!("file{i:03}")), format!("content {i}"))?; + } + + // Add some subdirectories with files too + for i in 0..10 { + let subdir = rootfs.join(format!("dir{i:02}")); + fs::create_dir_all(&subdir)?; + for j in 0..5 { + fs::write(subdir.join(format!("file{j}")), format!("content {i}.{j}"))?; + } + } + + let dumpfile = create_dumpfile_from_dir(&sh, &rootfs)?; + + eprintln!( + "Wide directory dumpfile: {} lines, {} bytes", + dumpfile.lines().count(), + dumpfile.len() + ); + + if compare_mkcomposefs_output(&dumpfile)? { + eprintln!("Wide directory: bit-for-bit match!"); + } + Ok(()) +} +integration_test!(test_wide_directory_compat); + +/// Test with symlinks (both absolute and relative). +fn test_symlinks_compat() -> Result<()> { + let sh = Shell::new()?; + let rootfs_dir = tempfile::tempdir()?; + let rootfs = rootfs_dir.path().join("rootfs"); + + fs::create_dir_all(rootfs.join("usr/bin"))?; + fs::create_dir_all(rootfs.join("usr/lib"))?; + fs::create_dir_all(rootfs.join("bin"))?; + fs::create_dir_all(rootfs.join("lib"))?; + + // Create target files + fs::write(rootfs.join("usr/bin/real"), "real binary")?; + fs::write(rootfs.join("usr/lib/libreal.so"), "real library")?; + + // Absolute symlinks + symlink("/usr/bin/real", rootfs.join("bin/link1"))?; + symlink("/usr/lib/libreal.so", rootfs.join("lib/liblink.so"))?; + + // Relative symlinks + symlink("../usr/bin/real", rootfs.join("bin/link2"))?; + symlink("../lib/libreal.so", rootfs.join("usr/bin/liblink"))?; + + // Symlink to symlink + symlink("link1", rootfs.join("bin/link3"))?; + + // Long symlink target + let long_target = "/very/long/path/that/goes/deep/into/the/filesystem/structure"; + symlink(long_target, rootfs.join("bin/longlink"))?; + + let dumpfile = create_dumpfile_from_dir(&sh, &rootfs)?; + + eprintln!( + "Symlinks dumpfile: {} lines, {} bytes", + dumpfile.lines().count(), + dumpfile.len() + ); + + if compare_mkcomposefs_output(&dumpfile)? { + eprintln!("Symlinks: bit-for-bit match!"); + } + Ok(()) +} +integration_test!(test_symlinks_compat); + +/// Test that `--digest-store` writes files in the C-compatible flat `XX/DIGEST` layout. +/// +/// Creates a rootfs with a file large enough to be stored externally (> 4096 bytes), +/// runs Rust mkcomposefs with `--digest-store`, and verifies that the store contains +/// objects at `XX/DIGEST` paths (not under an `objects/` subdirectory). +fn test_digest_store_flat_layout() -> Result<()> { + let rust_mkcomposefs = rust_mkcomposefs_path()?; + + let td = tempfile::tempdir()?; + let rootfs = td.path().join("rootfs"); + let store = td.path().join("store"); + let image = td.path().join("image.img"); + + fs::create_dir_all(&rootfs)?; + + // Write a file large enough to be stored as an external object (> 4096 bytes). + let large_content = "x".repeat(8192); + fs::write(rootfs.join("bigfile"), &large_content)?; + // Also a small inline file. + fs::write(rootfs.join("small"), "tiny")?; + + // Run Rust mkcomposefs with --digest-store on the directory directly. + let output = Command::new(&rust_mkcomposefs) + .args([ + "--digest-store", + store.to_str().unwrap(), + rootfs.to_str().unwrap(), + image.to_str().unwrap(), + ]) + .output() + .context("Failed to run Rust mkcomposefs")?; + + if !output.status.success() { + bail!( + "Rust mkcomposefs failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // The store should exist and contain objects in flat XX/DIGEST layout. + assert!(store.exists(), "Digest store directory should exist"); + + // Walk the store and collect object paths. + let mut found_objects = Vec::new(); + for entry in fs::read_dir(&store)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + // Should be exactly 2-char hex directories (e.g. "a3") — no "objects/" subdirectory. + if entry.file_type()?.is_dir() { + assert_eq!( + name.len(), + 2, + "Store subdirectory should be 2-char hex, got {:?}", + name + ); + assert!( + name.chars().all(|c| c.is_ascii_hexdigit()), + "Store subdirectory should be hex, got {:?}", + name + ); + for obj in fs::read_dir(entry.path())? { + let obj = obj?; + found_objects.push(format!("{}/{}", name, obj.file_name().to_string_lossy())); + } + } + } + + // The large file should have been stored externally. + assert!( + !found_objects.is_empty(), + "At least one object should be stored (large file should be external)" + ); + + eprintln!( + "Digest store flat layout: found {} object(s)", + found_objects.len() + ); + for path in &found_objects { + eprintln!(" {path}"); + // Verify it's a valid 2-char prefix / 62-char hex path. + let parts: Vec<&str> = path.splitn(2, '/').collect(); + assert_eq!(parts.len(), 2, "Expected XX/DIGEST format"); + assert_eq!(parts[0].len(), 2); + assert_eq!( + parts[1].len(), + 62, + "Expected 62-char remainder of sha256 hex" + ); + assert!( + parts + .iter() + .flat_map(|s| s.chars()) + .all(|c| c.is_ascii_hexdigit()), + "All characters should be hex" + ); + } + + Ok(()) +} +integration_test!(test_digest_store_flat_layout); diff --git a/crates/composefs-oci/src/boot.rs b/crates/composefs-oci/src/boot.rs index ad34eeeb..1ff8bddb 100644 --- a/crates/composefs-oci/src/boot.rs +++ b/crates/composefs-oci/src/boot.rs @@ -33,7 +33,7 @@ pub fn boot_image( repo: &Repository, manifest_digest: &OciDigest, ) -> Result> { - crate::composefs_boot_erofs_for_manifest(repo, manifest_digest, None) + crate::composefs_boot_erofs_for_manifest(repo, manifest_digest, None, repo.erofs_version()) } /// Remove the bootable EROFS image reference (idempotent). @@ -49,7 +49,7 @@ pub fn remove_boot_image( anyhow::bail!("not a container image"); } - if img.boot_image_ref().is_none() { + if img.boot_image_ref(repo.erofs_version()).is_none() { return Ok(()); } @@ -60,8 +60,10 @@ pub fn remove_boot_image( repo, &config_json, img.layer_refs().clone(), - img.image_ref(), - None, // no boot image + img.image_ref_v2(), // preserve existing V2 image ref + img.image_ref_v1(), // preserve existing V1 image ref + None, // no boot image (V2) + None, // no boot image (V1) )?; let manifest_json = img.read_manifest_json(repo)?; @@ -86,7 +88,6 @@ pub fn remove_boot_image( #[cfg(all(test, feature = "boot"))] mod test { use super::*; - use composefs::erofs::format::FormatVersion; use composefs::fsverity::Sha256HashValue; use composefs::test::TestRepo; use composefs_boot::bootloader::get_boot_resources; @@ -119,11 +120,13 @@ mod test { // Open by tag since manifest was rewritten let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); - assert_eq!(oci.boot_image_ref(), Some(&image_verity)); + assert_eq!( + oci.boot_image_ref(repo.erofs_version()), + Some(&image_verity) + ); - let mut plain_image = - crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); - let plain_verity = plain_image.compute_image_id(FormatVersion::V2); + let plain_image = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let plain_verity = plain_image.compute_image_id(repo.erofs_version()); assert_ne!( image_verity, plain_verity, "boot-transformed image should differ from non-transformed image" @@ -198,7 +201,10 @@ mod test { assert_eq!(gc.streams_pruned, 0); let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); - assert_eq!(oci.boot_image_ref(), Some(&image_verity)); + assert_eq!( + oci.boot_image_ref(repo.erofs_version()), + Some(&image_verity) + ); } #[tokio::test] @@ -238,7 +244,7 @@ mod test { let oci = OciImage::open_ref(repo, "myapp:v1").unwrap(); assert!(oci.is_container_image()); - assert!(oci.boot_image_ref().is_none()); + assert!(oci.boot_image_ref(repo.erofs_version()).is_none()); } /// Boot EROFS differs from plain EROFS and contains the expected boot entries. @@ -269,8 +275,7 @@ mod test { "tag={tag}: expected Type2 entry" ); - let mut plain_fs = - crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); + let plain_fs = crate::image::create_filesystem(repo, &img.config_digest, None).unwrap(); let plain_verity = plain_fs.commit_image(repo, None).unwrap(); assert_ne!(boot_verity, plain_verity, "tag={tag}"); } diff --git a/crates/composefs-oci/src/cstor.rs b/crates/composefs-oci/src/cstor.rs index 18eb759a..33004f5f 100644 --- a/crates/composefs-oci/src/cstor.rs +++ b/crates/composefs-oci/src/cstor.rs @@ -443,8 +443,12 @@ fn finalize_import( // Generate the composefs EROFS image and tag the manifest. // Skip if the image already has an EROFS ref (idempotent re-import). - let existing_erofs = - crate::composefs_erofs_for_manifest(repo, &manifest_digest, Some(&manifest_verity))?; + let existing_erofs = crate::composefs_erofs_for_manifest( + repo, + &manifest_digest, + Some(&manifest_verity), + repo.erofs_version(), + )?; if existing_erofs.is_none() { let erofs = crate::ensure_oci_composefs_erofs( repo, diff --git a/crates/composefs-oci/src/delta.rs b/crates/composefs-oci/src/delta.rs index eda3fe2f..361b9b09 100644 --- a/crates/composefs-oci/src/delta.rs +++ b/crates/composefs-oci/src/delta.rs @@ -704,8 +704,15 @@ pub(crate) async fn import_delta( .map(|(diff_id, verity)| (diff_id.to_string().into_boxed_str(), verity.clone())) .collect(); - let (_, config_verity) = - crate::write_config_raw(repo, &parsed.target_config_raw, refs_map, None, None)?; + let (_, config_verity) = crate::write_config_raw( + repo, + &parsed.target_config_raw, + refs_map, + None, + None, + None, + None, + )?; // Write manifest splitstream (using raw bytes to preserve original JSON) let layer_verities: Vec<_> = layer_refs diff --git a/crates/composefs-oci/src/image.rs b/crates/composefs-oci/src/image.rs index 44c05189..910b8c9e 100644 --- a/crates/composefs-oci/src/image.rs +++ b/crates/composefs-oci/src/image.rs @@ -334,7 +334,11 @@ mod test { /// with `get_entry()`, and verify every entry type round-trips correctly. #[tokio::test] async fn test_build_baseimage_roundtrip() -> Result<()> { - use composefs::{INLINE_CONTENT_MAX_V0, repository::Repository, test::tempdir}; + use composefs::{ + INLINE_CONTENT_MAX_V0, + repository::{Repository, RepositoryConfig}, + test::tempdir, + }; use rustix::fs::CWD; use std::ffi::OsStr; use std::sync::Arc; diff --git a/crates/composefs-oci/src/lib.rs b/crates/composefs-oci/src/lib.rs index 8b3f35f6..807ae966 100644 --- a/crates/composefs-oci/src/lib.rs +++ b/crates/composefs-oci/src/lib.rs @@ -53,6 +53,7 @@ use containers_image_proxy::oci_spec::image::{Descriptor, MediaType}; use sha2::{Digest, Sha256}; use composefs::{ + erofs::format::{FormatEpoch, FormatVersion}, fsverity::FsVerityHashValue, repository::{ObjectStoreMethod, Repository}, splitstream::SplitStreamStats, @@ -60,12 +61,18 @@ use composefs::{ use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, TAR_LAYER_CONTENT_TYPE}; -/// Named ref key for the EROFS image derived from this OCI config. +/// Named ref key for the V2 EROFS image derived from this OCI config. pub const IMAGE_REF_KEY: &str = "composefs.image"; -/// Named ref key for the boot EROFS image derived from this OCI config. +/// Named ref key for the V1 EROFS image derived from this OCI config. +pub const IMAGE_REF_KEY_V1: &str = "composefs.image.v1"; + +/// Named ref key for the V2 boot EROFS image derived from this OCI config. pub const BOOT_IMAGE_REF_KEY: &str = "composefs.image.boot"; +/// Named ref key for the V1 boot EROFS image derived from this OCI config. +pub const BOOT_IMAGE_REF_KEY_V1: &str = "composefs.image.boot.v1"; + // Re-export key types for convenience #[cfg(feature = "boot")] pub use boot::generate_boot_image; @@ -307,10 +314,14 @@ pub struct OpenConfig { pub config: ImageConfiguration, /// Map from layer diff_id to its fs-verity object ID. pub layer_refs: HashMap, ObjectID>, - /// The EROFS image ObjectID linked to this config, if any. + /// The V2 EROFS image ObjectID linked to this config, if any. pub image_ref: Option, - /// The boot EROFS image ObjectID linked to this config, if any. + /// The V1 EROFS image ObjectID linked to this config, if any. + pub image_ref_v1: Option, + /// The V2 boot EROFS image ObjectID linked to this config, if any. pub boot_image_ref: Option, + /// The V1 boot EROFS image ObjectID linked to this config, if any. + pub boot_image_ref_v1: Option, } impl std::fmt::Debug for OpenConfig { @@ -318,7 +329,9 @@ impl std::fmt::Debug for OpenConfig { f.debug_struct("OpenConfig") .field("layer_refs", &self.layer_refs) .field("image_ref", &self.image_ref) + .field("image_ref_v1", &self.image_ref_v1) .field("boot_image_ref", &self.boot_image_ref) + .field("boot_image_ref_v1", &self.boot_image_ref_v1) .finish_non_exhaustive() } } @@ -506,24 +519,32 @@ pub fn open_config( } let image_ref = named_refs.remove(IMAGE_REF_KEY); + let image_ref_v1 = named_refs.remove(IMAGE_REF_KEY_V1); let boot_image_ref = named_refs.remove(BOOT_IMAGE_REF_KEY); + let boot_image_ref_v1 = named_refs.remove(BOOT_IMAGE_REF_KEY_V1); let config = ImageConfiguration::from_reader(&data[..])?; Ok(OpenConfig { config, layer_refs: named_refs, image_ref, + image_ref_v1, boot_image_ref, + boot_image_ref_v1, }) } -/// Returns the composefs EROFS ObjectID referenced by the given OCI config, if any. +/// Returns the composefs EROFS ObjectID for `version` referenced by the given OCI config, if any. pub fn composefs_erofs_for_config( repo: &Repository, config_digest: &OciDigest, verity: Option<&ObjectID>, + version: FormatVersion, ) -> Result> { let oc = open_config(repo, config_digest, verity)?; - Ok(oc.image_ref) + Ok(match version.epoch() { + FormatEpoch::Epoch1 => oc.image_ref_v1, + FormatEpoch::Epoch2 => oc.image_ref, + }) } /// Returns the composefs EROFS ObjectID for an OCI image identified by manifest, if any. @@ -534,19 +555,24 @@ pub fn composefs_erofs_for_manifest( repo: &Repository, manifest_digest: &OciDigest, manifest_verity: Option<&ObjectID>, + version: FormatVersion, ) -> Result> { let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; - Ok(img.image_ref().cloned()) + Ok(img.image_ref(version).cloned()) } -/// Returns the boot EROFS ObjectID from the given OCI config, if any. +/// Returns the boot EROFS ObjectID for `version` from the given OCI config, if any. pub fn composefs_boot_erofs_for_config( repo: &Repository, config_digest: &OciDigest, verity: Option<&ObjectID>, + version: FormatVersion, ) -> Result> { let oc = open_config(repo, config_digest, verity)?; - Ok(oc.boot_image_ref) + Ok(match version.epoch() { + FormatEpoch::Epoch1 => oc.boot_image_ref_v1, + FormatEpoch::Epoch2 => oc.boot_image_ref, + }) } /// Returns the boot EROFS ObjectID for an OCI image identified by manifest, if any. @@ -554,9 +580,10 @@ pub fn composefs_boot_erofs_for_manifest( repo: &Repository, manifest_digest: &OciDigest, manifest_verity: Option<&ObjectID>, + version: FormatVersion, ) -> Result> { let img = oci_image::OciImage::open(repo, manifest_digest, manifest_verity)?; - Ok(img.boot_image_ref().cloned()) + Ok(img.boot_image_ref(version).cloned()) } /// Result of a repository upgrade operation. @@ -598,7 +625,7 @@ pub fn upgrade_repo( continue; } - if img.image_ref().is_some() { + if img.image_ref(repo.erofs_version()).is_some() { tracing::debug!("image {tag} already has EROFS ref, skipping"); result.already_current += 1; continue; @@ -631,11 +658,12 @@ pub fn upgrade_repo( /// fsverity can be independently enabled on it. /// /// If `image` is provided, a named ref with key [`IMAGE_REF_KEY`] is added to the -/// splitstream pointing to the EROFS image's ObjectID. This ensures the GC walk keeps -/// the EROFS image alive as long as the config is reachable. +/// splitstream pointing to the V2 EROFS image's ObjectID. If `image_v1` is provided, +/// a named ref with key [`IMAGE_REF_KEY_V1`] is added pointing to the V1 image. +/// These named refs ensure the GC walk keeps images alive as long as the config is reachable. /// -/// If `boot_image` is provided, a named ref with key [`BOOT_IMAGE_REF_KEY`] is added -/// pointing to the boot EROFS image's ObjectID. +/// If `boot_image` / `boot_image_v1` are provided, named refs with keys +/// [`BOOT_IMAGE_REF_KEY`] / [`BOOT_IMAGE_REF_KEY_V1`] are added. /// /// Returns a tuple of (sha256 content hash, fs-verity hash value). pub fn write_config( @@ -643,10 +671,20 @@ pub fn write_config( config: &ImageConfiguration, refs: HashMap, ObjectID>, image: Option<&ObjectID>, + image_v1: Option<&ObjectID>, boot_image: Option<&ObjectID>, + boot_image_v1: Option<&ObjectID>, ) -> Result> { let json = config.to_string()?; - write_config_raw(repo, json.as_bytes(), refs, image, boot_image) + write_config_raw( + repo, + json.as_bytes(), + refs, + image, + image_v1, + boot_image, + boot_image_v1, + ) } /// Rewrites a container configuration in the repository from raw JSON bytes. @@ -660,7 +698,9 @@ pub fn write_config_raw( config_json: &[u8], refs: HashMap, ObjectID>, image: Option<&ObjectID>, + image_v1: Option<&ObjectID>, boot_image: Option<&ObjectID>, + boot_image_v1: Option<&ObjectID>, ) -> Result> { let config_digest = hash_sha256(config_json); let mut stream = repo.create_stream(OCI_CONFIG_CONTENT_TYPE)?; @@ -679,9 +719,15 @@ pub fn write_config_raw( if let Some(image_id) = image { stream.add_named_stream_ref(IMAGE_REF_KEY, image_id); } + if let Some(image_id_v1) = image_v1 { + stream.add_named_stream_ref(IMAGE_REF_KEY_V1, image_id_v1); + } if let Some(boot_id) = boot_image { stream.add_named_stream_ref(BOOT_IMAGE_REF_KEY, boot_id); } + if let Some(boot_id_v1) = boot_image_v1 { + stream.add_named_stream_ref(BOOT_IMAGE_REF_KEY_V1, boot_id_v1); + } stream.write_external(config_json)?; let id = repo.write_stream(stream, &config_identifier(&config_digest), None)?; Ok((config_digest, id)) @@ -717,24 +763,37 @@ fn ensure_oci_composefs_erofs( } // Build the composefs filesystem from all layers - let mut fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?; + let fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?; + + // Commit as EROFS image(s) for all formats in the repository's default set. + // No named ref — the GC link comes from the config splitstream ref. + let mut erofs_map = fs.commit_images(repo, None)?; + let erofs_id_v2 = erofs_map.remove(&FormatVersion::V2); + let erofs_id_v1 = erofs_map.remove(&FormatVersion::V1); - // Commit as EROFS image (no name — the GC link comes from the config ref) - let erofs_id = fs.commit_image(repo, None)?; + let erofs_id = match repo.erofs_version().epoch() { + FormatEpoch::Epoch1 => erofs_id_v1.clone(), + FormatEpoch::Epoch2 => erofs_id_v2.clone(), + } + .ok_or_else(|| { + anyhow::anyhow!("commit_images did not produce the repository's default EROFS format") + })?; // Read original config JSON to preserve its exact bytes (and thus its // sha256 digest) when rewriting the splitstream with the new EROFS ref. let config_json = img.read_config_json(repo)?; - // Rewrite config with the EROFS image ref, using layer refs from the + // Rewrite config with the EROFS image ref(s), using layer refs from the // OciImage (which already stripped the old image ref if any). - // Preserve any existing boot image ref. + // Preserve any existing boot image refs (using explicit V2/V1 accessors). let (_config_digest, new_config_verity) = write_config_raw( repo, &config_json, img.layer_refs().clone(), - Some(&erofs_id), - img.boot_image_ref(), + erofs_id_v2.as_ref(), + erofs_id_v1.as_ref(), + img.boot_image_ref_v2(), + img.boot_image_ref_v1(), )?; // Read original manifest JSON for rewriting @@ -781,19 +840,32 @@ fn ensure_oci_composefs_erofs_boot( let mut fs = image::create_filesystem(repo, img.config_digest(), Some(img.config_verity()))?; fs.transform_for_boot(repo)?; - // Commit as EROFS image - let boot_erofs_id = fs.commit_image(repo, None)?; + // Commit as EROFS image(s) for all formats in the repository's default set. + let mut boot_erofs_map = fs.commit_images(repo, None)?; + let boot_erofs_id_v2 = boot_erofs_map.remove(&FormatVersion::V2); + let boot_erofs_id_v1 = boot_erofs_map.remove(&FormatVersion::V1); + + let boot_erofs_id = match repo.erofs_version().epoch() { + FormatEpoch::Epoch1 => boot_erofs_id_v1.clone(), + FormatEpoch::Epoch2 => boot_erofs_id_v2.clone(), + } + .ok_or_else(|| { + anyhow::anyhow!("commit_images did not produce the repository's default boot EROFS format") + })?; // Read original config JSON to preserve its exact bytes let config_json = img.read_config_json(repo)?; - // Rewrite config with the boot EROFS image ref, preserving the existing image ref + // Rewrite config with the boot EROFS image ref(s), preserving the existing image refs + // (using explicit V2/V1 accessors to avoid the V1-preferred fallback). let (_config_digest, new_config_verity) = write_config_raw( repo, &config_json, img.layer_refs().clone(), - img.image_ref(), - Some(&boot_erofs_id), + img.image_ref_v2(), + img.image_ref_v1(), + boot_erofs_id_v2.as_ref(), + boot_erofs_id_v1.as_ref(), )?; // Read original manifest JSON for rewriting @@ -989,7 +1061,7 @@ mod test { refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY); let (config_digest, config_verity) = - write_config(&repo, &config, refs.clone(), None, None).unwrap(); + write_config(&repo, &config, refs.clone(), None, None, None, None).unwrap(); assert!(config_digest.as_ref().starts_with("sha256:")); @@ -1025,7 +1097,7 @@ mod test { .unwrap(); let (config_digest, config_verity) = - write_config(&repo, &config, HashMap::new(), None, None).unwrap(); + write_config(&repo, &config, HashMap::new(), None, None, None, None).unwrap(); // Re-open the splitstream and check that the config JSON is stored // as an external object reference (not inline). This is important @@ -1111,8 +1183,8 @@ mod test { .map(|(d, v)| (d.as_str().into(), v.clone())) .collect(); - let (_digest1, verity1) = write_config(&repo, &config, refs1, None, None)?; - let (_digest2, verity2) = write_config(&repo, &config, refs2, None, None)?; + let (_digest1, verity1) = write_config(&repo, &config, refs1, None, None, None, None)?; + let (_digest2, verity2) = write_config(&repo, &config, refs2, None, None, None, None)?; // The verity must be identical regardless of HashMap iteration order assert_eq!( @@ -1150,7 +1222,7 @@ mod test { .unwrap(); let (config_digest, _config_verity) = - write_config(&repo, &config, HashMap::new(), None, None).unwrap(); + write_config(&repo, &config, HashMap::new(), None, None, None, None).unwrap(); let bad_digest: OciDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" @@ -1190,8 +1262,16 @@ mod test { let fake_erofs_id: Sha256HashValue = composefs::fsverity::compute_verity(b"fake-erofs-image"); - let (config_digest, config_verity) = - write_config(&repo, &config, refs.clone(), Some(&fake_erofs_id), None).unwrap(); + let (config_digest, config_verity) = write_config( + &repo, + &config, + refs.clone(), + Some(&fake_erofs_id), + None, + None, + None, + ) + .unwrap(); // Reopen and verify let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); @@ -1206,10 +1286,19 @@ mod test { Some(fake_erofs_id.clone()), "image ref should be returned" ); + assert!( + oc.image_ref_v1.is_none(), + "expected no V1 image ref for a V2-only config" + ); // Also verify via the convenience function - let img_ref = - composefs_erofs_for_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + let img_ref = composefs_erofs_for_config( + &repo, + &config_digest, + Some(&config_verity), + repo.erofs_version(), + ) + .unwrap(); assert_eq!(img_ref, Some(fake_erofs_id)); } @@ -1236,15 +1325,20 @@ mod test { refs.insert("sha256:abc123def456".into(), Sha256HashValue::EMPTY); let (config_digest, config_verity) = - write_config(&repo, &config, refs.clone(), None, None).unwrap(); + write_config(&repo, &config, refs.clone(), None, None, None, None).unwrap(); let oc = open_config(&repo, &config_digest, Some(&config_verity)).unwrap(); assert_eq!(oc.layer_refs.len(), 1); assert!(oc.layer_refs.contains_key("sha256:abc123def456")); assert!(oc.image_ref.is_none(), "no image ref should be present"); - let img_ref = - composefs_erofs_for_config(&repo, &config_digest, Some(&config_verity)).unwrap(); + let img_ref = composefs_erofs_for_config( + &repo, + &config_digest, + Some(&config_verity), + repo.erofs_version(), + ) + .unwrap(); assert!(img_ref.is_none()); } @@ -1281,19 +1375,27 @@ mod test { "manifest should have been rewritten with new config verity" ); assert_eq!( - oci.image_ref(), + oci.image_ref(repo.erofs_version()), Some(&erofs_id), "config should reference the EROFS image" ); // Also verify via the convenience functions - let erofs_ref = - composefs_erofs_for_config(repo, oci.config_digest(), Some(oci.config_verity())) - .unwrap(); + let erofs_ref = composefs_erofs_for_config( + repo, + oci.config_digest(), + Some(oci.config_verity()), + repo.erofs_version(), + ) + .unwrap(); assert_eq!(erofs_ref, Some(erofs_id.clone())); - let erofs_ref2 = - composefs_erofs_for_manifest(repo, &img.manifest_digest, Some(oci.manifest_verity())) - .unwrap(); + let erofs_ref2 = composefs_erofs_for_manifest( + repo, + &img.manifest_digest, + Some(oci.manifest_verity()), + repo.erofs_version(), + ) + .unwrap(); assert_eq!(erofs_ref2, Some(erofs_id.clone())); // Verify the EROFS content by round-tripping through erofs_to_filesystem @@ -1306,6 +1408,106 @@ mod test { similar_asserts::assert_eq!(dump, EXPECTED_BASE_IMAGE_DUMPFILE); } + /// Verify that a dual-format (V1+V2) repository populates both V1 and V2 + /// named refs in the config splitstream and that both image objects exist. + #[tokio::test] + async fn test_dual_format_both_image_refs() { + use composefs::erofs::format::{FormatConfig, FormatVersion}; + + // Create a dual-format repo (insecure, SHA-256): V1 primary + V2 extra. + let dir = tempdir(); + let repo_path = dir.path().join("repo"); + let mut both_config = RepositoryConfig::default().set_insecure(); + both_config.erofs_formats = FormatConfig { + default: FormatVersion::V1, + extra: [FormatVersion::V2].into(), + }; + let (repo_inner, _) = Repository::init_path(CWD, &repo_path, both_config) + .expect("initializing dual-format test repo"); + let repo = std::sync::Arc::new(repo_inner); + + assert_eq!( + repo.default_format_config(), + FormatConfig { + default: FormatVersion::V1, + extra: [FormatVersion::V2].into(), + } + ); + + // Pull a base image and generate EROFS. + let img = test_util::create_base_image(&repo, Some("dual:v1")).await; + let primary_id = ensure_oci_composefs_erofs( + &repo, + &img.manifest_digest, + Some(&img.manifest_verity), + Some("dual:v1"), + ) + .unwrap() + .expect("container image should produce EROFS"); + + // Re-open the rewritten config. + let oci = oci_image::OciImage::open_ref(&repo, "dual:v1").unwrap(); + let oc = open_config(&repo, oci.config_digest(), Some(oci.config_verity())).unwrap(); + + // Both V1 and V2 refs must be populated. + let id_v1 = oc + .image_ref_v1 + .as_ref() + .expect("V1 image ref should be set for dual-format repo"); + let id_v2 = oc + .image_ref + .as_ref() + .expect("V2 image ref should be set for dual-format repo"); + + // The two digests must differ (V1 and V2 produce different wire formats). + assert_ne!( + id_v1, id_v2, + "V1 and V2 EROFS images must have different digests" + ); + + // primary returned by ensure_oci_composefs_erofs is V1 (formats.iter() yields V1 first). + assert_eq!(&primary_id, id_v1, "primary ID should be the V1 digest"); + + // composefs_erofs_for_config returns repo default (V1 for dual-format repos). + let via_fn = composefs_erofs_for_config( + &repo, + oci.config_digest(), + Some(oci.config_verity()), + repo.erofs_version(), + ) + .unwrap(); + assert_eq!( + via_fn.as_ref(), + Some(id_v1), + "composefs_erofs_for_config should return repo default (V1)" + ); + + // OciImage::image_ref() returns repo default (V1 for dual-format repos). + assert_eq!(oci.image_ref(repo.erofs_version()), Some(id_v1)); + assert_eq!(oci.image_ref_v2(), Some(id_v2)); + + // Both image objects must actually exist in the repository. + assert!( + repo.open_image(&id_v1.to_hex()).is_ok(), + "V1 EROFS image should exist in repo" + ); + assert!( + repo.open_image(&id_v2.to_hex()).is_ok(), + "V2 EROFS image should exist in repo" + ); + + // Verify that commit_images with the dual-format repo wrote V1 and V2 in the map. + let fs = image::create_filesystem(&repo, oci.config_digest(), Some(oci.config_verity())) + .unwrap(); + let map = fs + .commit_images(&repo, None) + .expect("commit_images with dual-format config should succeed"); + assert!(map.contains_key(&FormatVersion::V1), "map must contain V1"); + assert!(map.contains_key(&FormatVersion::V2), "map must contain V2"); + assert_eq!(map[&FormatVersion::V1], *id_v1); + assert_eq!(map[&FormatVersion::V2], *id_v2); + } + #[tokio::test] async fn test_ensure_oci_composefs_erofs_gc() { use composefs::test::TestRepo; @@ -1422,6 +1624,8 @@ mod test { oci_before.layer_refs().clone(), None, None, + None, + None, ) .unwrap(); let new_config_digest = hash_sha256(&noncanonical_json); @@ -1482,7 +1686,7 @@ mod test { &new_config_digest, "config digest must be preserved after EROFS rewrite" ); - assert_eq!(oci_after.image_ref(), Some(&erofs_id)); + assert_eq!(oci_after.image_ref(repo.erofs_version()), Some(&erofs_id)); let stored_json = oci_after.read_config_json(repo).unwrap(); assert_eq!( @@ -1872,7 +2076,7 @@ mod test { // still open the image and read back the EROFS ref through them. let oci_after = oci_image::OciImage::open_ref(repo, "old:v1").unwrap(); assert_eq!( - oci_after.image_ref(), + oci_after.image_ref(repo.erofs_version()), Some(&erofs_id), "old-format rewritten config should reference the EROFS image" ); @@ -1907,7 +2111,7 @@ mod test { // Verify image_ref is None (no EROFS yet) let oci_before = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap(); assert!( - oci_before.image_ref().is_none(), + oci_before.image_ref(repo.erofs_version()).is_none(), "pre-EROFS pull should have no image ref" ); @@ -1925,7 +2129,7 @@ mod test { // Verify the OciImage now has image_ref let oci_after = oci_image::OciImage::open_ref(repo, "upgrade:v1").unwrap(); assert_eq!( - oci_after.image_ref(), + oci_after.image_ref(repo.erofs_version()), Some(&erofs_id), "config should reference the EROFS image after upgrade" ); @@ -1980,12 +2184,12 @@ mod test { // Verify neither image has an EROFS ref yet let oci1 = oci_image::OciImage::open_ref(repo, "app:v1").unwrap(); assert!( - oci1.image_ref().is_none(), + oci1.image_ref(repo.erofs_version()).is_none(), "app:v1 should have no EROFS ref before upgrade" ); let oci2 = oci_image::OciImage::open_ref(repo, "os:v1").unwrap(); assert!( - oci2.image_ref().is_none(), + oci2.image_ref(repo.erofs_version()).is_none(), "os:v1 should have no EROFS ref before upgrade" ); @@ -1998,7 +2202,7 @@ mod test { // Verify both images now have EROFS refs let oci1_after = oci_image::OciImage::open_ref(repo, "app:v1").unwrap(); let erofs1 = oci1_after - .image_ref() + .image_ref(repo.erofs_version()) .expect("app:v1 should have EROFS ref after upgrade"); assert!( repo.open_image(&erofs1.to_hex()).is_ok(), @@ -2006,7 +2210,7 @@ mod test { ); let oci2_after = oci_image::OciImage::open_ref(repo, "os:v1").unwrap(); let erofs2 = oci2_after - .image_ref() + .image_ref(repo.erofs_version()) .expect("os:v1 should have EROFS ref after upgrade"); assert!( repo.open_image(&erofs2.to_hex()).is_ok(), diff --git a/crates/composefs-oci/src/oci_image.rs b/crates/composefs-oci/src/oci_image.rs index 3876cd0c..d1875e2a 100644 --- a/crates/composefs-oci/src/oci_image.rs +++ b/crates/composefs-oci/src/oci_image.rs @@ -48,7 +48,11 @@ use rustix::fs::{AtFlags, Dir, Mode, OFlags, openat, readlinkat, unlinkat}; use rustix::io::Errno; use serde::Serialize; -use composefs::{fsverity::FsVerityHashValue, repository::Repository}; +use composefs::{ + erofs::format::{FormatEpoch, FormatVersion}, + fsverity::FsVerityHashValue, + repository::Repository, +}; use crate::ContentAndVerity; use crate::layer::is_tar_media_type; @@ -123,10 +127,14 @@ pub struct OciImage { config: Option, /// Map from layer diff_id to its fs-verity object ID layer_refs: HashMap, ObjectID>, - /// The EROFS image ObjectID linked to this config, if any + /// The V2 EROFS image ObjectID linked to this config, if any image_ref: Option, - /// The boot EROFS image ObjectID linked to this config, if any + /// The V1 EROFS image ObjectID linked to this config, if any + image_ref_v1: Option, + /// The V2 boot EROFS image ObjectID linked to this config, if any boot_image_ref: Option, + /// The V1 boot EROFS image ObjectID linked to this config, if any + boot_image_ref_v1: Option, /// The fs-verity ID of the manifest splitstream manifest_verity: ObjectID, } @@ -195,9 +203,11 @@ impl OciImage { } }; - // Strip the EROFS image ref from layer_refs (it's not a layer) + // Strip the EROFS image refs from layer_refs (they're not layers) let image_ref = layer_refs.remove(crate::IMAGE_REF_KEY); + let image_ref_v1 = layer_refs.remove(crate::IMAGE_REF_KEY_V1); let boot_image_ref = layer_refs.remove(crate::BOOT_IMAGE_REF_KEY); + let boot_image_ref_v1 = layer_refs.remove(crate::BOOT_IMAGE_REF_KEY_V1); let manifest_verity = if let Some(v) = verity { v.clone() @@ -220,7 +230,9 @@ impl OciImage { config, layer_refs, image_ref, + image_ref_v1, boot_image_ref, + boot_image_ref_v1, manifest_verity, }) } @@ -271,16 +283,49 @@ impl OciImage { &self.layer_refs } - /// Returns the EROFS image ObjectID linked to this config, if any. - pub fn image_ref(&self) -> Option<&ObjectID> { + /// Returns the EROFS image ObjectID for `version`, if present. + /// + /// Maps `version` to its on-disk storage slot via [`FormatVersion::epoch`]: + /// epoch1 (V0/V1) resolves the V1 ref; epoch2 (V2) resolves the V2 ref. + /// No fallback — returns `None` if that specific format was not generated. + pub fn image_ref(&self, version: FormatVersion) -> Option<&ObjectID> { + match version.epoch() { + FormatEpoch::Epoch1 => self.image_ref_v1.as_ref(), + FormatEpoch::Epoch2 => self.image_ref.as_ref(), + } + } + + /// Returns the V2 EROFS image ObjectID linked to this config, if any. + pub fn image_ref_v2(&self) -> Option<&ObjectID> { self.image_ref.as_ref() } - /// Returns the boot EROFS image ObjectID linked to this config, if any. - pub fn boot_image_ref(&self) -> Option<&ObjectID> { + /// Returns the V1 EROFS image ObjectID linked to this config, if any. + pub fn image_ref_v1(&self) -> Option<&ObjectID> { + self.image_ref_v1.as_ref() + } + + /// Returns the boot EROFS image ObjectID for `version`, if present. + /// + /// Maps `version` to its on-disk storage slot via [`FormatVersion::epoch`]. + /// No fallback — returns `None` if that specific format was not generated. + pub fn boot_image_ref(&self, version: FormatVersion) -> Option<&ObjectID> { + match version.epoch() { + FormatEpoch::Epoch1 => self.boot_image_ref_v1.as_ref(), + FormatEpoch::Epoch2 => self.boot_image_ref.as_ref(), + } + } + + /// Returns the V2 boot EROFS image ObjectID linked to this config, if any. + pub fn boot_image_ref_v2(&self) -> Option<&ObjectID> { self.boot_image_ref.as_ref() } + /// Returns the V1 boot EROFS image ObjectID linked to this config, if any. + pub fn boot_image_ref_v1(&self) -> Option<&ObjectID> { + self.boot_image_ref_v1.as_ref() + } + /// Returns the image architecture (empty string for artifacts). pub fn architecture(&self) -> String { self.config @@ -411,6 +456,40 @@ impl OciImage { )?; Ok(data) } + + /// Returns the full inspect output as a JSON value. + /// + /// This includes the manifest, config, and referrers in a single JSON object. + /// The manifest and config are included as their original JSON structure. + pub fn inspect_json(&self, repo: &Repository) -> Result { + let manifest_json = self.read_manifest_json(repo)?; + let config_json = self.read_config_json(repo)?; + let referrers = list_referrers(repo, &self.manifest_digest)?; + + let manifest_value: serde_json::Value = serde_json::from_slice(&manifest_json)?; + let config_value: serde_json::Value = serde_json::from_slice(&config_json)?; + + let referrers_value: Vec = referrers + .iter() + .map(|(digest, _verity)| serde_json::json!({ "digest": digest })) + .collect(); + + let mut result = serde_json::json!({ + "manifest": manifest_value, + "config": config_value, + "referrers": referrers_value, + }); + + if let Some(erofs_id) = self.image_ref(repo.erofs_version()) { + result["composefs_erofs"] = serde_json::json!(erofs_id.to_hex()); + } + + if let Some(boot_id) = self.boot_image_ref(repo.erofs_version()) { + result["composefs_boot_erofs"] = serde_json::json!(boot_id.to_hex()); + } + + Ok(result) + } } // ============================================================================= diff --git a/crates/composefs-setup-root/src/main.rs b/crates/composefs-setup-root/src/main.rs index cab37cb0..689aa605 100644 --- a/crates/composefs-setup-root/src/main.rs +++ b/crates/composefs-setup-root/src/main.rs @@ -14,7 +14,7 @@ use std::{ use anyhow::{Context, Result}; use clap::Parser; -use hex::FromHexError; + use rustix::{ fs::{CWD, Mode, OFlags, major, minor, mkdirat, openat, stat, symlink}, io::Errno, @@ -26,12 +26,12 @@ use rustix::{ use serde::Deserialize; use composefs::{ - fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}, + fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue}, mount::{FsHandle, mount_at}, mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount}, - repository::Repository, + repository::{ImageNotFound, Repository}, }; -use composefs_boot::cmdline::get_cmdline_composefs; +use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2, parse_digest_value, split_cmdline}; // Config file #[derive(Clone, Copy, Debug, Deserialize)] @@ -183,8 +183,15 @@ fn open_root_fs(path: &Path) -> Result { Ok(rootfs) } -fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Result { - match name.len() { +/// Try to mount a composefs image, returning `Ok(None)` if the image does not +/// exist in the repository. All other errors (permission, verity mismatch, +/// corrupt image, …) are propagated. +fn mount_composefs_image_if_exists( + sysroot: &OwnedFd, + name: &str, + insecure: bool, +) -> Result> { + let result = match name.len() { 128 => { let mut repo = Repository::::open_path(sysroot, "composefs")?; if insecure { @@ -192,7 +199,7 @@ fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Resul } else { repo.require_verity()?; } - repo.mount(name).context("Failed to mount composefs image") + repo.mount(name) } 64 => { let mut repo = Repository::::open_path(sysroot, "composefs")?; @@ -201,9 +208,14 @@ fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Resul } else { repo.require_verity()?; } - repo.mount(name).context("Failed to mount composefs image") + repo.mount(name) } _ => anyhow::bail!("Invalid composefs digest length: {}", name.len()), + }; + match result { + Ok(fd) => Ok(Some(fd)), + Err(e) if e.downcast_ref::().is_some() => Ok(None), + Err(e) => Err(e).context("Failed to mount composefs image"), } } @@ -246,19 +258,73 @@ fn gpt_workaround() -> Result<()> { Ok(()) } -// Try parse cmdline with sha512 digest address first, if failed with invalid length, parse again with legacy sha256 digest address -fn parse_image_address(cmdline: &str) -> Result<(String, bool)> { - match get_cmdline_composefs::(cmdline) { - Ok((id, insecure)) => Ok((id.to_hex(), insecure)), - Err(e) => { - if let Some(FromHexError::InvalidStringLength) = e.downcast_ref::() { - let (id, insecure) = get_cmdline_composefs::(cmdline)?; - Ok((id.to_hex(), insecure)) - } else { - Err(e) +/// Strips the optional insecure `?` prefix from a karg value. +/// +/// Returns `(hex, insecure)` where `hex` is the raw digest string and +/// `insecure` indicates whether the `?` prefix was present. +fn strip_insecure(val: &str) -> (&str, bool) { + if let Some(stripped) = val.strip_prefix('?') { + (stripped, true) + } else { + (val, false) + } +} + +/// Parses all composefs kargs from the kernel command line, in order. +/// +/// Scans cmdline tokens left-to-right, collecting every matching composefs +/// karg of any known type: +/// - `composefs.digest=v1-sha512-12:` (V1, sha512) +/// - `composefs.digest=v1-sha256-12:` (V1, sha256) +/// - `composefs=` (V2 legacy, length-disambiguated) +/// +/// The `?` insecure marker may appear directly after `=` for V1, e.g. +/// `composefs.digest=?v1-sha256-12:`. +/// +/// Returns all matches preserving cmdline order. The caller is expected to +/// try mounting each in sequence and use the first image that actually exists +/// in the repository. +fn parse_composefs_kargs(cmdline: &str) -> Result> { + let v1_key_prefix = format!("{KARG_COMPOSEFS_DIGEST}="); + let v2_prefix = format!("{KARG_V2}="); + + let mut results = Vec::new(); + for token in split_cmdline(cmdline) { + if let Some(val) = token.strip_prefix(&v1_key_prefix) { + let (val_no_q, insecure) = strip_insecure(val); + let (desc, hex) = parse_digest_value(val_no_q) + .with_context(|| format!("parsing {KARG_COMPOSEFS_DIGEST}= value: {val}"))?; + // Validate the hex digest against the parsed algorithm. + match desc.algorithm { + Algorithm::Sha512 { .. } => { + Sha512HashValue::from_hex(hex).with_context(|| { + format!("parsing {KARG_COMPOSEFS_DIGEST}= sha512 digest") + })?; + } + Algorithm::Sha256 { .. } => { + Sha256HashValue::from_hex(hex).with_context(|| { + format!("parsing {KARG_COMPOSEFS_DIGEST}= sha256 digest") + })?; + } + } + results.push((hex.to_string(), insecure)); + } else if let Some(val) = token.strip_prefix(&v2_prefix) { + let (hex, insecure) = strip_insecure(val); + match hex.len() { + 128 => { + Sha512HashValue::from_hex(hex) + .with_context(|| "parsing composefs= sha512 digest".to_string())?; + } + 64 => { + Sha256HashValue::from_hex(hex) + .with_context(|| "parsing composefs= sha256 digest".to_string())?; + } + _ => anyhow::bail!("invalid composefs= digest length: {}", hex.len()), } + results.push((hex.to_string(), insecure)); } } + Ok(results) } fn setup_root(args: Args) -> Result<()> { @@ -276,11 +342,32 @@ fn setup_root(args: Args) -> Result<()> { None => &std::fs::read_to_string("/proc/cmdline")?, }; - let (image_addr, insecure) = parse_image_address(cmdline)?; + let kargs = parse_composefs_kargs(cmdline)?; + if kargs.is_empty() { + anyhow::bail!("no composefs karg found in kernel cmdline"); + } - let new_root = match args.root_fs { - Some(path) => open_root_fs(&path).context("Failed to clone specified root fs")?, - None => mount_composefs_image(&sysroot, &image_addr, insecure)?, + let (new_root, image_addr) = match args.root_fs { + Some(path) => { + let root = open_root_fs(&path).context("Failed to clone specified root fs")?; + let addr = kargs[0].0.clone(); + (root, addr) + } + None => { + // Try each karg in cmdline order; use the first image that exists. + let mut mounted = None; + for (addr, insecure) in &kargs { + if let Some(root) = mount_composefs_image_if_exists(&sysroot, addr, *insecure)? { + mounted = Some((root, addr.clone())); + break; + } + eprintln!("composefs: image {addr} not found, trying next karg"); + } + mounted.with_context(|| { + let tried: Vec<_> = kargs.iter().map(|(a, _)| a.as_str()).collect(); + format!("no composefs image found (tried: {})", tried.join(", ")) + })? + } }; // we need to clone this before the next step to make sure we get the old one @@ -327,27 +414,116 @@ mod test { use super::*; #[test] - fn test_parse() { - let failing = ["", "foo", "composefs", "composefs=foo"]; - for case in failing { - assert!(parse_image_address(case).is_err()) + fn test_parse_no_kargs() { + // No composefs token at all → empty vec + let empty_cases = ["", "foo", "composefs", "root=UUID=abc quiet"]; + for case in empty_cases { + let kargs = parse_composefs_kargs(case).unwrap(); + assert!(kargs.is_empty(), "expected no kargs for {case:?}"); } + } + + #[test] + fn test_parse_invalid_digest_errors() { + // A composefs= token with a bad value is an error, not silently ignored + assert!(parse_composefs_kargs("composefs=foo").is_err()); + assert!(parse_composefs_kargs("composefs.digest=v1-sha256-12:notahex").is_err()); + } + + #[test] + fn test_parse_single_kargs() { + // Legacy V2: composefs= let digest_legacy = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; - let cmdline_legacy = &format!("composefs={digest_legacy}"); - let (digest_cmdline_legacy, _) = - get_cmdline_composefs::(cmdline_legacy).unwrap(); - similar_asserts::assert_eq!( - digest_cmdline_legacy, - Sha256HashValue::from_hex(digest_legacy).unwrap() - ); - let (parsed_addr_legacy, _) = parse_image_address(cmdline_legacy).unwrap(); - assert_eq!(digest_legacy, parsed_addr_legacy); + let kargs = parse_composefs_kargs(&format!("composefs={digest_legacy}")).unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (digest_legacy.to_string(), false)); + // Legacy V2: composefs= let digest = "6f06b5e82420abec546d6e6d3ddd612c50cfa9b707c129345b7ec16f456b92fe35df68999b042e1a6a70dfe75f2fed8cf9f67afd0bf08d2374678d75e2f65a02"; - let cmdline = &format!("composefs={digest}"); - let (digest_cmdline, _) = get_cmdline_composefs::(cmdline).unwrap(); - similar_asserts::assert_eq!(digest_cmdline, Sha512HashValue::from_hex(digest).unwrap()); - let (parsed_addr, _) = parse_image_address(cmdline).unwrap(); - assert_eq!(digest, parsed_addr); + let kargs = parse_composefs_kargs(&format!("composefs={digest}")).unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (digest.to_string(), false)); + + // V1: composefs.digest=v1-sha256-12: + let kargs = + parse_composefs_kargs(&format!("composefs.digest=v1-sha256-12:{digest_legacy}")) + .unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (digest_legacy.to_string(), false)); + + // V1: composefs.digest=v1-sha512-12: + let kargs = + parse_composefs_kargs(&format!("composefs.digest=v1-sha512-12:{digest}")).unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (digest.to_string(), false)); + } + + #[test] + fn test_parse_multiple_kargs_preserves_order() { + let sha256_hex = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + let sha512_hex = "6f06b5e82420abec546d6e6d3ddd612c50cfa9b707c129345b7ec16f456b92fe\ + 35df68999b042e1a6a70dfe75f2fed8cf9f67afd0bf08d2374678d75e2f65a02"; + let other_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + // All three types, sha256-v1 first + let cmdline = format!( + "composefs.digest=v1-sha256-12:{sha256_hex} \ + composefs.digest=v1-sha512-12:{sha512_hex} \ + composefs={other_sha256}" + ); + let kargs = parse_composefs_kargs(&cmdline).unwrap(); + assert_eq!(kargs.len(), 3); + assert_eq!(kargs[0].0, sha256_hex); + assert_eq!(kargs[1].0, sha512_hex); + assert_eq!(kargs[2].0, other_sha256); + + // Reversed order + let cmdline = format!( + "composefs={other_sha256} \ + composefs.digest=v1-sha512-12:{sha512_hex} \ + composefs.digest=v1-sha256-12:{sha256_hex}" + ); + let kargs = parse_composefs_kargs(&cmdline).unwrap(); + assert_eq!(kargs.len(), 3); + assert_eq!(kargs[0].0, other_sha256); + assert_eq!(kargs[1].0, sha512_hex); + assert_eq!(kargs[2].0, sha256_hex); + } + + #[test] + fn test_parse_insecure() { + let sha512_hex = "6f06b5e82420abec546d6e6d3ddd612c50cfa9b707c129345b7ec16f456b92fe\ + 35df68999b042e1a6a70dfe75f2fed8cf9f67afd0bf08d2374678d75e2f65a02"; + + let kargs = + parse_composefs_kargs(&format!("composefs.digest=?v1-sha512-12:{sha512_hex}")).unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (sha512_hex.to_string(), true)); + } + + #[test] + fn test_parse_mixed_insecure_and_secure() { + let sha256_hex = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + let sha512_hex = "6f06b5e82420abec546d6e6d3ddd612c50cfa9b707c129345b7ec16f456b92fe\ + 35df68999b042e1a6a70dfe75f2fed8cf9f67afd0bf08d2374678d75e2f65a02"; + + let cmdline = format!( + "composefs.digest=?v1-sha256-12:{sha256_hex} \ + composefs.digest=v1-sha512-12:{sha512_hex}" + ); + let kargs = parse_composefs_kargs(&cmdline).unwrap(); + assert_eq!(kargs.len(), 2); + assert_eq!(kargs[0], (sha256_hex.to_string(), true)); + assert_eq!(kargs[1], (sha512_hex.to_string(), false)); + } + + #[test] + fn test_parse_ignores_unrelated_tokens() { + let sha256_hex = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; + let cmdline = + format!("root=UUID=abc quiet splash composefs.digest=v1-sha256-12:{sha256_hex} rw"); + let kargs = parse_composefs_kargs(&cmdline).unwrap(); + assert_eq!(kargs.len(), 1); + assert_eq!(kargs[0], (sha256_hex.to_string(), false)); } } diff --git a/crates/composefs/src/erofs/writer.rs b/crates/composefs/src/erofs/writer.rs index 34993d15..406a87cf 100644 --- a/crates/composefs/src/erofs/writer.rs +++ b/crates/composefs/src/erofs/writer.rs @@ -1876,11 +1876,9 @@ fn prepare_erofs_inodes<'a, ObjectID: FsVerityHashValue>( /// 2. Second pass writes the actual image data /// /// Returns the complete EROFS image as a byte array. -pub fn mkfs_erofs( - fs: &mut ValidatedFileSystem, -) -> Box<[u8]> { +pub fn mkfs_erofs(fs: &ValidatedFileSystem) -> Box<[u8]> { mkfs_erofs_inner( - &mut fs.0, + &fs.0, format::FormatVersion::default(), #[cfg(test)] None, @@ -1892,14 +1890,30 @@ pub fn mkfs_erofs( /// Runs a layout pass (first pass) followed by an emit pass (second pass). /// When `faults` is `Some`, decisions are recorded during the first pass and /// replayed during the second so both passes make identical choices. +/// +/// Takes an immutable reference to the filesystem. For Epoch1 (V0/V1) formats, +/// whiteout stubs are added to a temporary clone so the caller's tree is never +/// mutated. pub(crate) fn mkfs_erofs_inner( - fs: &mut tree::FileSystem, + fs: &tree::FileSystem, version: format::FormatVersion, #[cfg(test)] faults: Option, ) -> Box<[u8]> { - if version.epoch() == FormatEpoch::Epoch1 { - fs.add_overlay_whiteouts(); - } + // For Epoch1 (V0/V1) formats, whiteout stubs must be present during image + // generation. Clone the tree and add stubs to the clone so the caller's + // filesystem is never mutated. + let fs_with_whiteouts; + let fs = if version.epoch() == FormatEpoch::Epoch1 { + fs_with_whiteouts = { + let mut cloned = fs.clone(); + cloned.add_overlay_whiteouts(); + cloned + }; + &fs_with_whiteouts + } else { + fs + }; + let (inodes, xattrs, min_mtime, header_flags, composefs_version) = prepare_erofs_inodes(fs, version); @@ -1945,11 +1959,11 @@ pub(crate) fn mkfs_erofs_inner( /// /// Returns the complete EROFS image as a byte array. pub fn mkfs_erofs_versioned( - fs: &mut ValidatedFileSystem, + fs: &ValidatedFileSystem, version: format::FormatVersion, ) -> Box<[u8]> { mkfs_erofs_inner( - &mut fs.0, + &fs.0, version, #[cfg(test)] None, @@ -1962,11 +1976,11 @@ pub fn mkfs_erofs_versioned( /// Pass `WriterFaults::new(seed)` with the desired rates set. #[cfg(test)] pub(crate) fn mkfs_erofs_with_faults( - fs: &mut ValidatedFileSystem, + fs: &ValidatedFileSystem, version: format::FormatVersion, faults: WriterFaults, ) -> Box<[u8]> { - mkfs_erofs_inner(&mut fs.0, version, Some(faults)) + mkfs_erofs_inner(&fs.0, version, Some(faults)) } #[cfg(test)] @@ -1993,4 +2007,66 @@ mod tests { // size=(1<<20)+1: ilog2(1<<20)+1=21 → result 9 assert_eq!(compute_chunk_format((1 << 20) + 1), 9, "size=(1<<20)+1"); } + + /// Generating a V2 image after a V1 image from the same `FileSystem` must + /// produce the same bytes as generating V2 alone. + /// + /// This is the regression test for the bug where `add_overlay_whiteouts()` + /// permanently mutated the tree during V1 generation, leaving 256 whiteout + /// stub entries in the root that then polluted the subsequent V2 image. + #[test] + fn test_v2_digest_unaffected_by_prior_v1_generation() { + use crate::{ + dumpfile::dumpfile_to_filesystem, + erofs::{ + format::FormatVersion, + writer::{ValidatedFileSystem, mkfs_erofs_inner}, + }, + fsverity::Sha256HashValue, + }; + + // A modest filesystem with a couple of entries to make the image non-trivial. + // Format: path size mode nlink uid gid rdev mtime payload content digest + let dumpfile = concat!( + "/ 0 40755 2 0 0 0 1000.0 - - -\n", + "/usr 0 40755 2 0 0 0 1000.0 - - -\n", + "/usr/lib 0 40755 2 0 0 0 1000.0 - - -\n", + "/usr/lib/libfoo.so 5 100644 1 0 0 0 1000.0 - hello -\n", + ); + + // Build V2-alone image first (before any V1 has touched the tree). + let fs_v2_only = dumpfile_to_filesystem::(dumpfile).unwrap(); + let v2_alone = mkfs_erofs_inner( + &ValidatedFileSystem::new(fs_v2_only).unwrap().0, + FormatVersion::V2, + None, + ); + + // Now build V1 then V2 from the same filesystem. + let fs_both = dumpfile_to_filesystem::(dumpfile).unwrap(); + let inner = ValidatedFileSystem::new(fs_both).unwrap().0; + let root_entries_before = inner.root.entries.len(); + let leaves_before = inner.leaves.len(); + + let _v1 = mkfs_erofs_inner(&inner, FormatVersion::V1, None); + + // After V1 generation the tree must be unchanged (clone-based, immutable). + assert_eq!( + inner.root.entries.len(), + root_entries_before, + "V1 generation unexpectedly modified the root directory" + ); + assert_eq!( + inner.leaves.len(), + leaves_before, + "V1 generation unexpectedly modified the leaves table" + ); + + let v2_after_v1 = mkfs_erofs_inner(&inner, FormatVersion::V2, None); + + assert_eq!( + v2_alone, v2_after_v1, + "V2 image differs depending on whether V1 was generated first" + ); + } } diff --git a/crates/composefs/src/filesystem_ops.rs b/crates/composefs/src/filesystem_ops.rs index 9e9da275..e20ecba0 100644 --- a/crates/composefs/src/filesystem_ops.rs +++ b/crates/composefs/src/filesystem_ops.rs @@ -34,12 +34,12 @@ impl FileSystem { /// typically via `copy_root_metadata_from_usr()` or `set_root_stat()`. #[context("Committing filesystem as EROFS images")] pub fn commit_images( - &mut self, + &self, repository: &Repository, image_name: Option<&str>, ) -> Result> { // Validate once before writing any version. - // add_overlay_whiteouts() for V1 is called inside mkfs_erofs_inner. + // add_overlay_whiteouts() for V1 is called inside mkfs_erofs_inner (on a clone). validate_filesystem(self)?; let formats = repository.format_config(); let mut result = HashMap::new(); @@ -70,7 +70,7 @@ impl FileSystem { /// typically via `copy_root_metadata_from_usr()` or `set_root_stat()`. #[context("Committing filesystem as EROFS image")] pub fn commit_image( - &mut self, + &self, repository: &Repository, image_name: Option<&str>, ) -> Result { @@ -88,7 +88,7 @@ impl FileSystem { /// /// Note: Callers should ensure root metadata is set before calling this, /// typically via `copy_root_metadata_from_usr()` or `set_root_stat()`. - pub fn compute_image_id(&mut self, version: FormatVersion) -> ObjectID { + pub fn compute_image_id(&self, version: FormatVersion) -> ObjectID { // Callers are responsible for ensuring the tree is valid before calling this. // In practice this is always called on freshly-built trees that don't have // invalid constructs like hardlinked whiteouts. diff --git a/crates/composefs/src/fsverity/hashvalue.rs b/crates/composefs/src/fsverity/hashvalue.rs index b582372b..0bd3e8f9 100644 --- a/crates/composefs/src/fsverity/hashvalue.rs +++ b/crates/composefs/src/fsverity/hashvalue.rs @@ -320,6 +320,15 @@ impl Algorithm { pub fn is_compatible(&self) -> bool { std::mem::discriminant(self) == std::mem::discriminant(&H::ALGORITHM) } + + /// The suffix used in V1 kernel cmdline karg values. + /// + /// Returns `"-"`, e.g. `"sha256-12"` or `"sha512-12"`. + /// This is the part after `v1-` in the format descriptor (e.g. `v1-sha256-12` in + /// `composefs.digest=v1-sha256-12:`). + pub fn verity_suffix(&self) -> String { + format!("{}-{}", self.hash_name(), self.lg_blocksize()) + } } impl FromStr for Algorithm { diff --git a/crates/composefs/src/generic_tree.rs b/crates/composefs/src/generic_tree.rs index e5c36d31..4a8aeaa6 100644 --- a/crates/composefs/src/generic_tree.rs +++ b/crates/composefs/src/generic_tree.rs @@ -602,7 +602,7 @@ impl Directory { /// Leaf nodes (non-directory files) are stored in a flat `Vec` and referenced /// by [`LeafId`] indices from the directory tree. This design is `Send + Sync`, /// supports hardlinks via shared `LeafId`, and avoids reference counting. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct FileSystem { /// The root directory of the filesystem. pub root: Directory, diff --git a/crates/composefs/src/repository.rs b/crates/composefs/src/repository.rs index 3e3500cf..bd6eb5c8 100644 --- a/crates/composefs/src/repository.rs +++ b/crates/composefs/src/repository.rs @@ -202,10 +202,10 @@ pub const REPO_FORMAT_VERSION: u32 = 1; /// - Unknown **incompatible** features cause the repository to be /// rejected entirely. pub mod known_features { - /// The ro-compat feature flag for V1 EROFS repositories. + /// The ro-compat feature flag for V1-only EROFS repositories. /// - /// When present in `read_only_compatible`, the repository uses the V1 - /// (C-tool compatible) EROFS format. Old tools that don't recognize this + /// When present in `read_only_compatible`, the repository generates only V1 + /// (C-tool compatible) EROFS images. Old tools that don't recognize this /// flag will open the repository as read-only, preventing accidental V2 /// image writes into a V1 repo. pub const V1_EROFS: &str = "v1_erofs"; @@ -334,8 +334,7 @@ impl RepoMetadata { /// /// Uses `erofs_formats.default` when the field was explicitly set (i.e. is /// not the serde default of `single(V2)`). For old repos that predate the - /// `erofs_formats` field, falls back to deriving the version from the - /// `"v1_erofs"` ro_compat flag: + /// `erofs_formats` field, falls back to the `"v1_erofs"` ro_compat flag: /// /// - `"v1_erofs"` present → [`FormatVersion::V1`] /// - absent → [`FormatVersion::V2`] @@ -347,8 +346,8 @@ impl RepoMetadata { /// /// If `erofs_formats` was explicitly set in `meta.json` (i.e. it is not the /// serde default `single(V2)`), it is returned as-is. Otherwise the config - /// is derived from the legacy `"v1_erofs"` feature flag for backward - /// compatibility with repos created before this field existed. + /// is derived from the legacy `"v1_erofs"` ro_compat flag for backward + /// compatibility with repos created before the `erofs_formats` field existed. pub fn format_config(&self) -> FormatConfig { let default_config = FormatConfig::default(); if self.erofs_formats != default_config { @@ -385,7 +384,7 @@ impl RepoMetadata { /// Build metadata with the correct feature flags for the given [`FormatConfig`]. /// - /// The primary format version is encoded in two places for compatibility: + /// The on-disk encoding uses a feature flag for compatibility: /// - `erofs_formats` field: stores the full [`FormatConfig`] directly. /// - `"v1_erofs"` ro_compat flag: present when the primary version is V1, /// so that older tools that don't know `erofs_formats` open the repository @@ -609,7 +608,7 @@ pub fn system_path() -> PathBuf { /// /// Delegates to [`RepoMetadata::format_config`], which prefers the explicit /// `erofs_formats` field when present and falls back to the legacy `"v1_erofs"` -/// flag for backward compatibility. +/// ro_compat flag for backward compatibility. fn repo_format_config_from_meta(meta: &RepoMetadata) -> FormatConfig { meta.format_config() } @@ -1430,9 +1429,8 @@ impl Repository { } // Use `new` (no `v1_erofs` flag) for legacy repos - // that pre-date the format-set feature. No feature flags → V2 + BOTH, which - // is correct: old repos may contain images of any version and should not be - // artificially restricted. + // that pre-date the FormatConfig feature. No feature flags → V2-only, + // which is correct: old repos used V2 exclusively. let meta = RepoMetadata::new(algorithm); write_repo_metadata(&repo_fd, &meta, has_verity)?; @@ -4287,7 +4285,7 @@ mod tests { let obj1_id = repo.ensure_object(&obj1)?; let obj2_id = repo.ensure_object(&obj2)?; - let mut fs = make_test_fs(&obj2_id, obj2_size); + let fs = make_test_fs(&obj2_id, obj2_size); let image1 = fs.commit_image(&repo, None)?; let image1_path = format!("images/{}", image1.to_hex()); @@ -4330,7 +4328,7 @@ mod tests { let obj1_id = repo.ensure_object(&obj1)?; let obj2_id = repo.ensure_object(&obj2)?; - let mut fs = make_test_fs(&obj2_id, obj2_size); + let fs = make_test_fs(&obj2_id, obj2_size); let image1 = fs.commit_image(&repo, None)?; let image1_path = format!("images/{}", image1.to_hex()); @@ -4379,7 +4377,7 @@ mod tests { let obj1_id = repo.ensure_object(&obj1)?; let obj2_id = repo.ensure_object(&obj2)?; - let mut fs = make_test_fs(&obj2_id, obj2_size); + let fs = make_test_fs(&obj2_id, obj2_size); let image1 = fs.commit_image(&repo, Some("ref-name"))?; let image1_path = format!("images/{}", image1.to_hex()); @@ -4456,11 +4454,11 @@ mod tests { let obj3_id = repo.ensure_object(&obj3)?; let obj4_id = repo.ensure_object(&obj4)?; - let mut fs = make_test_fs_with_two_files(&obj2_id, obj2_size, &obj3_id, obj3_size); + let fs = make_test_fs_with_two_files(&obj2_id, obj2_size, &obj3_id, obj3_size); let image1 = fs.commit_image(&repo, None)?; let image1_path = format!("images/{}", image1.to_hex()); - let mut fs = make_test_fs_with_two_files(&obj2_id, obj2_size, &obj4_id, obj4_size); + let fs = make_test_fs_with_two_files(&obj2_id, obj2_size, &obj4_id, obj4_size); let image2 = fs.commit_image(&repo, None)?; let image2_path = format!("images/{}", image2.to_hex()); @@ -4908,7 +4906,7 @@ mod tests { let obj = generate_test_data(obj_size, 0xBB); let obj_id = repo.ensure_object(&obj)?; - let mut fs = make_test_fs(&obj_id, obj_size); + let fs = make_test_fs(&obj_id, obj_size); let image_id = fs.commit_image(&repo, None)?; repo.sync()?; @@ -5053,7 +5051,7 @@ mod tests { let obj = generate_test_data(obj_size, 0xCC); let obj_id = repo.ensure_object(&obj)?; - let mut fs = make_test_fs(&obj_id, obj_size); + let fs = make_test_fs(&obj_id, obj_size); let image_id = fs.commit_image(&repo, None)?; repo.sync()?; @@ -5100,7 +5098,7 @@ mod tests { let obj = generate_test_data(obj_size, 0xDD); let obj_id = repo.ensure_object(&obj)?; - let mut fs = make_test_fs(&obj_id, obj_size); + let fs = make_test_fs(&obj_id, obj_size); let image_id = fs.commit_image(&repo, None)?; repo.sync()?; @@ -5690,7 +5688,7 @@ mod tests { Ok(()) } - // ---- FormatSet / v1_erofs feature flag tests ---- + // ---- v1_erofs feature flag tests ---- // // The `v1_erofs` ro_compat flag is the single on-disk signal for V1 EROFS. // It is derived from `erofs_formats.default`; the `extra` list is not @@ -5715,7 +5713,6 @@ mod tests { #[test] fn test_v1_erofs_flag_absent_for_v2_repos() { - // V2 primary → v1_erofs absent let meta = RepoMetadata::new_with_formats( Algorithm::SHA256, &FormatConfig::single(FormatVersion::V2), @@ -5728,6 +5725,11 @@ mod tests { "V2 repo must NOT set v1_erofs in ro_compat, got: {:?}", meta.features.read_only_compatible ); + assert!( + meta.features.incompatible.is_empty(), + "V2 repo must have no incompat flags, got: {:?}", + meta.features.incompatible + ); assert_eq!(meta.erofs_version(), FormatVersion::V2); } @@ -5794,13 +5796,22 @@ mod tests { let (repo, was_new) = Repository::::init_path(CWD, &path, config)?; assert!(was_new); assert_eq!(repo.erofs_version(), FormatVersion::V2); + assert_eq!( + repo.default_format_config(), + FormatConfig::single(FormatVersion::V2) + ); assert!( !repo .metadata() .features .read_only_compatible .contains(&known_features::V1_EROFS.to_string()), - "v1_erofs must NOT be in ro_compat for V2 repos" + "v1_erofs must NOT be in ro_compat for V2-only repos" + ); + assert!( + repo.metadata().features.incompatible.is_empty(), + "V2-only repo must have no incompat flags, got: {:?}", + repo.metadata().features.incompatible ); Ok(()) } @@ -5836,7 +5847,7 @@ mod tests { st_mtim_nsec: 0, xattrs: Default::default(), }; - let mut fs: FileSystem = FileSystem::new(root_stat); + let fs: FileSystem = FileSystem::new(root_stat); // commit_images now reads the FormatConfig from the repository itself. let map = fs.commit_images(&repo, Some("myref"))?; diff --git a/doc/booting.md b/doc/booting.md index 52282eb8..b958e6cd 100644 --- a/doc/booting.md +++ b/doc/booting.md @@ -11,41 +11,54 @@ overlayfs, and fs-verity is assumed. ## Kernel command-line -A single kernel argument controls which image is booted: +The initramfs code in composefs supports multiple kernel arguments; it +is possible to pre-compute the digest of an image using both e.g. SHA-256 and +SHA-512. On an installed system, the repository only supports one digest +by default today, and the first found will be selected. + +Additionally, it is opt-in to enable v1 EROFS, and again the first compatible +version will be found. ``` -composefs= +composefs.digest=v1-sha256-12: # V1 EROFS image (preferred; RHEL9-era kernels) +composefs.digest=v1-sha512-12: # V1 EROFS image (SHA-512 variant) +composefs.digest=v2-sha512-12: # V2 EROFS image (explicit form) +composefs= # V2 EROFS image (legacy shorthand) ``` -`` is the hex-encoded fs-verity digest of the EROFS metadata image to -mount as root. SHA-256 digests are 64 hex characters; SHA-512 digests are 128 -hex characters. `composefs-setup-root` tries SHA-512 first and falls back to -SHA-256 if the length does not match, so both algorithms are supported without -any additional configuration. +The value format is `--:`, where +`` is `v1` or `v2`, `` is `sha256` or `sha512`, and +`` is the log₂ block size (currently always `12`, i.e. 4096 +bytes). This mirrors how `meta.json` encodes the algorithm as +`fsverity-sha256-12`. + +`composefs.digest=` is checked first. Multiple entries may appear on the cmdline +(one per format/algorithm combination); the initramfs tries each in order and +mounts the first image that actually exists in the repository. -**Insecure mode.** Prefixing the digest with `?` (e.g. `composefs=?`) -makes fs-verity verification optional. The system will boot even when the -underlying filesystem does not support fs-verity or the image has no verity -metadata attached. This mode exists for development and testing only; it must -not be used in production. +`composefs=` is a legacy shorthand equivalent to +`composefs.digest=v2--12:` — the algorithm is inferred from the +digest length (64 hex chars → SHA-256, 128 → SHA-512). It is checked only when +no `composefs.digest=` token matches. -Parsing is handled by `composefs_boot::cmdline::get_cmdline_composefs` -(`crates/composefs-boot/src/cmdline.rs`). The splitter follows the kernel's -own logic: tokens are separated by ASCII whitespace, and whitespace inside -double-quoted strings is treated as literal. There is no escape mechanism, so a -literal double-quote character cannot appear in a token value. +**Insecure mode.** Placing `?` immediately after `=` (e.g. +`composefs.digest=?v1-sha256-12:` or `composefs=?`) makes +fs-verity verification optional. The system will boot even when the underlying +filesystem does not support fs-verity or the image has no verity metadata +attached. This mode exists for development and testing only; it must not be used +in production. ## On-disk layout The composefs repository must be present at `/sysroot/composefs` with the standard layout described in `doc/repository.md`. -The `composefs=` digest must correspond to a symlink under `images/`. +The digest must correspond to a symlink under `images/`. Persistent per-deployment state lives at `/sysroot/state/deploy//`, -where `` matches the `composefs=` kernel argument exactly. The `etc/` -and `var/` subdirectories within that directory serve as the upper layers for -the corresponding overlayfs mounts. +where `` matches the boot karg digest exactly. The `etc/` and `var/` +subdirectories within that directory serve as the upper layers for the +corresponding overlayfs mounts. ## Kernel requirements @@ -63,7 +76,7 @@ The following kernel features must be available: ## Kernel argument -The `composefs=` kernel argument is the authoritative selector for which image +The boot karg (`composefs.digest=` or `composefs=`) is the authoritative selector for which image is booted. Without the `?` insecure prefix, every file access through the overlayfs is verified against the object's stored digest by the kernel, combining fs-verity on the data objects with overlayfs `verity=require`. diff --git a/doc/erofs.md b/doc/erofs.md index 5ccb8306..2a49b60e 100644 --- a/doc/erofs.md +++ b/doc/erofs.md @@ -6,16 +6,16 @@ and referenced by their fs-verity digest. The EROFS image itself carries only me directory entries, extended attributes, and chunk index entries that point to the external files. composefs-rs supports two EROFS format versions. V1 is byte-for-byte compatible with the C -`mkcomposefs` tool. V2 is the composefs-rs native default and drops several V1 constraints -that exist only for C compatibility. New repositories use V2 unless `--erofs-version v1` is -passed to `cfsctl init`. +`mkcomposefs` tool. V2 is the composefs-rs native format and drops several V1 constraints +that exist only for C compatibility. -However, V2 is not mountable by RHEL9 era EROFS, and a goal is to transition to V1 by default -for maximum compatibility. +`cfsctl init` defaults to V2; pass `--erofs-version 1` to select V1. Higher-level tools +such as bootc initialize repositories with multiple formats enabled (V1 primary) so that images +can be booted on RHEL9-era kernels that require the `composefs.digest=` karg. ## Format V1 -V1 is selected with `cfsctl init --erofs-version v1`. The `v1_erofs` ro-compat feature flag +V1 is selected with `cfsctl init --erofs-version 1`. The `v1_erofs` ro-compat feature flag is written to `meta.json` so that tools without V1 support open the repository read-only. **`composefs_version` field values in V1:** @@ -48,7 +48,7 @@ serialized differently. The stub entries in the whiteout table are not escaped. ## Format V2 — Created in composefs-rs -V2 is the default for all repositories created without `--erofs-version v1`. +V2 is the default for repositories created with `cfsctl init` without `--erofs-version 1`. **`composefs_version` field:** Always `2` (the constant `COMPOSEFS_VERSION`). @@ -69,7 +69,7 @@ The format is fixed at repository initialization time and cannot be changed afte ``` cfsctl init # V2 (default) -cfsctl init --erofs-version v1 # V1 (C-tool compatible) +cfsctl init --erofs-version 1 # V1 (C-tool compatible) ``` The format is recorded in `meta.json` as the `v1_erofs` ro-compat feature flag: present diff --git a/doc/repository.md b/doc/repository.md index 7e400040..023d26cc 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -69,7 +69,7 @@ created by `cfsctl init` and contains: - `v1_erofs` (read-only-compatible) — present on repositories whose EROFS image format is V1 (C-tool compatible: compact inodes, BFS ordering, whiteout table). This is the single flag that encodes the - EROFS format version: present → V1, absent → V2 (the default). Old + EROFS format version: present → V1, absent → V2. Old tools that do not recognise this flag open the repository read-only rather than accidentally writing images in the wrong format. @@ -85,12 +85,15 @@ images. It controls the `v1_erofs` feature flag in `meta.json`: ``` cfsctl init # default: V2 EROFS (composefs-rs native) -cfsctl init --erofs-version v1 # V1 EROFS (C-tool compatible) +cfsctl init --erofs-version 1 # V1 EROFS (C-tool compatible) ``` -**V2** (default) uses extended inodes, DFS ordering, and `composefs_version=2` -in the EROFS superblock. This is the composefs-rs native format and is what -all repositories created before V1 support was added use. +**V2** (the `cfsctl` default) uses extended inodes, DFS ordering, and +`composefs_version=2` in the EROFS superblock. This is the composefs-rs native +format and is what all repositories created before V1 support was added use. +Higher-level tools (e.g. bootc) may configure a repository with multiple format +versions (V1 primary + V2 extra) so that images are usable on both RHEL9-era and +newer kernels. **V1** uses compact inodes where possible, BFS ordering, and a whiteout stub table, producing output byte-for-byte identical to the C `mkcomposefs` tool. diff --git a/examples/bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service index ffc404d6..ad9b5532 100644 --- a/examples/bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service +++ b/examples/bls/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -15,7 +15,8 @@ [Unit] DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount Requires=sysroot.mount diff --git a/examples/bls/extra/usr/lib/initcpio/hooks/composefs b/examples/bls/extra/usr/lib/initcpio/hooks/composefs index 775ea403..21f04379 100644 --- a/examples/bls/extra/usr/lib/initcpio/hooks/composefs +++ b/examples/bls/extra/usr/lib/initcpio/hooks/composefs @@ -3,7 +3,11 @@ run_latehook() { local composefs - composefs="$(getarg composefs)" + # composefs.digest= is the V1 EROFS karg; composefs= is the legacy V2 karg. + composefs="$(getarg composefs.digest)" + if [ -z "$composefs" ]; then + composefs="$(getarg composefs)" + fi if [ -z "$composefs" ]; then return 0 fi diff --git a/examples/uki/Containerfile b/examples/uki/Containerfile index 3f31bf73..80017dbe 100644 --- a/examples/uki/Containerfile +++ b/examples/uki/Containerfile @@ -9,9 +9,9 @@ # changes may be made vs. the base image. This is best-accomplished with a # multi-stage build. # -# - during the build stages following 'base', the `COMPOSEFS_FSVERITY` build -# arg will be set to the fsverity digest of the container image. This should -# be baked into the UKI. +# - during the build stages following 'base', the `COMPOSEFS_KARG` build +# arg will be set to the composefs kernel argument string (e.g. +# composefs.digest=v1-sha256-12:). This should be baked into the UKI. FROM fedora:43 AS base RUN --mount=type=cache,target=/var/cache/libdnf5 < /etc/kernel/cmdline + echo "${COMPOSEFS_KARG} rw" > /etc/kernel/cmdline kernel-install add-all EOF diff --git a/examples/uki/Containerfile.arch b/examples/uki/Containerfile.arch index 2283c539..acfbb89e 100644 --- a/examples/uki/Containerfile.arch +++ b/examples/uki/Containerfile.arch @@ -30,10 +30,10 @@ RUN < /etc/kernel/cmdline + echo "root=/dev/vda2 ${COMPOSEFS_KARG} rw" > /etc/kernel/cmdline mkinitcpio -p linux EOF diff --git a/examples/uki/build b/examples/uki/build index dfc3eb30..e29ac9be 100755 --- a/examples/uki/build +++ b/examples/uki/build @@ -31,7 +31,7 @@ CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -${CFSCTL} init --algorithm=fsverity-sha256-12 +${CFSCTL} init --algorithm=fsverity-sha256-12 --erofs-version 1 ${PODMAN_BUILD} \ --iidfile=tmp/base.iid \ @@ -41,13 +41,18 @@ ${PODMAN_BUILD} \ BASE_ID="$(cat tmp/base.iid)" ${CFSCTL} oci pull containers-storage:"${BASE_ID}" -BASE_IMAGE_FSVERITY="$(${CFSCTL} oci compute-id --bootable "@${BASE_ID}")" + +# Compute the composefs kernel argument from the repo-side view of the base image. +# Using oci compute-id --bootable gives the same digest that prepare-boot will use +# when processing the final image (both go through transform_for_boot with the repo), +# so the karg baked into the UKI will match what prepare-boot validates. +BASE_BOOTABLE_ID="$(${CFSCTL} oci compute-id --bootable "@${BASE_ID}")" +BASE_KARG="composefs.digest=v1-sha256-12:${BASE_BOOTABLE_ID}" ${PODMAN_BUILD} \ --iidfile=tmp/final.iid \ --build-context=base="container-image://${BASE_ID}" \ - --build-arg=COMPOSEFS_FSVERITY="${BASE_IMAGE_FSVERITY}" \ - --label=containers.composefs.fsverity="${BASE_IMAGE_FSVERITY}" \ + --build-arg=COMPOSEFS_KARG="${BASE_KARG}" \ -f "${containerfile}" \ . diff --git a/examples/uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service index ffc404d6..ad9b5532 100644 --- a/examples/uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service +++ b/examples/uki/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -15,7 +15,8 @@ [Unit] DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount Requires=sysroot.mount diff --git a/examples/uki/extra/usr/lib/initcpio/hooks/composefs b/examples/uki/extra/usr/lib/initcpio/hooks/composefs index 775ea403..21f04379 100644 --- a/examples/uki/extra/usr/lib/initcpio/hooks/composefs +++ b/examples/uki/extra/usr/lib/initcpio/hooks/composefs @@ -3,7 +3,11 @@ run_latehook() { local composefs - composefs="$(getarg composefs)" + # composefs.digest= is the V1 EROFS karg; composefs= is the legacy V2 karg. + composefs="$(getarg composefs.digest)" + if [ -z "$composefs" ]; then + composefs="$(getarg composefs)" + fi if [ -z "$composefs" ]; then return 0 fi diff --git a/examples/unified-secureboot/Containerfile b/examples/unified-secureboot/Containerfile index 8472d046..61e78812 100644 --- a/examples/unified-secureboot/Containerfile +++ b/examples/unified-secureboot/Containerfile @@ -44,11 +44,10 @@ FROM base AS kernel RUN --mount=type=bind,from=base,target=/mnt/base < /etc/kernel/cmdline + echo "${COMPOSEFS_KARG} rw" > /etc/kernel/cmdline EOF RUN --mount=type=cache,target=/var/cache/libdnf5 \ --mount=type=secret,id=key \ diff --git a/examples/unified-secureboot/build b/examples/unified-secureboot/build index db1847fd..e51d3ba9 100755 --- a/examples/unified-secureboot/build +++ b/examples/unified-secureboot/build @@ -20,7 +20,7 @@ CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -${CFSCTL} init --algorithm=fsverity-sha256-12 +${CFSCTL} init --algorithm=fsverity-sha256-12 --erofs-version 1 # See: https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot # Alternative to generate keys for testing: `sbctl create-keys` diff --git a/examples/unified-secureboot/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/unified-secureboot/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service index ffc404d6..ad9b5532 100644 --- a/examples/unified-secureboot/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service +++ b/examples/unified-secureboot/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -15,7 +15,8 @@ [Unit] DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount Requires=sysroot.mount diff --git a/examples/unified/Containerfile b/examples/unified/Containerfile index da113a2b..853b777b 100644 --- a/examples/unified/Containerfile +++ b/examples/unified/Containerfile @@ -42,11 +42,10 @@ FROM base AS kernel RUN --mount=type=bind,from=base,target=/mnt/base < /etc/kernel/cmdline + echo "${COMPOSEFS_KARG} rw" > /etc/kernel/cmdline kernel-install add-all EOF diff --git a/examples/unified/build b/examples/unified/build index fa89d630..7e9683b6 100755 --- a/examples/unified/build +++ b/examples/unified/build @@ -20,7 +20,7 @@ CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -${CFSCTL} init --algorithm=fsverity-sha256-12 +${CFSCTL} init --algorithm=fsverity-sha256-12 --erofs-version 1 # For debugging, add --no-cache to podman command mkdir -p tmp/internal-sysroot diff --git a/examples/unified/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service b/examples/unified/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service index ffc404d6..ad9b5532 100644 --- a/examples/unified/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service +++ b/examples/unified/extra/usr/lib/dracut/modules.d/37composefs/composefs-setup-root.service @@ -15,7 +15,8 @@ [Unit] DefaultDependencies=no -ConditionKernelCommandLine=composefs +ConditionKernelCommandLine=|composefs +ConditionKernelCommandLine=|composefs.digest ConditionPathExists=/etc/initrd-release After=sysroot.mount Requires=sysroot.mount