vauban-claim 0.1.0

Vauban Claim Algebra — reference implementation of draft-vauban-claim-algebra-00 (post-quantum claim sextuplet + 5 composition operators, canonical CBOR/JSON codec).
Documentation
//! CDDL conformance validator — structural validation against the Claim grammar.
//!
//! Checks field presence, type correctness, and cross-field invariants
//! (I-1 through I-5, T-1 through T-4, M-1) that the CDDL grammar alone cannot
//! enforce. Conformant Verifiers MUST run this validator before accepting any
//! Claim, per `draft-vauban-claim-algebra-00` §4.

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;

/// A single validation violation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
    /// Invariant code (e.g. "I-1", "I-2", "T-1").
    pub code: String,
    /// Human-readable description.
    pub message: String,
    /// Optional field path the violation refers to.
    pub path: Option<String>,
}

/// Result of a validation pass.
#[derive(Debug, Clone, Default)]
pub struct ValidationReport {
    /// Violations found; empty = conformant.
    pub violations: Vec<Violation>,
}

impl ValidationReport {
    /// Whether the Claim is conformant (zero violations).
    pub fn is_conformant(&self) -> bool {
        self.violations.is_empty()
    }

    /// Add a violation.
    fn violate(&mut self, code: impl Into<String>, message: impl Into<String>) {
        self.violations.push(Violation {
            code: code.into(),
            message: message.into(),
            path: None,
        });
    }
}

/// Validate a Claim against the CDDL grammar invariants.
///
/// Returns a `ValidationReport` listing all violations found. An empty
/// violations list means the Claim is CDDL-conformant.
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
}

// ── I-1: Subject type/id shape consistency ─────────────────────────

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}"));
    }
}

// ── I-2: Evidence scheme ↔ Predicate domain coherence ──────────────

fn check_i2(r: &mut ValidationReport, c: &Claim) {
    let dom = c.predicate.domain();
    let scheme = &c.evidence.scheme;

    // mdoc evidence → predicate domain must start with "iso:18013:5"
    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}\""
        ));
    }

    // tee-* evidence → subject must be enclave-measurement
    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()
        ));
    }
}

// ── I-3: Temporal coherence ───────────────────────────────────────

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})"
            ));
        }
    }
}

// ── I-4: Anchor non-empty ─────────────────────────────────────────

fn check_i4(r: &mut ValidationReport, c: &Claim) {
    if c.anchor.0.is_empty() {
        r.violate("I-4", "anchor list must be non-empty");
    }
}

// ── I-5: Revelation mask disjointness ─────────────────────────────

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:?}"
        ));
    }
}

// ── T-1: issued-at REQUIRED ───────────────────────────────────────

fn check_t1_t4(_r: &mut ValidationReport, _c: &Claim) {
    // T-1: issued_at is always present (enforced by TemporalFrame::new).
    // T-4: observed-at must be in the past (if present) — no wall clock in no-std.
}

// ── M-1: Disjointness already covered by I-5 ──────────────────────

fn check_m1(_r: &mut ValidationReport, _c: &Claim) {
    // M-1 is identical to I-5; already checked.
}

// ── Structural checks ─────────────────────────────────────────────

fn check_structural(r: &mut ValidationReport, c: &Claim) {
    // Subject: id must be non-empty
    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");
        }
        _ => {}
    }

    // Predicate: body must be non-empty for non-existence predicates
    if c.predicate.body().is_empty() && !matches!(c.predicate.predicate_type(), PredicateType::Existence) {
        r.violate("CDDL", "non-existence predicate requires non-empty body");
    }

    // Evidence: proof must be non-empty
    if c.evidence.proof().is_empty() {
        r.violate("CDDL", "evidence.proof must be non-empty");
    }

    // Evidence: scheme/envelope coherence (I-2 hook extended)
    if let Err(e) = c.evidence.validate_shape() {
        r.violate("CDDL", format!("evidence shape: {e}"));
    }

    // Anchor: each entry must pass shape validation
    for (i, entry) in c.anchor.0.iter().enumerate() {
        if let Err(e) = entry.validate_shape() {
            r.violate("CDDL", format!("anchor[{i}]: {e}"));
        }
        // Chain anchors (StarknetL3, EthereumL1, BitcoinOpReturn) require ref=32 bytes
        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()
                    ));
                }
            }
            _ => {}
        }
    }

    // Revelation mask: wildcard "*" must be the only disclosed entry if present
    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() {
        // DID subject with bytes (should be text starting with "did:")
        // validate_shape catches this at Subject-level, so test it directly
        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() {
        // Subject::new catches empty bytes at construction for artefact-digest
        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() {
        // Verify the validator is callable and returns a non-panicking report
        let c = valid_claim();
        let r = validate(&c);
        assert!(r.is_conformant());

        // Verify I-2 TEE check
        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);
    }
}