Skip to main content

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