commonware_conformance/
lib.rs1pub 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
54pub const DEFAULT_CASES: usize = 65536;
56
57pub trait Conformance: Send + Sync {
77 fn commit(seed: u64) -> impl Future<Output = Vec<u8>> + Send;
82}
83
84#[derive(Debug, Serialize, Deserialize, Default)]
88#[serde(transparent)]
89pub struct ConformanceFile {
90 pub types: BTreeMap<String, TypeEntry>,
92}
93
94#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct TypeEntry {
97 pub n_cases: usize,
99 pub hash: String,
101}
102
103#[derive(Debug)]
105pub enum ConformanceError {
106 Io(std::path::PathBuf, std::io::Error),
108 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 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 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
141fn 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
152fn 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
171pub 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 hasher.update((committed.len() as u64).to_le_bytes());
183 hasher.update(&committed);
184 }
185
186 hex_encode(&hasher.finalize())
187}
188
189pub 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 let actual_hash = compute_conformance_hash::<C>(n_cases).await;
236
237 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 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 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 file.types.insert(
279 type_name.to_string(),
280 TypeEntry {
281 n_cases,
282 hash: actual_hash,
283 },
284 );
285
286 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 let hash = compute_conformance_hash::<C>(n_cases).await;
306
307 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 file.types
323 .insert(type_name.to_string(), TypeEntry { n_cases, hash });
324
325 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 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}