stemedb/applications/aphoria/src/verify.rs
jml 65065f3d8f feat(aphoria): implement community corpus with wiki import and pattern aggregation
Implements Phase 4 (A4) - Community corpus as first-class citizens:

- **Community Corpus Builder** - Queries StemeDB pattern aggregates
- **Wiki Import** - Bootstrap corpus from markdown docs (aphoria corpus import wiki)
- **Pattern Aggregation** - Automatic learning from local scans (--sync flag)
- **Storage Layer** - StemeDBPatternStore with content-addressed deduplication
- **Promotion Logic** - Multi-tier thresholds (95%/80%/50% adoption rates)
- **Corpus Build** - Unified registry for RFC/OWASP/Vendor/Community sources
- **Trust Packs** - Export corpus as signed, distributable artifacts
- **Documentation** - bootstrap-corpus.md guide + CLI reference updates

Technical details:
- Pattern aggregates stored as assertions with predicate "pattern_aggregate"
- Content-addressed subjects via BLAKE3(subject:predicate:value)
- PatternAggregator handles write path (observations → patterns)
- StemeDBPatternStore handles read path (pattern queries)
- Integration tests + fixtures in tests/wiki_import_test.rs

Deleted hardcoded.rs (368 lines) - corpus now fully emergent from StemeDB.
Deleted enriched-corpus-patterns.md (677 lines) - feature shipped.

Closes VG-026 (community corpus), part of A4 milestone.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 00:12:31 +00:00

1143 lines
40 KiB
Rust

//! 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<AuthoredClaim>,
/// The verdict: pass, conflict, missing, or unclaimed.
pub verdict: AuditVerdict,
/// Observations that matched this claim's tail-path.
pub matching_observations: Vec<Observation>,
/// 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<VerifyResult>,
/// 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<String> {
// 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<String, Vec<&Observation>> = 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<String, bool> = 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<String> =
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<String> =
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<String> =
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<String> = 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<String>,
}
/// 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<ExtractorClaimMapping>,
/// Extractors without matching claims.
pub unmatched_extractors: Vec<UnmatchedExtractor>,
}
/// 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<dyn crate::extractors::Extractor>],
) -> ExtractorClaimMap {
let mut claim_mappings = Vec::new();
let mut extractor_matched: HashMap<String, bool> = 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<Observation> = vec![];
let report = verify_claims(&claims, &obs);
assert_eq!(report.summary.missing, 1);
}
#[test]
fn test_verify_unclaimed() {
let claims: Vec<AuthoredClaim> = 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<Observation> = 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<Observation> = 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);
}
}