use crate::solver::{Certificate, Model};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attests {
pub kind: String,
pub claim: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub standards: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tool {
pub name: String,
pub version: String,
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
let d = h.finalize();
let mut out = String::with_capacity(64);
for b in d {
out.push_str(&format!("{b:02x}"));
}
out
}
fn cnf_text(cnf: &[Vec<i32>]) -> String {
let mut s = String::new();
for clause in cnf {
for lit in clause {
s.push_str(&lit.to_string());
s.push(' ');
}
s.push_str("0\n");
}
s
}
#[derive(Serialize, Deserialize)]
struct ProblemBlock {
encoding: String,
num_clauses: usize,
clauses: Vec<Vec<i32>>,
}
#[derive(Serialize, Deserialize)]
struct ProofBlock {
encoding: String,
body: String,
}
#[derive(Serialize, Deserialize)]
struct RecheckBlock {
tool: String,
min_version: String,
cmd: String,
problem_sha256: String,
proof_sha256: String,
}
#[derive(Serialize, Deserialize)]
struct UnsatEnvelope {
format: String,
verdict: String,
produced_by: Tool,
checked_by: Tool,
attests: Attests,
problem: ProblemBlock,
proof: ProofBlock,
recheck: RecheckBlock,
}
#[derive(Debug)]
pub struct UnsatBundle {
pub certificate: Certificate,
pub attests: Attests,
pub produced_by: Tool,
}
impl UnsatBundle {
pub fn recheck(&self) -> Result<(), crate::solver::CertificateError> {
self.certificate.recheck()
}
}
#[derive(Debug)]
pub enum BundleError {
Malformed(String),
WrongFormat(String),
WrongVerdict(String),
HashMismatch(&'static str),
Unsupported(String),
}
impl std::fmt::Display for BundleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BundleError::Malformed(m) => write!(f, "malformed bundle: {m}"),
BundleError::WrongFormat(g) => write!(f, "not ordeal-cert/v1 (format: {g})"),
BundleError::WrongVerdict(g) => write!(f, "unexpected verdict: {g}"),
BundleError::HashMismatch(which) => {
write!(
f,
"content hash mismatch on {which} — bundle not trustworthy"
)
}
BundleError::Unsupported(m) => write!(f, "unsupported by this reader: {m}"),
}
}
}
impl std::error::Error for BundleError {}
impl Certificate {
#[allow(clippy::missing_panics_doc)] pub fn to_cert_v1(&self, attests: &Attests) -> String {
let problem_text = cnf_text(&self.cnf);
let proof_text = self.lrat_text().unwrap_or_default().to_string();
let env = UnsatEnvelope {
format: "ordeal-cert/v1".into(),
verdict: "unsat".into(),
produced_by: Tool {
name: "ordeal".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
checked_by: Tool {
name: "ordeal-lrat".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
attests: attests.clone(),
problem: ProblemBlock {
encoding: "dimacs-cnf".into(),
num_clauses: self.cnf.len(),
clauses: self.cnf.clone(),
},
proof: ProofBlock {
encoding: "lrat".into(),
body: proof_text.clone(),
},
recheck: RecheckBlock {
tool: "ordeal-lrat".into(),
min_version: "0.9.0".into(),
cmd: "ordeal-lrat check <problem> <proof>".into(),
problem_sha256: sha256_hex(problem_text.as_bytes()),
proof_sha256: sha256_hex(proof_text.as_bytes()),
},
};
serde_json::to_string_pretty(&env).expect("own-struct serialization")
}
pub fn from_cert_v1(json: &str) -> Result<UnsatBundle, BundleError> {
let env: UnsatEnvelope =
serde_json::from_str(json).map_err(|e| BundleError::Malformed(e.to_string()))?;
if env.format != "ordeal-cert/v1" {
return Err(BundleError::WrongFormat(env.format));
}
if env.verdict != "unsat" {
return Err(BundleError::WrongVerdict(env.verdict));
}
if env.problem.encoding != "dimacs-cnf" {
return Err(BundleError::Unsupported(format!(
"problem encoding {}",
env.problem.encoding
)));
}
if env.proof.encoding != "lrat" {
return Err(BundleError::Unsupported(format!(
"proof encoding {}",
env.proof.encoding
)));
}
let problem_text = cnf_text(&env.problem.clauses);
if sha256_hex(problem_text.as_bytes()) != env.recheck.problem_sha256 {
return Err(BundleError::HashMismatch("problem"));
}
if sha256_hex(env.proof.body.as_bytes()) != env.recheck.proof_sha256 {
return Err(BundleError::HashMismatch("proof"));
}
Ok(UnsatBundle {
certificate: Certificate {
lrat: env.proof.body.into_bytes(),
cnf: env.problem.clauses,
},
attests: env.attests,
produced_by: env.produced_by,
})
}
}
pub fn model_to_cert_v1(model: &Model, attests: &Attests) -> String {
#[derive(Serialize)]
struct SatEnvelope<'a> {
format: &'a str,
verdict: &'a str,
produced_by: Tool,
attests: &'a Attests,
model: SatModel,
recheck: SatRecheck<'a>,
}
#[derive(Serialize)]
struct SatModel {
encoding: &'static str,
assignments: Vec<(String, u128)>,
}
#[derive(Serialize)]
struct SatRecheck<'a> {
note: &'a str,
}
let env = SatEnvelope {
format: "ordeal-cert/v1",
verdict: "sat",
produced_by: Tool {
name: "ordeal".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
attests,
model: SatModel {
encoding: "assignment",
assignments: model.assignments.clone(),
},
recheck: SatRecheck {
note: "model self-checked against all constraints at solve time; \
an independently re-checkable SAT witness is future work",
},
};
serde_json::to_string_pretty(&env).expect("own-struct serialization")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cnf::CnfFormula;
use crate::{CheckResult, Solver};
fn a_real_certificate() -> Certificate {
let f = CnfFormula {
num_vars: 3,
clauses: vec![vec![1], vec![-2, -3], vec![2], vec![3]],
};
match Solver::check_cnf(&f) {
CheckResult::Unsat(cert) => cert,
other => panic!("expected UNSAT, got {other:?}"),
}
}
fn attests() -> Attests {
Attests {
kind: "propositional_consistency".into(),
claim: "variant tls+nomalloc inconsistent with feature model".into(),
standards: vec!["EU-AI-Act:Art12".into()],
}
}
#[test]
fn bundle_round_trips_and_rechecks() {
let cert = a_real_certificate();
let json = cert.to_cert_v1(&attests());
let bundle = Certificate::from_cert_v1(&json).expect("bundle must parse");
assert_eq!(bundle.attests, attests());
bundle.recheck().expect("reconstructed pair must re-check");
}
#[test]
fn tampered_proof_is_rejected_by_hash() {
let cert = a_real_certificate();
let json = cert.to_cert_v1(&attests());
let tampered = json.replacen("\"body\": \"", "\"body\": \"9 ", 1);
match Certificate::from_cert_v1(&tampered) {
Err(BundleError::HashMismatch("proof")) => {}
other => panic!("tampered proof must be a proof-hash mismatch, got {other:?}"),
}
}
#[test]
fn tampered_problem_is_rejected_by_hash() {
let cert = a_real_certificate();
let json = cert.to_cert_v1(&attests());
let mut v: serde_json::Value = serde_json::from_str(&json).unwrap();
let lit = &mut v["problem"]["clauses"][0][0];
*lit = serde_json::json!(-lit.as_i64().unwrap());
let tampered = serde_json::to_string(&v).unwrap();
match Certificate::from_cert_v1(&tampered) {
Err(BundleError::HashMismatch("problem")) => {}
other => panic!("tampered problem must be a problem-hash mismatch, got {other:?}"),
}
}
#[test]
fn wrong_format_and_verdict_are_refused() {
let cert = a_real_certificate();
let json = cert.to_cert_v1(&attests());
let wrong_fmt = json.replacen("ordeal-cert/v1", "other-cert/v9", 1);
assert!(matches!(
Certificate::from_cert_v1(&wrong_fmt),
Err(BundleError::WrongFormat(_))
));
let wrong_verdict = json.replacen("\"verdict\": \"unsat\"", "\"verdict\": \"sat\"", 1);
assert!(matches!(
Certificate::from_cert_v1(&wrong_verdict),
Err(BundleError::WrongVerdict(_))
));
}
#[test]
fn sat_model_bundle_has_the_contract_shape() {
let f = CnfFormula {
num_vars: 2,
clauses: vec![vec![-1, 2], vec![1]],
};
let model = match Solver::check_cnf(&f) {
CheckResult::Sat(m) => m,
other => panic!("expected SAT, got {other:?}"),
};
let json = model_to_cert_v1(&model, &attests());
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["format"], "ordeal-cert/v1");
assert_eq!(v["verdict"], "sat");
assert_eq!(v["model"]["encoding"], "assignment");
assert!(
v["recheck"]["note"]
.as_str()
.unwrap()
.contains("self-checked")
);
}
}