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 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
165pub 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 hasher.update((committed.len() as u64).to_le_bytes());
177 hasher.update(&committed);
178 }
179
180 commonware_formatting::hex(&hasher.finalize())
181}
182
183pub 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 let actual_hash = compute_conformance_hash::<C>(n_cases).await;
230
231 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 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 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 file.types.insert(
273 type_name.to_string(),
274 TypeEntry {
275 n_cases,
276 hash: actual_hash,
277 },
278 );
279
280 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 let hash = compute_conformance_hash::<C>(n_cases).await;
300
301 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 file.types
317 .insert(type_name.to_string(), TypeEntry { n_cases, hash });
318
319 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 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}