commonware_conformance/
lib.rs

1//! Automatically assert the stability of encoding and mechanisms over time.
2//!
3//! This crate provides a unified infrastructure for verifying that
4//! implementations maintain backward compatibility by comparing output
5//! against known-good hash values stored in TOML files.
6//!
7//! # The `Conformance` Trait
8//!
9//! The core abstraction is the [`Conformance`] trait, which represents
10//! types that can produce deterministic bytes from a seed.
11//!
12//! This enables conformance testing across different domains, for example:
13//! - **Codec**: Verify wire format stability
14//! - **Storage**: Verify on-disk format stability
15//! - **Network**: Verify message ordering consistency
16//!
17//! # Storage Format
18//!
19//! Test vectors are stored in a TOML file with a single hash per type:
20//!
21//! ```toml
22//! ["Vec<u8>"]
23//! n_cases = 100
24//! hash = "abc123..."
25//!
26//! ["Vec<u16>"]
27//! n_cases = 100
28//! hash = "def456..."
29//! ```
30//!
31//! The hash is computed by generating `n_cases` commitments (using seeds
32//! 0..n_cases), and hashing all the bytes together.
33//!
34//! # Regeneration Mode
35//!
36//! When `cfg(generate_conformance_tests)` is set, tests regenerate their
37//! expected hashes in the TOML file. Use this to intentionally update
38//! the format:
39//!
40//! ```bash
41//! RUSTFLAGS="--cfg generate_conformance_tests" cargo test
42//! ```
43
44pub use commonware_conformance_macros::conformance_tests;
45#[doc(hidden)]
46pub use commonware_macros;
47use core::future::Future;
48#[doc(hidden)]
49pub use futures;
50use serde::{Deserialize, Serialize};
51use sha2::{Digest, Sha256};
52use std::{collections::BTreeMap, fs, path::Path};
53
54/// Default number of test cases when not explicitly specified.
55pub const DEFAULT_CASES: usize = 65536;
56
57/// Trait for types that can produce deterministic bytes for conformance testing.
58///
59/// Implementations must be deterministic: the same seed must always produce
60/// the same output across runs and platforms.
61///
62/// # Example
63///
64/// ```rs
65/// use commonware_conformance::Conformance;
66///
67/// struct MyConformance;
68///
69/// impl Conformance for MyConformance {
70///     async fn commit(seed: u64) -> Vec<u8> {
71///         // Generate deterministic bytes from the seed
72///         seed.to_le_bytes().to_vec()
73///     }
74/// }
75/// ```
76pub trait Conformance: Send + Sync {
77    /// Produce deterministic bytes from a seed for conformance testing.
78    ///
79    /// The implementation should use the seed to generate deterministic
80    /// test data and return a byte vector representing the commitment.
81    fn commit(seed: u64) -> impl Future<Output = Vec<u8>> + Send;
82}
83
84/// A conformance test file containing test data for multiple types.
85///
86/// The file is a TOML document with sections for each type name.
87#[derive(Debug, Serialize, Deserialize, Default)]
88#[serde(transparent)]
89pub struct ConformanceFile {
90    /// Test data indexed by stringified type name.
91    pub types: BTreeMap<String, TypeEntry>,
92}
93
94/// Conformance test data for a single type.
95#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct TypeEntry {
97    /// Number of test cases that were hashed together.
98    pub n_cases: usize,
99    /// Hex-encoded SHA-256 hash of all committed values concatenated together.
100    pub hash: String,
101}
102
103/// Errors that can occur when loading conformance files.
104#[derive(Debug)]
105pub enum ConformanceError {
106    /// Failed to read the file.
107    Io(std::path::PathBuf, std::io::Error),
108    /// Failed to parse the TOML.
109    Parse(std::path::PathBuf, toml::de::Error),
110}
111
112impl std::fmt::Display for ConformanceError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::Io(path, err) => write!(f, "failed to read {}: {}", path.display(), err),
116            Self::Parse(path, err) => write!(f, "failed to parse {}: {}", path.display(), err),
117        }
118    }
119}
120
121impl std::error::Error for ConformanceError {}
122
123impl ConformanceFile {
124    /// Load a conformance file from the given path.
125    pub fn load(path: &Path) -> Result<Self, ConformanceError> {
126        let contents =
127            fs::read_to_string(path).map_err(|e| ConformanceError::Io(path.to_path_buf(), e))?;
128        toml::from_str(&contents).map_err(|e| ConformanceError::Parse(path.to_path_buf(), e))
129    }
130
131    /// Load a conformance file, returning an empty file if it doesn't exist.
132    pub fn load_or_default(path: &Path) -> Result<Self, ConformanceError> {
133        if path.exists() {
134            Self::load(path)
135        } else {
136            Ok(Self::default())
137        }
138    }
139}
140
141/// Encode bytes as a lowercase hex string.
142fn hex_encode(bytes: &[u8]) -> String {
143    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
144    let mut result = String::with_capacity(bytes.len() * 2);
145    for &byte in bytes {
146        result.push(HEX_CHARS[(byte >> 4) as usize] as char);
147        result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
148    }
149    result
150}
151
152/// Acquire an exclusive lock on the conformance file.
153///
154/// Uses OS-level file locking which is automatically released when the
155/// process exits, even if killed.
156fn acquire_lock(path: &Path) -> fs::File {
157    let file = fs::OpenOptions::new()
158        .read(true)
159        .write(true)
160        .create(true)
161        .truncate(false)
162        .open(path)
163        .unwrap_or_else(|e| panic!("failed to open conformance file: {e}"));
164
165    file.lock()
166        .unwrap_or_else(|e| panic!("failed to lock conformance file: {e}"));
167
168    file
169}
170
171/// Compute the conformance hash for a type using the [`Conformance`] trait.
172///
173/// Generates `n_cases` commitments (using seeds 0..n_cases), and hashes
174/// all the bytes together using SHA-256.
175pub async fn compute_conformance_hash<C: Conformance>(n_cases: usize) -> String {
176    let mut hasher = Sha256::new();
177
178    for seed in 0..n_cases as u64 {
179        let committed = C::commit(seed).await;
180
181        // Write length prefix to avoid ambiguity between concatenated values
182        hasher.update((committed.len() as u64).to_le_bytes());
183        hasher.update(&committed);
184    }
185
186    hex_encode(&hasher.finalize())
187}
188
189/// Run conformance tests using the [`Conformance`] trait.
190///
191/// This function is the generic version that works with any `Conformance`
192/// implementation.
193///
194/// # Behavior
195///
196/// - If the type is missing from the file, it is automatically added.
197/// - If the hash differs, the test fails (format changed).
198/// - When `cfg(generate_conformance_tests)` is set, regenerates the hash.
199///
200/// # Arguments
201///
202/// * `type_name` - The stringified type name (used as the TOML section key)
203/// * `n_cases` - Number of test cases to hash together (seeds 0..n_cases)
204/// * `conformance_path` - Path to the conformance TOML file
205///
206/// # Panics
207///
208/// Panics if the hash doesn't match (format changed).
209pub async fn run_conformance_test<C: Conformance>(
210    type_name: &str,
211    n_cases: usize,
212    conformance_path: &Path,
213) {
214    #[cfg(generate_conformance_tests)]
215    {
216        regenerate_conformance::<C>(type_name, n_cases, conformance_path).await;
217    }
218
219    #[cfg(not(generate_conformance_tests))]
220    {
221        verify_and_update_conformance::<C>(type_name, n_cases, conformance_path).await;
222    }
223}
224
225#[cfg(not(generate_conformance_tests))]
226async fn verify_and_update_conformance<C: Conformance>(
227    type_name: &str,
228    n_cases: usize,
229    path: &Path,
230) {
231    use std::io::{Read, Seek, Write};
232
233    // Compute the hash first WITHOUT holding the lock - this is the expensive part
234    // and can run in parallel across all conformance tests
235    let actual_hash = compute_conformance_hash::<C>(n_cases).await;
236
237    // Now acquire the lock only for file I/O
238    let mut lock = acquire_lock(path);
239
240    let mut contents = String::new();
241    lock.read_to_string(&mut contents)
242        .unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
243
244    let mut file: ConformanceFile = if contents.is_empty() {
245        ConformanceFile::default()
246    } else {
247        toml::from_str(&contents)
248            .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
249    };
250
251    match file.types.get(type_name) {
252        Some(entry) => {
253            // Verify the hash matches
254            if entry.hash != actual_hash {
255                panic!(
256                    "Conformance test failed for '{type_name}'.\n\n\
257                     Format change detected:\n\
258                     - expected: \"{}\"\n\
259                     - actual:   \"{actual_hash}\"\n\n\
260                     If this change is intentional, regenerate with:\n\
261                     RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
262                    entry.hash
263                );
264            }
265            // Verify n_cases matches
266            if entry.n_cases != n_cases {
267                panic!(
268                    "Conformance test failed for '{type_name}'.\n\n\
269                     n_cases mismatch: expected {}, got {n_cases}\n\n\
270                     If this change is intentional, regenerate with:\n\
271                     RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
272                    entry.n_cases
273                );
274            }
275        }
276        None => {
277            // Add the missing entry
278            file.types.insert(
279                type_name.to_string(),
280                TypeEntry {
281                    n_cases,
282                    hash: actual_hash,
283                },
284            );
285
286            // Write the updated file
287            let toml_str =
288                toml::to_string_pretty(&file).expect("failed to serialize conformance file");
289            lock.set_len(0)
290                .expect("failed to truncate conformance file");
291            lock.seek(std::io::SeekFrom::Start(0))
292                .expect("failed to seek conformance file");
293            lock.write_all(toml_str.as_bytes())
294                .expect("failed to write conformance file");
295        }
296    }
297}
298
299#[cfg(generate_conformance_tests)]
300async fn regenerate_conformance<C: Conformance>(type_name: &str, n_cases: usize, path: &Path) {
301    use std::io::{Read, Seek, Write};
302
303    // Compute the hash first WITHOUT holding the lock - this is the expensive part
304    // and can run in parallel across all conformance tests
305    let hash = compute_conformance_hash::<C>(n_cases).await;
306
307    // Now acquire the lock only for file I/O
308    let mut lock = acquire_lock(path);
309
310    let mut contents = String::new();
311    lock.read_to_string(&mut contents)
312        .unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
313
314    let mut file: ConformanceFile = if contents.is_empty() {
315        ConformanceFile::default()
316    } else {
317        toml::from_str(&contents)
318            .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
319    };
320
321    // Update or insert the entry for this type
322    file.types
323        .insert(type_name.to_string(), TypeEntry { n_cases, hash });
324
325    // Write the updated file
326    let toml_str = toml::to_string_pretty(&file).expect("failed to serialize conformance file");
327    lock.set_len(0)
328        .expect("failed to truncate conformance file");
329    lock.seek(std::io::SeekFrom::Start(0))
330        .expect("failed to seek conformance file");
331    lock.write_all(toml_str.as_bytes())
332        .expect("failed to write conformance file");
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_hex_encode() {
341        assert_eq!(hex_encode(&[]), "");
342        assert_eq!(hex_encode(&[0x00]), "00");
343        assert_eq!(hex_encode(&[0xff]), "ff");
344        assert_eq!(hex_encode(&[0x12, 0x34, 0xab, 0xcd]), "1234abcd");
345    }
346
347    // Test conformance trait with a simple implementation
348    struct SimpleConformance;
349
350    impl Conformance for SimpleConformance {
351        async fn commit(seed: u64) -> Vec<u8> {
352            seed.to_le_bytes().to_vec()
353        }
354    }
355
356    #[test]
357    fn test_compute_conformance_hash_deterministic() {
358        let hash_1 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
359        let hash_2 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
360        assert_eq!(hash_1, hash_2);
361    }
362
363    #[test]
364    fn test_compute_conformance_hash_different_n_cases() {
365        let hash_10 =
366            futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(10));
367        let hash_20 =
368            futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(20));
369        assert_ne!(hash_10, hash_20);
370    }
371
372    #[test]
373    fn test_conformance_file_parse() {
374        let toml = r#"
375["u32"]
376n_cases = 100
377hash = "abc123"
378
379["Vec<u8>"]
380n_cases = 50
381hash = "def456"
382"#;
383
384        let file: ConformanceFile = toml::from_str(toml).unwrap();
385        assert_eq!(file.types.len(), 2);
386        assert!(file.types.contains_key("u32"));
387        assert!(file.types.contains_key("Vec<u8>"));
388
389        let u32_entry = file.types.get("u32").unwrap();
390        assert_eq!(u32_entry.n_cases, 100);
391        assert_eq!(u32_entry.hash, "abc123");
392    }
393}