From 728c7bd95c1ee512621798def0a6552b0f08914e Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 14 Mar 2026 19:48:50 +0000 Subject: [PATCH 1/3] fsverity: Add Algorithm newtype for fs-verity algorithm identifiers Add a validated Algorithm type that wraps the fsverity-- string format (e.g. 'fsverity-sha512-12'). Implements FromStr for parsing with proper error types and Display for serialization, so it can be used as a clap value_parser argument. Includes for_hash::() constructor to derive from FsVerityHashValue types at compile time. Prep for repository metadata support. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/composefs/src/fsverity/hashvalue.rs | 201 ++++++++++++++++++++- crates/composefs/src/fsverity/mod.rs | 5 +- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/crates/composefs/src/fsverity/hashvalue.rs b/crates/composefs/src/fsverity/hashvalue.rs index 4581da84..552da784 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}; @@ -197,6 +197,143 @@ impl FsVerityHashValue for Sha512HashValue { 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: hash function + block size. +/// +/// The string representation is `fsverity--`, +/// e.g. `fsverity-sha256-12` (SHA-256 with 4 KiB blocks) or +/// `fsverity-sha512-12` (SHA-512 with 4 KiB blocks). +/// +/// This type implements [`FromStr`] and [`Display`](fmt::Display) so it +/// can be used directly as a clap argument via `value_parser` and +/// round-trips cleanly through serialisation. +/// +/// # Examples +/// +/// ``` +/// use composefs::fsverity::Algorithm; +/// +/// let alg: Algorithm = "fsverity-sha512-12".parse().unwrap(); +/// assert_eq!(alg.hash(), "sha512"); +/// assert_eq!(alg.lg_blocksize(), 12); +/// 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"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Algorithm { + hash: &'static str, + 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 { + /// Build the algorithm identifier for a given [`FsVerityHashValue`] type. + /// + /// Uses [`DEFAULT_LG_BLOCKSIZE`] (12, i.e. 4096-byte blocks). + pub fn for_hash() -> Self { + Self { + hash: H::ID, + lg_blocksize: DEFAULT_LG_BLOCKSIZE, + } + } + + /// The hash algorithm name (e.g. `"sha256"` or `"sha512"`). + pub fn hash(&self) -> &str { + self.hash + } + + /// The log2 block size (e.g. `12` for 4096-byte blocks). + pub fn lg_blocksize(&self) -> u8 { + self.lg_blocksize + } + + /// Check whether this algorithm is compatible with the given hash type. + /// + /// Returns `true` if the hash name matches `H::ID`. + pub fn is_compatible(&self) -> bool { + self.hash == H::ID + } +} + +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)?; + + // Validate and intern the hash name to a &'static str + let hash: &'static str = match hash { + "sha256" => "sha256", + "sha512" => "sha512", + other => return Err(AlgorithmParseError::UnknownHash(other.to_owned())), + }; + + 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)); + } + + Ok(Self { hash, lg_blocksize }) + } +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "fsverity-{}-{}", self.hash, self.lg_blocksize) + } +} + #[cfg(test)] mod test { use super::*; @@ -274,4 +411,60 @@ mod test { fn test_sha512hashvalue() { test_fsverity_hash::(); } + + #[test] + fn test_algorithm_for_hash() { + let a256 = Algorithm::for_hash::(); + assert_eq!(a256.hash(), "sha256"); + 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(), "sha512"); + 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/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}; From c3ebac26e35e9eb0d8975e96917cf612cd0d145b Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 17 Mar 2026 11:30:53 +0000 Subject: [PATCH 2/3] composefs: Add serde_json dependency Prep for repository metadata (meta.json) serialization. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/composefs/Cargo.toml | 1 + 1 file changed, 1 insertion(+) 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"] } From 3c9fc35bcd923e2c110f481151c8fa1159212584 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Tue, 17 Mar 2026 11:31:23 +0000 Subject: [PATCH 3/3] repository: Add meta.json for repo metadata and cfsctl init Add a meta.json file to the repository format that records the digest algorithm, format version, and feature flags, so tools can auto-detect the configuration instead of requiring --hash on every invocation. 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: - compatible: old tools can safely ignore - read-only-compatible: old tools may read but must not write - incompatible: old tools must refuse the repository entirely Because creating a repo is no longer just `mkdir`, add 'cfsctl init --algorithm=fsverity-sha512-12 [path]'. Closes: https://github.com/composefs/composefs-rs/issues/181 Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/cfsctl/src/lib.rs | 206 ++++++- crates/cfsctl/src/main.rs | 9 +- crates/composefs/src/fsverity/hashvalue.rs | 12 + crates/composefs/src/repository.rs | 595 ++++++++++++++++++++- crates/integration-tests/src/tests/cli.rs | 246 +++++++++ doc/repository.md | 25 + examples/bls/build | 4 +- examples/uki/build | 4 +- examples/unified-secureboot/Containerfile | 4 +- examples/unified-secureboot/build | 4 +- examples/unified/Containerfile | 4 +- examples/unified/build | 4 +- 12 files changed, 1070 insertions(+), 47 deletions(-) diff --git a/crates/cfsctl/src/lib.rs b/crates/cfsctl/src/lib.rs index 8479507c..9276e05f 100644 --- a/crates/cfsctl/src/lib.rs +++ b/crates/cfsctl/src/lib.rs @@ -23,20 +23,21 @@ 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::CWD; +use rustix::fs::{Mode, OFlags, CWD}; use serde::Serialize; #[cfg(feature = "oci")] @@ -48,9 +49,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::{write_repo_metadata, RepoMetadata, Repository, REPO_METADATA_FILENAME}, tree::RegularFile, }; @@ -85,9 +86,11 @@ 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), + /// falling back to sha512. + #[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 @@ -100,12 +103,11 @@ pub struct App { } /// 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 +278,20 @@ 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. Subsequent commands + /// will auto-detect the algorithm from this file and error on mismatch. + 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, + }, /// Take a transaction lock on the repository. /// This prevents garbage collection from occurring. Transaction, @@ -375,10 +391,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,6 +405,149 @@ where }) } +/// Resolve the repository path from CLI args without opening it. +fn resolve_repo_path(args: &App) -> PathBuf { + if let Some(path) = &args.repo { + path.clone() + } else if args.system { + PathBuf::from("/sysroot/composefs") + } else if args.user { + let home = std::env::var("HOME").expect("$HOME must be set when in user mode"); + PathBuf::from(home).join(".var/lib/composefs") + } else if rustix::process::getuid().is_root() { + PathBuf::from("/sysroot/composefs") + } else { + let home = std::env::var("HOME").expect("$HOME must be set"); + PathBuf::from(home).join(".var/lib/composefs") + } +} + +/// 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 { + // Try reading meta.json from the repo path + let meta_path = repo_path.join(REPO_METADATA_FILENAME); + let data = match std::fs::read(&meta_path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No metadata: use CLI flag or default + return Ok(cli_hash.unwrap_or(HashType::Sha512)); + } + Err(e) => { + return Err(anyhow::Error::new(e).context("reading repository meta.json")); + } + }; + + let meta = RepoMetadata::from_json(&data)?; + let detected = match meta.algorithm.hash() { + "sha256" => HashType::Sha256, + "sha512" => HashType::Sha512, + other => anyhow::bail!("unsupported hash algorithm '{other}' in repository metadata"), + }; + + // 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 {} (from {}) but --hash {} was specified", + meta.algorithm, + REPO_METADATA_FILENAME, + 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, + } = args.cmd + { + return run_init(algorithm, path.as_deref(), &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>, args: &App) -> Result<()> { + let meta = RepoMetadata::new(algorithm.clone()); + + let repo_path = if let Some(p) = path { + p.to_path_buf() + } else { + resolve_repo_path(args) + }; + + // Create the directory if it doesn't exist + std::fs::create_dir_all(&repo_path) + .with_context(|| format!("creating repository directory {}", repo_path.display()))?; + + // Check if meta.json already exists + let meta_path = repo_path.join(REPO_METADATA_FILENAME); + if meta_path.exists() { + // Read existing and compare + let existing_data = std::fs::read(&meta_path) + .with_context(|| format!("reading existing {}", meta_path.display()))?; + let existing = RepoMetadata::from_json(&existing_data)?; + if existing == meta { + // Idempotent: same config, nothing to do + println!("Repository already initialized at {}", repo_path.display()); + return Ok(()); + } + anyhow::bail!( + "repository at {} already initialized with algorithm '{}' (version {}); \ + cannot re-initialize with '{}'", + repo_path.display(), + existing.algorithm, + existing.version, + algorithm, + ); + } + + // Open the repo dir fd and write metadata + let repo_fd = rustix::fs::open( + &repo_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .with_context(|| format!("opening repository directory {}", repo_path.display()))?; + + write_repo_metadata(&repo_fd, &meta)?; + + println!( + "Initialized composefs repository at {}", + repo_path.display() + ); + println!(" algorithm: {}", meta.algorithm); + println!(" version: {}", meta.version); + + Ok(()) +} + /// Open a repo pub fn open_repo(args: &App) -> Result> where @@ -509,6 +665,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 { @@ -814,19 +974,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/src/fsverity/hashvalue.rs b/crates/composefs/src/fsverity/hashvalue.rs index 552da784..a73380e8 100644 --- a/crates/composefs/src/fsverity/hashvalue.rs +++ b/crates/composefs/src/fsverity/hashvalue.rs @@ -334,6 +334,18 @@ impl fmt::Display for Algorithm { } } +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::*; diff --git a/crates/composefs/src/repository.rs b/crates/composefs/src/repository.rs index b3834f25..196315c4 100644 --- a/crates/composefs/src/repository.rs +++ b/crates/composefs/src/repository.rs @@ -108,7 +108,7 @@ use rustix::{ use crate::{ fsverity::{ - compute_verity, enable_verity_maybe_copy, ensure_verity_equal, measure_verity, + compute_verity, enable_verity_maybe_copy, ensure_verity_equal, measure_verity, Algorithm, CompareVerityError, EnableVerityError, FsVerityHashValue, FsVerityHasher, MeasureVerityError, }, @@ -117,6 +117,267 @@ use crate::{ util::{proc_self_fd, replace_symlinkat, ErrnoFilter}, }; +/// The filename used for repository metadata. +pub const REPO_METADATA_FILENAME: &str = "meta.json"; + +/// 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<&str> = self + .incompatible + .iter() + .map(String::as_str) + .filter(|f| !known_features::INCOMPAT.contains(f)) + .collect(); + if !unknown_incompat.is_empty() { + bail!( + "repository requires unknown incompatible features: {}; \ + upgrade your tools", + unknown_incompat.join(", "), + ); + } + + // 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 { + bail!( + "unsupported repository format version {} (this tool supports up to {})", + self.version, + REPO_FORMAT_VERSION, + ); + } + let access = self.features.check()?; + if !self.algorithm.is_compatible::() { + bail!( + "repository uses algorithm '{}' but was opened as {} \ + (hint: use --hash {} or omit --hash to auto-detect)", + self.algorithm, + ObjectID::ID, + self.algorithm.hash(), + ); + } + 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 `meta.json` from a repository directory fd, if it exists. +/// +/// Returns `Ok(None)` when the file is absent (backward-compatible repos). +#[context("Reading repository metadata")] +pub 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 mut data = Vec::new(); + File::from(fd) + .read_to_end(&mut data) + .context("reading meta.json")?; + Ok(Some(RepoMetadata::from_json(&data)?)) + } + Err(Errno::NOENT) => Ok(None), + Err(e) => Err(e).context("opening meta.json")?, + } +} + +/// 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. +#[context("Writing repository metadata")] +pub fn write_repo_metadata(repo_fd: &impl AsFd, meta: &RepoMetadata) -> 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")?; + // Link into place + linkat( + CWD, + proc_self_fd(&file), + 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")?; + } + 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 @@ -281,6 +542,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 +556,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 +569,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 +636,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", @@ -1643,6 +1929,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 +1948,35 @@ impl Repository { Ok(result) } + /// Validate `meta.json` if present. + /// + /// Checks that the file parses correctly and that the declared algorithm + /// matches the repository's `ObjectID` type. A missing `meta.json` is + /// not an error (older repositories won't have one). + 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::ID.to_string(), + }); + log::warn!("meta.json algorithm mismatch: {e}"); + } + } + Ok(None) => { + // No meta.json — that's fine for older repositories. + log::info!("no meta.json found (pre-0.7.0 repository)"); + } + 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 +2348,16 @@ impl Repository { self.repository.as_fd() } + /// Read the repository metadata from `meta.json`, if it exists. + /// + /// Returns `Ok(None)` for repositories created before metadata support + /// was added. When metadata is present, its version and algorithm are + /// not validated against this repository's generic type — call + /// [`RepoMetadata::check_compatible`] for that. + pub fn metadata(&self) -> Result> { + read_repo_metadata(&self.repository) + } + /// Lists all named stream references under a given prefix. /// /// Returns (name, target) pairs where name is relative to the prefix. @@ -3544,4 +3872,269 @@ mod tests { ); Ok(()) } + + // ---- Fsck metadata validation tests ---- + + #[tokio::test] + async fn test_fsck_no_metadata() -> Result<()> { + // Repos without meta.json should fsck cleanly. + 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()); + // Display should say "absent" + assert!( + result.to_string().contains("meta.json: absent"), + "display should show absent: {result}" + ); + Ok(()) + } + + #[tokio::test] + async fn test_fsck_valid_metadata() -> Result<()> { + let tmp = tempdir(); + let repo = create_test_repo(&tmp.path().join("repo"))?; + + let meta = RepoMetadata::for_hash::(); + write_repo_metadata(&repo.repo_fd(), &meta)?; + + 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 — fsck should report a parse error. + let tmp = tempdir(); + let repo = create_test_repo(&tmp.path().join("repo"))?; + + let dir = open_test_repo_dir(&tmp); + 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(()) + } + + #[tokio::test] + async fn test_fsck_metadata_algorithm_mismatch() -> Result<()> { + // Write sha256 metadata, open repo as sha512 — fsck should report mismatch. + let tmp = tempdir(); + let repo = create_test_repo(&tmp.path().join("repo"))?; + + let meta = RepoMetadata::for_hash::(); + write_repo_metadata(&repo.repo_fd(), &meta)?; + + let result = repo.fsck().await?; + assert!(!result.is_ok()); + assert!(result.has_metadata()); + assert!(result + .errors() + .iter() + .any(|e| matches!(e, FsckError::MetadataAlgorithmMismatch { .. }))); + assert!( + result.to_string().contains("meta.json: error"), + "display should show error for mismatch: {result}" + ); + Ok(()) + } + + // ---- RepoMetadata tests ---- + + #[test] + fn test_metadata_for_hash() { + let meta = RepoMetadata::for_hash::(); + assert_eq!(meta.version, REPO_FORMAT_VERSION); + assert_eq!(meta.algorithm.hash(), "sha256"); + assert_eq!(meta.algorithm.to_string(), "fsverity-sha256-12"); + + let meta = RepoMetadata::for_hash::(); + assert_eq!(meta.version, REPO_FORMAT_VERSION); + assert_eq!(meta.algorithm.hash(), "sha512"); + } + + #[test] + fn test_metadata_json_roundtrip() { + let meta = RepoMetadata::for_hash::(); + let json = meta.to_json().unwrap(); + + // Check it's valid JSON with trailing newline + assert!(json.ends_with(b"\n")); + let parsed: serde_json::Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(parsed["version"], REPO_FORMAT_VERSION); + assert_eq!(parsed["algorithm"], "fsverity-sha512-12"); + // Features always present, with empty arrays + let features = parsed.get("features").expect("features key must exist"); + assert_eq!(features["compatible"], serde_json::json!([])); + assert_eq!(features["read-only-compatible"], serde_json::json!([])); + assert_eq!(features["incompatible"], serde_json::json!([])); + + // Roundtrip + let meta2 = RepoMetadata::from_json(&json).unwrap(); + assert_eq!(meta, meta2); + } + + #[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_metadata_check_compatible() { + let meta = RepoMetadata::for_hash::(); + assert_eq!( + meta.check_compatible::().unwrap(), + FeatureCheck::ReadWrite + ); + assert!(meta + .check_compatible::() + .is_err()); + + let meta256 = RepoMetadata::for_hash::(); + assert_eq!( + meta256 + .check_compatible::() + .unwrap(), + FeatureCheck::ReadWrite + ); + assert!(meta256.check_compatible::().is_err()); + } + + #[test] + fn test_metadata_version_mismatch() { + let meta = RepoMetadata { + version: 999, + algorithm: Algorithm::for_hash::(), + features: FeatureFlags::default(), + }; + assert!(meta.check_compatible::().is_err()); + } + + #[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 + ); + } + + #[test] + fn test_write_and_read_metadata() -> Result<()> { + let tmp = tempdir(); + let repo_fd = rustix::fs::open( + tmp.path(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + + // No metadata initially + assert!(read_repo_metadata(&repo_fd)?.is_none()); + + // Write metadata + let meta = RepoMetadata::for_hash::(); + write_repo_metadata(&repo_fd, &meta)?; + + // Read it back + let read_meta = read_repo_metadata(&repo_fd)?.expect("metadata should exist"); + assert_eq!(read_meta, meta); + + Ok(()) + } + + #[test] + fn test_repo_metadata_method() -> Result<()> { + let tmp = tempdir(); + let repo_path = tmp.path().join("repo"); + let repo = create_test_repo(&repo_path)?; + + // No metadata initially + assert!(repo.metadata()?.is_none()); + + // Write metadata via low-level function, then read via method + let meta = RepoMetadata::for_hash::(); + write_repo_metadata(&repo.repo_fd(), &meta)?; + + let read_meta = repo.metadata()?.expect("metadata should exist"); + assert_eq!(read_meta, meta); + assert_eq!(read_meta.algorithm.hash(), "sha512"); + + Ok(()) + } + + #[test] + fn test_write_metadata_already_exists() -> Result<()> { + let tmp = tempdir(); + let repo_fd = rustix::fs::open( + tmp.path(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + + let meta = RepoMetadata::for_hash::(); + write_repo_metadata(&repo_fd, &meta)?; + + // Second write should fail (file already exists) + assert!(write_repo_metadata(&repo_fd, &meta).is_err()); + Ok(()) + } } diff --git a/crates/integration-tests/src/tests/cli.rs b/crates/integration-tests/src/tests/cli.rs index b84248a2..1097288c 100644 --- a/crates/integration-tests/src/tests/cli.rs +++ b/crates/integration-tests/src/tests/cli.rs @@ -581,6 +581,252 @@ fn corrupt_one_object(repo: &std::path::Path) -> Result<()> { anyhow::bail!("no object found to corrupt"); } +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_backcompat() -> 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 work with default sha512 + let output = cmd!(sh, "{cfsctl} --insecure --repo {repo} gc").read()?; + assert!( + output.contains("Objects: 0 removed"), + "should work without meta.json (backcompat), got: {output}" + ); + + // Should also work with explicit --hash sha256 (no metadata to conflict) + let output = cmd!(sh, "{cfsctl} --insecure --hash sha256 --repo {repo} gc").read()?; + assert!( + output.contains("Objects: 0 removed"), + "should work with --hash sha256 and no metadata, got: {output}" + ); + + Ok(()) +} +integration_test!(test_no_metadata_backcompat); + +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()?; 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