use alloc::collections::BTreeSet;
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use crate::claim::Claim;
use crate::primitives::anchor::AnchorType;
use crate::primitives::evidence::EvidenceScheme;
use crate::primitives::predicate::PredicateType;
use crate::primitives::subject::SubjectType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub code: String,
pub message: String,
pub path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ValidationReport {
pub violations: Vec<Violation>,
}
impl ValidationReport {
pub fn is_conformant(&self) -> bool {
self.violations.is_empty()
}
fn violate(&mut self, code: impl Into<String>, message: impl Into<String>) {
self.violations.push(Violation {
code: code.into(),
message: message.into(),
path: None,
});
}
}
pub fn validate(claim: &Claim) -> ValidationReport {
let mut r = ValidationReport::default();
check_i1(&mut r, claim);
check_i2(&mut r, claim);
check_i3(&mut r, claim);
check_i4(&mut r, claim);
check_i5(&mut r, claim);
check_t1_t4(&mut r, claim);
check_m1(&mut r, claim);
check_structural(&mut r, claim);
r
}
fn check_i1(r: &mut ValidationReport, c: &Claim) {
if let Err(e) = c.subject.validate_shape() {
r.violate("I-1", format!("subject type/id shape mismatch: {e}"));
}
}
fn check_i2(r: &mut ValidationReport, c: &Claim) {
let dom = c.predicate.domain();
let scheme = &c.evidence.scheme;
if matches!(scheme, EvidenceScheme::Mdoc) && !dom.starts_with("iso:18013:5") {
r.violate("I-2", format!(
"mdoc evidence requires predicate.domain starting with \"iso:18013:5\", got \"{dom}\""
));
}
if matches!(scheme, EvidenceScheme::TeeTdx | EvidenceScheme::TeeSevSnp)
&& !matches!(c.subject.subject_type, SubjectType::EnclaveMeasurement)
{
r.violate("I-2", format!(
"{s} evidence requires subject.type = \"enclave-measurement\", got \"{t}\"",
s = c.evidence.scheme_tag(),
t = c.subject.subject_type_str()
));
}
}
fn check_i3(r: &mut ValidationReport, c: &Claim) {
let tf = &c.temporal_frame;
if let (Some(from), Some(until)) = (tf.not_after(), Some(tf.not_before())) {
if from < until {
r.violate("I-3", format!(
"valid-until ({until}) must be >= valid-from ({from})"
));
}
}
}
fn check_i4(r: &mut ValidationReport, c: &Claim) {
if c.anchor.0.is_empty() {
r.violate("I-4", "anchor list must be non-empty");
}
}
fn check_i5(r: &mut ValidationReport, c: &Claim) {
let disclosed: BTreeSet<&str> = c.revelation_mask.disclosed().iter().map(String::as_str).collect();
let committed_paths: BTreeSet<&str> = c.revelation_mask.committed_paths().into_iter().collect();
let intersection: Vec<_> = disclosed.intersection(&committed_paths).collect();
if !intersection.is_empty() {
r.violate("I-5", format!(
"disclosed ∩ committed must be empty; found: {intersection:?}"
));
}
}
fn check_t1_t4(_r: &mut ValidationReport, _c: &Claim) {
}
fn check_m1(_r: &mut ValidationReport, _c: &Claim) {
}
fn check_structural(r: &mut ValidationReport, c: &Claim) {
match &c.subject.id {
crate::primitives::subject::SubjectId::Text(s) if s.is_empty() => {
r.violate("CDDL", "subject.id (tstr) must be non-empty");
}
crate::primitives::subject::SubjectId::Bytes(b) if b.is_empty() => {
r.violate("CDDL", "subject.id (bstr) must be non-empty");
}
_ => {}
}
if c.predicate.body().is_empty() && !matches!(c.predicate.predicate_type(), PredicateType::Existence) {
r.violate("CDDL", "non-existence predicate requires non-empty body");
}
if c.evidence.proof().is_empty() {
r.violate("CDDL", "evidence.proof must be non-empty");
}
if let Err(e) = c.evidence.validate_shape() {
r.violate("CDDL", format!("evidence shape: {e}"));
}
for (i, entry) in c.anchor.0.iter().enumerate() {
if let Err(e) = entry.validate_shape() {
r.violate("CDDL", format!("anchor[{i}]: {e}"));
}
match entry.anchor_type {
AnchorType::StarknetL3 | AnchorType::EthereumL1 | AnchorType::BitcoinOpReturn => {
if entry.r#ref.len() != 32 {
r.violate("CDDL", format!(
"anchor[{i}].ref must be 32 bytes for chain anchor, got {} bytes",
entry.r#ref.len()
));
}
}
_ => {}
}
}
let disclosed: &[String] = c.revelation_mask.disclosed();
if disclosed.len() > 1 && disclosed.iter().any(|s| s == "*") {
r.violate("CDDL", "revelation-mask: wildcard \"*\" must be sole disclosed entry when present");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::ClaimBuilder;
use crate::primitives::anchor::{Anchor, AnchorEntry, AnchorType};
use crate::primitives::evidence::{Evidence, EvidenceScheme};
use crate::primitives::predicate::{Predicate, PredicateType};
use crate::primitives::revelation_mask::RevelationMask;
use crate::primitives::subject::{Subject, SubjectId, SubjectType};
use crate::primitives::temporal::TemporalFrame;
fn valid_claim() -> Claim {
ClaimBuilder::default()
.subject(Subject {
subject_type: SubjectType::Wallet,
id: SubjectId::Bytes(hex::decode(
"bbbe91b88ff2842d7f7af15cd8154cdcc753dc3997e46be3568e7ef1ab5e90f4",
).unwrap()),
binding: None,
})
.predicate(Predicate::new(PredicateType::Equality, "vauban.claim", b"body".to_vec()).unwrap())
.evidence(Evidence::new(EvidenceScheme::Stark, vec![0xcd; 64], None).unwrap())
.temporal_frame(TemporalFrame::new(1_700_000_000, None, None).unwrap())
.revelation_mask(RevelationMask::new(vec!["*".into()], vec![], None).unwrap())
.anchor(Anchor::new(vec![AnchorEntry {
anchor_type: AnchorType::StarknetL3,
r#ref: vec![0xab; 32],
epoch: Some(42),
nullifier: None,
meta: None,
}]).unwrap())
.build()
.expect("valid claim")
}
#[test]
fn valid_claim_passes_all() {
let r = validate(&valid_claim());
assert!(r.is_conformant(), "valid claim must have no violations: {:?}", r.violations);
}
#[test]
fn fails_i1_subject_shape() {
let s = Subject {
subject_type: SubjectType::Did,
id: SubjectId::Bytes(vec![1, 2, 3]),
binding: None,
};
assert!(s.validate_shape().is_err());
}
#[test]
fn fails_i2_mdoc_wrong_domain() {
let c = ClaimBuilder::default()
.subject(Subject {
subject_type: SubjectType::Wallet,
id: SubjectId::Bytes(vec![0xbb; 32]),
binding: None,
})
.predicate(Predicate::new(PredicateType::Equality, "not.iso.domain", b"body".to_vec()).unwrap())
.evidence(Evidence::new(EvidenceScheme::Mdoc, vec![0xcd; 64], None).unwrap())
.temporal_frame(TemporalFrame::new(1_700_000_000, None, None).unwrap())
.revelation_mask(RevelationMask::new(vec!["*".into()], vec![], None).unwrap())
.anchor(Anchor::new(vec![AnchorEntry {
anchor_type: AnchorType::StarknetL3,
r#ref: vec![0xab; 32],
epoch: Some(42),
nullifier: None,
meta: None,
}]).unwrap())
.build()
.expect("claim");
let r = validate(&c);
assert!(r.violations.iter().any(|v| v.code == "I-2"), "expected I-2 violation: {:?}", r.violations);
}
#[test]
fn fails_i4_empty_anchor() {
let anchor = Anchor::new(vec![]);
assert!(anchor.is_err(), "empty anchor must be rejected at construction");
}
#[test]
fn fails_empty_proof() {
let e = Evidence::new(EvidenceScheme::Stark, vec![], None);
assert!(e.is_err(), "empty proof must be rejected at construction");
}
#[test]
fn fails_empty_subject_id() {
let s = Subject {
subject_type: SubjectType::ArtefactDigest,
id: SubjectId::Bytes(vec![]),
binding: None,
};
assert!(s.validate_shape().is_err(), "empty subject id must be rejected");
}
#[test]
fn validator_checks_all_invariants_independently() {
let c = valid_claim();
let r = validate(&c);
assert!(r.is_conformant());
let c2 = ClaimBuilder::default()
.subject(Subject {
subject_type: SubjectType::Wallet,
id: SubjectId::Bytes(vec![0xbb; 32]),
binding: None,
})
.predicate(Predicate::new(PredicateType::Equality, "vauban.claim", b"body".to_vec()).unwrap())
.evidence(Evidence::new(EvidenceScheme::TeeTdx, vec![0xcd; 64], None).unwrap())
.temporal_frame(TemporalFrame::new(1_700_000_000, None, None).unwrap())
.revelation_mask(RevelationMask::new(vec!["*".into()], vec![], None).unwrap())
.anchor(Anchor::new(vec![AnchorEntry {
anchor_type: AnchorType::StarknetL3,
r#ref: vec![0xab; 32],
epoch: Some(42),
nullifier: None,
meta: None,
}]).unwrap())
.build()
.expect("claim");
let r = validate(&c2);
assert!(r.violations.iter().any(|v| v.code == "I-2"),
"TEE evidence with non-enclave subject must violate I-2: {:?}", r.violations);
}
}