commonware_conformance/
lib.rs1#[doc(hidden)]
46pub use commonware_macros;
47use core::future::Future;
48#[doc(hidden)]
50pub use futures;
51#[doc(hidden)]
53pub use paste;
54use serde::{Deserialize, Serialize};
55use sha2::{Digest, Sha256};
56use std::{collections::BTreeMap, fs, path::Path};
57
58pub const DEFAULT_CASES: usize = 65536;
60
61pub trait Conformance: Send + Sync {
81 fn commit(seed: u64) -> impl Future<Output = Vec<u8>> + Send;
86}
87
88#[derive(Debug, Serialize, Deserialize, Default)]
92#[serde(transparent)]
93pub struct ConformanceFile {
94 pub types: BTreeMap<String, TypeEntry>,
96}
97
98#[derive(Debug, Serialize, Deserialize, Clone)]
100pub struct TypeEntry {
101 pub n_cases: usize,
103 pub hash: String,
105}
106
107#[derive(Debug)]
109pub enum ConformanceError {
110 Io(std::path::PathBuf, std::io::Error),
112 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 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 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
145fn 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
156fn 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
175pub 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 hasher.update((committed.len() as u64).to_le_bytes());
187 hasher.update(&committed);
188 }
189
190 hex_encode(&hasher.finalize())
191}
192
193pub 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 let actual_hash = compute_conformance_hash::<C>(n_cases).await;
240
241 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 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 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 file.types.insert(
283 type_name.to_string(),
284 TypeEntry {
285 n_cases,
286 hash: actual_hash,
287 },
288 );
289
290 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 let hash = compute_conformance_hash::<C>(n_cases).await;
310
311 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 file.types
327 .insert(type_name.to_string(), TypeEntry { n_cases, hash });
328
329 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#[macro_export]
365macro_rules! conformance_tests {
366 (@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 (@internal [$($counter:tt)*]) => {};
383
384 (@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 (@internal [$($counter:tt)*] $type:ty => $n_cases:expr) => {
392 $crate::conformance_tests!(@emit [$($counter)*] $type, $n_cases);
393 };
394
395 (@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 (@internal [$($counter:tt)*] $type:ty) => {
403 $crate::conformance_tests!(@emit [$($counter)*] $type, $crate::DEFAULT_CASES);
404 };
405
406 ($($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 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}