use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::Context;
use freenet::conformance::ConformanceOracle;
use freenet::conformance::generator::Corpus;
use freenet::conformance::host_clock;
use freenet::conformance::verifier::Bytes;
use freenet::conformance::{
ConformanceCase, ConformanceEvidence, ConformanceProperty, EVIDENCE_SCHEMA_VERSION,
EvidenceRejected, GeneratorConfig, Inconclusive, MinimizeConfig, OracleBuildError,
PropertyOutcome, ReplayBundle, RuntimeOracle, Severity, Transition, generate_cases, minimize,
verify_case,
};
use freenet_stdlib::prelude::{CodeHash, ContractCode, ContractInstanceId, State, UpdateData};
use serde::Serialize;
#[derive(clap::Parser, Clone)]
pub struct ConformanceConfig {
#[arg(long)]
pub(crate) wasm: Option<PathBuf>,
#[arg(long)]
pub(crate) params: Option<PathBuf>,
#[arg(long = "state")]
pub(crate) states: Vec<PathBuf>,
#[arg(long = "transition", num_args = 2, value_names = ["BASE", "RESULT"])]
pub(crate) transitions: Vec<PathBuf>,
#[arg(long)]
pub(crate) bundle: Option<PathBuf>,
#[arg(long)]
pub(crate) contract_store: Option<PathBuf>,
#[arg(long)]
pub(crate) max_cases: Option<usize>,
#[arg(long = "property")]
pub(crate) properties: Vec<String>,
#[arg(long)]
pub(crate) json: bool,
#[arg(long = "evidence-out")]
pub(crate) evidence_out: Option<PathBuf>,
#[arg(long = "evidence")]
pub(crate) evidence_in: Option<PathBuf>,
#[arg(long = "bundle-out")]
pub(crate) bundle_out: Option<PathBuf>,
}
pub async fn conformance(config: ConformanceConfig) -> anyhow::Result<()> {
if let Some(path) = config.evidence_in.clone() {
return verify_evidence(&config, &path).await;
}
let properties = parse_properties(&config.properties)?;
let LoadedInputs {
wasm,
parameters,
corpus,
hand_supplied_steps,
} = load_inputs(&config)?;
let code_diagnostics = code_diagnostics(&wasm);
if corpus.is_empty() {
report_code_diagnostics_standalone(&code_diagnostics);
anyhow::bail!("no states to check: the corpus is empty");
}
let mut generator_config = GeneratorConfig::default();
if let Some(max_cases) = config.max_cases {
generator_config.max_cases = max_cases;
}
if !properties.is_empty() {
generator_config.properties = properties;
}
let cases = generate_cases(&corpus, &generator_config);
if cases.is_empty() {
report_code_diagnostics_standalone(&code_diagnostics);
anyhow::bail!(
"no cases could be generated from this corpus: {} state(s), {} delta(s), \
{} summary/summaries for the selected properties. Nothing was checked, \
so this is not a pass — supply more states (commutativity and \
reconciliation need at least two), captured deltas for the delta \
properties, or --transition BASE RESULT for transition_path_agreement.",
corpus.states.len(),
corpus.deltas.len(),
corpus.summaries.len(),
);
}
if let Some(path) = &config.bundle_out {
write_bundle(path, &wasm, ¶meters, &corpus)?;
}
let mut oracle = RuntimeOracle::standalone(wasm, parameters.clone())
.await
.map_err(describe_oracle_build_error)?;
let instance = oracle.instance_id();
let _ = warn_on_reversed_transitions(&mut oracle, &hand_supplied_steps);
let outcomes: Vec<(ConformanceCase, PropertyOutcome)> = cases
.into_iter()
.map(|case| {
let outcome = verify_case(&mut oracle, &case);
(case, outcome)
})
.collect();
let evidence = match &config.evidence_out {
Some(dir) => Some(write_evidence(
dir,
instance,
¶meters,
&outcomes,
&mut oracle,
&corpus.states,
&corpus.deltas,
)?),
None => None,
};
let report = Report::build(&corpus, &outcomes, evidence, code_diagnostics);
if config.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
report.print_human();
}
let just_outcomes: Vec<&PropertyOutcome> = outcomes.iter().map(|(_, o)| o).collect();
let enforceable = just_outcomes
.iter()
.filter(|o| o.is_enforceable_violation())
.count();
if exit_code(just_outcomes) != 0 {
return Err(ConformanceViolations { count: enforceable }.into());
}
Ok(())
}
fn warn_on_reversed_transitions<O: ConformanceOracle + ?Sized>(
oracle: &mut O,
steps: &[(Bytes, Bytes)],
) -> usize {
let mut suspicious = 0;
for (base, result) in steps {
let reversed =
oracle.update_state(result, &[UpdateData::State(State::from(base.to_vec()))]);
let Ok(modification) = reversed else {
continue;
};
let Some(state) = modification.new_state else {
continue;
};
if state.as_ref() == base.as_ref() {
suspicious += 1;
eprintln!(
"warning: for one --transition pair, merging BASE into RESULT \
reproduces BASE, which is what a reversed pair looks like. \
--transition takes the state the peer HELD first and the state it \
REACHED second; supplied the other way round, a conforming \
contract is reported as violating transition_path_agreement and \
nothing can tell that apart from a real finding."
);
}
}
suspicious
}
fn write_bundle(
path: &Path,
wasm: &[u8],
parameters: &[u8],
corpus: &Corpus,
) -> anyhow::Result<()> {
let mut bundle = ReplayBundle::new(wasm.to_vec(), parameters.to_vec());
bundle.states = corpus.states.iter().map(|s| s.to_vec()).collect();
bundle.summaries = corpus.summaries.iter().map(|s| s.to_vec()).collect();
let (with_bases, loose): (Vec<usize>, Vec<usize>) =
(0..corpus.deltas.len()).partition(|i| corpus.delta_base(*i).is_some());
let mut unassigned = with_bases;
let take_for = |base: &Bytes, unassigned: &mut Vec<usize>| -> Option<usize> {
unassigned
.iter()
.position(|i| corpus.delta_base(*i).map(|b| b.as_ref()) == Some(base.as_ref()))
.map(|slot| unassigned.remove(slot))
};
let mut steps: Vec<Transition> = Vec::with_capacity(corpus.transitions.len());
for (base, result) in &corpus.transitions {
let step = |delta: Option<usize>| Transition {
base_state: base.to_vec(),
result_state: result.to_vec(),
delta: delta.map(|i| corpus.deltas[i].to_vec()),
..Default::default()
};
steps.push(step(take_for(base, &mut unassigned)));
while let Some(extra) = take_for(base, &mut unassigned) {
steps.push(step(Some(extra)));
}
}
bundle.transitions = steps;
bundle.deltas = loose
.into_iter()
.chain(unassigned)
.map(|i| corpus.deltas[i].to_vec())
.collect();
bundle.note = Some(format!(
"captured by fdev verify-merge {}",
env!("CARGO_PKG_VERSION")
));
bundle
.write_to(path)
.with_context(|| format!("writing bundle to {}", path.display()))?;
eprintln!(
"wrote replay bundle to {} ({} state(s), {} delta(s), {} summary/summaries, \
{} transition(s))",
path.display(),
bundle.states.len(),
bundle.deltas.len(),
bundle.summaries.len(),
bundle.transitions.len(),
);
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error(
"{count} merge-law violation(s) found: this contract cannot converge, so peers \
holding it will disagree and keep retrying"
)]
pub struct ConformanceViolations {
pub count: usize,
}
fn exit_code<'a>(outcomes: impl IntoIterator<Item = &'a PropertyOutcome>) -> i32 {
if outcomes
.into_iter()
.any(PropertyOutcome::is_enforceable_violation)
{
1
} else {
0
}
}
fn parse_properties(names: &[String]) -> anyhow::Result<Vec<ConformanceProperty>> {
names
.iter()
.map(|name| {
ConformanceProperty::ALL
.iter()
.copied()
.find(|p| p.as_str() == name)
.ok_or_else(|| {
let valid = ConformanceProperty::ALL
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("unknown property {name:?}; valid properties: {valid}")
})
})
.collect()
}
async fn verify_evidence(config: &ConformanceConfig, path: &PathBuf) -> anyhow::Result<()> {
let bytes = read_file(path)?;
let evidence: ConformanceEvidence = bincode::deserialize(&bytes)
.with_context(|| format!("decoding evidence {}", path.display()))?;
if evidence.schema_version != EVIDENCE_SCHEMA_VERSION {
anyhow::bail!(
"evidence uses schema version {}, this build understands {}",
evidence.schema_version,
EVIDENCE_SCHEMA_VERSION
);
}
evidence
.check_bounds()
.map_err(|rejected| anyhow::anyhow!("evidence is not shippable: {rejected}"))?;
let code = match (&config.wasm, &config.contract_store) {
(Some(wasm), _) => read_file(wasm)?,
(None, Some(store)) => find_code_for_instance(store, &evidence)?,
(None, None) => anyhow::bail!(
"verifying evidence needs the contract it accuses: pass --wasm, or \
--contract-store pointing at a node that hosts it"
),
};
report_code_diagnostics_standalone(&code_diagnostics(&code));
let mut oracle = RuntimeOracle::standalone(code, evidence.parameters.clone())
.await
.map_err(describe_oracle_build_error)?;
if oracle.instance_id() != evidence.contract {
anyhow::bail!(
"the contract supplied is {} but the evidence is about {}",
oracle.instance_id(),
evidence.contract
);
}
let case = evidence
.to_case()
.map_err(|rejected| anyhow::anyhow!("evidence is not shippable: {rejected}"))?;
let outcome = verify_case(&mut oracle, &case);
println!("evidence {}", evidence.id());
println!(" contract : {}", evidence.contract);
println!(" property : {}", evidence.property);
println!(
" claimed : {}",
match &evidence.observed {
Some(v) => format!("{} ({})", v.property, v.detail),
None => "nothing recorded by the sender".to_string(),
}
);
match &outcome {
PropertyOutcome::Violated(v) => {
println!(
" verdict : REPRODUCED \u{2014} {} ({})",
v.property, v.detail
);
if evidence
.observed
.as_ref()
.is_some_and(|o| o.property != v.property)
{
println!(" note : a DIFFERENT law broke here than the sender reported");
}
if v.severity == Severity::Violation {
return Err(ConformanceViolations { count: 1 }.into());
}
Ok(())
}
PropertyOutcome::Holds => {
println!(" verdict : NOT REPRODUCED \u{2014} the law holds on this runtime");
println!(
" note : the sender's runtime was {:?}; a finding that does not \
reproduce must not be acted on",
evidence.runtime
);
Ok(())
}
PropertyOutcome::Inconclusive(reason) => {
println!(" verdict : INCONCLUSIVE \u{2014} {reason}");
Ok(())
}
}
}
fn find_code_for_instance(store: &Path, evidence: &ConformanceEvidence) -> anyhow::Result<Vec<u8>> {
let params = freenet_stdlib::prelude::Parameters::from(evidence.parameters.clone());
let mut examined = 0usize;
for entry in std::fs::read_dir(store)
.with_context(|| format!("reading contract store {}", store.display()))?
{
let path = entry
.with_context(|| format!("listing contract store {}", store.display()))?
.path();
if !path.is_file() || path.extension().is_none_or(|ext| ext != "wasm") {
continue;
}
let Ok((code, _version)) = ContractCode::load_versioned_from_path(&path) else {
continue;
};
examined += 1;
if ContractInstanceId::from_params_and_code(¶ms, &code) == evidence.contract {
return Ok(code.data().to_vec());
}
}
anyhow::bail!(
"no contract in {} is instance {} ({} candidate(s) examined). A peer only \
stores contracts it hosts, so pass --wasm if this node does not.",
store.display(),
evidence.contract,
examined
)
}
fn find_code_in_store(store: &Path, bundle: &ReplayBundle) -> anyhow::Result<Vec<u8>> {
let Some(hash) = bundle.code_hash else {
anyhow::bail!(
"bundle names no contract (no code hash), so no store lookup could \
identify the right WASM"
);
};
let path = store
.join(CodeHash::new(hash).encode())
.with_extension("wasm");
if !path.exists() {
anyhow::bail!(
"this node's contract store has no code for the bundle's contract \
(looked for {}). A peer only stores contracts it hosts, so a capture \
replayed on a different node may need --wasm.",
path.display()
);
}
let (code, _version) = ContractCode::load_versioned_from_path(&path)
.with_context(|| format!("reading contract code from {}", path.display()))?;
Ok(code.data().to_vec())
}
fn load_inputs(config: &ConformanceConfig) -> anyhow::Result<LoadedInputs> {
if let Some(bundle_path) = &config.bundle {
let bundle = ReplayBundle::read_from(bundle_path)
.with_context(|| format!("reading bundle {}", bundle_path.display()))?;
let supplied = match (&config.wasm, &config.contract_store) {
(Some(path), _) => Some(read_file(path)?),
(None, Some(store)) => Some(find_code_in_store(store, &bundle)?),
(None, None) => None,
};
let wasm = bundle.resolve_code(supplied).with_context(|| {
format!(
"resolving contract code for bundle {}",
bundle_path.display()
)
})?;
let parameters = bundle.parameters.clone();
if let Some(note) = bundle.note.as_deref() {
eprintln!("bundle note: {note}");
}
let corpus = bundle.to_corpus();
Ok(LoadedInputs {
wasm,
parameters,
corpus,
hand_supplied_steps: Vec::new(),
})
} else {
let wasm_path = config
.wasm
.as_ref()
.context("--wasm is required unless --bundle is given")?;
let wasm = read_file(wasm_path)?;
let parameters = match &config.params {
Some(path) => read_file(path)?,
None => Vec::new(),
};
if config.states.is_empty() && config.transitions.is_empty() {
report_code_diagnostics_standalone(&code_diagnostics(&wasm));
anyhow::bail!(
"at least one --state or --transition is required unless --bundle is given"
);
}
let mut states = Vec::with_capacity(config.states.len());
for path in &config.states {
states.push(read_file(path)?);
}
if config.transitions.len() % 2 != 0 {
anyhow::bail!(
"--transition takes two paths per occurrence, got {}",
config.transitions.len()
);
}
let mut steps: Vec<(Bytes, Bytes)> = Vec::with_capacity(config.transitions.len() / 2);
for pair in config.transitions.chunks(2) {
let base = read_file(&pair[0])?;
let result = read_file(&pair[1])?;
states.push(base.clone());
states.push(result.clone());
steps.push((Bytes::from(base), Bytes::from(result)));
}
let corpus = Corpus {
transitions: steps.clone(),
..Corpus::from_states(states)
}
.deduplicated();
Ok(LoadedInputs {
wasm,
parameters,
corpus,
hand_supplied_steps: steps,
})
}
}
#[derive(Debug)]
struct LoadedInputs {
wasm: Vec<u8>,
parameters: Vec<u8>,
corpus: Corpus,
hand_supplied_steps: Vec<(Bytes, Bytes)>,
}
fn read_file(path: &PathBuf) -> anyhow::Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))
}
fn describe_oracle_build_error(err: OracleBuildError) -> anyhow::Error {
match &err {
OracleBuildError::Runtime(_) => {
anyhow::anyhow!("the contract WASM failed to load into the verifier runtime: {err}")
}
OracleBuildError::Scratch(_) | OracleBuildError::Storage(_) => {
anyhow::anyhow!("could not set up the local verifier environment: {err}")
}
}
}
fn write_evidence<O: ConformanceOracle + ?Sized>(
dir: &PathBuf,
instance: ContractInstanceId,
parameters: &[u8],
outcomes: &[(ConformanceCase, PropertyOutcome)],
oracle: &mut O,
state_candidates: &[Bytes],
delta_candidates: &[Bytes],
) -> anyhow::Result<EvidenceSummary> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating evidence directory {}", dir.display()))?;
let mut written = HashSet::new();
let mut oversized = 0usize;
let mut local_only = 0usize;
let mut shrunk_from = 0usize;
let mut shrunk_to = 0usize;
let mut local_only_notes: Vec<UnwritableNote> = Vec::new();
let mut oversized_notes: Vec<UnwritableNote> = Vec::new();
for (case, outcome) in outcomes {
if !outcome.is_enforceable_violation() {
continue;
}
if !case.property.is_self_verifying() {
local_only += 1;
note_unwritable(&mut local_only_notes, case.property, None);
continue;
}
let (minimized, shrink) = minimize(
oracle,
case,
state_candidates,
delta_candidates,
&MinimizeConfig::default(),
);
shrunk_from += shrink.original_bytes;
shrunk_to += shrink.final_bytes;
let observed = verify_case(oracle, &minimized).violation().cloned();
let evidence =
ConformanceEvidence::new(instance, parameters.to_vec(), &minimized, observed);
if let Err(rejected) = evidence.check_bounds() {
oversized += 1;
note_unwritable(
&mut oversized_notes,
minimized.property,
Some(rejected.to_string()),
);
continue;
}
let id = evidence.id();
if !written.insert(id) {
continue;
}
let bytes =
bincode::serialize(&evidence).with_context(|| format!("encoding evidence {id}"))?;
let path = dir.join(format!("{id}.bin"));
std::fs::write(&path, bytes)
.with_context(|| format!("writing evidence to {}", path.display()))?;
}
let _ = write_unwritable_notes(
&mut std::io::stderr().lock(),
&local_only_notes,
&oversized_notes,
);
Ok(EvidenceSummary {
directory: dir.display().to_string(),
files_written: written.len(),
findings_local_only: local_only,
findings_too_large: oversized,
input_bytes_before_shrinking: shrunk_from,
input_bytes_after_shrinking: shrunk_to,
})
}
struct UnwritableNote {
property: ConformanceProperty,
cases: usize,
example: Option<String>,
}
fn note_unwritable(
notes: &mut Vec<UnwritableNote>,
property: ConformanceProperty,
example: Option<String>,
) {
match notes.iter_mut().find(|n| n.property == property) {
Some(note) => note.cases += 1,
None => notes.push(UnwritableNote {
property,
cases: 1,
example,
}),
}
}
fn write_unwritable_notes(
out: &mut impl std::io::Write,
local_only: &[UnwritableNote],
oversized: &[UnwritableNote],
) -> std::io::Result<()> {
for note in local_only {
writeln!(
out,
"note: {} {} finding(s) cannot be carried as evidence at all ({}); they \
appear in the report's findings list and no evidence file is written \
for them",
note.cases,
note.property,
EvidenceRejected::NotSelfVerifying {
property: note.property
}
)?;
}
for note in oversized {
writeln!(
out,
"warning: {} {} finding(s) stayed over the evidence size limit even after \
shrinking, so no evidence file was written for them — example: {}",
note.cases,
note.property,
note.example.as_deref().unwrap_or("(no detail)")
)?;
}
Ok(())
}
#[derive(Serialize)]
struct EvidenceSummary {
directory: String,
files_written: usize,
findings_local_only: usize,
findings_too_large: usize,
input_bytes_before_shrinking: usize,
input_bytes_after_shrinking: usize,
}
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
struct CodeDiagnostic {
diagnostic: &'static str,
detail: String,
}
fn code_diagnostics(wasm: &[u8]) -> Vec<CodeDiagnostic> {
let mut diagnostics = Vec::new();
if host_clock::imports_host_clock(wasm) {
diagnostics.push(CodeDiagnostic {
diagnostic: "host_clock_import",
detail: format!(
"this contract imports the host wall clock ({}::{}), which is \
DEPRECATED for contracts. update_state must be a function of its \
inputs or replicas cannot be guaranteed to converge, so the merge \
laws checked above are not well-formed statements about a contract \
that reads the clock. In a future release the call will TRAP \
(issue #5465): the contract will still load, but any actual call \
to the clock will fail that operation. Trapping is per-call, so a \
contract that imports the symbol without reaching it keeps working \
and needs no re-key. Carry a client-signed timestamp in the state \
and enforce only monotonicity (new > current) instead. Delegates \
are unaffected. See {}",
host_clock::HOST_CLOCK_NAMESPACE,
host_clock::HOST_CLOCK_IMPORT,
host_clock::HOST_CLOCK_DEPRECATION_DOC,
),
});
}
diagnostics
}
fn render_code_diagnostics_standalone(diagnostics: &[CodeDiagnostic]) -> Option<String> {
if diagnostics.is_empty() {
return None;
}
let mut out =
String::from("code diagnostics (about the contract's code, not about a law it broke):\n");
for d in diagnostics {
out.push_str(&format!(" - {}: {}\n", d.diagnostic, d.detail));
}
Some(out)
}
fn report_code_diagnostics_standalone(diagnostics: &[CodeDiagnostic]) {
if let Some(text) = render_code_diagnostics_standalone(diagnostics) {
eprint!("{text}");
}
}
#[derive(Serialize)]
struct Report {
corpus_states: usize,
corpus_deltas: usize,
corpus_summaries: usize,
cases_run: usize,
holds: usize,
violations: usize,
enforceable_violations: usize,
diagnostic_violations: usize,
inconclusive: usize,
findings: Vec<Finding>,
inconclusive_reasons: Vec<InconclusiveReason>,
evidence: Option<EvidenceSummary>,
code_diagnostics: Vec<CodeDiagnostic>,
}
#[derive(Serialize, Clone)]
struct Finding {
property: String,
severity: &'static str,
detail: String,
left: String,
right: String,
}
#[derive(Serialize)]
struct InconclusiveReason {
reason: &'static str,
occurrences: usize,
}
impl Report {
fn build(
corpus: &Corpus,
outcomes: &[(ConformanceCase, PropertyOutcome)],
evidence: Option<EvidenceSummary>,
code_diagnostics: Vec<CodeDiagnostic>,
) -> Self {
let mut holds = 0usize;
let mut violations = 0usize;
let mut enforceable_violations = 0usize;
let mut diagnostic_violations = 0usize;
let mut inconclusive = 0usize;
let mut findings: Vec<Finding> = Vec::new();
let mut inconclusive_reasons: HashMap<&'static str, usize> = HashMap::new();
for (_, outcome) in outcomes {
match outcome {
PropertyOutcome::Holds => holds += 1,
PropertyOutcome::Violated(v) => {
violations += 1;
match v.severity {
Severity::Violation => enforceable_violations += 1,
Severity::Diagnostic => diagnostic_violations += 1,
}
findings.push(Finding {
property: v.property.as_str().to_string(),
severity: match v.severity {
Severity::Violation => "violation",
Severity::Diagnostic => "diagnostic",
},
detail: v.detail.clone(),
left: v.left.to_string(),
right: v.right.to_string(),
});
}
PropertyOutcome::Inconclusive(reason) => {
inconclusive += 1;
*inconclusive_reasons
.entry(inconclusive_label(reason))
.or_insert(0) += 1;
}
}
}
let mut inconclusive_reasons: Vec<InconclusiveReason> = inconclusive_reasons
.into_iter()
.map(|(reason, occurrences)| InconclusiveReason {
reason,
occurrences,
})
.collect();
inconclusive_reasons.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
Report {
corpus_states: corpus.states.len(),
corpus_deltas: corpus.deltas.len(),
corpus_summaries: corpus.summaries.len(),
cases_run: outcomes.len(),
holds,
violations,
enforceable_violations,
diagnostic_violations,
inconclusive,
findings,
inconclusive_reasons,
evidence,
code_diagnostics,
}
}
fn print_human(&self) {
let _ = self.write_human(&mut std::io::stdout().lock());
}
fn write_human(&self, out: &mut impl std::io::Write) -> std::io::Result<()> {
writeln!(
out,
"merge check: {} state(s), {} delta(s), {} summary/summaries in the corpus",
self.corpus_states, self.corpus_deltas, self.corpus_summaries
)?;
writeln!(
out,
"merge check: {} case(s) run \u{2014} {} held, {} violation(s) ({} enforceable, {} diagnostic-only), {} inconclusive",
self.cases_run,
self.holds,
self.violations,
self.enforceable_violations,
self.diagnostic_violations,
self.inconclusive
)?;
if !self.findings.is_empty() {
writeln!(out, "\nfindings:")?;
for (f, count) in group_findings(&self.findings) {
let cases = if count == 1 {
"1 case".to_string()
} else {
format!("{count} cases")
};
writeln!(
out,
" [{}] {} ({cases}): {}\n example \u{2014} left: {}; right: {}",
f.severity, f.property, f.detail, f.left, f.right
)?;
}
}
if !self.inconclusive_reasons.is_empty() {
writeln!(
out,
"\ninconclusive ({} total \u{2014} NOT passes: these cases reached no verdict, so they say nothing about the contract):",
self.inconclusive
)?;
for r in &self.inconclusive_reasons {
writeln!(out, " {}: {}", r.reason, r.occurrences)?;
}
}
if let Some(evidence) = &self.evidence {
writeln!(
out,
"\nwrote {} evidence file(s) to {}",
evidence.files_written, evidence.directory
)?;
if evidence.findings_local_only > 0 {
writeln!(
out,
" {} finding(s) came from a property whose premise the evidence \
bytes cannot carry, so no evidence exists to write; they are \
listed above and are local-only by design",
evidence.findings_local_only
)?;
}
if evidence.findings_too_large > 0 {
writeln!(
out,
" {} finding(s) stayed over the evidence size limit even after \
shrinking; they are listed above and simply cannot be \
propagated",
evidence.findings_too_large
)?;
}
}
if !self.code_diagnostics.is_empty() {
writeln!(
out,
"\ncode diagnostics (about the contract's code, not about a law it broke; these never fail this command):"
)?;
for d in &self.code_diagnostics {
writeln!(out, " [{}] {}", d.diagnostic, d.detail)?;
}
}
if self.enforceable_violations == 0 {
writeln!(out, "\nno enforceable violations found.")?;
if self.diagnostic_violations > 0 {
writeln!(
out,
"({} diagnostic finding(s) above are efficiency notes, not merge-law breaks, and do not fail this command.)",
self.diagnostic_violations
)?;
}
if !self.code_diagnostics.is_empty() {
writeln!(
out,
"({} code diagnostic(s) above are about the contract's code rather than a merge law, so they do not fail this command, but they still need addressing.)",
self.code_diagnostics.len()
)?;
}
}
Ok(())
}
}
fn group_findings(findings: &[Finding]) -> Vec<(&Finding, usize)> {
let mut groups: Vec<(&Finding, usize)> = Vec::new();
for f in findings {
match groups
.iter_mut()
.find(|(g, _)| g.property == f.property && g.detail == f.detail)
{
Some((_, count)) => *count += 1,
None => groups.push((f, 1)),
}
}
groups
}
fn inconclusive_label(reason: &Inconclusive) -> &'static str {
match reason {
Inconclusive::InputNotValid => "input not valid",
Inconclusive::RelatedRequired => "requires related contract state",
Inconclusive::ContractError(_) => "contract error",
Inconclusive::NoOutputState => "update produced no output state",
Inconclusive::ResourceLimit(_) => "resource limit hit",
Inconclusive::RoundLimit => "reconciliation round budget exhausted",
Inconclusive::MalformedCase(_) => "malformed case",
Inconclusive::NoDeltaPath => "no delta path to compare against",
Inconclusive::StateNotSettled => "observed result state never settles",
Inconclusive::NotReproducible => "finding did not reproduce",
_ => "other",
}
}
#[cfg(test)]
mod stdout_purity_pin {
fn fn_body(signature: &str) -> &'static str {
let src = include_str!("conformance.rs");
let start = src
.find(signature)
.unwrap_or_else(|| panic!("{signature} not found in conformance.rs"));
if let Some(tests) = src.find("\n#[cfg(test)]") {
assert!(
start < tests,
"{signature} matched only inside a test module, so this pin would be \
scoped to a test rather than to production code"
);
}
let after = &src[start..];
let open = after.find('{').expect("signature has no body");
let masked = blank_literals(&after[open..]);
let mut depth = 0usize;
for (offset, ch) in masked.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &after[..open + offset + 1];
}
}
_ => {}
}
}
panic!("{signature}'s body is not brace-balanced");
}
fn blank_literals(src: &str) -> String {
fn excerpt(src: &str, at: usize) -> &str {
let end = (at + 48).min(src.len());
src.get(at..end).unwrap_or("<not a char boundary>")
}
fn char_literal_len(bytes: &[u8], at: usize) -> Option<usize> {
let escaped = bytes.get(at + 1) == Some(&b'\\');
let body_start = if escaped { at + 2 } else { at + 1 };
for (end, byte) in bytes.iter().enumerate().skip(body_start).take(4) {
if *byte == b'\'' {
return (end > body_start).then_some(end - at + 1);
}
}
None
}
let bytes = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'r' if bytes[i + 1..].starts_with(b"\"") || bytes[i + 1..].starts_with(b"#") => {
panic!(
"blank_literals cannot mask a raw string, so the brace count \
it feeds would be wrong and the scrape would silently cover \
the wrong region. EXTEND this function to handle raw strings; \
do not delete the call. At byte {i} of the scraped region: {:?}",
excerpt(src, i)
);
}
b'/' if bytes[i + 1..].starts_with(b"*") => {
panic!(
"blank_literals cannot mask a block comment, so the brace count \
it feeds would be wrong and the scrape would silently cover \
the wrong region. EXTEND this function to handle block \
comments; do not delete the call. At byte {i} of the scraped \
region: {:?}",
excerpt(src, i)
);
}
b'/' if bytes[i + 1..].starts_with(b"/") => {
while i < bytes.len() && bytes[i] != b'\n' {
out.push(' ');
i += 1;
}
}
b'"' => {
out.push(' ');
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' {
out.push(' ');
i += 1;
}
if i < bytes.len() {
out.push(' ');
i += 1;
}
}
assert!(i < bytes.len(), "unterminated string literal");
out.push(' ');
i += 1;
}
b'\'' if char_literal_len(bytes, i).is_some() => {
let len = char_literal_len(bytes, i).expect("just checked");
for _ in 0..len {
out.push(' ');
}
i += len;
}
_ => {
let ch = src[i..].chars().next().expect("in bounds");
out.push(ch);
i += ch.len_utf8();
}
}
}
debug_assert_eq!(out.len(), src.len(), "blank_literals must preserve offsets");
out
}
#[test]
fn braces_inside_literals_are_not_counted_as_structure() {
let masked = blank_literals("{ f(\"}}}{\"); g('{'); }");
assert_eq!(
masked.matches('{').count(),
1,
"a brace inside a literal was counted as structure: {masked}"
);
assert_eq!(masked.matches('}').count(), 1, "{masked}");
assert_eq!(
masked.len(),
"{ f(\"}}}{\"); g('{'); }".len(),
"the mask changed byte offsets, so they no longer index the original"
);
}
#[test]
fn comments_and_escaped_quotes_are_handled() {
let masked = blank_literals("{ // }}}\n f(\"a\\\"}\"); }");
assert_eq!(masked.matches('{').count(), 1, "{masked}");
assert_eq!(masked.matches('}').count(), 1, "{masked}");
}
#[test]
fn a_lifetime_is_not_mistaken_for_a_char_literal() {
let src = "{ fn f<'a>(x: &'a str) -> &'a str { x } }";
assert_eq!(blank_literals(src), src);
}
#[test]
fn a_char_literal_holding_a_quote_does_not_open_a_string() {
let masked = blank_literals("{ let _q = '\"'; f(); }");
assert_eq!(
masked.matches('{').count(),
1,
"structure was lost after a quote char literal: {masked}"
);
assert_eq!(masked.matches('}').count(), 1, "{masked}");
assert!(
masked.contains("f()"),
"the code after a quote char literal was blanked as if it were \
inside a string: {masked}"
);
assert_eq!(masked.len(), "{ let _q = '\"'; f(); }".len());
}
#[test]
fn a_byte_char_literal_holding_a_quote_does_not_open_a_string() {
let masked = blank_literals("{ if c == b'\"' { g(); } }");
assert_eq!(
masked.matches('{').count(),
2,
"structure was lost after a byte quote literal: {masked}"
);
assert_eq!(masked.matches('}').count(), 2, "{masked}");
}
#[test]
fn escaped_char_literals_are_masked_whole() {
let masked = blank_literals("{ a('\\''); b('\\\\'); c('\\n'); d(); }");
assert_eq!(masked.matches('{').count(), 1, "{masked}");
assert_eq!(masked.matches('}').count(), 1, "{masked}");
assert!(masked.contains("d()"), "code after was blanked: {masked}");
}
#[test]
fn ordinary_char_literals_are_masked_without_losing_structure() {
let src = "{ m(' '); n('x'); o('é'); }";
let masked = blank_literals(src);
assert_eq!(masked.matches('{').count(), 1, "{masked}");
assert_eq!(masked.matches('}').count(), 1, "{masked}");
assert_eq!(
masked.len(),
src.len(),
"masking a multi-byte char literal changed byte offsets"
);
}
#[test]
#[should_panic(expected = "raw string")]
fn a_raw_string_fails_closed() {
blank_literals("{ let s = r\"}{\"; }");
}
#[test]
#[should_panic(expected = "block comment")]
fn a_block_comment_fails_closed() {
blank_literals("{ /* } */ }");
}
#[test]
fn the_two_blank_literals_have_not_drifted() {
const START: &str = " fn blank_literals(src: &str) -> String {\n";
const END: &str = " out\n }\n";
fn extract(src: &str, whose: &str) -> String {
let start = src.find(START).unwrap_or_else(|| {
panic!(
"{whose} has no `blank_literals`; if it was renamed or removed, \
this pin must be updated, not deleted"
)
});
let rest = &src[start..];
let end = rest
.find(END)
.unwrap_or_else(|| panic!("{whose}'s `blank_literals` does not end as expected"))
+ END.len();
rest[..end].to_string()
}
let ours = extract(include_str!("conformance.rs"), "fdev");
let theirs = extract(
include_str!("../../core/src/wasm_runtime/runtime.rs"),
"freenet's runtime.rs",
);
for (whose, body) in [("fdev", &ours), ("freenet", &theirs)] {
assert!(
body.contains("cannot mask a raw string")
&& body.contains("cannot mask a block comment")
&& body.contains("unterminated string literal"),
"the extracted `blank_literals` from {whose} is not the real \
function:\n{body}"
);
}
assert_eq!(
ours, theirs,
"the two copies of `blank_literals` have drifted. One of them now \
masks something the other does not, which means one of the two \
source scrapes is silently covering the wrong region while its own \
tests still pass. Re-sync them byte for byte."
);
}
pub(super) fn code_only(signature: &str) -> String {
fn_body(signature)
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
fn stdout_macros_only() -> String {
code_only("fn load_inputs(")
.replace("eprintln!", "")
.replace("eprint!", "")
}
#[test]
fn load_inputs_never_writes_to_stdout() {
let body = stdout_macros_only();
assert!(
!body.contains("println!") && !body.contains("print!("),
"load_inputs writes to stdout, which lands ahead of the --json document \
and corrupts it for every consumer that parses stdout"
);
}
#[test]
fn the_standalone_report_writes_to_stderr_only() {
let raw = code_only("fn report_code_diagnostics_standalone(");
assert!(
raw.contains("eprint!"),
"the standalone report no longer writes anything, so the paths that \
abort before a Report exists say nothing at all:\n{raw}"
);
let stdout_only = raw.replace("eprintln!", "").replace("eprint!", "");
assert!(
!stdout_only.contains("print!") && !stdout_only.contains("println!"),
"the standalone report writes to stdout, which lands in the middle \
of a --json document and corrupts it for every consumer that parses \
stdout:\n{raw}"
);
}
#[test]
fn the_no_corpus_bail_reports_diagnostics_first() {
let body = code_only("fn load_inputs(");
let reported = body.find("report_code_diagnostics_standalone(").expect(
"the no-corpus bail no longer reports code diagnostics, so \
`--wasm` with no corpus tells an author nothing about the clock",
);
let bail = body
.find("at least one --state or --transition")
.expect("the no-corpus bail is gone; re-check what this pin guards");
assert!(
reported < bail,
"code diagnostics are reported after the bail that aborts the run, \
so they are never reached:\n{body}"
);
assert_eq!(
body.matches("report_code_diagnostics_standalone(").count(),
1,
"expected exactly one report call in `load_inputs`:\n{body}"
);
}
#[test]
fn the_bundle_note_still_reaches_the_reader() {
let body = code_only("fn load_inputs(");
assert!(
body.contains("eprintln!"),
"the bundle note is no longer surfaced at all; a corpus whose related \
state was refused would replay as a clean bill of health"
);
}
}
#[cfg(test)]
mod host_clock_diagnostic_pin {
use super::stdout_purity_pin::code_only;
#[test]
fn the_command_computes_code_diagnostics() {
let body = code_only("pub async fn conformance(");
assert_eq!(
body.matches("code_diagnostics(&wasm)").count(),
1,
"`fdev verify-merge` no longer computes code diagnostics from the \
contract's WASM, so a clock-reading contract is reported as clean:\n{body}"
);
}
#[test]
fn the_scrape_sees_real_code() {
let body = code_only("pub async fn conformance(");
assert!(
body.contains("Report::build("),
"the scraped region is not `conformance`'s body any more:\n{body}"
);
}
#[test]
fn diagnostics_are_computed_before_the_guards_that_abort() {
let body = code_only("pub async fn conformance(");
let computed = body
.find("code_diagnostics(&wasm)")
.expect("conformance no longer computes code diagnostics");
let corpus_guard = body
.find("corpus.is_empty()")
.expect("the empty-corpus guard is gone; re-check what this pin is guarding");
let cases_guard = body
.find("cases.is_empty()")
.expect("the no-cases guard is gone; re-check what this pin is guarding");
assert!(
computed < corpus_guard && computed < cases_guard,
"code diagnostics are computed after a guard that aborts, so an author \
whose corpus cannot produce a case is told nothing about the clock"
);
assert_eq!(
body.matches("report_code_diagnostics_standalone(").count(),
2,
"both aborting guards must report the diagnostics they are about to \
skip past; found a different number of call sites:\n{body}"
);
}
#[test]
fn verifying_evidence_also_reports_code_diagnostics() {
let body = code_only("async fn verify_evidence(");
assert_eq!(
body.matches("report_code_diagnostics_standalone(").count(),
1,
"`--evidence` no longer reports code diagnostics:\n{body}"
);
assert!(
body.contains("RuntimeOracle::standalone("),
"the scraped region is not `verify_evidence`'s body any more:\n{body}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use freenet::conformance::{OracleError, Violation};
use freenet_stdlib::prelude::{RelatedContracts, UpdateModification, ValidateResult};
fn render_human(report: &Report) -> String {
let mut rendered = Vec::new();
report
.write_human(&mut rendered)
.expect("writing to a Vec cannot fail");
String::from_utf8(rendered).expect("the report is utf-8")
}
struct GrowOnly;
impl ConformanceOracle for GrowOnly {
fn validate_state(
&mut self,
_state: &[u8],
_related: &freenet_stdlib::prelude::RelatedContracts<'_>,
) -> Result<freenet_stdlib::prelude::ValidateResult, freenet::conformance::OracleError>
{
Ok(freenet_stdlib::prelude::ValidateResult::Valid)
}
fn update_state(
&mut self,
state: &[u8],
updates: &[UpdateData<'_>],
) -> Result<
freenet_stdlib::prelude::UpdateModification<'static>,
freenet::conformance::OracleError,
> {
let mut out = state.to_vec();
for update in updates {
match update {
UpdateData::State(incoming) => out.extend_from_slice(incoming.as_ref()),
UpdateData::Delta(delta) => out.extend_from_slice(delta.as_ref()),
_ => {}
}
}
out.sort_unstable();
out.dedup();
Ok(freenet_stdlib::prelude::UpdateModification::valid(
State::from(out),
))
}
fn summarize_state(
&mut self,
state: &[u8],
) -> Result<Vec<u8>, freenet::conformance::OracleError> {
Ok(state.to_vec())
}
fn get_state_delta(
&mut self,
state: &[u8],
summary: &[u8],
) -> Result<Vec<u8>, freenet::conformance::OracleError> {
Ok(state
.iter()
.copied()
.filter(|b| !summary.contains(b))
.collect())
}
}
#[test]
fn a_reversed_transition_pair_is_warned_about_and_a_correct_one_is_not() {
let earlier = Bytes::from(vec![1u8, 2]);
let later = Bytes::from(vec![1u8, 2, 3]);
assert_eq!(
warn_on_reversed_transitions(&mut GrowOnly, &[(earlier.clone(), later.clone())]),
0,
"a correctly ordered pair must not be warned about, or the warning is \
noise on every run and stops being read"
);
assert_eq!(
warn_on_reversed_transitions(&mut GrowOnly, &[(later, earlier)]),
1,
"merging BASE into RESULT reproducing BASE is what a reversed pair \
looks like, and it is the only tell there is"
);
}
#[test]
fn an_odd_number_of_transition_paths_is_an_error_not_a_panic() {
let dir = tempfile::tempdir().expect("temp dir");
let wasm = dir.path().join("c.wasm");
std::fs::write(&wasm, b"\0asm-stand-in").expect("write wasm");
let config = ConformanceConfig {
wasm: Some(wasm),
params: None,
states: Vec::new(),
transitions: vec![PathBuf::from("only-one.bin")],
bundle: None,
contract_store: None,
max_cases: None,
properties: Vec::new(),
json: false,
evidence_out: None,
evidence_in: None,
bundle_out: None,
};
let err = load_inputs(&config).expect_err("an odd count must be rejected");
assert!(
err.to_string().contains("--transition takes two paths"),
"the error must name the argument, got: {err}"
);
}
#[test]
fn a_re_exported_bundle_keeps_what_each_delta_was_applied_to() {
let code = b"\0asm-stand-in".to_vec();
let base = vec![1u8, 2];
let bundle_in = ReplayBundle {
transitions: vec![
Transition {
base_state: base.clone(),
result_state: vec![1, 2, 3],
delta: Some(vec![3]),
..Default::default()
},
Transition {
base_state: base.clone(),
result_state: vec![1, 2, 4],
delta: Some(vec![4]),
..Default::default()
},
],
..ReplayBundle::new(code.clone(), Vec::new())
};
let original = bundle_in.to_corpus();
assert_eq!(
original.delta_bases,
vec![
Some(Bytes::from(base.clone())),
Some(Bytes::from(base.clone()))
],
"the fixture must actually carry provenance, or the round trip below \
has nothing to lose"
);
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("out.bin");
write_bundle(&path, &code, &[], &original).expect("write bundle");
let round_tripped = ReplayBundle::read_from(&path)
.expect("read bundle")
.to_corpus();
assert_eq!(round_tripped.transitions, original.transitions);
assert_eq!(round_tripped.deltas, original.deltas);
assert_eq!(
round_tripped.delta_bases, original.delta_bases,
"a re-exported bundle must pair each delta with the state it was \
applied to, exactly as the corpus it was written from did"
);
assert_eq!(round_tripped.states, original.states);
}
#[test]
fn a_re_exported_bundle_keeps_every_delta_of_a_collapsed_step() {
let code = b"\0asm-stand-in".to_vec();
let base = vec![1u8, 2];
let result = vec![1u8, 2, 3];
let bundle_in = ReplayBundle {
transitions: vec![
Transition {
base_state: base.clone(),
result_state: result.clone(),
delta: Some(vec![3]),
..Default::default()
},
Transition {
base_state: base.clone(),
result_state: result.clone(),
delta: Some(vec![4]),
..Default::default()
},
],
..ReplayBundle::new(code.clone(), Vec::new())
};
let original = bundle_in.to_corpus();
assert_eq!(
original.transitions.len(),
1,
"the fixture must actually collapse to one step, or it is the test above"
);
assert_eq!(
original.delta_bases,
vec![
Some(Bytes::from(base.clone())),
Some(Bytes::from(base.clone()))
],
"and both deltas must start out provenanced, or the round trip below has \
nothing to lose"
);
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("out.bin");
write_bundle(&path, &code, &[], &original).expect("write bundle");
let round_tripped = ReplayBundle::read_from(&path)
.expect("read bundle")
.to_corpus();
assert_eq!(round_tripped.transitions, original.transitions);
assert_eq!(round_tripped.deltas, original.deltas);
assert_eq!(
round_tripped.delta_bases, original.delta_bases,
"the second delta of a collapsed step must keep its base too, or the \
one pairing this corpus can support is gone from the replay"
);
assert_eq!(round_tripped.states, original.states);
}
#[derive(Default)]
struct CountingOracle {
calls: std::cell::Cell<usize>,
}
impl freenet::conformance::ConformanceOracle for CountingOracle {
fn validate_state(
&mut self,
_state: &[u8],
_related: &RelatedContracts<'_>,
) -> Result<ValidateResult, OracleError> {
self.calls.set(self.calls.get() + 1);
Ok(ValidateResult::Valid)
}
fn update_state(
&mut self,
state: &[u8],
_updates: &[UpdateData<'_>],
) -> Result<UpdateModification<'static>, OracleError> {
self.calls.set(self.calls.get() + 1);
Ok(UpdateModification::valid(State::from(state.to_vec())))
}
fn summarize_state(&mut self, _state: &[u8]) -> Result<Vec<u8>, OracleError> {
self.calls.set(self.calls.get() + 1);
Ok(Vec::new())
}
fn get_state_delta(
&mut self,
_state: &[u8],
_summary: &[u8],
) -> Result<Vec<u8>, OracleError> {
self.calls.set(self.calls.get() + 1);
Ok(Vec::new())
}
}
#[test]
fn a_local_only_finding_costs_no_minimisation_and_no_shrink_bytes() {
let dir = tempfile::tempdir().expect("temp dir");
let out = dir.path().join("evidence");
let mut oracle = CountingOracle::default();
let property = ConformanceProperty::TransitionPathAgreement;
assert!(
!property.is_self_verifying() && property.severity() == Severity::Violation,
"the fixture depends on this property being enforceable AND local-only"
);
let case = ConformanceCase::new(
property,
vec![Bytes::from(vec![7u8; 64]), Bytes::from(vec![8u8; 64])],
);
assert!(
case.input_bytes() > 0,
"the case must carry bytes, or the counter assertion below is vacuous"
);
let summary = write_evidence(
&out,
ContractInstanceId::new([3u8; 32]),
&[],
&[(case, PropertyOutcome::Violated(violation_of(property)))],
&mut oracle,
&[],
&[],
)
.expect("write evidence");
assert_eq!(summary.files_written, 0);
assert_eq!(summary.findings_local_only, 1);
assert_eq!(summary.findings_too_large, 0);
assert_eq!(
oracle.calls.get(),
0,
"minimisation and re-verification must not run for a finding that cannot \
travel; they are the expensive part of this loop"
);
assert_eq!(
(
summary.input_bytes_before_shrinking,
summary.input_bytes_after_shrinking
),
(0, 0),
"and its bytes must not land in the shrink ratio, whose denominator is \
supposed to be work shrinking was asked to do"
);
}
#[test]
fn a_shippable_finding_is_minimised_and_counted() {
let dir = tempfile::tempdir().expect("temp dir");
let out = dir.path().join("evidence");
let mut oracle = CountingOracle::default();
let property = ConformanceProperty::StateCommutativity;
assert!(property.is_self_verifying() && property.severity() == Severity::Violation);
let case = ConformanceCase::new(
property,
vec![Bytes::from(vec![7u8; 64]), Bytes::from(vec![8u8; 64])],
);
let summary = write_evidence(
&out,
ContractInstanceId::new([3u8; 32]),
&[],
&[(case, PropertyOutcome::Violated(violation_of(property)))],
&mut oracle,
&[],
&[],
)
.expect("write evidence");
assert_eq!(summary.files_written, 1);
assert_eq!(summary.findings_local_only, 0);
assert!(
oracle.calls.get() > 0,
"a shippable finding must actually go through minimisation"
);
assert_eq!(summary.input_bytes_before_shrinking, 128);
}
#[test]
fn the_human_report_says_why_no_evidence_was_written() {
let report = Report {
corpus_states: 1,
corpus_deltas: 1,
corpus_summaries: 0,
cases_run: 2,
holds: 0,
violations: 2,
enforceable_violations: 2,
diagnostic_violations: 0,
inconclusive: 0,
findings: Vec::new(),
inconclusive_reasons: Vec::new(),
code_diagnostics: Vec::new(),
evidence: Some(EvidenceSummary {
directory: "/tmp/evidence".to_string(),
files_written: 0,
findings_local_only: 1,
findings_too_large: 1,
input_bytes_before_shrinking: 0,
input_bytes_after_shrinking: 0,
}),
};
let rendered = render_human(&report);
assert!(
rendered.contains("wrote 0 evidence file(s)"),
"the fixture no longer produces the line this pin is about:\n{rendered}"
);
assert!(
rendered.contains("the evidence bytes cannot carry"),
"a local-only finding drew no explanation, so the report says \
'wrote 0 evidence file(s)' and nothing else — which reads exactly \
like a clean run:\n{rendered}"
);
assert!(
rendered.contains("stayed over the evidence size limit"),
"an over-size finding drew no explanation, so the report says \
'wrote 0 evidence file(s)' and nothing else — which reads exactly \
like a clean run:\n{rendered}"
);
}
#[test]
fn the_unwritable_notes_are_one_line_per_property_not_one_per_case() {
let mut local_only = Vec::new();
for _ in 0..24 {
note_unwritable(
&mut local_only,
ConformanceProperty::TransitionPathAgreement,
None,
);
}
note_unwritable(
&mut local_only,
ConformanceProperty::DeltaPermutationInvariance,
None,
);
let mut oversized = Vec::new();
for found in [900usize, 800, 700] {
note_unwritable(
&mut oversized,
ConformanceProperty::StateCommutativity,
Some(format!("{found} bytes over")),
);
}
let mut rendered = Vec::new();
write_unwritable_notes(&mut rendered, &local_only, &oversized)
.expect("writing to a Vec cannot fail");
let rendered = String::from_utf8(rendered).expect("notes are utf-8");
assert_eq!(
rendered.lines().count(),
3,
"28 unwritable findings across 3 properties should print 3 lines:\n{rendered}"
);
assert!(
rendered.contains("24 transition_path_agreement finding(s)"),
"the note does not say how many cases it stands for:\n{rendered}"
);
assert!(
rendered.contains("1 delta_permutation_invariance finding(s)"),
"a second property's findings were folded into the first's note:\n{rendered}"
);
assert!(
rendered.contains("example: 900 bytes over"),
"the over-size note lost its representative rejection:\n{rendered}"
);
}
#[test]
fn the_no_evidence_explanations_are_conditional() {
let report = Report {
corpus_states: 1,
corpus_deltas: 1,
corpus_summaries: 0,
cases_run: 1,
holds: 1,
violations: 0,
enforceable_violations: 0,
diagnostic_violations: 0,
inconclusive: 0,
findings: Vec::new(),
inconclusive_reasons: Vec::new(),
code_diagnostics: Vec::new(),
evidence: Some(EvidenceSummary {
directory: "/tmp/evidence".to_string(),
files_written: 0,
findings_local_only: 0,
findings_too_large: 0,
input_bytes_before_shrinking: 0,
input_bytes_after_shrinking: 0,
}),
};
let rendered = render_human(&report);
assert!(
!rendered.contains("the evidence bytes cannot carry")
&& !rendered.contains("stayed over the evidence size limit"),
"a run with zero unwritable findings explained away findings it does \
not have:\n{rendered}"
);
}
#[test]
fn evidence_resolves_by_instance_so_parameters_cannot_be_confused() {
let store = tempfile::tempdir().expect("temp dir");
let code = b"\0asm-stand-in-bytes".to_vec();
let encoded = ContractCode::from(code.clone())
.to_bytes_versioned(freenet_stdlib::prelude::APIVersion::Version0_0_1)
.expect("encode versioned");
std::fs::write(
store
.path()
.join(CodeHash::from_code(&code).encode())
.with_extension("wasm"),
&encoded,
)
.expect("write store entry");
let params = vec![1, 2, 3];
let instance_for = |p: Vec<u8>| {
ContractInstanceId::from_params_and_code(
freenet_stdlib::prelude::Parameters::from(p),
ContractCode::from(code.clone()),
)
};
let case = ConformanceCase::new(
ConformanceProperty::StateCommutativity,
vec![Bytes::from(vec![1u8]), Bytes::from(vec![2u8])],
);
let right = ConformanceEvidence::new(instance_for(params.clone()), params, &case, None);
assert_eq!(
find_code_for_instance(store.path(), &right).expect("should resolve"),
code,
"evidence naming this instance must resolve to its code"
);
let wrong =
ConformanceEvidence::new(instance_for(vec![9, 9, 9]), vec![1, 2, 3], &case, None);
let err = find_code_for_instance(store.path(), &wrong)
.expect_err("a different instance must not resolve to this contract");
assert!(
err.to_string().contains("is instance"),
"error should name the instance it could not find, got: {err}"
);
}
#[test]
fn code_is_resolved_from_a_contract_store_the_way_the_node_writes_it() {
let store = tempfile::tempdir().expect("temp dir");
let code = b"\0asm-not-really-but-bytes-are-bytes".to_vec();
let encoded = ContractCode::from(code.clone())
.to_bytes_versioned(freenet_stdlib::prelude::APIVersion::Version0_0_1)
.expect("encode versioned");
let path = store
.path()
.join(CodeHash::from_code(&code).encode())
.with_extension("wasm");
std::fs::write(&path, &encoded).expect("write store entry");
assert!(
encoded.len() > code.len(),
"the stored form must carry a header, or this test proves nothing \
about stripping one"
);
let bundle = ReplayBundle::new(code.clone(), Vec::new());
let resolved = find_code_in_store(store.path(), &bundle).expect("resolve from store");
assert_eq!(resolved, code, "resolved code must be the raw WASM");
let absent = ReplayBundle::new(b"different code entirely".to_vec(), Vec::new());
let err = find_code_in_store(store.path(), &absent)
.expect_err("a contract absent from the store must not resolve");
assert!(
err.to_string()
.contains("no code for the bundle's contract"),
"error should name the miss, got: {err}"
);
}
#[test]
fn every_property_name_round_trips() {
for property in ConformanceProperty::ALL {
let parsed = parse_properties(&[property.as_str().to_string()])
.unwrap_or_else(|e| panic!("{}: {e}", property.as_str()));
assert_eq!(parsed, vec![*property]);
}
}
#[test]
fn unknown_property_name_lists_valid_names_in_error() {
let err = parse_properties(&["not_a_real_property".to_string()])
.expect_err("unknown property name should fail to parse");
let message = err.to_string();
assert!(message.contains("not_a_real_property"));
assert!(message.contains(ConformanceProperty::StateCommutativity.as_str()));
}
fn digest() -> freenet::conformance::OutputDigest {
freenet::conformance::OutputDigest::of(b"x")
}
fn violation_of(property: ConformanceProperty) -> Violation {
Violation {
property,
severity: property.severity(),
left: digest(),
right: digest(),
detail: "test".to_string(),
}
}
#[test]
fn exit_code_treats_diagnostic_violation_as_success() {
let outcome =
PropertyOutcome::Violated(violation_of(ConformanceProperty::DeltaIdempotence));
assert_eq!(exit_code([&outcome]), 0);
}
#[test]
fn exit_code_treats_enforceable_violation_as_failure() {
let outcome =
PropertyOutcome::Violated(violation_of(ConformanceProperty::StateCommutativity));
assert_eq!(exit_code([&outcome]), 1);
}
#[test]
fn exit_code_is_zero_for_holds_and_inconclusive() {
let holds = PropertyOutcome::Holds;
let inconclusive = PropertyOutcome::Inconclusive(Inconclusive::RoundLimit);
assert_eq!(exit_code([&holds, &inconclusive]), 0);
}
fn finding(property: &str, detail: &str, left: &str, right: &str) -> Finding {
Finding {
property: property.to_string(),
severity: "violation",
detail: detail.to_string(),
left: left.to_string(),
right: right.to_string(),
}
}
#[test]
fn group_findings_collapses_same_property_and_detail_with_different_digests() {
let a = finding(
"state_commutativity",
"merge(A, B) must equal merge(B, A)",
"2 bytes, blake3:aaaaaa",
"2 bytes, blake3:bbbbbb",
);
let b = finding(
"state_commutativity",
"merge(A, B) must equal merge(B, A)",
"1 bytes, blake3:cccccc",
"2 bytes, blake3:dddddd",
);
let findings = vec![a, b];
let groups = group_findings(&findings);
assert_eq!(
groups.len(),
1,
"same property + detail must collapse into one group regardless of digests"
);
assert_eq!(groups[0].1, 2);
}
#[test]
fn group_findings_keeps_distinct_property_or_detail_separate() {
let a = finding("state_commutativity", "detail A", "l1", "r1");
let b = finding("state_associativity", "detail A", "l2", "r2");
let c = finding("state_commutativity", "detail B", "l3", "r3");
let findings = vec![a, b, c];
let groups = group_findings(&findings);
assert_eq!(groups.len(), 3);
assert!(groups.iter().all(|(_, count)| *count == 1));
}
fn module_importing(imports: &[(&str, &str)]) -> Vec<u8> {
let mut wat = String::from("(module\n");
for (i, (namespace, name)) in imports.iter().enumerate() {
wat.push_str(&format!(
" (import \"{namespace}\" \"{name}\" (func $f{i} (param i64 i64)))\n"
));
}
wat.push_str(")\n");
wat::parse_str(&wat).expect("test fixture is valid wat")
}
fn report_with_code_diagnostics(diagnostics: Vec<CodeDiagnostic>) -> Report {
Report {
corpus_states: 2,
corpus_deltas: 0,
corpus_summaries: 0,
cases_run: 4,
holds: 4,
violations: 0,
enforceable_violations: 0,
diagnostic_violations: 0,
inconclusive: 0,
findings: Vec::new(),
inconclusive_reasons: Vec::new(),
evidence: None,
code_diagnostics: diagnostics,
}
}
#[test]
fn a_clock_importing_contract_draws_a_code_diagnostic() {
let wasm = module_importing(&[(
host_clock::HOST_CLOCK_NAMESPACE,
host_clock::HOST_CLOCK_IMPORT,
)]);
let diagnostics = code_diagnostics(&wasm);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].diagnostic, "host_clock_import");
}
#[test]
fn a_contract_that_reads_no_clock_draws_no_code_diagnostic() {
let wasm = module_importing(&[("freenet_log", "__frnt__logger__info")]);
assert_eq!(code_diagnostics(&wasm), Vec::new());
}
#[test]
fn a_code_diagnostic_is_never_removal_eligible() {
let outcomes = vec![(
ConformanceCase::new(
ConformanceProperty::StateCommutativity,
vec![Bytes::from(vec![1u8]), Bytes::from(vec![2u8])],
),
PropertyOutcome::Holds,
)];
let report = Report::build(
&Corpus::default(),
&outcomes,
None,
vec![CodeDiagnostic {
diagnostic: "host_clock_import",
detail: "reads the clock".to_string(),
}],
);
assert_eq!(
(report.violations, report.enforceable_violations),
(0, 0),
"a code diagnostic was counted as a merge-law violation; it is about \
the contract's code, no law was checked, and counting it here makes \
it removal-eligible"
);
assert_eq!(report.code_diagnostics.len(), 1);
let just_outcomes: Vec<&PropertyOutcome> = outcomes.iter().map(|(_, o)| o).collect();
assert_eq!(exit_code(just_outcomes), 0);
}
#[test]
fn the_human_report_shows_a_code_diagnostic() {
let report = report_with_code_diagnostics(vec![CodeDiagnostic {
diagnostic: "host_clock_import",
detail: "this contract imports the host wall clock".to_string(),
}]);
let rendered = render_human(&report);
assert!(
rendered.contains("host_clock_import")
&& rendered.contains("this contract imports the host wall clock"),
"the code diagnostic never reached the default (non-json) output:\n{rendered}"
);
assert!(
rendered.contains("code diagnostic(s) above"),
"'no enforceable violations found.' is the last thing this report \
says, so a contract whose clock call will trap in a future release \
reads as a clean bill of health:\n{rendered}"
);
let json = serde_json::to_string(&report).expect("the report serializes");
assert!(
json.contains("host_clock_import"),
"--json dropped the code diagnostic, so no automation can see it:\n{json}"
);
}
#[test]
fn a_report_with_no_code_diagnostics_prints_no_section() {
let rendered = render_human(&report_with_code_diagnostics(Vec::new()));
assert!(
!rendered.contains("code diagnostic"),
"a clean run was told about code diagnostics it does not have:\n{rendered}"
);
}
#[test]
fn the_standalone_rendering_carries_the_detail() {
let wasm = module_importing(&[(
host_clock::HOST_CLOCK_NAMESPACE,
host_clock::HOST_CLOCK_IMPORT,
)]);
let rendered = render_code_diagnostics_standalone(&code_diagnostics(&wasm))
.expect("a clock-importing contract must render a standalone diagnostic");
assert!(
rendered.contains("host_clock_import"),
"the standalone rendering omits the diagnostic's machine-readable \
name:\n{rendered}"
);
assert!(
rendered.contains(freenet::conformance::HOST_CLOCK_DEPRECATION_DOC),
"the standalone rendering omits the docs link, which is the only part \
that tells an author what to do:\n{rendered}"
);
}
#[test]
fn the_standalone_rendering_is_silent_when_there_is_nothing_to_say() {
assert_eq!(render_code_diagnostics_standalone(&[]), None);
}
#[test]
fn the_plain_wasm_only_invocation_bails_on_a_contract_that_has_something_to_report() {
use clap::Parser;
let wasm = module_importing(&[(
host_clock::HOST_CLOCK_NAMESPACE,
host_clock::HOST_CLOCK_IMPORT,
)]);
assert!(
!code_diagnostics(&wasm).is_empty(),
"the fixture must have something to report, or this test proves nothing"
);
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("mycontract.wasm");
std::fs::write(&path, &wasm).expect("write fixture");
let config = ConformanceConfig::parse_from([
"verify-merge",
"--wasm",
path.to_str().expect("utf-8 temp path"),
]);
let err = load_inputs(&config)
.expect_err("--wasm with no --state must not silently succeed")
.to_string();
assert!(
err.contains("at least one --state or --transition is required"),
"this test is no longer exercising the no-corpus bail: {err}"
);
}
}