//! Verification engine for matching authored claims against observations. //! //! This module compares what developers have declared in `.aphoria/claims.toml` //! against what extractors actually find in code. It produces a `VerifyReport` //! with pass/conflict/missing/unclaimed verdicts for each claim. use std::collections::HashMap; use serde::Serialize; use stemedb_core::types::ObjectValue; use crate::types::authored_claim::{AuthoredClaim, ClaimStatus, ComparisonMode}; use crate::types::Observation; /// Result of verifying a single claim against observations. #[derive(Debug, Clone)] pub struct VerifyResult { /// The claim being verified (None for unclaimed observations). pub claim: Option, /// The verdict: pass, conflict, missing, or unclaimed. pub verdict: AuditVerdict, /// Observations that matched this claim's tail-path. pub matching_observations: Vec, /// Human-readable explanation of the verdict. pub explanation: String, } /// Verdict for a single claim verification. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AuditVerdict { /// Observation matches the claim. Pass, /// Observation contradicts the claim. Conflict, /// No matching observation found for the claim. Missing, /// Observation exists but has no covering claim (only for unclaimed observations). Unclaimed, } impl std::fmt::Display for AuditVerdict { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AuditVerdict::Pass => write!(f, "PASS"), AuditVerdict::Conflict => write!(f, "CONFLICT"), AuditVerdict::Missing => write!(f, "MISSING"), AuditVerdict::Unclaimed => write!(f, "UNCLAIMED"), } } } /// Summary counts for a verification report. #[derive(Debug, Clone, Default, Serialize)] pub struct VerifySummary { /// Total number of active claims verified. pub total_claims: usize, /// Claims whose observations match. pub pass: usize, /// Claims contradicted by observations. pub conflict: usize, /// Claims with no matching observations. pub missing: usize, /// Observations with no covering claim. pub unclaimed: usize, } /// Full verification report: per-claim results plus summary. #[derive(Debug, Clone)] pub struct VerifyReport { /// Per-claim results. pub results: Vec, /// Aggregate counts. pub summary: VerifySummary, } /// Extract the tail path (last 2 segments) from a concept path. /// /// Mirrors `ConceptIndex::make_key` logic but without the predicate suffix. /// /// # Examples /// - `"code://rust/myapp/tls/cert_verification"` → `Some("tls/cert_verification")` /// - `"maxwell/wallet/atomics/ordering"` → `Some("atomics/ordering")` /// - `"single"` → `None` (fewer than 2 segments) pub fn tail_path(concept_path: &str) -> Option { // Strip scheme if present let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path); let mut segments = path.rsplit('/').filter(|s| !s.is_empty()); let tail2 = segments.next()?; let tail1 = segments.next()?; Some(format!("{tail1}/{tail2}")) } /// Verify authored claims against extracted observations. /// /// For each active claim: /// 1. Compute the tail-path from the claim's concept_path /// 2. Look up observations with matching tail-paths /// 3. Apply the claim's `ComparisonMode` to determine the verdict /// /// Observations whose tail-paths are not covered by any claim are reported /// as `Unclaimed`. pub fn verify_claims(claims: &[AuthoredClaim], observations: &[Observation]) -> VerifyReport { // Index observations by tail-path let mut obs_by_tail: HashMap> = HashMap::new(); for obs in observations { if let Some(tp) = tail_path(&obs.concept_path) { obs_by_tail.entry(tp).or_default().push(obs); } } let mut results = Vec::new(); let mut claimed_tails: HashMap = HashMap::new(); let mut summary = VerifySummary::default(); // Verify each active claim for claim in claims { if claim.status != ClaimStatus::Active { continue; } // Check if claim path contains wildcard let has_wildcard = claim.concept_path.contains("/*"); let matching: Vec<&Observation> = if has_wildcard { // Wildcard mode: match against observation full concept paths let mut matched_obs = Vec::new(); for (obs_tail, obs_list) in &obs_by_tail { // Check each observation's full concept_path against the wildcard pattern for obs in obs_list.iter() { // Strip scheme (e.g., "code://rust/" or "rfc://") from observation's concept_path let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") { // Find the first '/' after the scheme let after_scheme = &obs.concept_path[scheme_end + 3..]; if let Some(slash_pos) = after_scheme.find('/') { &after_scheme[slash_pos + 1..] } else { after_scheme } } else { &obs.concept_path }; if wildcard_matches(&claim.concept_path, obs_path) && obs.predicate == claim.predicate { // Mark this tail as claimed claimed_tails.insert(obs_tail.clone(), true); matched_obs.push(*obs); } } } matched_obs } else { // Exact mode: use tail-path lookup let tp = match tail_path(&claim.concept_path) { Some(tp) => tp, None => { results.push(VerifyResult { claim: Some(claim.clone()), verdict: AuditVerdict::Missing, matching_observations: Vec::new(), explanation: format!( "Cannot compute tail-path from concept_path '{}'", claim.concept_path ), }); summary.missing += 1; summary.total_claims += 1; continue; } }; claimed_tails.insert(tp.clone(), true); obs_by_tail .get(&tp) .map(|v| v.as_slice()) .unwrap_or(&[]) .iter() .filter(|obs| { // Filter by predicate if obs.predicate != claim.predicate { return false; } // Also check that the observation's full path matches the claim's path prefix // Strip scheme from observation's concept_path let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") { let after_scheme = &obs.concept_path[scheme_end + 3..]; if let Some(slash_pos) = after_scheme.find('/') { &after_scheme[slash_pos + 1..] } else { after_scheme } } else { &obs.concept_path }; // Check if observation path starts with claim path (without the tail) // E.g., claim "maxwell/core/imports/serde" should only match observations // under "maxwell/core/", not "maxwell/hypervisor/" let claim_segments: Vec<&str> = claim.concept_path.split('/').collect(); let obs_segments: Vec<&str> = obs_path.split('/').collect(); // Check if all claim segments (except last 2, which are the tail) match observation if claim_segments.len() > 2 { let claim_prefix = &claim_segments[..claim_segments.len() - 2]; let obs_prefix = if obs_segments.len() >= claim_prefix.len() { &obs_segments[..claim_prefix.len()] } else { &obs_segments[..] }; claim_prefix == obs_prefix } else { // Claim has no prefix (<=2 segments), so just match by tail true } }) .copied() .collect() }; let claim_obj_value = claim.value.to_object_value(); let (verdict, explanation) = match claim.comparison { ComparisonMode::Equals => { if matching.is_empty() { (AuditVerdict::Missing, "No matching observation found".to_string()) } else if matching.iter().any(|o| o.value == claim_obj_value) { ( AuditVerdict::Pass, format!("Observation matches claim value: {}", claim.value), ) } else { let found_values: Vec = matching.iter().map(|o| format!("{:?}", o.value)).collect(); ( AuditVerdict::Conflict, format!("Expected {}, found: {}", claim.value, found_values.join(", ")), ) } } ComparisonMode::NotEquals => { if matching.is_empty() { // No observations means no contradiction — pass (AuditVerdict::Pass, "No observations found (no contradiction)".to_string()) } else if matching.iter().any(|o| o.value == claim_obj_value) { ( AuditVerdict::Conflict, format!("Found observation with forbidden value: {}", claim.value), ) } else { (AuditVerdict::Pass, "All observations differ from forbidden value".to_string()) } } ComparisonMode::Present => { if matching.is_empty() { ( AuditVerdict::Missing, "Expected observation to be present, but none found".to_string(), ) } else { ( AuditVerdict::Pass, format!("Found {} matching observation(s)", matching.len()), ) } } ComparisonMode::Absent => { // Find observations that match the claim's specific value let matching_value: Vec<&Observation> = matching.iter().filter(|obs| obs.value == claim_obj_value).copied().collect(); if matching_value.is_empty() { // The specific value is NOT present - this is what we want (AuditVerdict::Pass, "Forbidden value not found (as expected)".to_string()) } else { // The specific value IS present - conflict let locations: Vec = matching_value.iter().map(|o| format!("{}:{}", o.file, o.line)).collect(); ( AuditVerdict::Conflict, format!( "Expected value {} to be absent, but found at: {}", claim.value, locations.join(", ") ), ) } } ComparisonMode::Contains => { if matching.is_empty() { (AuditVerdict::Missing, "No observations found to check contains".to_string()) } else { // Check if ANY observation contains the claim value let found_containing = matching.iter().any(|obs| { match (&obs.value, &claim_obj_value) { (ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => { // Check if claim value appears as: // 1. Exact match (obs == claim) // 2. Substring (obs.contains(claim)) // 3. List element (split on comma and check) if obs_str == claim_str { return true; } if obs_str.contains(claim_str.as_str()) { return true; } // For comma-separated lists, check if it's a complete element obs_str.split(',').any(|element| element.trim() == claim_str) } _ => obs.value == claim_obj_value, // Fallback to exact equality } }); if found_containing { ( AuditVerdict::Pass, format!("Found observation containing '{}'", claim.value), ) } else { let found_values: Vec = matching.iter().map(|o| format!("{:?}", o.value)).collect(); ( AuditVerdict::Conflict, format!( "Expected observation to contain '{}', found: {}", claim.value, found_values.join(", ") ), ) } } } ComparisonMode::NotContains => { // Find observations that contain the claim value let matching_containing: Vec<&Observation> = matching .iter() .filter(|obs| match (&obs.value, &claim_obj_value) { (ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => { // Check substring or list element if obs_str.contains(claim_str.as_str()) { return true; } obs_str.split(',').any(|element| element.trim() == claim_str) } _ => obs.value == claim_obj_value, }) .copied() .collect(); if matching_containing.is_empty() { // The forbidden value is NOT present - this is what we want ( AuditVerdict::Pass, format!("Forbidden value '{}' not found (as expected)", claim.value), ) } else { // The forbidden value IS present - conflict let locations: Vec = matching_containing .iter() .map(|o| format!("{}:{}", o.file, o.line)) .collect(); ( AuditVerdict::Conflict, format!( "Expected '{}' to not be present, but found at: {}", claim.value, locations.join(", ") ), ) } } }; match verdict { AuditVerdict::Pass => summary.pass += 1, AuditVerdict::Conflict => summary.conflict += 1, AuditVerdict::Missing => summary.missing += 1, AuditVerdict::Unclaimed => summary.unclaimed += 1, } summary.total_claims += 1; results.push(VerifyResult { claim: Some(claim.clone()), verdict, matching_observations: matching.into_iter().cloned().collect(), explanation, }); } // Find unclaimed observations for (tp, obs_list) in &obs_by_tail { if !claimed_tails.contains_key(tp) { summary.unclaimed += obs_list.len(); for obs in obs_list { results.push(VerifyResult { claim: None, verdict: AuditVerdict::Unclaimed, matching_observations: vec![(*obs).clone()], explanation: format!("Observation at {tp} has no covering claim"), }); } } } VerifyReport { results, summary } } /// Entry in the extractor→claim map showing which extractors cover which claims. #[derive(Debug, Clone, Serialize)] pub struct ExtractorClaimMapping { /// Claim ID. pub claim_id: String, /// Claim tail-path. pub claim_tail_path: String, /// Extractor names that can verify this claim. pub covering_extractors: Vec, } /// Entry for extractors that have no matching claims. #[derive(Debug, Clone, Serialize)] pub struct UnmatchedExtractor { /// Extractor name. pub name: String, /// Predicates the extractor declares. pub predicates: Vec<(String, String)>, } /// Full extractor↔claim map result. #[derive(Debug, Clone, Serialize)] pub struct ExtractorClaimMap { /// Per-claim coverage. pub claim_mappings: Vec, /// Extractors without matching claims. pub unmatched_extractors: Vec, } /// Check if a wildcard pattern matches a tail-path suffix. /// /// Supports `*` as a single-segment wildcard in two forms: /// - `"imports/*"` matches `"imports/tokio"` but not `"imports/tokio/runtime"` /// - `"message/*/derives"` matches `"message/agentmessage/derives"` but not `"message/a/b/derives"` fn wildcard_matches(pattern: &str, target: &str) -> bool { if pattern == target { return true; } // Check for wildcard in pattern if let Some(wildcard_pos) = pattern.find("/*") { let before_wildcard = &pattern[..wildcard_pos]; let after_wildcard = &pattern[wildcard_pos + 2..]; // Skip "/*" // Target must start with prefix if !target.starts_with(before_wildcard) { return false; } // Target must end with suffix (if suffix exists) if !after_wildcard.is_empty() && !target.ends_with(after_wildcard) { return false; } // Extract the middle part between prefix and suffix let middle_start = before_wildcard.len(); let middle_end = if after_wildcard.is_empty() { target.len() } else { target.len() - after_wildcard.len() }; if middle_end <= middle_start { return false; } let middle = &target[middle_start..middle_end]; // Middle must be exactly one segment (starts with '/' and has no other '/') middle.starts_with('/') && !middle[1..].contains('/') } else { false } } /// Compute the mapping between extractors and authored claims. /// /// For each active claim, finds extractors whose `verifiable_predicates()` /// match the claim's tail-path. Reports claims with no covering extractor /// and extractors with no matching claims. pub fn compute_extractor_claim_map( claims: &[AuthoredClaim], extractors: &[Box], ) -> ExtractorClaimMap { let mut claim_mappings = Vec::new(); let mut extractor_matched: HashMap = HashMap::new(); // Initialize all extractors as unmatched for ext in extractors { extractor_matched.insert(ext.name().to_string(), false); } for claim in claims { if claim.status != ClaimStatus::Active { continue; } let claim_tp = match tail_path(&claim.concept_path) { Some(tp) => tp, None => continue, }; let mut covering = Vec::new(); for ext in extractors { let preds = ext.verifiable_predicates(); for (tp_pattern, pred) in &preds { if wildcard_matches(tp_pattern, &claim_tp) && *pred == claim.predicate { covering.push(ext.name().to_string()); extractor_matched.insert(ext.name().to_string(), true); break; } } } claim_mappings.push(ExtractorClaimMapping { claim_id: claim.id.clone(), claim_tail_path: claim_tp, covering_extractors: covering, }); } let unmatched_extractors = extractors .iter() .filter(|ext| { let preds = ext.verifiable_predicates(); !preds.is_empty() && !extractor_matched.get(ext.name()).copied().unwrap_or(false) }) .map(|ext| UnmatchedExtractor { name: ext.name().to_string(), predicates: ext .verifiable_predicates() .into_iter() .map(|(a, b)| (a.to_string(), b.to_string())) .collect(), }) .collect(); ExtractorClaimMap { claim_mappings, unmatched_extractors } } #[cfg(test)] mod tests { use super::*; use crate::types::authored_claim::AuthoredValue; use stemedb_core::types::ObjectValue; fn make_claim( id: &str, concept_path: &str, predicate: &str, value: AuthoredValue, comparison: ComparisonMode, ) -> AuthoredClaim { AuthoredClaim { id: id.to_string(), concept_path: concept_path.to_string(), predicate: predicate.to_string(), value, comparison, provenance: "test".to_string(), invariant: "test invariant".to_string(), consequence: "test consequence".to_string(), authority_tier: "expert".to_string(), evidence: vec![], category: "test".to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "tester".to_string(), created_at: "2026-02-08T12:00:00Z".to_string(), updated_at: None, } } fn make_obs(concept_path: &str, predicate: &str, value: ObjectValue) -> Observation { Observation { concept_path: concept_path.to_string(), predicate: predicate.to_string(), value, file: "src/test.rs".to_string(), line: 42, matched_text: "test match".to_string(), confidence: 1.0, description: "test observation".to_string(), } } #[test] fn test_tail_path() { assert_eq!( tail_path("code://rust/myapp/tls/cert_verification"), Some("tls/cert_verification".to_string()) ); assert_eq!( tail_path("maxwell/wallet/atomics/ordering"), Some("atomics/ordering".to_string()) ); assert_eq!(tail_path("single"), None); assert_eq!( tail_path("rfc://5246/tls/cert_verification"), Some("tls/cert_verification".to_string()) ); } #[test] fn test_verify_pass_equals() { let claims = vec![make_claim( "c1", "project/atomics/ordering", "ordering", AuthoredValue::Text("SeqCst".to_string()), ComparisonMode::Equals, )]; let obs = vec![make_obs( "code://rust/project/atomics/ordering", "ordering", ObjectValue::Text("SeqCst".to_string()), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.pass, 1); assert_eq!(report.summary.conflict, 0); } #[test] fn test_verify_conflict_equals() { let claims = vec![make_claim( "c1", "project/atomics/ordering", "ordering", AuthoredValue::Text("SeqCst".to_string()), ComparisonMode::Equals, )]; let obs = vec![make_obs( "code://rust/project/atomics/ordering", "ordering", ObjectValue::Text("Relaxed".to_string()), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.conflict, 1); assert_eq!(report.summary.pass, 0); } #[test] fn test_verify_missing() { let claims = vec![make_claim( "c1", "project/config/timeout", "timeout_ms", AuthoredValue::Number(30.0), ComparisonMode::Equals, )]; let obs: Vec = vec![]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.missing, 1); } #[test] fn test_verify_unclaimed() { let claims: Vec = vec![]; let obs = vec![make_obs( "code://rust/project/tls/cert_verification", "enabled", ObjectValue::Boolean(false), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.unclaimed, 1); } #[test] fn test_verify_absent_pass() { let claims = vec![make_claim( "c1", "core/imports/tokio", "imported", AuthoredValue::Bool(true), ComparisonMode::Absent, )]; let obs: Vec = vec![]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_absent_conflict() { let claims = vec![make_claim( "c1", "core/imports/tokio", "imported", AuthoredValue::Bool(true), ComparisonMode::Absent, )]; let obs = vec![make_obs( "code://rust/core/imports/tokio", "imported", ObjectValue::Boolean(true), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.conflict, 1); } #[test] fn test_verify_present_pass() { let claims = vec![make_claim( "c1", "project/tls/cert_verification", "enabled", AuthoredValue::Bool(true), ComparisonMode::Present, )]; let obs = vec![make_obs( "code://rust/project/tls/cert_verification", "enabled", ObjectValue::Boolean(true), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_present_missing() { let claims = vec![make_claim( "c1", "project/tls/cert_verification", "enabled", AuthoredValue::Bool(true), ComparisonMode::Present, )]; let obs: Vec = vec![]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.missing, 1); } #[test] fn test_verify_not_equals_pass() { let claims = vec![make_claim( "c1", "project/tls/min_version", "version", AuthoredValue::Text("1.0".to_string()), ComparisonMode::NotEquals, )]; let obs = vec![make_obs( "code://rust/project/tls/min_version", "version", ObjectValue::Text("1.3".to_string()), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_not_equals_conflict() { let claims = vec![make_claim( "c1", "project/tls/min_version", "version", AuthoredValue::Text("1.0".to_string()), ComparisonMode::NotEquals, )]; let obs = vec![make_obs( "code://rust/project/tls/min_version", "version", ObjectValue::Text("1.0".to_string()), )]; let report = verify_claims(&claims, &obs); assert_eq!(report.summary.conflict, 1); } #[test] fn test_deprecated_claims_skipped() { let mut claim = make_claim( "c1", "project/atomics/ordering", "required_ordering", AuthoredValue::Text("SeqCst".to_string()), ComparisonMode::Equals, ); claim.status = ClaimStatus::Deprecated; let obs = vec![make_obs( "code://rust/project/atomics/ordering", "ordering", ObjectValue::Text("Relaxed".to_string()), )]; let report = verify_claims(&[claim], &obs); // Deprecated claim should not be verified — only unclaimed observation assert_eq!(report.summary.total_claims, 0); assert_eq!(report.summary.unclaimed, 1); } #[test] fn test_backward_compat_no_comparison_field() { // Simulate a TOML claim without the `comparison` field — should default to Equals let toml_str = r#" [[claim]] id = "old-claim" concept_path = "test/concept/path" predicate = "test_pred" value = "test_value" provenance = "Test" invariant = "Test invariant" consequence = "Test consequence" authority_tier = "expert" category = "safety" created_by = "tester" created_at = "2026-02-08T12:00:00Z" "#; let claims_file: crate::claims_file::ClaimsFile = toml::from_str(toml_str).expect("parse TOML without comparison field"); assert_eq!(claims_file.claims.len(), 1); assert_eq!(claims_file.claims[0].comparison, ComparisonMode::Equals); } #[test] fn test_wildcard_matches() { assert!(wildcard_matches("imports/*", "imports/tokio")); assert!(wildcard_matches("imports/*", "imports/serde")); assert!(!wildcard_matches("imports/*", "exports/tokio")); assert!(!wildcard_matches("imports/*", "imports/tokio/runtime")); assert!(wildcard_matches("tls/cert_verification", "tls/cert_verification")); assert!(!wildcard_matches("tls/cert_verification", "tls/min_version")); } #[test] fn test_compute_extractor_claim_map() { use crate::config::AphoriaConfig; use crate::extractors::ExtractorRegistry; let config = AphoriaConfig::default(); let registry = ExtractorRegistry::new(&config); let claims = vec![ make_claim( "tls-001", "project/tls/cert_verification", "enabled", AuthoredValue::Bool(true), ComparisonMode::Equals, ), make_claim( "import-001", "core/imports/tokio", "imported", AuthoredValue::Bool(true), ComparisonMode::Absent, ), ]; let map = compute_extractor_claim_map(&claims, registry.extractors()); // tls_verify should cover tls-001 let tls_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "tls-001"); assert!(tls_mapping.is_some()); assert!(tls_mapping .map(|m| m.covering_extractors.contains(&"tls_verify".to_string())) .unwrap_or(false)); // import_graph should cover import-001 via wildcard let import_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "import-001"); assert!(import_mapping.is_some()); assert!(import_mapping .map(|m| m.covering_extractors.contains(&"import_graph".to_string())) .unwrap_or(false)); } #[test] fn test_verify_filters_by_predicate() { // Bug 1 fix: Path matching must respect predicates // Claim: core/imports/serde with predicate "imported" must be absent // Observation 1: core/imports/serde with predicate "imported" = true → CONFLICT // Observation 2: core/imports/serde with predicate "version" = "1.0" → ignore (different predicate) let claim = make_claim( "core-no-serde", "core/imports/serde", "imported", AuthoredValue::Bool(true), ComparisonMode::Absent, ); let obs1 = make_obs("code://rust/core/imports/serde", "imported", ObjectValue::Boolean(true)); let obs2 = make_obs( "code://rust/core/imports/serde", "version", ObjectValue::Text("1.0".to_string()), ); // With obs1 (matching predicate): should CONFLICT let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs1)); assert_eq!(report.summary.conflict, 1); // With obs2 (different predicate): should PASS (ignores obs2) let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs2)); assert_eq!(report.summary.pass, 1); // With both: should CONFLICT (only obs1 matters) let report = verify_claims(&[claim], &[obs1, obs2]); assert_eq!(report.summary.conflict, 1); } #[test] fn test_verify_absent_checks_specific_value() { // Bug 2 fix: Absent mode must check the specific claim value // Claim: algorithm = "md5" must be absent // Observation: algorithm = "sha256" → PASS (md5 not present) // Observation: algorithm = "md5" → CONFLICT (md5 is present) let claim = make_claim( "no-md5", "project/crypto/hashing/algorithm", "algorithm", AuthoredValue::Text("md5".to_string()), ComparisonMode::Absent, ); let obs_sha = make_obs( "code://rust/project/crypto/hashing/algorithm", "algorithm", ObjectValue::Text("sha256".to_string()), ); let obs_md5 = make_obs( "code://rust/project/crypto/hashing/algorithm", "algorithm", ObjectValue::Text("md5".to_string()), ); // With sha256: should PASS (md5 not found) let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs_sha)); assert_eq!(report.summary.pass, 1); assert_eq!(report.summary.conflict, 0); // With md5: should CONFLICT (md5 found) let report = verify_claims(&[claim], &[obs_md5]); assert_eq!(report.summary.conflict, 1); assert_eq!(report.summary.pass, 0); } #[test] fn test_verify_wildcard_pattern() { // Bug 3 fix: Wildcard patterns must be supported in verification // Claim: message/*/derives with predicate "traits" must include "Serialize" // Observation 1: message/agentmessage/derives with "Clone,Debug,Serialize" // Observation 2: message/daemonmessage/derives with "Clone,Debug,Serialize" // Expected: Both observations match the wildcard → PASS let claim = make_claim( "vsock-serialize", "project/message/*/derives", "traits", AuthoredValue::Text("Serialize".to_string()), ComparisonMode::Present, ); let obs1 = make_obs( "code://rust/project/message/agentmessage/derives", "traits", ObjectValue::Text("Clone,Debug,Serialize".to_string()), ); let obs2 = make_obs( "code://rust/project/message/daemonmessage/derives", "traits", ObjectValue::Text("Clone,Debug,Serialize".to_string()), ); let report = verify_claims(&[claim], &[obs1, obs2]); assert_eq!(report.summary.pass, 1); assert_eq!(report.summary.missing, 0); assert_eq!(report.results[0].matching_observations.len(), 2); // Both matched } #[test] fn test_verify_contains_substring() { // Test 1: Single value contains substring let claim = make_claim( "contains-test", "project/message/content", "text", AuthoredValue::Text("error".to_string()), ComparisonMode::Contains, ); let obs = make_obs( "code://rust/project/message/content", "text", ObjectValue::Text("This is an error message".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_contains_list_element() { // Test 2: Comma-separated list contains element let claim = make_claim( "serialize-present", "project/message/derives", "traits", AuthoredValue::Text("Serialize".to_string()), ComparisonMode::Contains, ); let obs = make_obs( "code://rust/project/message/derives", "traits", ObjectValue::Text("Clone,Debug,Serialize".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_contains_missing() { // Test 3: Value not found in observation let claim = make_claim( "serialize-present", "project/message/derives", "traits", AuthoredValue::Text("Serialize".to_string()), ComparisonMode::Contains, ); let obs = make_obs( "code://rust/project/message/derives", "traits", ObjectValue::Text("Clone,Debug".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.conflict, 1); } #[test] fn test_verify_not_contains_pass() { // Test 4: Forbidden value NOT present (PASS) let claim = make_claim( "no-clone", "project/wallet/derives", "traits", AuthoredValue::Text("Clone".to_string()), ComparisonMode::NotContains, ); let obs = make_obs( "code://rust/project/wallet/derives", "traits", ObjectValue::Text("Debug".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_not_contains_conflict() { // Test 5: Forbidden value IS present (CONFLICT) let claim = make_claim( "no-clone", "project/wallet/derives", "traits", AuthoredValue::Text("Clone".to_string()), ComparisonMode::NotContains, ); let obs = make_obs( "code://rust/project/wallet/derives", "traits", ObjectValue::Text("Clone,Debug".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.conflict, 1); } #[test] fn test_verify_not_contains_substring() { // Test 6: Forbidden substring present (CONFLICT) let claim = make_claim( "no-hardcoded", "project/config/password", "value", AuthoredValue::Text("hardcoded".to_string()), ComparisonMode::NotContains, ); let obs = make_obs( "code://rust/project/config/password", "value", ObjectValue::Text("my_hardcoded_password".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.conflict, 1); } #[test] fn test_verify_contains_with_whitespace() { // Test 7: List with spaces around commas let claim = make_claim( "serialize-present", "project/message/derives", "traits", AuthoredValue::Text("Serialize".to_string()), ComparisonMode::Contains, ); let obs = make_obs( "code://rust/project/message/derives", "traits", ObjectValue::Text("Clone, Debug, Serialize".to_string()), ); let report = verify_claims(&[claim], &[obs]); assert_eq!(report.summary.pass, 1); } #[test] fn test_verify_not_contains_no_observation() { // Test 8: No observation (vacuously true - PASS) let claim = make_claim( "no-clone", "project/wallet/derives", "traits", AuthoredValue::Text("Clone".to_string()), ComparisonMode::NotContains, ); let report = verify_claims(&[claim], &[]); assert_eq!(report.summary.pass, 1); } }