commonware_conformance/
lib.rs1#![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
59pub const DEFAULT_CASES: usize = 65536;
61
62pub trait Conformance: Send + Sync {
82 fn commit(seed: u64) -> impl Future<Output = Vec<u8>> + Send;
87}
88
89#[derive(Debug, Serialize, Deserialize, Default)]
93#[serde(transparent)]
94pub struct ConformanceFile {
95 pub types: BTreeMap<String, TypeEntry>,
97}
98
99#[derive(Debug, Serialize, Deserialize, Clone)]
101pub struct TypeEntry {
102 pub n_cases: usize,
104 pub hash: String,
106}
107
108#[derive(Debug)]
110pub enum ConformanceError {
111 Io(std::path::PathBuf, std::io::Error),
113 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 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 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
146fn 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
157fn 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
176pub 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 hasher.update((committed.len() as u64).to_le_bytes());
188 hasher.update(&committed);
189 }
190
191 hex_encode(&hasher.finalize())
192}
193
194pub 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 let actual_hash = compute_conformance_hash::<C>(n_cases).await;
241
242 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 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 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 file.types.insert(
284 type_name.to_string(),
285 TypeEntry {
286 n_cases,
287 hash: actual_hash,
288 },
289 );
290
291 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 let hash = compute_conformance_hash::<C>(n_cases).await;
311
312 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 file.types
328 .insert(type_name.to_string(), TypeEntry { n_cases, hash });
329
330 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 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}