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/// Acquire an exclusive lock on the conformance file.
147///
148/// Uses OS-level file locking which is automatically released when the
149/// process exits, even if killed.
150fn acquire_lock(path: &Path) -> fs::File {
151    let file = fs::OpenOptions::new()
152        .read(true)
153        .write(true)
154        .create(true)
155        .truncate(false)
156        .open(path)
157        .unwrap_or_else(|e| panic!("failed to open conformance file: {e}"));
158
159    file.lock()
160        .unwrap_or_else(|e| panic!("failed to lock conformance file: {e}"));
161
162    file
163}
164
165/// Compute the conformance hash for a type using the [`Conformance`] trait.
166///
167/// Generates `n_cases` commitments (using seeds 0..n_cases), and hashes
168/// all the bytes together using SHA-256.
169pub async fn compute_conformance_hash<C: Conformance>(n_cases: usize) -> String {
170    let mut hasher = Sha256::new();
171
172    for seed in 0..n_cases as u64 {
173        let committed = C::commit(seed).await;
174
175        // Write length prefix to avoid ambiguity between concatenated values
176        hasher.update((committed.len() as u64).to_le_bytes());
177        hasher.update(&committed);
178    }
179
180    commonware_formatting::hex(&hasher.finalize())
181}
182
183/// Run conformance tests using the [`Conformance`] trait.
184///
185/// This function is the generic version that works with any `Conformance`
186/// implementation.
187///
188/// # Behavior
189///
190/// - If the type is missing from the file, it is automatically added.
191/// - If the hash differs, the test fails (format changed).
192/// - When `cfg(generate_conformance_tests)` is set, regenerates the hash.
193///
194/// # Arguments
195///
196/// * `type_name` - The stringified type name (used as the TOML section key)
197/// * `n_cases` - Number of test cases to hash together (seeds 0..n_cases)
198/// * `conformance_path` - Path to the conformance TOML file
199///
200/// # Panics
201///
202/// Panics if the hash doesn't match (format changed).
203pub async fn run_conformance_test<C: Conformance>(
204    type_name: &str,
205    n_cases: usize,
206    conformance_path: &Path,
207) {
208    #[cfg(generate_conformance_tests)]
209    {
210        regenerate_conformance::<C>(type_name, n_cases, conformance_path).await;
211    }
212
213    #[cfg(not(generate_conformance_tests))]
214    {
215        verify_and_update_conformance::<C>(type_name, n_cases, conformance_path).await;
216    }
217}
218
219#[cfg(not(generate_conformance_tests))]
220async fn verify_and_update_conformance<C: Conformance>(
221    type_name: &str,
222    n_cases: usize,
223    path: &Path,
224) {
225    use std::io::{Read, Seek, Write};
226
227    // Compute the hash first WITHOUT holding the lock - this is the expensive part
228    // and can run in parallel across all conformance tests
229    let actual_hash = compute_conformance_hash::<C>(n_cases).await;
230
231    // Now acquire the lock only for file I/O
232    let mut lock = acquire_lock(path);
233
234    let mut contents = String::new();
235    lock.read_to_string(&mut contents)
236        .unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
237
238    let mut file: ConformanceFile = if contents.is_empty() {
239        ConformanceFile::default()
240    } else {
241        toml::from_str(&contents)
242            .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
243    };
244
245    match file.types.get(type_name) {
246        Some(entry) => {
247            // Verify the hash matches
248            if entry.hash != actual_hash {
249                panic!(
250                    "Conformance test failed for '{type_name}'.\n\n\
251                     Format change detected:\n\
252                     - expected: \"{}\"\n\
253                     - actual:   \"{actual_hash}\"\n\n\
254                     If this change is intentional, regenerate with:\n\
255                     RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
256                    entry.hash
257                );
258            }
259            // Verify n_cases matches
260            if entry.n_cases != n_cases {
261                panic!(
262                    "Conformance test failed for '{type_name}'.\n\n\
263                     n_cases mismatch: expected {}, got {n_cases}\n\n\
264                     If this change is intentional, regenerate with:\n\
265                     RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
266                    entry.n_cases
267                );
268            }
269        }
270        None => {
271            // Add the missing entry
272            file.types.insert(
273                type_name.to_string(),
274                TypeEntry {
275                    n_cases,
276                    hash: actual_hash,
277                },
278            );
279
280            // Write the updated file
281            let toml_str =
282                toml::to_string_pretty(&file).expect("failed to serialize conformance file");
283            lock.set_len(0)
284                .expect("failed to truncate conformance file");
285            lock.seek(std::io::SeekFrom::Start(0))
286                .expect("failed to seek conformance file");
287            lock.write_all(toml_str.as_bytes())
288                .expect("failed to write conformance file");
289        }
290    }
291}
292
293#[cfg(generate_conformance_tests)]
294async fn regenerate_conformance<C: Conformance>(type_name: &str, n_cases: usize, path: &Path) {
295    use std::io::{Read, Seek, Write};
296
297    // Compute the hash first WITHOUT holding the lock - this is the expensive part
298    // and can run in parallel across all conformance tests
299    let hash = compute_conformance_hash::<C>(n_cases).await;
300
301    // Now acquire the lock only for file I/O
302    let mut lock = acquire_lock(path);
303
304    let mut contents = String::new();
305    lock.read_to_string(&mut contents)
306        .unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
307
308    let mut file: ConformanceFile = if contents.is_empty() {
309        ConformanceFile::default()
310    } else {
311        toml::from_str(&contents)
312            .unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
313    };
314
315    // Update or insert the entry for this type
316    file.types
317        .insert(type_name.to_string(), TypeEntry { n_cases, hash });
318
319    // Write the updated file
320    let toml_str = toml::to_string_pretty(&file).expect("failed to serialize conformance file");
321    lock.set_len(0)
322        .expect("failed to truncate conformance file");
323    lock.seek(std::io::SeekFrom::Start(0))
324        .expect("failed to seek conformance file");
325    lock.write_all(toml_str.as_bytes())
326        .expect("failed to write conformance file");
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    // Test conformance trait with a simple implementation
334    struct SimpleConformance;
335
336    impl Conformance for SimpleConformance {
337        async fn commit(seed: u64) -> Vec<u8> {
338            seed.to_le_bytes().to_vec()
339        }
340    }
341
342    #[test]
343    fn test_compute_conformance_hash_deterministic() {
344        let hash_1 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
345        let hash_2 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
346        assert_eq!(hash_1, hash_2);
347    }
348
349    #[test]
350    fn test_compute_conformance_hash_different_n_cases() {
351        let hash_10 =
352            futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(10));
353        let hash_20 =
354            futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(20));
355        assert_ne!(hash_10, hash_20);
356    }
357
358    #[test]
359    fn test_conformance_file_parse() {
360        let toml = r#"
361["u32"]
362n_cases = 100
363hash = "abc123"
364
365["Vec<u8>"]
366n_cases = 50
367hash = "def456"
368"#;
369
370        let file: ConformanceFile = toml::from_str(toml).unwrap();
371        assert_eq!(file.types.len(), 2);
372        assert!(file.types.contains_key("u32"));
373        assert!(file.types.contains_key("Vec<u8>"));
374
375        let u32_entry = file.types.get("u32").unwrap();
376        assert_eq!(u32_entry.n_cases, 100);
377        assert_eq!(u32_entry.hash, "abc123");
378    }
379}