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