#![doc(
html_logo_url = "https://commonware.xyz/imgs/rustdoc_logo.svg",
html_favicon_url = "https://commonware.xyz/favicon.ico"
)]
pub use commonware_conformance_macros::conformance_tests;
#[doc(hidden)]
pub use commonware_macros;
use core::future::Future;
#[doc(hidden)]
pub use futures;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, fs, path::Path};
pub const DEFAULT_CASES: usize = 65536;
pub trait Conformance: Send + Sync {
fn commit(seed: u64) -> impl Future<Output = Vec<u8>> + Send;
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(transparent)]
pub struct ConformanceFile {
pub types: BTreeMap<String, TypeEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TypeEntry {
pub n_cases: usize,
pub hash: String,
}
#[derive(Debug)]
pub enum ConformanceError {
Io(std::path::PathBuf, std::io::Error),
Parse(std::path::PathBuf, toml::de::Error),
}
impl std::fmt::Display for ConformanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "failed to read {}: {}", path.display(), err),
Self::Parse(path, err) => write!(f, "failed to parse {}: {}", path.display(), err),
}
}
}
impl std::error::Error for ConformanceError {}
impl ConformanceFile {
pub fn load(path: &Path) -> Result<Self, ConformanceError> {
let contents =
fs::read_to_string(path).map_err(|e| ConformanceError::Io(path.to_path_buf(), e))?;
toml::from_str(&contents).map_err(|e| ConformanceError::Parse(path.to_path_buf(), e))
}
pub fn load_or_default(path: &Path) -> Result<Self, ConformanceError> {
if path.exists() {
Self::load(path)
} else {
Ok(Self::default())
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
let mut result = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
result.push(HEX_CHARS[(byte >> 4) as usize] as char);
result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
}
result
}
fn acquire_lock(path: &Path) -> fs::File {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.unwrap_or_else(|e| panic!("failed to open conformance file: {e}"));
file.lock()
.unwrap_or_else(|e| panic!("failed to lock conformance file: {e}"));
file
}
pub async fn compute_conformance_hash<C: Conformance>(n_cases: usize) -> String {
let mut hasher = Sha256::new();
for seed in 0..n_cases as u64 {
let committed = C::commit(seed).await;
hasher.update((committed.len() as u64).to_le_bytes());
hasher.update(&committed);
}
hex_encode(&hasher.finalize())
}
pub async fn run_conformance_test<C: Conformance>(
type_name: &str,
n_cases: usize,
conformance_path: &Path,
) {
#[cfg(generate_conformance_tests)]
{
regenerate_conformance::<C>(type_name, n_cases, conformance_path).await;
}
#[cfg(not(generate_conformance_tests))]
{
verify_and_update_conformance::<C>(type_name, n_cases, conformance_path).await;
}
}
#[cfg(not(generate_conformance_tests))]
async fn verify_and_update_conformance<C: Conformance>(
type_name: &str,
n_cases: usize,
path: &Path,
) {
use std::io::{Read, Seek, Write};
let actual_hash = compute_conformance_hash::<C>(n_cases).await;
let mut lock = acquire_lock(path);
let mut contents = String::new();
lock.read_to_string(&mut contents)
.unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
let mut file: ConformanceFile = if contents.is_empty() {
ConformanceFile::default()
} else {
toml::from_str(&contents)
.unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
};
match file.types.get(type_name) {
Some(entry) => {
if entry.hash != actual_hash {
panic!(
"Conformance test failed for '{type_name}'.\n\n\
Format change detected:\n\
- expected: \"{}\"\n\
- actual: \"{actual_hash}\"\n\n\
If this change is intentional, regenerate with:\n\
RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
entry.hash
);
}
if entry.n_cases != n_cases {
panic!(
"Conformance test failed for '{type_name}'.\n\n\
n_cases mismatch: expected {}, got {n_cases}\n\n\
If this change is intentional, regenerate with:\n\
RUSTFLAGS=\"--cfg generate_conformance_tests\" cargo test",
entry.n_cases
);
}
}
None => {
file.types.insert(
type_name.to_string(),
TypeEntry {
n_cases,
hash: actual_hash,
},
);
let toml_str =
toml::to_string_pretty(&file).expect("failed to serialize conformance file");
lock.set_len(0)
.expect("failed to truncate conformance file");
lock.seek(std::io::SeekFrom::Start(0))
.expect("failed to seek conformance file");
lock.write_all(toml_str.as_bytes())
.expect("failed to write conformance file");
}
}
}
#[cfg(generate_conformance_tests)]
async fn regenerate_conformance<C: Conformance>(type_name: &str, n_cases: usize, path: &Path) {
use std::io::{Read, Seek, Write};
let hash = compute_conformance_hash::<C>(n_cases).await;
let mut lock = acquire_lock(path);
let mut contents = String::new();
lock.read_to_string(&mut contents)
.unwrap_or_else(|e| panic!("failed to read conformance file: {e}"));
let mut file: ConformanceFile = if contents.is_empty() {
ConformanceFile::default()
} else {
toml::from_str(&contents)
.unwrap_or_else(|e| panic!("failed to parse conformance file: {e}"))
};
file.types
.insert(type_name.to_string(), TypeEntry { n_cases, hash });
let toml_str = toml::to_string_pretty(&file).expect("failed to serialize conformance file");
lock.set_len(0)
.expect("failed to truncate conformance file");
lock.seek(std::io::SeekFrom::Start(0))
.expect("failed to seek conformance file");
lock.write_all(toml_str.as_bytes())
.expect("failed to write conformance file");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hex_encode() {
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0x00]), "00");
assert_eq!(hex_encode(&[0xff]), "ff");
assert_eq!(hex_encode(&[0x12, 0x34, 0xab, 0xcd]), "1234abcd");
}
struct SimpleConformance;
impl Conformance for SimpleConformance {
async fn commit(seed: u64) -> Vec<u8> {
seed.to_le_bytes().to_vec()
}
}
#[test]
fn test_compute_conformance_hash_deterministic() {
let hash_1 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
let hash_2 = futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(1));
assert_eq!(hash_1, hash_2);
}
#[test]
fn test_compute_conformance_hash_different_n_cases() {
let hash_10 =
futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(10));
let hash_20 =
futures::executor::block_on(compute_conformance_hash::<SimpleConformance>(20));
assert_ne!(hash_10, hash_20);
}
#[test]
fn test_conformance_file_parse() {
let toml = r#"
["u32"]
n_cases = 100
hash = "abc123"
["Vec<u8>"]
n_cases = 50
hash = "def456"
"#;
let file: ConformanceFile = toml::from_str(toml).unwrap();
assert_eq!(file.types.len(), 2);
assert!(file.types.contains_key("u32"));
assert!(file.types.contains_key("Vec<u8>"));
let u32_entry = file.types.get("u32").unwrap();
assert_eq!(u32_entry.n_cases, 100);
assert_eq!(u32_entry.hash, "abc123");
}
}