//! Automatically assert the stability of encoding and mechanisms over time. //! //! This crate provides a unified infrastructure for verifying that //! implementations maintain backward compatibility by comparing output //! against known-good hash values stored in TOML files. //! //! # The `Conformance` Trait //! //! The core abstraction is the [`Conformance`] trait, which represents //! types that can produce deterministic bytes from a seed. //! //! This enables conformance testing across different domains, for example: //! - **Codec**: Verify wire format stability //! - **Storage**: Verify on-disk format stability //! - **Network**: Verify message ordering consistency //! //! # Storage Format //! //! Test vectors are stored in a TOML file with a single hash per type: //! //! ```toml //! ["Vec"] //! n_cases = 100 //! hash = "abc123..." //! //! ["Vec"] //! n_cases = 100 //! hash = "def456..." //! ``` //! //! The hash is computed by generating `n_cases` commitments (using seeds //! 0..n_cases), and hashing all the bytes together. //! //! # Regeneration Mode //! //! When `cfg(generate_conformance_tests)` is set, tests regenerate their //! expected hashes in the TOML file. Use this to intentionally update //! the format: //! //! ```bash //! RUSTFLAGS="--cfg generate_conformance_tests" cargo test //! ``` pub use commonware_conformance_macros::conformance_tests; #[doc(hidden)] pub use commonware_macros; use core::future::Future; #[doc(hidden)] pub use futures; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{collections::BTreeMap, fs, path::Path}; /// Default number of test cases when not explicitly specified. pub const DEFAULT_CASES: usize = 65536; /// Trait for types that can produce deterministic bytes for conformance testing. /// /// Implementations must be deterministic: the same seed must always produce /// the same output across runs and platforms. /// /// # Example /// /// ```rs /// use commonware_conformance::Conformance; /// /// struct MyConformance; /// /// impl Conformance for MyConformance { /// async fn commit(seed: u64) -> Vec { /// // Generate deterministic bytes from the seed /// seed.to_le_bytes().to_vec() /// } /// } /// ``` pub trait Conformance: Send + Sync { /// Produce deterministic bytes from a seed for conformance testing. /// /// The implementation should use the seed to generate deterministic /// test data and return a byte vector representing the commitment. fn commit(seed: u64) -> impl Future> + Send; } /// A conformance test file containing test data for multiple types. /// /// The file is a TOML document with sections for each type name. #[derive(Debug, Serialize, Deserialize, Default)] #[serde(transparent)] pub struct ConformanceFile { /// Test data indexed by stringified type name. pub types: BTreeMap, } /// Conformance test data for a single type. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TypeEntry { /// Number of test cases that were hashed together. pub n_cases: usize, /// Hex-encoded SHA-256 hash of all committed values concatenated together. pub hash: String, } /// Errors that can occur when loading conformance files. #[derive(Debug)] pub enum ConformanceError { /// Failed to read the file. Io(std::path::PathBuf, std::io::Error), /// Failed to parse the TOML. Parse(std::path::PathBuf, toml::de::Error), } impl std::fmt::Display for ConformanceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(path, err) => write!(f, "failed to read {}: {}", path.display(), err), Self::Parse(path, err) => write!(f, "failed to parse {}: {}", path.display(), err), } } } impl std::error::Error for ConformanceError {} impl ConformanceFile { /// Load a conformance file from the given path. pub fn load(path: &Path) -> Result { let contents = fs::read_to_string(path).map_err(|e| ConformanceError::Io(path.to_path_buf(), e))?; toml::from_str(&contents).map_err(|e| ConformanceError::Parse(path.to_path_buf(), e)) } /// Load a conformance file, returning an empty file if it doesn't exist. pub fn load_or_default(path: &Path) -> Result { if path.exists() { Self::load(path) } else { Ok(Self::default()) } } } /// Encode bytes as a lowercase hex string. fn hex_encode(bytes: &[u8]) -> String { const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; let mut result = String::with_capacity(bytes.len() * 2); for &byte in bytes { result.push(HEX_CHARS[(byte >> 4) as usize] as char); result.push(HEX_CHARS[(byte & 0x0f) as usize] as char); } result } /// Acquire an exclusive lock on the conformance file. /// /// Uses OS-level file locking which is automatically released when the /// process exits, even if killed. fn acquire_lock(path: &Path) -> fs::File { let file = fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path) .unwrap_or_else(|e| panic!("failed to open conformance file: {e}")); file.lock() .unwrap_or_else(|e| panic!("failed to lock conformance file: {e}")); file } /// Compute the conformance hash for a type using the [`Conformance`] trait. /// /// Generates `n_cases` commitments (using seeds 0..n_cases), and hashes /// all the bytes together using SHA-256. pub async fn compute_conformance_hash(n_cases: usize) -> String { let mut hasher = Sha256::new(); for seed in 0..n_cases as u64 { let committed = C::commit(seed).await; // Write length prefix to avoid ambiguity between concatenated values hasher.update((committed.len() as u64).to_le_bytes()); hasher.update(&committed); } hex_encode(&hasher.finalize()) } /// Run conformance tests using the [`Conformance`] trait. /// /// This function is the generic version that works with any `Conformance` /// implementation. /// /// # Behavior /// /// - If the type is missing from the file, it is automatically added. /// - If the hash differs, the test fails (format changed). /// - When `cfg(generate_conformance_tests)` is set, regenerates the hash. /// /// # Arguments /// /// * `type_name` - The stringified type name (used as the TOML section key) /// * `n_cases` - Number of test cases to hash together (seeds 0..n_cases) /// * `conformance_path` - Path to the conformance TOML file /// /// # Panics /// /// Panics if the hash doesn't match (format changed). pub async fn run_conformance_test( type_name: &str, n_cases: usize, conformance_path: &Path, ) { #[cfg(generate_conformance_tests)] { regenerate_conformance::(type_name, n_cases, conformance_path).await; } #[cfg(not(generate_conformance_tests))] { verify_and_update_conformance::(type_name, n_cases, conformance_path).await; } } #[cfg(not(generate_conformance_tests))] async fn verify_and_update_conformance( type_name: &str, n_cases: usize, path: &Path, ) { use std::io::{Read, Seek, Write}; // Compute the hash first WITHOUT holding the lock - this is the expensive part // and can run in parallel across all conformance tests let actual_hash = compute_conformance_hash::(n_cases).await; // Now acquire the lock only for file I/O let mut lock = acquire_lock(path); let mut contents = String::new(); lock.read_to_string(&mut contents) .unwrap_or_else(|e| panic!("failed to read conformance file: {e}")); let mut file: ConformanceFile = if contents.is_empty() { ConformanceFile::default() } else { toml::from_str(&contents) .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}")) }; match file.types.get(type_name) { Some(entry) => { // Verify the hash matches if entry.hash != actual_hash { panic!( "Conformance test failed for '{type_name}'.\n\n\ Format change detected:\n\ - expected: \"{}\"\n\ - actual: \"{actual_hash}\"\n\n\ If this change is intentional, regenerate with:\n\ RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test", entry.hash ); } // Verify n_cases matches if entry.n_cases != n_cases { panic!( "Conformance test failed for '{type_name}'.\n\n\ n_cases mismatch: expected {}, got {n_cases}\n\n\ If this change is intentional, regenerate with:\n\ RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test", entry.n_cases ); } } None => { // Add the missing entry file.types.insert( type_name.to_string(), TypeEntry { n_cases, hash: actual_hash, }, ); // Write the updated file let toml_str = toml::to_string_pretty(&file).expect("failed to serialize conformance file"); lock.set_len(0) .expect("failed to truncate conformance file"); lock.seek(std::io::SeekFrom::Start(0)) .expect("failed to seek conformance file"); lock.write_all(toml_str.as_bytes()) .expect("failed to write conformance file"); } } } #[cfg(generate_conformance_tests)] async fn regenerate_conformance(type_name: &str, n_cases: usize, path: &Path) { use std::io::{Read, Seek, Write}; // Compute the hash first WITHOUT holding the lock - this is the expensive part // and can run in parallel across all conformance tests let hash = compute_conformance_hash::(n_cases).await; // Now acquire the lock only for file I/O let mut lock = acquire_lock(path); let mut contents = String::new(); lock.read_to_string(&mut contents) .unwrap_or_else(|e| panic!("failed to read conformance file: {e}")); let mut file: ConformanceFile = if contents.is_empty() { ConformanceFile::default() } else { toml::from_str(&contents) .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}")) }; // Update or insert the entry for this type file.types .insert(type_name.to_string(), TypeEntry { n_cases, hash }); // Write the updated file let toml_str = toml::to_string_pretty(&file).expect("failed to serialize conformance file"); lock.set_len(0) .expect("failed to truncate conformance file"); lock.seek(std::io::SeekFrom::Start(0)) .expect("failed to seek conformance file"); lock.write_all(toml_str.as_bytes()) .expect("failed to write conformance file"); } #[cfg(test)] mod tests { use super::*; #[test] fn test_hex_encode() { assert_eq!(hex_encode(&[]), ""); assert_eq!(hex_encode(&[0x00]), "00"); assert_eq!(hex_encode(&[0xff]), "ff"); assert_eq!(hex_encode(&[0x12, 0x34, 0xab, 0xcd]), "1234abcd"); } // Test conformance trait with a simple implementation struct SimpleConformance; impl Conformance for SimpleConformance { async fn commit(seed: u64) -> Vec { seed.to_le_bytes().to_vec() } } #[test] fn test_compute_conformance_hash_deterministic() { let hash_1 = futures::executor::block_on(compute_conformance_hash::(1)); let hash_2 = futures::executor::block_on(compute_conformance_hash::(1)); assert_eq!(hash_1, hash_2); } #[test] fn test_compute_conformance_hash_different_n_cases() { let hash_10 = futures::executor::block_on(compute_conformance_hash::(10)); let hash_20 = futures::executor::block_on(compute_conformance_hash::(20)); assert_ne!(hash_10, hash_20); } #[test] fn test_conformance_file_parse() { let toml = r#" ["u32"] n_cases = 100 hash = "abc123" ["Vec"] n_cases = 50 hash = "def456" "#; let file: ConformanceFile = toml::from_str(toml).unwrap(); assert_eq!(file.types.len(), 2); assert!(file.types.contains_key("u32")); assert!(file.types.contains_key("Vec")); let u32_entry = file.types.get("u32").unwrap(); assert_eq!(u32_entry.n_cases, 100); assert_eq!(u32_entry.hash, "abc123"); } }