diff --git a/crates/cfsctl/src/lib.rs b/crates/cfsctl/src/lib.rs index 8479507c..c21e241f 100644 --- a/crates/cfsctl/src/lib.rs +++ b/crates/cfsctl/src/lib.rs @@ -23,18 +23,20 @@ pub use composefs_http; pub use composefs_oci; use std::io::Read; +use std::path::Path; use std::{ffi::OsString, path::PathBuf}; #[cfg(feature = "oci")] -use std::{fs::create_dir_all, io::IsTerminal, path::Path}; +use std::{fs::create_dir_all, io::IsTerminal}; #[cfg(any(feature = "oci", feature = "http"))] use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::{Parser, Subcommand, ValueEnum}; #[cfg(feature = "oci")] use comfy_table::{presets::UTF8_FULL, Table}; +use rustix::fs::{Mode, OFlags}; use rustix::fs::CWD; use serde::Serialize; @@ -48,9 +50,9 @@ use composefs::shared_internals::IO_BUF_CAPACITY; use composefs::{ dumpfile::{dump_single_dir, dump_single_file}, erofs::reader::erofs_to_filesystem, - fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}, + fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue}, generic_tree::{FileSystem, Inode}, - repository::Repository, + repository::{read_repo_algorithm, system_path, user_path, Repository, REPO_METADATA_FILENAME}, tree::RegularFile, }; @@ -85,27 +87,31 @@ pub struct App { #[clap(long, group = "repopath")] system: bool, - /// What hash digest type to use for composefs repo - #[clap(long, value_enum, default_value_t = HashType::Sha512)] - pub hash: HashType, + /// What hash digest type to use for composefs repo. + /// If omitted, auto-detected from repository metadata (meta.json). + #[clap(long, value_enum)] + pub hash: Option, - /// Sets the repository to insecure before running any operation and - /// prepend '?' to the composefs kernel command line when writing - /// boot entry. - #[clap(long)] + /// Deprecated: security mode is now auto-detected from meta.json. + /// Use `cfsctl init --insecure` to create a repo without verity. + /// Kept for backward compatibility. + #[clap(long, hide = true)] insecure: bool, + /// Error if the repository does not have fs-verity enabled. + #[clap(long)] + require_verity: bool, + #[clap(subcommand)] cmd: Command, } /// The Hash algorithm used for FsVerity computation -#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum, Default)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)] pub enum HashType { /// Sha256 Sha256, /// Sha512 - #[default] Sha512, } @@ -276,6 +282,30 @@ struct FsReadOptions { #[derive(Debug, Subcommand)] enum Command { + /// Initialize a new composefs repository with a metadata file. + /// + /// Creates the repository directory (if it doesn't exist) and writes + /// a `meta.json` recording the digest algorithm. By default fs-verity + /// is enabled on `meta.json`, signaling that all objects require + /// verity. Use `--insecure` to skip (e.g. on tmpfs). + Init { + /// The fs-verity algorithm identifier. + /// Format: fsverity--, e.g. fsverity-sha512-12 + #[clap(long, value_parser = clap::value_parser!(Algorithm), default_value = "fsverity-sha512-12")] + algorithm: Algorithm, + /// Path to the repository directory (created if it doesn't exist). + /// If omitted, uses --repo/--user/--system location. + path: Option, + /// Do not enable fs-verity on meta.json (insecure repository). + #[clap(long)] + insecure: bool, + /// Migrate an old-format repository: remove streams/ and images/ + /// (which encode the algorithm) but keep objects/, then write + /// fresh meta.json. Streams and images will need to be + /// re-imported after migration. + #[clap(long)] + reset_metadata: bool, + }, /// Take a transaction lock on the repository. /// This prevents garbage collection from occurring. Transaction, @@ -375,10 +405,7 @@ where std::iter::once(OsString::from("cfsctl")).chain(args.into_iter().map(Into::into)), ); - match args.hash { - HashType::Sha256 => run_cmd_with_repo(open_repo::(&args)?, args).await, - HashType::Sha512 => run_cmd_with_repo(open_repo::(&args)?, args).await, - } + run_app(args).await } #[cfg(feature = "oci")] @@ -392,25 +419,170 @@ where }) } -/// Open a repo -pub fn open_repo(args: &App) -> Result> -where - ObjectID: FsVerityHashValue, -{ - let mut repo = (if let Some(path) = &args.repo { - Repository::open_path(CWD, path) +/// Resolve the repository path from CLI args without opening it. +/// +/// Uses [`user_path`] and [`system_path`] to avoid duplicating +/// path constants. +fn resolve_repo_path(args: &App) -> Result { + if let Some(path) = &args.repo { + Ok(path.clone()) } else if args.system { - Repository::open_system() + Ok(system_path()) } else if args.user { - Repository::open_user() + user_path() } else if rustix::process::getuid().is_root() { - Repository::open_system() + Ok(system_path()) } else { - Repository::open_user() + user_path() + } +} + +/// Determine the effective hash type for a repository. +/// +/// Resolution order: +/// 1. If `meta.json` exists, use its algorithm. Error if `--hash` was +/// explicitly passed and conflicts. +/// 2. If no metadata, use `--hash` if given. +/// 3. Otherwise default to sha512. +/// +/// Note: we read the metadata file directly here (rather than via +/// `Repository::metadata`) because this runs *before* we know which +/// generic `ObjectID` type to use — that's exactly what we're deciding. +fn resolve_hash_type(repo_path: &Path, cli_hash: Option) -> Result { + let repo_fd = rustix::fs::open( + repo_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .with_context(|| format!("opening repository {}", repo_path.display()))?; + + let algorithm = read_repo_algorithm(&repo_fd)?.ok_or_else(|| { + anyhow::anyhow!( + "{REPO_METADATA_FILENAME} not found in {}; \ + this repository must be initialized with `cfsctl init`", + repo_path.display(), + ) })?; - repo.set_insecure(args.insecure); + let detected = match algorithm { + Algorithm::Sha256 { .. } => HashType::Sha256, + Algorithm::Sha512 { .. } => HashType::Sha512, + }; + + // If the user explicitly passed --hash and it doesn't match, error + if let Some(explicit) = cli_hash { + if explicit != detected { + anyhow::bail!( + "repository is configured for {algorithm} (from {REPO_METADATA_FILENAME}) \ + but --hash {} was specified", + match explicit { + HashType::Sha256 => "sha256", + HashType::Sha512 => "sha512", + }, + ); + } + } + + Ok(detected) +} + +/// Top-level dispatch: handle init specially, otherwise open repo and run. +pub async fn run_app(args: App) -> Result<()> { + // Init is handled before opening a repo since it creates one + if let Command::Init { + ref algorithm, + ref path, + insecure, + reset_metadata, + } = args.cmd + { + return run_init( + algorithm, + path.as_deref(), + insecure || args.insecure, + reset_metadata, + &args, + ); + } + + let repo_path = resolve_repo_path(&args)?; + let effective_hash = resolve_hash_type(&repo_path, args.hash)?; + match effective_hash { + HashType::Sha256 => run_cmd_with_repo(open_repo::(&args)?, args).await, + HashType::Sha512 => run_cmd_with_repo(open_repo::(&args)?, args).await, + } +} + +/// Handle `cfsctl init` +fn run_init( + algorithm: &Algorithm, + path: Option<&Path>, + insecure: bool, + reset_metadata: bool, + args: &App, +) -> Result<()> { + let repo_path = if let Some(p) = path { + p.to_path_buf() + } else { + resolve_repo_path(args)? + }; + + if reset_metadata { + composefs::repository::reset_metadata(&repo_path)?; + } + + // Ensure parent directories exist (init_path only creates the final dir). + if let Some(parent) = repo_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating parent directories for {}", repo_path.display()))?; + } + + // init_path handles idempotency: same algorithm is a no-op, + // different algorithm is an error. + let created = match algorithm { + Algorithm::Sha256 { .. } => { + Repository::::init_path(CWD, &repo_path, *algorithm, !insecure)?.1 + } + Algorithm::Sha512 { .. } => { + Repository::::init_path(CWD, &repo_path, *algorithm, !insecure)?.1 + } + }; + + if created { + println!( + "Initialized composefs repository at {}", + repo_path.display() + ); + println!(" algorithm: {algorithm}"); + if insecure { + println!(" verity: not required (insecure)"); + } else { + println!(" verity: required"); + } + } else { + println!("Repository already initialized at {}", repo_path.display()); + } + + Ok(()) +} + +/// Open a repo +pub fn open_repo(args: &App) -> Result> +where + ObjectID: FsVerityHashValue, +{ + let path = resolve_repo_path(args)?; + let mut repo = Repository::open_path(CWD, path)?; + // Hidden --insecure flag for backward compatibility; the default + // now is to inherit the repo config, but if it's specified we + // disable requiring verity even if the repo says to use it. + if args.insecure { + repo.set_insecure(); + } + if args.require_verity { + repo.require_verity()?; + } Ok(repo) } @@ -509,6 +681,10 @@ where ObjectID: FsVerityHashValue, { match args.cmd { + Command::Init { .. } => { + // Handled in run_app before we get here + unreachable!("init is handled before opening a repository"); + } Command::Transaction => { // just wait for ^C loop { @@ -717,7 +893,7 @@ where &repo, entry, &id, - args.insecure, + repo.is_insecure(), bootdir, None, entry_id.as_deref(), @@ -814,19 +990,11 @@ where let mut img_buf = Vec::new(); std::fs::File::from(img_fd).read_to_end(&mut img_buf)?; - match args.hash { - HashType::Sha256 => dump_file_impl( - erofs_to_filesystem::(&img_buf)?, - &files, - backing_path_only, - )?, - - HashType::Sha512 => dump_file_impl( - erofs_to_filesystem::(&img_buf)?, - &files, - backing_path_only, - )?, - }; + dump_file_impl( + erofs_to_filesystem::(&img_buf)?, + &files, + backing_path_only, + )?; } Command::Fsck { json } => { let result = repo.fsck().await?; diff --git a/crates/cfsctl/src/main.rs b/crates/cfsctl/src/main.rs index 40b8781f..4873e608 100644 --- a/crates/cfsctl/src/main.rs +++ b/crates/cfsctl/src/main.rs @@ -4,20 +4,15 @@ //! creating and mounting filesystem images, handling OCI containers, and performing //! repository maintenance operations like garbage collection. -use cfsctl::{open_repo, run_cmd_with_repo, App, HashType}; +use cfsctl::App; use anyhow::Result; use clap::Parser; -use composefs::fsverity::{Sha256HashValue, Sha512HashValue}; #[tokio::main] async fn main() -> Result<()> { env_logger::init(); let args = App::parse(); - - match args.hash { - HashType::Sha256 => run_cmd_with_repo(open_repo::(&args)?, args).await, - HashType::Sha512 => run_cmd_with_repo(open_repo::(&args)?, args).await, - } + cfsctl::run_app(args).await } diff --git a/crates/composefs-oci/src/image.rs b/crates/composefs-oci/src/image.rs index 7ddc33a4..98d1cd06 100644 --- a/crates/composefs-oci/src/image.rs +++ b/crates/composefs-oci/src/image.rs @@ -326,7 +326,14 @@ mod test { let (tar_data, diff_id) = build_baseimage(); let repo_dir = tempdir(); - let repo = Arc::new(Repository::::open_path(CWD, &repo_dir)?); + let repo_path = repo_dir.path().join("repo"); + let (repo, _) = Repository::::init_path( + CWD, + &repo_path, + composefs::fsverity::Algorithm::SHA256, + false, + )?; + let repo = Arc::new(repo); let (verity, _stats) = crate::import_layer(&repo, &diff_id, Some("layer"), &tar_data[..]).await?; diff --git a/crates/composefs-oci/src/lib.rs b/crates/composefs-oci/src/lib.rs index 325aa70e..ce220e70 100644 --- a/crates/composefs-oci/src/lib.rs +++ b/crates/composefs-oci/src/lib.rs @@ -318,6 +318,20 @@ mod test { use super::*; + /// Create a test repository with meta.json in insecure mode. + fn create_test_repo() -> (tempfile::TempDir, Arc>) { + let dir = tempdir(); + let repo_path = dir.path().join("repo"); + let (repo, _) = Repository::init_path( + CWD, + &repo_path, + composefs::fsverity::Algorithm::SHA256, + false, + ) + .expect("initializing test repo"); + (dir, Arc::new(repo)) + } + fn append_data(builder: &mut ::tar::Builder>, name: &str, size: usize) { let mut header = ::tar::Header::new_ustar(); header.set_uid(0); @@ -346,8 +360,7 @@ mod test { context.update(&layer); let layer_id = format!("sha256:{}", hex::encode(context.finalize())); - let repo_dir = tempdir(); - let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + let (_repo_dir, repo) = create_test_repo(); let (id, _stats) = import_layer(&repo, &layer_id, Some("name"), &layer[..]) .await .unwrap(); @@ -369,8 +382,7 @@ mod test { fn test_write_and_open_config() { use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; - let repo_dir = tempdir(); - let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + let (_repo_dir, repo) = create_test_repo(); let rootfs = RootFsBuilder::default() .typ("layers") @@ -407,8 +419,7 @@ mod test { fn test_config_stored_as_external_object() { use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; - let repo_dir = tempdir(); - let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + let (_repo_dir, repo) = create_test_repo(); let rootfs = RootFsBuilder::default() .typ("layers") @@ -466,8 +477,7 @@ mod test { fn test_open_config_bad_hash() { use oci_spec::image::{ImageConfigurationBuilder, RootFsBuilder}; - let repo_dir = tempdir(); - let repo = Arc::new(Repository::::open_path(CWD, &repo_dir).unwrap()); + let (_repo_dir, repo) = create_test_repo(); let rootfs = RootFsBuilder::default() .typ("layers") diff --git a/crates/composefs-oci/src/tar.rs b/crates/composefs-oci/src/tar.rs index 5fee3524..0d0b890d 100644 --- a/crates/composefs-oci/src/tar.rs +++ b/crates/composefs-oci/src/tar.rs @@ -457,12 +457,13 @@ mod tests { Lazy::new(|| Mutex::new(Vec::new())); pub(crate) fn create_test_repository() -> Result>> { - // Create a temporary repository for testing and store it in static let tempdir = tempfile::TempDir::new().unwrap(); - let fd = rustix::fs::open( - tempdir.path(), - rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::PATH, - 0.into(), + let repo_path = tempdir.path().join("repo"); + let (repo, _) = Repository::init_path( + rustix::fs::CWD, + &repo_path, + composefs::fsverity::Algorithm::SHA256, + false, )?; // Store tempdir in static to keep it alive @@ -471,9 +472,6 @@ mod tests { guard.push(tempdir); } - let mut repo = Repository::open_path(&fd, ".").unwrap(); - repo.set_insecure(true); - Ok(Arc::new(repo)) } diff --git a/crates/composefs-setup-root/src/main.rs b/crates/composefs-setup-root/src/main.rs index 216065f5..c82becf5 100644 --- a/crates/composefs-setup-root/src/main.rs +++ b/crates/composefs-setup-root/src/main.rs @@ -182,12 +182,20 @@ fn mount_composefs_image(sysroot: &OwnedFd, name: &str, insecure: bool) -> Resul match name.len() { 128 => { let mut repo = Repository::::open_path(sysroot, "composefs")?; - repo.set_insecure(insecure); + if insecure { + repo.set_insecure(); + } else { + repo.require_verity()?; + } repo.mount(name).context("Failed to mount composefs image") } 64 => { let mut repo = Repository::::open_path(sysroot, "composefs")?; - repo.set_insecure(insecure); + if insecure { + repo.set_insecure(); + } else { + repo.require_verity()?; + } repo.mount(name).context("Failed to mount composefs image") } _ => anyhow::bail!("Invalid composefs digest length: {}", name.len()), diff --git a/crates/composefs/Cargo.toml b/crates/composefs/Cargo.toml index 39a3bed2..adbbe1d8 100644 --- a/crates/composefs/Cargo.toml +++ b/crates/composefs/Cargo.toml @@ -24,6 +24,7 @@ hex = { version = "0.4.0", default-features = false, features = ["std"] } log = { version = "0.4.8", default-features = false } once_cell = { version = "1.21.3", default-features = false, features = ["std"] } rustix = { version = "1.0.0", default-features = false, features = ["fs", "mount", "process", "std"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } sha2 = { version = "0.10.1", default-features = false, features = ["std"] } thiserror = { version = "2.0.0", default-features = false } tokio = { version = "1.24.2", default-features = false, features = ["macros", "process", "io-util", "rt-multi-thread", "sync"] } diff --git a/crates/composefs/src/erofs/composefs.rs b/crates/composefs/src/erofs/composefs.rs index a743e65b..3acf1844 100644 --- a/crates/composefs/src/erofs/composefs.rs +++ b/crates/composefs/src/erofs/composefs.rs @@ -24,7 +24,7 @@ impl OverlayMetacopy { version: 0, len: size_of::() as u8, flags: 0, - digest_algo: H::ALGORITHM, + digest_algo: H::ALGORITHM.kernel_id(), digest: digest.clone(), } } @@ -33,7 +33,7 @@ impl OverlayMetacopy { self.version == 0 && self.len == size_of::() as u8 && self.flags == 0 - && self.digest_algo == H::ALGORITHM + && self.digest_algo == H::ALGORITHM.kernel_id() } pub(super) fn version(&self) -> u8 { diff --git a/crates/composefs/src/erofs/reader.rs b/crates/composefs/src/erofs/reader.rs index b3a8439d..ec15fee4 100644 --- a/crates/composefs/src/erofs/reader.rs +++ b/crates/composefs/src/erofs/reader.rs @@ -1248,7 +1248,7 @@ fn check_metacopy_xattr( value.flags(), value.digest_algo(), size_of::>(), - ObjectID::ALGORITHM, + ObjectID::ALGORITHM.kernel_id(), ); } Ok(None) diff --git a/crates/composefs/src/fsverity/digest.rs b/crates/composefs/src/fsverity/digest.rs index 728a3a90..d99d632e 100644 --- a/crates/composefs/src/fsverity/digest.rs +++ b/crates/composefs/src/fsverity/digest.rs @@ -169,7 +169,7 @@ impl FsVerityHasher { let mut context = H::Digest::new(); context.update(1u8.to_le_bytes()); /* version */ - context.update(H::ALGORITHM.to_le_bytes()); /* hash_algorithm */ + context.update(H::ALGORITHM.kernel_id().to_le_bytes()); /* hash_algorithm */ context.update(LG_BLKSZ.to_le_bytes()); /* log_blocksize */ context.update(0u8.to_le_bytes()); /* salt_size */ context.update([0; 4]); /* reserved */ diff --git a/crates/composefs/src/fsverity/hashvalue.rs b/crates/composefs/src/fsverity/hashvalue.rs index 4581da84..5a77f5c9 100644 --- a/crates/composefs/src/fsverity/hashvalue.rs +++ b/crates/composefs/src/fsverity/hashvalue.rs @@ -1,10 +1,10 @@ //! Hash value types and trait definitions for fs-verity. //! -//! This module defines the FsVerityHashValue trait and concrete implementations -//! for SHA-256 and SHA-512 hash values, including parsing from hex strings -//! and object pathnames. +//! This module defines the [`FsVerityHashValue`] trait, concrete implementations +//! for SHA-256 and SHA-512 hash values, and the [`Algorithm`] type that +//! identifies an fs-verity algorithm configuration (hash + block size). -use core::{fmt, hash::Hash}; +use core::{fmt, hash::Hash, str::FromStr}; use hex::FromHexError; use sha2::{digest::FixedOutputReset, digest::Output, Digest, Sha256, Sha512}; @@ -25,12 +25,10 @@ where { /// The underlying hash digest algorithm type. type Digest: Digest + FixedOutputReset + fmt::Debug; - /// The fs-verity algorithm identifier (1 for SHA-256, 2 for SHA-512). - const ALGORITHM: u8; + /// The fs-verity algorithm for this hash type. + const ALGORITHM: Algorithm; /// An empty hash value with all bytes set to zero. const EMPTY: Self; - /// The algorithm identifier string ("sha256" or "sha512"). - const ID: &str; /// Parse a hash value from a hexadecimal string. /// @@ -141,19 +139,19 @@ where /// # Returns /// A string in the format "algorithm:hexhash" (e.g., "sha256:abc123..."). fn to_id(&self) -> String { - format!("{}:{}", Self::ID, self.to_hex()) + format!("{}:{}", Self::ALGORITHM.hash_name(), self.to_hex()) } } impl fmt::Debug for Sha256HashValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "sha256:{}", self.to_hex()) + write!(f, "{}:{}", Self::ALGORITHM.hash_name(), self.to_hex()) } } impl fmt::Debug for Sha512HashValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "sha512:{}", self.to_hex()) + write!(f, "{}:{}", Self::ALGORITHM.hash_name(), self.to_hex()) } } @@ -172,9 +170,8 @@ impl From> for Sha256HashValue { impl FsVerityHashValue for Sha256HashValue { type Digest = Sha256; - const ALGORITHM: u8 = 1; + const ALGORITHM: Algorithm = Algorithm::SHA256; const EMPTY: Self = Self([0; 32]); - const ID: &str = "sha256"; } /// A SHA-512 hash value for fs-verity operations. @@ -192,11 +189,183 @@ impl From> for Sha512HashValue { impl FsVerityHashValue for Sha512HashValue { type Digest = Sha512; - const ALGORITHM: u8 = 2; + const ALGORITHM: Algorithm = Algorithm::SHA512; const EMPTY: Self = Self([0; 64]); - const ID: &str = "sha512"; } +/// Default log2 block size for fs-verity (4096 bytes). +pub const DEFAULT_LG_BLOCKSIZE: u8 = 12; + +/// An fs-verity algorithm identifier. +/// +/// Each variant corresponds to a hash function supported by the Linux +/// kernel's fs-verity subsystem. The `lg_blocksize` field is the log2 +/// of the Merkle tree block size (always 12, i.e. 4096 bytes, today). +/// +/// The string representation is `fsverity--`, +/// e.g. `fsverity-sha256-12` or `fsverity-sha512-12`. +/// +/// # Examples +/// +/// ``` +/// use composefs::fsverity::Algorithm; +/// +/// let alg: Algorithm = "fsverity-sha512-12".parse().unwrap(); +/// assert_eq!(alg.hash_name(), "sha512"); +/// assert_eq!(alg.lg_blocksize(), 12); +/// assert_eq!(alg.kernel_id(), 2); +/// assert_eq!(alg.to_string(), "fsverity-sha512-12"); +/// +/// // Construct from a hash type at compile time +/// use composefs::fsverity::Sha256HashValue; +/// let alg = Algorithm::for_hash::(); +/// assert_eq!(alg.to_string(), "fsverity-sha256-12"); +/// assert_eq!(alg.kernel_id(), 1); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Algorithm { + /// SHA-256 with the given log2 block size. + Sha256 { + /// Log2 of the Merkle tree block size (e.g. 12 for 4096 bytes). + lg_blocksize: u8, + }, + /// SHA-512 with the given log2 block size. + Sha512 { + /// Log2 of the Merkle tree block size (e.g. 12 for 4096 bytes). + lg_blocksize: u8, + }, +} + +/// Errors from parsing an [`Algorithm`] string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AlgorithmParseError { + /// The string does not start with `fsverity-`. + MissingPrefix, + /// The hash-blocksize separator is missing. + MissingSeparator, + /// The hash name is not recognised. + UnknownHash(String), + /// The log2 block size is not a valid number. + InvalidBlockSize(String), + /// The log2 block size value is not currently supported. + UnsupportedBlockSize(u8), +} + +impl fmt::Display for AlgorithmParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingPrefix => write!(f, "algorithm must start with 'fsverity-'"), + Self::MissingSeparator => { + write!(f, "algorithm must be 'fsverity--'") + } + Self::UnknownHash(h) => { + write!( + f, + "unsupported hash algorithm '{h}' (expected sha256 or sha512)" + ) + } + Self::InvalidBlockSize(s) => write!(f, "invalid lg_blocksize '{s}'"), + Self::UnsupportedBlockSize(n) => write!( + f, + "unsupported lg_blocksize {n} (only {DEFAULT_LG_BLOCKSIZE} is currently supported)" + ), + } + } +} + +impl std::error::Error for AlgorithmParseError {} + +impl Algorithm { + /// SHA-256 with the default block size (convenience constant). + pub const SHA256: Self = Self::Sha256 { + lg_blocksize: DEFAULT_LG_BLOCKSIZE, + }; + + /// SHA-512 with the default block size (convenience constant). + pub const SHA512: Self = Self::Sha512 { + lg_blocksize: DEFAULT_LG_BLOCKSIZE, + }; + + /// Build the algorithm identifier for a given [`FsVerityHashValue`] type. + pub fn for_hash() -> Self { + H::ALGORITHM + } + + /// The hash algorithm name (e.g. `"sha256"` or `"sha512"`). + pub const fn hash_name(&self) -> &'static str { + match self { + Self::Sha256 { .. } => "sha256", + Self::Sha512 { .. } => "sha512", + } + } + + /// The Linux kernel `FS_VERITY_HASH_ALGORITHM_*` identifier. + /// + /// Returns 1 for SHA-256, 2 for SHA-512. + pub const fn kernel_id(&self) -> u8 { + match self { + Self::Sha256 { .. } => 1, + Self::Sha512 { .. } => 2, + } + } + + /// The log2 block size (e.g. `12` for 4096-byte blocks). + pub const fn lg_blocksize(&self) -> u8 { + match self { + Self::Sha256 { lg_blocksize } | Self::Sha512 { lg_blocksize } => *lg_blocksize, + } + } + + /// Check whether this algorithm is compatible with the given hash type. + pub fn is_compatible(&self) -> bool { + std::mem::discriminant(self) == std::mem::discriminant(&H::ALGORITHM) + } +} + +impl FromStr for Algorithm { + type Err = AlgorithmParseError; + + fn from_str(s: &str) -> Result { + let rest = s + .strip_prefix("fsverity-") + .ok_or(AlgorithmParseError::MissingPrefix)?; + let (hash, lg_bs) = rest + .rsplit_once('-') + .ok_or(AlgorithmParseError::MissingSeparator)?; + + let lg_blocksize: u8 = lg_bs + .parse() + .map_err(|_| AlgorithmParseError::InvalidBlockSize(lg_bs.to_owned()))?; + if lg_blocksize != DEFAULT_LG_BLOCKSIZE { + return Err(AlgorithmParseError::UnsupportedBlockSize(lg_blocksize)); + } + + match hash { + "sha256" => Ok(Self::Sha256 { lg_blocksize }), + "sha512" => Ok(Self::Sha512 { lg_blocksize }), + other => Err(AlgorithmParseError::UnknownHash(other.to_owned())), + } + } +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "fsverity-{}-{}", self.hash_name(), self.lg_blocksize()) + } +} + +impl serde::Serialize for Algorithm { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for Algorithm { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} #[cfg(test)] mod test { use super::*; @@ -208,8 +377,14 @@ mod test { let hex = H::EMPTY.to_hex(); assert_eq!(hex.as_bytes(), [b'0'].repeat(hexlen)); - assert_eq!(H::EMPTY.to_id(), format!("{}:{}", H::ID, hex)); - assert_eq!(format!("{:?}", H::EMPTY), format!("{}:{}", H::ID, hex)); + assert_eq!( + H::EMPTY.to_id(), + format!("{}:{}", H::ALGORITHM.hash_name(), hex) + ); + assert_eq!( + format!("{:?}", H::EMPTY), + format!("{}:{}", H::ALGORITHM.hash_name(), hex) + ); assert_eq!(H::from_hex(&hex), Ok(H::EMPTY)); @@ -274,4 +449,62 @@ mod test { fn test_sha512hashvalue() { test_fsverity_hash::(); } + + #[test] + fn test_algorithm_for_hash() { + let a256 = Algorithm::for_hash::(); + assert_eq!(a256.hash_name(), "sha256"); + assert_eq!(a256.kernel_id(), 1); + assert_eq!(a256.lg_blocksize(), 12); + assert_eq!(a256.to_string(), "fsverity-sha256-12"); + assert!(a256.is_compatible::()); + assert!(!a256.is_compatible::()); + + let a512 = Algorithm::for_hash::(); + assert_eq!(a512.hash_name(), "sha512"); + assert_eq!(a512.kernel_id(), 2); + assert_eq!(a512.to_string(), "fsverity-sha512-12"); + assert!(a512.is_compatible::()); + assert!(!a512.is_compatible::()); + } + + #[test] + fn test_algorithm_parse_roundtrip() { + for s in ["fsverity-sha256-12", "fsverity-sha512-12"] { + let alg: Algorithm = s.parse().unwrap(); + assert_eq!(alg.to_string(), s); + } + } + + #[test] + fn test_algorithm_parse_errors() { + let cases = [ + ("sha256-12", AlgorithmParseError::MissingPrefix), + ("garbage", AlgorithmParseError::MissingPrefix), + ("fsverity-sha256", AlgorithmParseError::MissingSeparator), + ( + "fsverity-sha1-12", + AlgorithmParseError::UnknownHash("sha1".to_owned()), + ), + ( + "fsverity-sha256-abc", + AlgorithmParseError::InvalidBlockSize("abc".to_owned()), + ), + ( + "fsverity-sha256-16", + AlgorithmParseError::UnsupportedBlockSize(16), + ), + ]; + for (input, expected) in cases { + let err = input.parse::().unwrap_err(); + assert_eq!(err, expected, "input: {input}"); + } + } + + #[test] + fn test_algorithm_equality() { + let a: Algorithm = "fsverity-sha512-12".parse().unwrap(); + let b = Algorithm::for_hash::(); + assert_eq!(a, b); + } } diff --git a/crates/composefs/src/fsverity/ioctl.rs b/crates/composefs/src/fsverity/ioctl.rs index 43b52026..9e60e0ae 100644 --- a/crates/composefs/src/fsverity/ioctl.rs +++ b/crates/composefs/src/fsverity/ioctl.rs @@ -18,7 +18,7 @@ use super::FsVerityHashValue; pub(super) fn fs_ioc_enable_verity( fd: impl AsFd, ) -> Result<(), EnableVerityError> { - composefs_ioctls::fsverity::fs_ioc_enable_verity(fd.as_fd(), H::ALGORITHM, 4096) + composefs_ioctls::fsverity::fs_ioc_enable_verity(fd.as_fd(), H::ALGORITHM.kernel_id(), 4096) } /// Measure the fsverity digest of the provided file descriptor. @@ -28,20 +28,19 @@ pub(super) fn fs_ioc_measure_verity( fd: impl AsFd, ) -> Result { // Dispatch based on algorithm to call the appropriate const-generic version - match H::ALGORITHM { + let kid = H::ALGORITHM.kernel_id(); + match kid { 1 => { - // SHA-256 let digest: [u8; 32] = - composefs_ioctls::fsverity::fs_ioc_measure_verity(fd.as_fd(), H::ALGORITHM)?; + composefs_ioctls::fsverity::fs_ioc_measure_verity(fd.as_fd(), kid)?; Ok(H::read_from_bytes(&digest).expect("size mismatch")) } 2 => { - // SHA-512 let digest: [u8; 64] = - composefs_ioctls::fsverity::fs_ioc_measure_verity(fd.as_fd(), H::ALGORITHM)?; + composefs_ioctls::fsverity::fs_ioc_measure_verity(fd.as_fd(), kid)?; Ok(H::read_from_bytes(&digest).expect("size mismatch")) } - _ => panic!("Unsupported algorithm: {}", H::ALGORITHM), + _ => unreachable!(), } } diff --git a/crates/composefs/src/fsverity/mod.rs b/crates/composefs/src/fsverity/mod.rs index 8ec320be..3b74555e 100644 --- a/crates/composefs/src/fsverity/mod.rs +++ b/crates/composefs/src/fsverity/mod.rs @@ -22,7 +22,10 @@ use std::{ use rustix::fs::{open, openat, Mode, OFlags}; use thiserror::Error; -pub use hashvalue::{FsVerityHashValue, Sha256HashValue, Sha512HashValue}; +pub use hashvalue::{ + Algorithm, AlgorithmParseError, FsVerityHashValue, Sha256HashValue, Sha512HashValue, + DEFAULT_LG_BLOCKSIZE, +}; // Re-export error types from composefs-ioctls pub use ioctl::{EnableVerityError, MeasureVerityError}; diff --git a/crates/composefs/src/repository.rs b/crates/composefs/src/repository.rs index b3834f25..f5c0cfc3 100644 --- a/crates/composefs/src/repository.rs +++ b/crates/composefs/src/repository.rs @@ -100,7 +100,7 @@ use fn_error_context::context; use once_cell::sync::OnceCell; use rustix::{ fs::{ - flock, linkat, mkdirat, open, openat, readlinkat, statat, syncfs, unlinkat, AtFlags, Dir, + flock, linkat, mkdirat, openat, readlinkat, statat, syncfs, unlinkat, AtFlags, Dir, FileType, FlockOperation, Mode, OFlags, CWD, }, io::{Errno, Result as ErrnoResult}, @@ -109,14 +109,413 @@ use rustix::{ use crate::{ fsverity::{ compute_verity, enable_verity_maybe_copy, ensure_verity_equal, measure_verity, - CompareVerityError, EnableVerityError, FsVerityHashValue, FsVerityHasher, - MeasureVerityError, + measure_verity_opt, Algorithm, CompareVerityError, EnableVerityError, FsVerityHashValue, + FsVerityHasher, MeasureVerityError, }, mount::{composefs_fsmount, mount_at}, splitstream::{SplitStreamReader, SplitStreamWriter}, - util::{proc_self_fd, replace_symlinkat, ErrnoFilter}, + util::{proc_self_fd, reopen_tmpfile_ro, replace_symlinkat, ErrnoFilter}, }; +/// The filename used for repository metadata. +pub const REPO_METADATA_FILENAME: &str = "meta.json"; + +/// Errors that can occur when opening a repository. +#[derive(Debug, thiserror::Error)] +pub enum RepositoryOpenError { + /// `meta.json` is missing and the directory does not appear to be + /// an existing repository. + #[error("{REPO_METADATA_FILENAME} not found; this repository must be initialized with `cfsctl init`")] + MetadataMissing, + /// `meta.json` is missing but `objects/` exists, indicating an + /// old-format repository that predates `meta.json`. + #[error("{REPO_METADATA_FILENAME} not found; this appears to be an old-format repository — run `cfsctl init --reset-metadata` to migrate")] + OldFormatRepository, + /// `meta.json` exists but could not be parsed. + #[error("failed to parse {REPO_METADATA_FILENAME}")] + MetadataInvalid(#[source] serde_json::Error), + /// The algorithm in `meta.json` does not match the expected type. + #[error("repository algorithm {found} does not match expected {expected}")] + AlgorithmMismatch { + /// The algorithm found in `meta.json`. + found: Algorithm, + /// The algorithm expected for this repository type. + expected: Algorithm, + }, + /// The repository format version is newer than this tool supports. + #[error( + "unsupported repository format version {found} (this tool supports up to {REPO_FORMAT_VERSION})" + )] + UnsupportedVersion { + /// The version found in `meta.json`. + found: u32, + }, + /// The repository requires features this tool does not understand. + #[error("repository requires unknown incompatible features: {0:?}")] + IncompatibleFeatures(Vec), + /// An I/O error occurred while opening or probing the repository. + #[error(transparent)] + Io(std::io::Error), +} + +impl From for RepositoryOpenError { + fn from(e: Errno) -> Self { + Self::Io(e.into()) + } +} + +impl From for RepositoryOpenError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +/// The current repository format version. +/// +/// This is a simple integer that is bumped only for fundamental, +/// incompatible changes to the repository layout. Finer-grained +/// evolution uses the [`FeatureFlags`] system instead. +pub const REPO_FORMAT_VERSION: u32 = 1; + +/// Set of feature flags understood by this version of the code. +/// +/// When reading a repository whose metadata lists features not in +/// these sets, the rules are: +/// +/// - Unknown **compatible** features are silently ignored. +/// - Unknown **read-only compatible** features allow read operations +/// but prevent any writes (adding objects, creating images, GC, …). +/// - Unknown **incompatible** features cause the repository to be +/// rejected entirely. +/// +/// There are currently no defined features. +pub mod known_features { + /// Compatible features understood by this version. + pub const COMPAT: &[&str] = &[]; + /// Read-only compatible features understood by this version. + pub const RO_COMPAT: &[&str] = &[]; + /// Incompatible features understood by this version. + pub const INCOMPAT: &[&str] = &[]; +} + +/// Feature flags for a composefs repository. +/// +/// Inspired by the ext4/XFS/EROFS on-disk feature model: +/// +/// - **compatible**: old tools that don't understand these can still +/// fully read and write the repository. +/// - **read_only_compatible**: old tools can read but must not write. +/// - **incompatible**: old tools must refuse to open the repository. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct FeatureFlags { + /// Features that can be safely ignored by older tools. + #[serde(default)] + pub compatible: Vec, + + /// Features that allow reading but prevent writing by older tools. + #[serde(default)] + pub read_only_compatible: Vec, + + /// Features that require newer tools; older tools must refuse entirely. + #[serde(default)] + pub incompatible: Vec, +} + +/// Result of checking repository feature compatibility. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FeatureCheck { + /// All features are understood; full read-write access. + ReadWrite, + /// Unknown read-only-compatible features present; read access only. + /// The vec contains the unknown feature names. + ReadOnly(Vec), +} + +impl FeatureFlags { + /// Check these flags against the known feature sets. + /// + /// Returns an error if any unknown incompatible features are present. + /// Returns [`FeatureCheck::ReadOnly`] if unknown ro-compat features + /// are present. Returns [`FeatureCheck::ReadWrite`] otherwise. + pub fn check(&self) -> Result { + // Check incompatible features first + let unknown_incompat: Vec = self + .incompatible + .iter() + .filter(|f| !known_features::INCOMPAT.contains(&f.as_str())) + .cloned() + .collect(); + if !unknown_incompat.is_empty() { + return Err(RepositoryOpenError::IncompatibleFeatures(unknown_incompat)); + } + + // Check ro-compat features + let unknown_ro: Vec = self + .read_only_compatible + .iter() + .filter(|f| !known_features::RO_COMPAT.contains(&f.as_str())) + .cloned() + .collect(); + if !unknown_ro.is_empty() { + return Ok(FeatureCheck::ReadOnly(unknown_ro)); + } + + // Compatible features are ignored by definition + Ok(FeatureCheck::ReadWrite) + } +} + +/// Repository metadata stored in `meta.json` at the repository root. +/// +/// This file records the repository's format version, digest algorithm, +/// and feature flags so that tools can detect misconfigured invocations +/// (e.g. opening a sha256 repo with `--hash sha512`) and so the +/// algorithm doesn't need to be specified on every command. +/// +/// The versioning model is inspired by Linux filesystem superblocks +/// (ext4, XFS, EROFS): a base version integer for fundamental layout +/// changes, plus three tiers of feature flags for finer-grained +/// evolution. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct RepoMetadata { + /// Base repository format version. Tools must refuse to operate + /// on a repository whose version exceeds what they understand. + pub version: u32, + + /// The fs-verity algorithm configuration for this repository. + pub algorithm: Algorithm, + + /// Feature flags. + #[serde(default)] + pub features: FeatureFlags, +} + +impl RepoMetadata { + /// Build metadata for a repository using the given hash type. + pub fn for_hash() -> Self { + Self { + version: REPO_FORMAT_VERSION, + algorithm: Algorithm::for_hash::(), + features: FeatureFlags::default(), + } + } + + /// Build metadata from an explicit [`Algorithm`]. + pub fn new(algorithm: Algorithm) -> Self { + Self { + version: REPO_FORMAT_VERSION, + algorithm, + features: FeatureFlags::default(), + } + } + + /// Check whether this metadata is compatible with the given hash type. + /// + /// Validates the base version, feature flags, and algorithm. + /// Returns a [`FeatureCheck`] indicating read-write or read-only access. + pub fn check_compatible( + &self, + ) -> Result { + if self.version > REPO_FORMAT_VERSION { + return Err(RepositoryOpenError::UnsupportedVersion { + found: self.version, + }); + } + if !self.algorithm.is_compatible::() { + return Err(RepositoryOpenError::AlgorithmMismatch { + found: self.algorithm, + expected: Algorithm::for_hash::(), + }); + } + let access = self.features.check()?; + Ok(access) + } + + /// Serialize to pretty-printed JSON with a trailing newline. + pub fn to_json(&self) -> Result> { + let mut buf = serde_json::to_vec_pretty(self).context("serializing repository metadata")?; + buf.push(b'\n'); + Ok(buf) + } + + /// Deserialize from JSON bytes. + #[context("Parsing repository metadata JSON")] + pub fn from_json(data: &[u8]) -> Result { + serde_json::from_slice(data).context("deserializing repository metadata") + } +} + +/// Read the fs-verity algorithm from a repository's `meta.json`. +/// +/// This is the public API for determining which algorithm a repository +/// uses before opening it (needed to choose the correct `ObjectID` +/// generic parameter for [`Repository::open_path`]). +/// +/// Returns `Ok(None)` when `meta.json` is absent. +#[context("Reading repository algorithm")] +pub fn read_repo_algorithm(repo_fd: &impl AsFd) -> Result> { + Ok(read_repo_metadata(repo_fd)?.map(|m| m.algorithm)) +} + +/// Read `meta.json` from a repository directory fd, if it exists. +/// +/// Returns `Ok(None)` when the file is absent. +#[context("Reading repository metadata")] +pub(crate) fn read_repo_metadata(repo_fd: &impl AsFd) -> Result> { + match openat( + repo_fd, + REPO_METADATA_FILENAME, + OFlags::RDONLY | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => { + let meta = serde_json::from_reader(std::io::BufReader::new(File::from(fd))) + .context("parsing meta.json")?; + Ok(Some(meta)) + } + Err(Errno::NOENT) => Ok(None), + Err(e) => Err(e).context("opening meta.json")?, + } +} + +/// Enable fs-verity on an fd, dispatching to the correct hash type +/// based on the [`Algorithm`]. +fn enable_verity_for_algorithm( + dirfd: &impl AsFd, + fd: BorrowedFd, + algorithm: &Algorithm, +) -> Result<()> { + match algorithm { + Algorithm::Sha256 { .. } => { + enable_verity_maybe_copy::(dirfd, fd) + .context("enabling verity (sha256)")?; + } + Algorithm::Sha512 { .. } => { + enable_verity_maybe_copy::(dirfd, fd) + .context("enabling verity (sha512)")?; + } + } + Ok(()) +} + +/// Remove algorithm-specific data from a repository directory. +/// +/// Deletes `streams/`, `images/`, and `meta.json` but preserves +/// `objects/` (content-addressed blobs that are algorithm-agnostic). +/// This prepares the repository for re-initialization with a +/// (potentially different) algorithm via [`Repository::init_path`]. +/// +/// After calling this, streams and images will need to be re-imported. +#[context("Resetting repository metadata at {}", path.as_ref().display())] +pub fn reset_metadata(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + for dir in ["streams", "images"] { + let p = path.join(dir); + if p.exists() { + std::fs::remove_dir_all(&p).with_context(|| format!("removing {}", p.display()))?; + } + } + let meta_path = path.join(REPO_METADATA_FILENAME); + if meta_path.exists() { + std::fs::remove_file(&meta_path) + .with_context(|| format!("removing {}", meta_path.display()))?; + } + Ok(()) +} + +/// Return the default path for the user-owned composefs repository. +pub fn user_path() -> Result { + let home = std::env::var("HOME").with_context(|| "$HOME must be set when in user mode")?; + Ok(PathBuf::from(home).join(".var/lib/composefs")) +} + +/// Return the default path for the system-global composefs repository. +pub fn system_path() -> PathBuf { + PathBuf::from("/sysroot/composefs") +} + +/// Write `meta.json` into a repository directory fd. +/// +/// This atomically writes (via O_TMPFILE + linkat) the metadata file. +/// It will fail if the file already exists. +/// +/// If `enable_verity` is true, fs-verity is enabled on `meta.json` +/// before linking it into place. This signals to future +/// [`Repository::open_path`] callers that verity is required on all +/// objects. +#[context("Writing repository metadata")] +pub(crate) fn write_repo_metadata( + repo_fd: &impl AsFd, + meta: &RepoMetadata, + enable_verity: bool, +) -> Result<()> { + let data = meta.to_json()?; + + // Try O_TMPFILE for atomic creation + match openat( + repo_fd, + ".", + OFlags::WRONLY | OFlags::TMPFILE | OFlags::CLOEXEC, + Mode::from_raw_mode(0o644), + ) { + Ok(fd) => { + let mut file = File::from(fd); + file.write_all(&data) + .context("writing metadata to tmpfile")?; + file.sync_all().context("syncing metadata tmpfile")?; + + let ro_fd = reopen_tmpfile_ro(file).context("re-opening tmpfile read-only")?; + + if enable_verity { + enable_verity_for_algorithm(repo_fd, ro_fd.as_fd(), &meta.algorithm) + .context("enabling verity on meta.json")?; + } + + linkat( + CWD, + proc_self_fd(&ro_fd), + repo_fd, + REPO_METADATA_FILENAME, + AtFlags::SYMLINK_FOLLOW, + ) + .context("linking meta.json into repository")?; + } + Err(Errno::OPNOTSUPP | Errno::NOSYS) => { + // Fallback: direct create (no tmpfs O_TMPFILE support). + // Use O_EXCL to avoid overwriting, and fsync to ensure the + // file is complete on disk before we consider init done. + let fd = openat( + repo_fd, + REPO_METADATA_FILENAME, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC, + Mode::from_raw_mode(0o644), + ) + .context("creating meta.json")?; + let mut file = File::from(fd); + file.write_all(&data).context("writing meta.json")?; + file.sync_all().context("syncing meta.json to disk")?; + + if enable_verity { + let ro_fd = openat( + repo_fd, + REPO_METADATA_FILENAME, + OFlags::RDONLY | OFlags::CLOEXEC, + Mode::empty(), + ) + .context("re-opening meta.json for verity")?; + drop(file); + enable_verity_for_algorithm(repo_fd, ro_fd.as_fd(), &meta.algorithm) + .context("enabling verity on meta.json")?; + } + } + Err(e) => { + return Err(e).context("creating tmpfile for meta.json")?; + } + } + Ok(()) +} + /// How an object was stored in the repository. /// /// Returned by [`Repository::ensure_object_from_file_with_stats`] to indicate @@ -165,6 +564,7 @@ pub struct Repository { objects: OnceCell, write_semaphore: OnceCell>, insecure: bool, + metadata: RepoMetadata, _data: std::marker::PhantomData, } @@ -281,6 +681,12 @@ pub enum FsckError { #[error("fsck: image-missing-object: {path}: {object_id}")] #[serde(rename_all = "camelCase")] ImageMissingObject { path: String, object_id: String }, + + #[error("fsck: metadata-parse-failed: meta.json: {detail}")] + MetadataParseFailed { detail: String }, + + #[error("fsck: metadata-algorithm-mismatch: meta.json: expected {expected}, repository opened as {actual}")] + MetadataAlgorithmMismatch { expected: String, actual: String }, } /// Results from a filesystem consistency check. @@ -289,6 +695,7 @@ pub enum FsckError { #[derive(Debug, Clone, Default, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct FsckResult { + pub(crate) has_metadata: bool, pub(crate) objects_checked: u64, pub(crate) objects_corrupted: u64, pub(crate) streams_checked: u64, @@ -301,6 +708,11 @@ pub struct FsckResult { } impl FsckResult { + /// Whether the repository has a `meta.json` file. + pub fn has_metadata(&self) -> bool { + self.has_metadata + } + /// Returns true if no corruption or errors were found. pub fn is_ok(&self) -> bool { debug_assert!( @@ -363,6 +775,19 @@ impl FsckResult { impl fmt::Display for FsckResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let metadata_errors = self.errors.iter().any(|e| { + matches!( + e, + FsckError::MetadataParseFailed { .. } | FsckError::MetadataAlgorithmMismatch { .. } + ) + }); + if metadata_errors { + writeln!(f, "meta.json: error")?; + } else if self.has_metadata { + writeln!(f, "meta.json: ok")?; + } else { + writeln!(f, "meta.json: absent")?; + } writeln!( f, "objects: {}/{} ok", @@ -420,39 +845,175 @@ impl Repository { .clone() } + /// Initialize a new repository at the target path and open it. + /// + /// Creates the directory (mode 0700) if it does not exist, writes + /// `meta.json` for the given `algorithm`, and returns the opened + /// repository together with a flag indicating whether this was a + /// fresh initialization (`true`) or an idempotent open of an + /// existing repository with the same algorithm (`false`). + /// + /// The `algorithm` must be compatible with this repository's + /// `ObjectID` type (e.g. `Algorithm::Sha512` for + /// `Repository`). + /// + /// If `enable_verity` is true, fs-verity is enabled on `meta.json`, + /// signaling that all objects must also have verity. + /// + /// If `meta.json` already exists with a different algorithm, an + /// error is returned. + #[context("Initializing repository at {}", path.as_ref().display())] + pub fn init_path( + dirfd: impl AsFd, + path: impl AsRef, + algorithm: Algorithm, + enable_verity: bool, + ) -> Result<(Self, bool)> { + let path = path.as_ref(); + + if !algorithm.is_compatible::() { + bail!( + "algorithm {} is not compatible with this repository type (expected {})", + algorithm, + Algorithm::for_hash::(), + ); + } + + mkdirat(&dirfd, path, Mode::from_raw_mode(0o700)) + .or_else(|e| if e == Errno::EXIST { Ok(()) } else { Err(e) }) + .with_context(|| format!("creating repository directory {}", path.display()))?; + + let repo_fd = openat( + &dirfd, + path, + OFlags::RDONLY | OFlags::CLOEXEC, + Mode::empty(), + ) + .with_context(|| format!("opening repository directory {}", path.display()))?; + + let meta = RepoMetadata::new(algorithm); + + // Try to write meta.json. If it already exists, check for + // idempotency: same algorithm is fine, different is an error. + if let Err(write_err) = write_repo_metadata(&repo_fd, &meta, enable_verity) { + match read_repo_metadata(&repo_fd)? { + Some(existing) if existing == meta => { + // Idempotent: same config, already initialized. + let repo = Self::open_path(dirfd, path)?; + return Ok((repo, false)); + } + Some(existing) => { + bail!( + "repository already initialized with algorithm '{}'; \ + cannot re-initialize with '{}'", + existing.algorithm, + meta.algorithm, + ); + } + None => { + // meta.json doesn't exist, so the write failure + // was something else — propagate original error. + return Err(write_err); + } + } + } + + drop(repo_fd); + let repo = Self::open_path(dirfd, path)?; + Ok((repo, true)) + } + /// Open a repository at the target directory and path. - #[context("Opening repository at {}", path.as_ref().display())] - pub fn open_path(dirfd: impl AsFd, path: impl AsRef) -> Result { + /// + /// `meta.json` is read, parsed, and validated against this + /// repository's `ObjectID` type. Parsing or compatibility errors + /// are propagated immediately so that broken metadata is never + /// silently ignored. + /// + /// The repository's security mode is auto-detected: if `meta.json` + /// has fs-verity enabled the repo requires verity on all objects + /// (secure mode). Otherwise the repository operates in insecure + /// mode. Use [`set_insecure`] to override after opening. + pub fn open_path( + dirfd: impl AsFd, + path: impl AsRef, + ) -> Result { let path = path.as_ref(); // O_PATH isn't enough because flock() - let repository = openat(dirfd, path, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()) - .with_context(|| format!("Cannot open composefs repository at {}", path.display()))?; + let repository = openat(dirfd, path, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())?; - flock(&repository, FlockOperation::LockShared) - .context("Cannot lock composefs repository")?; + flock(&repository, FlockOperation::LockShared)?; + + // Read, parse, and validate meta.json up front so that broken + // or incompatible metadata is caught immediately rather than + // being discovered lazily on first use. + let (metadata, has_verity) = Self::read_and_probe_metadata(&repository)?; + metadata.check_compatible::()?; Ok(Self { repository, objects: OnceCell::new(), write_semaphore: OnceCell::new(), - insecure: false, + insecure: !has_verity, + metadata, _data: std::marker::PhantomData, }) } + /// Read, parse, and probe verity on `meta.json`. + /// + /// Returns `Ok((metadata, has_verity))` when the file exists, + /// and `Err` when absent or on I/O / parse failures. + fn read_and_probe_metadata( + repo_fd: &OwnedFd, + ) -> Result<(RepoMetadata, bool), RepositoryOpenError> { + let meta_fd = match openat( + repo_fd, + REPO_METADATA_FILENAME, + OFlags::RDONLY | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(Errno::NOENT) => { + // Detect old-format repositories that have objects/ but + // no meta.json. Use filter_errno so non-ENOENT errors + // from statat are propagated. + return Err( + match statat(repo_fd, "objects", AtFlags::empty()).filter_errno(Errno::NOENT) { + Ok(Some(_)) => RepositoryOpenError::OldFormatRepository, + Ok(None) => RepositoryOpenError::MetadataMissing, + Err(e) => e.into(), + }, + ); + } + Err(e) => return Err(e.into()), + }; + + // Clone the fd: one for reading, one for the verity probe. + let read_fd = meta_fd.try_clone()?; + let meta: RepoMetadata = + serde_json::from_reader(std::io::BufReader::new(File::from(read_fd))) + .map_err(RepositoryOpenError::MetadataInvalid)?; + + // Probe verity on the original fd. + let has_verity = measure_verity_opt::(&meta_fd) + .map_err(|e| std::io::Error::other(e.to_string()))? + .is_some(); + + Ok((meta, has_verity)) + } + /// Open the default user-owned composefs repository. #[context("Opening user repository")] pub fn open_user() -> Result { - let home = std::env::var("HOME").with_context(|| "$HOME must be set when in user mode")?; - - Self::open_path(CWD, PathBuf::from(home).join(".var/lib/composefs")) + Ok(Self::open_path(CWD, user_path()?)?) } /// Open the default system-global composefs repository. #[context("Opening system repository")] pub fn open_system() -> Result { - Self::open_path(CWD, PathBuf::from("/sysroot/composefs".to_string())) + Ok(Self::open_path(CWD, system_path())?) } fn ensure_dir(&self, dir: impl AsRef) -> ErrnoResult<()> { @@ -534,13 +1095,8 @@ impl Repository { file: File, size: u64, ) -> Result<(ObjectID, ObjectStoreMethod)> { - // Re-open as read-only via /proc/self/fd (required for verity enable) - let fd_path = proc_self_fd(&file); - let ro_fd = open(&*fd_path, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()) - .context("Re-opening tmpfile as read-only for verity")?; - - // Must close writable fd before enabling verity - drop(file); + let ro_fd = + reopen_tmpfile_ro(file).context("Re-opening tmpfile as read-only for verity")?; // Get objects_dir early since we may need it for verity copy let objects_dir = self @@ -679,17 +1235,10 @@ impl Repository { .with_context(|| "Creating tempfile in object subdirectory")?; let mut file = File::from(fd); file.write_all(data).context("Writing data to tmpfile")?; - // We can't enable verity with an open writable fd, so re-open and close the old one. - let ro_fd = open( - proc_self_fd(&file), - OFlags::RDONLY | OFlags::CLOEXEC, - Mode::empty(), - ) - .context("Re-opening file as read-only for verity")?; // NB: We should do fdatasync() or fsync() here, but doing this for each file forces the // creation of a massive number of journal commits and is a performance disaster. We need // to coordinate this at a higher level. See .write_stream(). - drop(file); + let ro_fd = reopen_tmpfile_ro(file).context("Re-opening file as read-only for verity")?; let ro_fd = match enable_verity_maybe_copy::(dirfd, ro_fd.as_fd()) { Ok(maybe_fd) => { @@ -752,15 +1301,38 @@ impl Repository { Ok(fd) } - /// By default fsverity is required to be enabled on the target - /// filesystem. Setting this disables verification of digests - /// and an instance of [`Self`] can be used on a filesystem - /// without fsverity support. - pub fn set_insecure(&mut self, insecure: bool) -> &mut Self { - self.insecure = insecure; + /// Returns whether the repository is in insecure mode. + /// + /// This is auto-detected from whether `meta.json` has fs-verity + /// enabled, but can be overridden with [`set_insecure`]. + pub fn is_insecure(&self) -> bool { + self.insecure + } + + /// Mark this repository as insecure, disabling verification of + /// fs-verity digests. This allows operation on filesystems + /// without verity support. + pub fn set_insecure(&mut self) -> &mut Self { + self.insecure = true; self } + /// Require that this repository has fs-verity enabled. + /// + /// Returns an error if the repository was not initialized with + /// verity on `meta.json`, since there is no mechanism to + /// retroactively enable verity on existing objects. + pub fn require_verity(&self) -> Result<()> { + if self.insecure { + bail!( + "repository was not initialized with fs-verity \ + (hint: re-create with `cfsctl init` on a \ + verity-capable filesystem)" + ); + } + Ok(()) + } + /// Creates a SplitStreamWriter for writing a split stream. /// You should write the data to the returned object and then pass it to .store_stream() to /// store the result. @@ -1643,6 +2215,9 @@ impl Repository { pub async fn fsck(&self) -> Result { let mut result = FsckResult::default(); + // Phase 0: Validate meta.json if present + self.fsck_metadata(&mut result); + // Phase 1: Verify all objects (parallel across object subdirectories) self.fsck_objects(&mut result) .await @@ -1659,6 +2234,41 @@ impl Repository { Ok(result) } + /// Validate `meta.json`. + /// + /// Since `open_path` already requires `meta.json` to exist and be + /// parseable, this re-reads from disk to verify on-disk integrity + /// and checks algorithm compatibility. + fn fsck_metadata(&self, result: &mut FsckResult) { + match read_repo_metadata(&self.repository) { + Ok(Some(meta)) => { + result.has_metadata = true; + if let Err(e) = meta.check_compatible::() { + result.errors.push(FsckError::MetadataAlgorithmMismatch { + expected: meta.algorithm.to_string(), + actual: ObjectID::ALGORITHM.hash_name().to_string(), + }); + log::warn!("meta.json algorithm mismatch: {e}"); + } + } + Ok(None) => { + // Should not happen since open_path requires meta.json, + // but report it if the file was removed after open. + result.errors.push(FsckError::MetadataParseFailed { + detail: format!( + "{REPO_METADATA_FILENAME} not found; \ + expected because repository was opened successfully" + ), + }); + } + Err(e) => { + result.errors.push(FsckError::MetadataParseFailed { + detail: format!("{e:#}"), + }); + } + } + } + /// Verify all objects in the repository have correct fsverity digests. /// /// Each `objects/XX/` subdirectory is checked on a blocking thread via @@ -2030,6 +2640,15 @@ impl Repository { self.repository.as_fd() } + /// Return the repository metadata parsed from `meta.json` at open time. + /// + /// The metadata was already validated against this repository's + /// `ObjectID` type when the repository was opened, so no further + /// compatibility check is needed. + pub fn metadata(&self) -> &RepoMetadata { + &self.metadata + } + /// Lists all named stream references under a given prefix. /// /// Returns (name, target) pairs where name is relative to the prefix. @@ -2184,16 +2803,14 @@ fn fsck_measure_object( #[cfg(test)] mod tests { use super::*; - use crate::fsverity::Sha512HashValue; + use crate::fsverity::{Sha256HashValue, Sha512HashValue}; use crate::test::tempdir; use rustix::fs::{statat, CWD}; use tempfile::TempDir; /// Create a test repository in insecure mode (no fs-verity required). fn create_test_repo(path: &Path) -> Result>> { - mkdirat(CWD, path, Mode::from_raw_mode(0o755))?; - let mut repo = Repository::open_path(CWD, path)?; - repo.set_insecure(true); + let (repo, _) = Repository::init_path(CWD, path, Algorithm::SHA512, false)?; Ok(Arc::new(repo)) } @@ -3544,4 +4161,147 @@ mod tests { ); Ok(()) } + + // ---- Fsck metadata validation tests ---- + + #[tokio::test] + async fn test_fsck_valid_metadata() -> Result<()> { + let tmp = tempdir(); + let repo = create_test_repo(&tmp.path().join("repo"))?; + + let result = repo.fsck().await?; + assert!(result.is_ok()); + assert!(result.has_metadata()); + assert!(result.errors().is_empty()); + assert!( + result.to_string().contains("meta.json: ok"), + "display should show ok: {result}" + ); + Ok(()) + } + + #[tokio::test] + async fn test_fsck_corrupt_metadata() -> Result<()> { + // Write garbage to meta.json after opening — fsck re-reads from disk. + let tmp = tempdir(); + let repo = create_test_repo(&tmp.path().join("repo"))?; + + let dir = open_test_repo_dir(&tmp); + // Remove the valid meta.json and replace with garbage + dir.remove_file(REPO_METADATA_FILENAME)?; + dir.write(REPO_METADATA_FILENAME, b"not valid json {{")?; + + let result = repo.fsck().await?; + assert!(!result.is_ok()); + assert!(result + .errors() + .iter() + .any(|e| matches!(e, FsckError::MetadataParseFailed { .. }))); + assert!( + result.to_string().contains("meta.json: error"), + "display should show error: {result}" + ); + Ok(()) + } + + #[test] + fn test_open_path_requires_metadata() { + // Opening a directory without meta.json should fail with MetadataMissing. + let tmp = tempdir(); + let path = tmp.path().join("bare-repo"); + mkdirat(CWD, &path, Mode::from_raw_mode(0o755)).unwrap(); + assert!(matches!( + Repository::::open_path(CWD, &path), + Err(RepositoryOpenError::MetadataMissing) + )); + } + + #[test] + fn test_open_path_detects_old_format() { + // A directory with objects/ but no meta.json → OldFormatRepository. + let tmp = tempdir(); + let path = tmp.path().join("old-repo"); + mkdirat(CWD, &path, Mode::from_raw_mode(0o755)).unwrap(); + mkdirat(CWD, &path.join("objects"), Mode::from_raw_mode(0o755)).unwrap(); + assert!(matches!( + Repository::::open_path(CWD, &path), + Err(RepositoryOpenError::OldFormatRepository) + )); + } + + #[test] + fn test_open_path_algorithm_mismatch() { + // Open a sha512 repo as sha256 → AlgorithmMismatch. + let tmp = tempdir(); + let path = tmp.path().join("sha512-repo"); + Repository::::init_path(CWD, &path, Algorithm::SHA512, false).unwrap(); + assert!(matches!( + Repository::::open_path(CWD, &path), + Err(RepositoryOpenError::AlgorithmMismatch { .. }) + )); + } + + // ---- RepoMetadata / FeatureFlags tests ---- + // + // Basic metadata construction, JSON roundtrip, algorithm compatibility, + // and read/write are covered by the fsck tests above and the CLI + // integration tests (init, hash-mismatch, backcompat). The tests + // below focus on the three-tier feature-flag compatibility model and + // JSON serialization of populated feature vectors, which aren't + // exercised elsewhere. + + #[test] + fn test_metadata_json_with_features() { + let mut meta = RepoMetadata::for_hash::(); + meta.features.compatible.push("some-compat".to_string()); + meta.features + .read_only_compatible + .push("some-rocompat".to_string()); + + let json = meta.to_json().unwrap(); + let parsed: serde_json::Value = serde_json::from_slice(&json).unwrap(); + + assert_eq!(parsed["features"]["compatible"][0], "some-compat"); + assert_eq!( + parsed["features"]["read-only-compatible"][0], + "some-rocompat" + ); + + // Roundtrip + let meta2 = RepoMetadata::from_json(&json).unwrap(); + assert_eq!(meta, meta2); + } + + #[test] + fn test_feature_flags_unknown_incompat() { + let mut meta = RepoMetadata::for_hash::(); + meta.features + .incompatible + .push("fancy-new-thing".to_string()); + let err = meta.check_compatible::().unwrap_err(); + assert!( + format!("{err}").contains("fancy-new-thing"), + "error should name the unknown feature: {err}" + ); + } + + #[test] + fn test_feature_flags_unknown_ro_compat() { + let mut meta = RepoMetadata::for_hash::(); + meta.features + .read_only_compatible + .push("new-index".to_string()); + let check = meta.check_compatible::().unwrap(); + assert_eq!(check, FeatureCheck::ReadOnly(vec!["new-index".to_string()])); + } + + #[test] + fn test_feature_flags_unknown_compat_ignored() { + let mut meta = RepoMetadata::for_hash::(); + meta.features.compatible.push("optional-hint".to_string()); + assert_eq!( + meta.check_compatible::().unwrap(), + FeatureCheck::ReadWrite + ); + } } diff --git a/crates/composefs/src/splitstream.rs b/crates/composefs/src/splitstream.rs index 827a02ac..720ac804 100644 --- a/crates/composefs/src/splitstream.rs +++ b/crates/composefs/src/splitstream.rs @@ -561,7 +561,7 @@ impl SplitStreamWriter { magic: SPLITSTREAM_MAGIC, version: 0, _flags: U16::ZERO, - algorithm: ObjectID::ALGORITHM, + algorithm: ObjectID::ALGORITHM.kernel_id(), lg_blocksize: LG_BLOCKSIZE, info: (info_start..info_end).into(), } @@ -678,7 +678,7 @@ impl SplitStreamReader { bail!("Invalid splitstream version {}", header.version); } - if header.algorithm != ObjectID::ALGORITHM { + if header.algorithm != ObjectID::ALGORITHM.kernel_id() { bail!("Invalid splitstream fs-verity algorithm type"); } @@ -994,9 +994,8 @@ mod tests { /// Create a test repository in insecure mode (no fs-verity required). fn create_test_repo(path: &Path) -> Result>> { - mkdirat(CWD, path, Mode::from_raw_mode(0o755))?; - let mut repo = Repository::open_path(CWD, path)?; - repo.set_insecure(true); + let (repo, _) = + Repository::init_path(CWD, path, crate::fsverity::Algorithm::SHA256, false)?; Ok(Arc::new(repo)) } diff --git a/crates/composefs/src/test.rs b/crates/composefs/src/test.rs index 7e165f76..5375ebc2 100644 --- a/crates/composefs/src/test.rs +++ b/crates/composefs/src/test.rs @@ -49,6 +49,8 @@ pub(crate) fn tempfile() -> std::fs::File { pub struct TestRepo { /// The repository, wrapped in Arc for sharing. pub repo: Arc>, + /// Path to the repository directory within the tempdir. + repo_path: PathBuf, /// The backing temporary directory (kept alive for the repo's lifetime). _tempdir: TempDir, } @@ -60,10 +62,12 @@ impl TestRepo { /// to work without fs-verity support. pub fn new() -> Self { let dir = tempdir(); - let mut repo = Repository::open_path(CWD, dir.path()).unwrap(); - repo.set_insecure(true); + let repo_path = dir.path().join("repo"); + let (repo, _) = Repository::init_path(CWD, &repo_path, ObjectID::ALGORITHM, false) + .expect("initializing test repo"); Self { repo: Arc::new(repo), + repo_path, _tempdir: dir, } } @@ -73,7 +77,7 @@ impl TestRepo { /// Useful in tests that need to manipulate the on-disk layout directly /// (e.g. corruption tests for fsck). pub fn path(&self) -> &std::path::Path { - self._tempdir.path() + &self.repo_path } /// Returns a capability-based directory handle for the repository root. @@ -86,8 +90,7 @@ impl TestRepo { /// `cap_std::fs::Dir` from [`path()`](Self::path) directly. #[cfg(test)] pub fn dir(&self) -> cap_std::fs::Dir { - cap_std::fs::Dir::open_ambient_dir(self._tempdir.path(), cap_std::ambient_authority()) - .unwrap() + cap_std::fs::Dir::open_ambient_dir(&self.repo_path, cap_std::ambient_authority()).unwrap() } } diff --git a/crates/composefs/src/util.rs b/crates/composefs/src/util.rs index 88f7809e..8e011031 100644 --- a/crates/composefs/src/util.rs +++ b/crates/composefs/src/util.rs @@ -28,6 +28,23 @@ pub(crate) fn proc_self_fd(fd: impl AsFd) -> String { format!("/proc/self/fd/{}", fd.as_fd().as_raw_fd()) } +/// Re-open a writable file as read-only, consuming the original. +/// +/// This is the standard preparation step before enabling fs-verity: +/// the kernel requires that no writable file descriptors exist for +/// the inode. The re-open goes through `/proc/self/fd` so it works +/// on anonymous O_TMPFILE inodes that have no directory entry yet. +pub(crate) fn reopen_tmpfile_ro(file: std::fs::File) -> std::io::Result { + let path = proc_self_fd(&file); + let ro = rustix::fs::open( + &*path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + drop(file); + Ok(ro) +} + /// This function reads the exact amount of bytes required to fill the buffer, possibly performing /// multiple reads to do so (and also retrying if required to deal with EINTR). /// diff --git a/crates/integration-tests/src/tests/cli.rs b/crates/integration-tests/src/tests/cli.rs index b84248a2..70f8ceaf 100644 --- a/crates/integration-tests/src/tests/cli.rs +++ b/crates/integration-tests/src/tests/cli.rs @@ -17,10 +17,20 @@ const OCI_LAYOUT_COMPOSEFS_ID: &str = "f26c6eb439749b82f0d1520e83455bb21766572fb2b5cfe009dd7749a61caf74e0c42c56f1a2cbd9d\ 359e7d172c8e2c65641666c9a18cc484a8b0f6e4e6d47ab"; +/// Create a fresh initialized insecure repository in a tempdir. +/// +/// Returns the tempdir (for lifetime) and the path to the repo. +fn init_insecure_repo(sh: &Shell, cfsctl: &std::path::Path) -> Result { + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + cmd!(sh, "{cfsctl} --repo {repo} init --insecure").read()?; + Ok(repo_dir) +} + fn test_gc_empty_repo() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; let repo = repo_dir.path(); let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} gc").read()?; @@ -35,7 +45,7 @@ integration_test!(test_gc_empty_repo); fn test_create_image_from_path() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -56,7 +66,7 @@ integration_test!(test_create_image_from_path); fn test_create_image_idempotent() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -83,7 +93,7 @@ integration_test!(test_create_image_idempotent); fn test_create_and_list_objects() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -111,7 +121,7 @@ integration_test!(test_create_and_list_objects); fn test_gc_after_create() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -135,7 +145,7 @@ integration_test!(test_gc_after_create); fn test_gc_dry_run() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -171,7 +181,7 @@ integration_test!(test_gc_dry_run); fn test_oci_images_empty_repo() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; let repo = repo_dir.path(); let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images").read()?; @@ -186,7 +196,7 @@ integration_test!(test_oci_images_empty_repo); fn test_oci_images_json_empty_repo() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; let repo = repo_dir.path(); let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?; @@ -273,7 +283,7 @@ fn create_oci_layout(parent: &std::path::Path) -> Result { fn test_oci_pull_and_inspect() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -377,7 +387,7 @@ fn test_oci_layer_inspect() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -493,7 +503,7 @@ integration_test!(test_oci_layer_inspect); fn test_dump_files() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -581,12 +591,258 @@ fn corrupt_one_object(repo: &std::path::Path) -> Result<()> { anyhow::bail!("no object found to corrupt"); } -fn test_fsck_empty_repo() -> Result<()> { +fn test_init_creates_metadata() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Init with default algorithm (--repo before subcommand) + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + assert!( + output.contains("Initialized"), + "expected initialization message, got: {output}" + ); + assert!( + output.contains("fsverity-sha512-12"), + "expected algorithm in output, got: {output}" + ); + + // Check meta.json exists and is valid + let meta_path = repo.join("meta.json"); + assert!(meta_path.exists(), "meta.json should exist after init"); + + let meta_content = std::fs::read_to_string(&meta_path)?; + let meta: serde_json::Value = serde_json::from_str(&meta_content)?; + assert_eq!(meta["version"], 1); + assert_eq!(meta["algorithm"], "fsverity-sha512-12"); + assert!( + meta.get("features").is_some(), + "features key should always be present" + ); + + Ok(()) +} +integration_test!(test_init_creates_metadata); + +fn test_init_sha256() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + let output = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} init --algorithm fsverity-sha256-12" + ) + .read()?; + assert!( + output.contains("fsverity-sha256-12"), + "expected sha256 algorithm, got: {output}" + ); + + // Verify operations work with auto-detected hash + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + let image_id = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} create-image {rootfs}" + ) + .read()?; + assert!( + !image_id.trim().is_empty(), + "should produce image ID with auto-detected sha256" + ); + + Ok(()) +} +integration_test!(test_init_sha256); + +fn test_init_idempotent() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + + // Second init with same algorithm should succeed (idempotent) + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + assert!( + output.contains("already initialized"), + "expected idempotent message, got: {output}" + ); + + Ok(()) +} +integration_test!(test_init_idempotent); + +fn test_init_conflict() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; let repo_dir = tempfile::tempdir()?; let repo = repo_dir.path(); + cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + + // Re-init with different algorithm should fail + let result = cmd!( + sh, + "{cfsctl} --insecure --repo {repo} init --algorithm fsverity-sha256-12" + ) + .read(); + assert!( + result.is_err(), + "re-init with different algorithm should fail" + ); + + Ok(()) +} +integration_test!(test_init_conflict); + +fn test_hash_mismatch_errors() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Init as sha512 repo + cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + + // Explicitly passing --hash sha256 on a sha512 repo should error + let result = cmd!(sh, "{cfsctl} --insecure --hash sha256 --repo {repo} gc").read(); + assert!( + result.is_err(), + "should error when --hash sha256 used on sha512 repo" + ); + + Ok(()) +} +integration_test!(test_hash_mismatch_errors); + +fn test_hash_match_ok() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Init as sha512 repo + cmd!(sh, "{cfsctl} --insecure --repo {repo} init").read()?; + + // Explicitly passing --hash sha512 on a sha512 repo should work + let output = cmd!(sh, "{cfsctl} --insecure --hash sha512 --repo {repo} gc").read()?; + assert!( + output.contains("Objects: 0 removed"), + "should succeed with matching --hash, got: {output}" + ); + + Ok(()) +} +integration_test!(test_hash_match_ok); + +fn test_no_metadata_errors() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Use repo without init (no meta.json) - should error + let result = cmd!(sh, "{cfsctl} --insecure --repo {repo} gc").read(); + assert!( + result.is_err(), + "should fail without meta.json, got: {result:?}" + ); + + // Should also fail with explicit --hash sha256 (no metadata) + let result = cmd!(sh, "{cfsctl} --insecure --hash sha256 --repo {repo} gc").read(); + assert!( + result.is_err(), + "should fail with --hash sha256 and no metadata, got: {result:?}" + ); + + Ok(()) +} +integration_test!(test_no_metadata_errors); + +fn test_init_creates_directory() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let parent = tempfile::tempdir()?; + let repo = parent.path().join("new-repo"); + + // Init with positional path argument + let output = cmd!(sh, "{cfsctl} --insecure init {repo}").read()?; + assert!( + output.contains("Initialized"), + "expected initialization message, got: {output}" + ); + assert!(repo.exists(), "repo directory should be created"); + assert!( + repo.join("meta.json").exists(), + "meta.json should exist in created dir" + ); + + Ok(()) +} +integration_test!(test_init_creates_directory); + +fn test_auto_detect_hash_for_operations() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + + // Create a sha256 repo + let repo_dir = tempfile::tempdir()?; + let repo256 = repo_dir.path(); + cmd!( + sh, + "{cfsctl} --insecure --repo {repo256} init --algorithm fsverity-sha256-12" + ) + .read()?; + + // Create a sha512 repo + let repo_dir2 = tempfile::tempdir()?; + let repo512 = repo_dir2.path(); + cmd!( + sh, + "{cfsctl} --insecure --repo {repo512} init --algorithm fsverity-sha512-12" + ) + .read()?; + + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + + // Create image in sha256 repo (no --hash flag needed) + let id256 = cmd!( + sh, + "{cfsctl} --insecure --repo {repo256} create-image {rootfs}" + ) + .read()?; + + // Create image in sha512 repo (no --hash flag needed) + let id512 = cmd!( + sh, + "{cfsctl} --insecure --repo {repo512} create-image {rootfs}" + ) + .read()?; + + // The image IDs should differ because different hash algorithms produce + // different fs-verity digests + assert_ne!( + id256.trim(), + id512.trim(), + "sha256 and sha512 should produce different image IDs" + ); + + Ok(()) +} +integration_test!(test_auto_detect_hash_for_operations); + +fn test_fsck_empty_repo() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; + let repo = repo_dir.path(); + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} fsck --json").read()?; let v: serde_json::Value = serde_json::from_str(&output)?; assert_eq!(v["ok"], true); @@ -600,7 +856,7 @@ integration_test!(test_fsck_empty_repo); fn test_fsck_healthy_repo() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -624,7 +880,7 @@ integration_test!(test_fsck_healthy_repo); fn test_fsck_detects_corrupted_object() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -649,7 +905,7 @@ integration_test!(test_fsck_detects_corrupted_object); fn test_fsck_nonzero_exit_on_corruption() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -671,7 +927,7 @@ integration_test!(test_fsck_nonzero_exit_on_corruption); fn test_oci_fsck_healthy() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -696,7 +952,7 @@ fn test_oci_fsck_detects_corrupted_manifest() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -739,7 +995,7 @@ integration_test!(test_oci_fsck_detects_corrupted_manifest); fn test_oci_fsck_single_image() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -776,7 +1032,7 @@ fn test_fsck_detects_broken_image_ref() -> Result<()> { let sh = Shell::new()?; let cfsctl = cfsctl()?; - let repo_dir = tempfile::tempdir()?; + 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())?; @@ -816,3 +1072,186 @@ fn test_fsck_detects_broken_image_ref() -> Result<()> { Ok(()) } integration_test!(test_fsck_detects_broken_image_ref); + +fn test_init_insecure() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + let output = cmd!(sh, "{cfsctl} --repo {repo} init --insecure").read()?; + assert!( + output.contains("Initialized"), + "expected initialization message, got: {output}" + ); + assert!( + output.contains("insecure"), + "expected insecure in output, got: {output}" + ); + + // Operations should work without --insecure flag (auto-detected) + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + let image_id = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; + assert!( + !image_id.trim().is_empty(), + "should produce image ID on insecure repo" + ); + + let output = cmd!(sh, "{cfsctl} --repo {repo} gc").read()?; + assert!( + output.contains("Objects:"), + "gc should work on insecure repo, got: {output}" + ); + + Ok(()) +} +integration_test!(test_init_insecure); + +fn test_require_verity_fails_on_insecure_repo() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Create an insecure repo + cmd!(sh, "{cfsctl} --repo {repo} init --insecure").read()?; + + // --require-verity should fail + let result = cmd!(sh, "{cfsctl} --require-verity --repo {repo} gc").read(); + assert!( + result.is_err(), + "--require-verity should fail on insecure repo" + ); + + Ok(()) +} +integration_test!(test_require_verity_fails_on_insecure_repo); + +fn test_missing_metadata_fails() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Legacy repo: no init, no meta.json — should fail at open time + let result = cmd!(sh, "{cfsctl} --repo {repo} gc").read(); + assert!( + result.is_err(), + "repo without meta.json should fail to open" + ); + + Ok(()) +} +integration_test!(test_missing_metadata_fails); + +fn test_old_format_repo_gives_migration_hint() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Simulate an old-format repo: has objects/ but no meta.json + std::fs::create_dir(repo.join("objects"))?; + + // Should fail with a helpful migration hint + let result = cmd!(sh, "{cfsctl} --repo {repo} gc").read(); + assert!(result.is_err(), "old-format repo should fail to open"); + + Ok(()) +} +integration_test!(test_old_format_repo_gives_migration_hint); + +fn test_init_reset_metadata() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = init_insecure_repo(&sh, &cfsctl)?; + let repo = repo_dir.path(); + + // Create some content so streams/ and images/ exist + let fixture_dir = tempfile::tempdir()?; + let rootfs = create_test_rootfs(fixture_dir.path())?; + cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; + + // Verify streams/ and/or images/ exist + assert!( + repo.join("streams").exists() || repo.join("images").exists(), + "repo should have streams/ or images/ after create-image" + ); + + // Reset metadata — should remove streams/ and images/ but keep objects/ + let output = cmd!( + sh, + "{cfsctl} --repo {repo} init --insecure --reset-metadata" + ) + .read()?; + assert!( + output.contains("Initialized") || output.contains("Removed"), + "expected init output after reset, got: {output}" + ); + + // streams/ and images/ should be gone + assert!( + !repo.join("streams").exists(), + "streams/ should be removed after --reset-metadata" + ); + assert!( + !repo.join("images").exists(), + "images/ should be removed after --reset-metadata" + ); + + // objects/ should still exist + assert!( + repo.join("objects").exists(), + "objects/ should be preserved after --reset-metadata" + ); + + // Repo should be usable again + let output = cmd!(sh, "{cfsctl} --repo {repo} gc").read()?; + assert!( + output.contains("Objects:"), + "gc should work after --reset-metadata, got: {output}" + ); + + Ok(()) +} +integration_test!(test_init_reset_metadata); + +fn test_init_reset_metadata_changes_algorithm() -> Result<()> { + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let repo_dir = tempfile::tempdir()?; + let repo = repo_dir.path(); + + // Init with sha256 + cmd!( + sh, + "{cfsctl} --repo {repo} init --insecure --algorithm fsverity-sha256-12" + ) + .read()?; + + // Trying to re-init with sha512 without --reset-metadata should fail + let result = cmd!( + sh, + "{cfsctl} --repo {repo} init --insecure --algorithm fsverity-sha512-12" + ) + .read(); + assert!( + result.is_err(), + "re-init with different algorithm should fail without --reset-metadata" + ); + + // With --reset-metadata it should succeed + let output = cmd!( + sh, + "{cfsctl} --repo {repo} init --insecure --reset-metadata --algorithm fsverity-sha512-12" + ) + .read()?; + assert!( + output.contains("Initialized"), + "expected init output after reset with new algorithm, got: {output}" + ); + + Ok(()) +} +integration_test!(test_init_reset_metadata_changes_algorithm); diff --git a/crates/integration-tests/src/tests/digest_stability.rs b/crates/integration-tests/src/tests/digest_stability.rs index b7572e63..baa4e028 100644 --- a/crates/integration-tests/src/tests/digest_stability.rs +++ b/crates/integration-tests/src/tests/digest_stability.rs @@ -119,6 +119,7 @@ fn test_oci_container_digest_stability() -> Result<()> { eprintln!("--- {} ---", image.label); let repo_dir = tempfile::tempdir()?; let repo = repo_dir.path(); + cmd!(sh, "{cfsctl} --repo {repo} init --insecure").read()?; eprintln!("Pulling {} (this may take a while)...", image.label); let config = pull_image(&sh, &cfsctl, repo, image.image_ref, image.label)?; diff --git a/crates/integration-tests/src/tests/privileged.rs b/crates/integration-tests/src/tests/privileged.rs index 85b7bfd0..2daf3cdc 100644 --- a/crates/integration-tests/src/tests/privileged.rs +++ b/crates/integration-tests/src/tests/privileged.rs @@ -116,6 +116,9 @@ fn privileged_repo_without_insecure() -> Result<()> { let verity_dir = VerityTempDir::new()?; let repo = verity_dir.path().join("repo"); + // Init on ext4+verity: meta.json gets verity enabled → secure repo + cmd!(sh, "{cfsctl} --repo {repo} init").run()?; + let output = cmd!(sh, "{cfsctl} --repo {repo} gc").read()?; ensure!( output.contains("Objects: 0 removed"), @@ -137,6 +140,8 @@ fn privileged_create_image() -> Result<()> { let fixture_dir = tempfile::tempdir()?; let rootfs = create_test_rootfs(fixture_dir.path())?; + cmd!(sh, "{cfsctl} --repo {repo} init").run()?; + let output = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; ensure!( !output.trim().is_empty(), @@ -161,6 +166,8 @@ fn privileged_mount_image() -> Result<()> { let fixture_dir = tempfile::tempdir()?; let rootfs = create_test_rootfs(fixture_dir.path())?; + cmd!(sh, "{cfsctl} --repo {repo} init").run()?; + let image_id_full = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; // create-image outputs "algo:hex", mount expects just the hex part let image_id = image_id_full @@ -196,6 +203,8 @@ fn privileged_create_image_idempotent() -> Result<()> { let fixture_dir = tempfile::tempdir()?; let rootfs = create_test_rootfs(fixture_dir.path())?; + cmd!(sh, "{cfsctl} --repo {repo} init").run()?; + let id1 = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; let id2 = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; ensure!( @@ -205,3 +214,69 @@ fn privileged_create_image_idempotent() -> Result<()> { Ok(()) } integration_test!(privileged_create_image_idempotent); + +/// Verify that `init` on a verity-capable filesystem enables verity on +/// meta.json, and that `--require-verity` succeeds on such a repo. +fn privileged_init_enables_verity() -> Result<()> { + if require_privileged("privileged_init_enables_verity")?.is_some() { + return Ok(()); + } + + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let verity_dir = VerityTempDir::new()?; + let repo = verity_dir.path().join("repo"); + + let output = cmd!(sh, "{cfsctl} --repo {repo} init").read()?; + ensure!( + output.contains("verity") && output.contains("required"), + "init should report verity as required, got: {output}" + ); + + // --require-verity should succeed on this repo + let output = cmd!(sh, "{cfsctl} --require-verity --repo {repo} gc").read()?; + ensure!( + output.contains("Objects: 0 removed"), + "--require-verity gc should work on secure repo, got: {output}" + ); + + Ok(()) +} +integration_test!(privileged_init_enables_verity); + +/// Verify that `init --insecure` on a verity-capable filesystem does NOT +/// enable verity on meta.json, and `--require-verity` fails. +fn privileged_init_insecure_skips_verity() -> Result<()> { + if require_privileged("privileged_init_insecure_skips_verity")?.is_some() { + return Ok(()); + } + + let sh = Shell::new()?; + let cfsctl = cfsctl()?; + let verity_dir = VerityTempDir::new()?; + let repo = verity_dir.path().join("repo"); + + let output = cmd!(sh, "{cfsctl} --repo {repo} init --insecure").read()?; + ensure!( + output.contains("insecure"), + "init --insecure should say insecure, got: {output}" + ); + + // --require-verity should fail even though the filesystem supports verity, + // because init --insecure skipped enabling it on meta.json + let result = cmd!(sh, "{cfsctl} --require-verity --repo {repo} gc").read(); + ensure!( + result.is_err(), + "--require-verity should fail on insecure-initialized repo" + ); + + // But operations without --require-verity should work fine + let output = cmd!(sh, "{cfsctl} --repo {repo} gc").read()?; + ensure!( + output.contains("Objects: 0 removed"), + "gc should work on insecure repo, got: {output}" + ); + + Ok(()) +} +integration_test!(privileged_init_insecure_skips_verity); diff --git a/doc/repository.md b/doc/repository.md index c9c2a3ed..0a3bed37 100644 --- a/doc/repository.md +++ b/doc/repository.md @@ -23,6 +23,7 @@ A composefs repository has a layout that looks something like ``` composefs +├── meta.json ├── objects │   ├── 00 │   │   ├── 002183fb91[...] @@ -45,6 +46,30 @@ composefs └── some/name.tar -> ../../streams/502b126bca0c[...] ``` +## `meta.json` + +Added in 0.7.0. This file records repository-level metadata. When present, it is +created by `cfsctl init` and contains: + + - `version` — the base repository format version (currently `1`). Tools + must refuse to operate on a repository whose version exceeds what they + understand. + + - `algorithm` — the fs-verity digest algorithm identifier, in the format + `fsverity--`. For example `fsverity-sha512-12` + means SHA-512 with 4 KiB (2^12) blocks. + + - `features` (optional) — an object with three arrays of feature-flag + strings, following the ext4/XFS/EROFS compatibility model: + - `compatible` — old tools can safely ignore these. + - `read-only-compatible` — old tools may read but must not write. + - `incompatible` — old tools must refuse the repository entirely. + +When `meta.json` is present, `cfsctl` auto-detects the hash algorithm and +errors if `--hash` is explicitly passed with a conflicting value. When +the file is absent (for repositories created before this feature), `--hash` +is honored as before and defaults to `sha512`. + ## `objects/` This is where the content-addressed data is stored. The immediate children of diff --git a/examples/bls/build b/examples/bls/build index 35abb4a0..acbdfe64 100755 --- a/examples/bls/build +++ b/examples/bls/build @@ -41,11 +41,11 @@ esac cp ../../target/release/cfsctl . cp ../../target/release/composefs-setup-root extra/usr/lib/dracut/modules.d/37composefs/ -CFSCTL='./cfsctl --repo tmp/sysroot/composefs --hash sha256' +CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -mkdir -p tmp/sysroot/composefs +${CFSCTL} init --algorithm=fsverity-sha256-12 podman build \ --iidfile=tmp/base.iid \ diff --git a/examples/uki/build b/examples/uki/build index 22354e32..c1d32877 100755 --- a/examples/uki/build +++ b/examples/uki/build @@ -27,11 +27,11 @@ cargo build --release cp ../../target/release/cfsctl . cp ../../target/release/composefs-setup-root extra/usr/lib/dracut/modules.d/37composefs/ -CFSCTL='./cfsctl --repo tmp/sysroot/composefs --hash sha256' +CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -mkdir -p tmp/sysroot/composefs +${CFSCTL} init --algorithm=fsverity-sha256-12 ${PODMAN_BUILD} \ --iidfile=tmp/base.iid \ diff --git a/examples/unified-secureboot/Containerfile b/examples/unified-secureboot/Containerfile index ef38f848..8472d046 100644 --- a/examples/unified-secureboot/Containerfile +++ b/examples/unified-secureboot/Containerfile @@ -44,8 +44,8 @@ FROM base AS kernel RUN --mount=type=bind,from=base,target=/mnt/base < /etc/kernel/cmdline diff --git a/examples/unified-secureboot/build b/examples/unified-secureboot/build index bd5ab2f1..9580eb7a 100755 --- a/examples/unified-secureboot/build +++ b/examples/unified-secureboot/build @@ -16,11 +16,11 @@ cargo build --release cp ../../target/release/cfsctl . cp ../../target/release/composefs-setup-root extra/usr/lib/dracut/modules.d/37composefs/ -CFSCTL='./cfsctl --repo tmp/sysroot/composefs --hash sha256' +CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -mkdir -p tmp/sysroot/composefs +${CFSCTL} init --algorithm=fsverity-sha256-12 # 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/Containerfile b/examples/unified/Containerfile index 6a680664..da113a2b 100644 --- a/examples/unified/Containerfile +++ b/examples/unified/Containerfile @@ -42,8 +42,8 @@ FROM base AS kernel RUN --mount=type=bind,from=base,target=/mnt/base < /etc/kernel/cmdline diff --git a/examples/unified/build b/examples/unified/build index 7ae52259..e94ae645 100755 --- a/examples/unified/build +++ b/examples/unified/build @@ -16,11 +16,11 @@ cargo build --release cp ../../target/release/cfsctl . cp ../../target/release/composefs-setup-root extra/usr/lib/dracut/modules.d/37composefs/ -CFSCTL='./cfsctl --repo tmp/sysroot/composefs --hash sha256' +CFSCTL='./cfsctl --repo tmp/sysroot/composefs' rm -rf tmp rm -rf tmp/efi tmp/sysroot/composefs/images -mkdir -p tmp/sysroot/composefs +${CFSCTL} init --algorithm=fsverity-sha256-12 # For debugging, add --no-cache to podman command mkdir -p tmp/internal-sysroot