use super::data_ref::{fetch_and_verify_data_ref, DataRefFetcher};
use super::registry::{DiscoveryBudget, RegistryClient};
use acdp_did::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::{body::FullContext, primitives::CtxId};
use acdp_verify::Verifier;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerificationPolicy {
pub validate_body_schema: bool,
pub allow_unknown_status: bool,
pub receipts: ReceiptPolicy,
pub historical_keys: HistoricalKeyPolicy,
pub lineage_head: LineageHeadPolicy,
pub revocations: RevocationPolicy,
}
impl Default for VerificationPolicy {
fn default() -> Self {
Self {
validate_body_schema: true,
allow_unknown_status: true,
receipts: ReceiptPolicy::VerifyIfPresent,
historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
lineage_head: LineageHeadPolicy::default(),
revocations: RevocationPolicy::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct RevocationPolicy {
pub known: Vec<acdp_types::revocation::KeyRevocation>,
pub discover: Option<RevocationDiscovery>,
}
impl RevocationPolicy {
#[must_use]
pub fn new(known: Vec<acdp_types::revocation::KeyRevocation>) -> Self {
Self {
known,
discover: None,
}
}
#[must_use]
pub fn with_discovery(mut self, discovery: RevocationDiscovery) -> Self {
self.discover = Some(discovery);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct RevocationDiscovery {
pub include_registry_attested: bool,
pub on_failure: DiscoveryFailurePolicy,
pub total_timeout: Duration,
pub max_requests: Option<std::num::NonZeroUsize>,
pub max_bytes: Option<u64>,
pub freshness: Duration,
}
impl RevocationDiscovery {
#[must_use]
pub fn producer_signed_only() -> Self {
Self {
include_registry_attested: false,
on_failure: DiscoveryFailurePolicy::FailClosed,
total_timeout: Duration::from_secs(30),
max_requests: None,
max_bytes: None,
freshness: Duration::ZERO,
}
}
#[must_use]
pub fn all_trust_classes() -> Self {
Self {
include_registry_attested: true,
on_failure: DiscoveryFailurePolicy::FailClosed,
total_timeout: Duration::from_secs(30),
max_requests: None,
max_bytes: None,
freshness: Duration::ZERO,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DiscoveryFailurePolicy {
#[default]
FailClosed,
ProceedWithKnown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct DiscoveryOutcome {
pub producer_signed: usize,
pub registry_attested: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReceiptPolicy {
Ignore,
#[default]
VerifyIfPresent,
Require,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineageHeadPolicy {
pub receipts: ReceiptPolicy,
pub max_clock_skew_seconds: u32,
pub max_age_seconds: Option<u32>,
}
impl Default for LineageHeadPolicy {
fn default() -> Self {
Self {
receipts: ReceiptPolicy::VerifyIfPresent,
max_clock_skew_seconds: 120,
max_age_seconds: Some(300),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HistoricalKeyPolicy {
Reject,
#[default]
AcceptWithReceipt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyAuthorization {
CurrentlyAuthorized,
HistoricallyAuthorized,
HistoricallyAuthorizedPreCompromise,
}
impl VerificationPolicy {
pub fn strict_v0_1_0() -> Self {
Self {
validate_body_schema: true,
allow_unknown_status: true,
receipts: ReceiptPolicy::Ignore,
historical_keys: HistoricalKeyPolicy::Reject,
lineage_head: LineageHeadPolicy {
receipts: ReceiptPolicy::Ignore,
..LineageHeadPolicy::default()
},
revocations: RevocationPolicy::default(),
}
}
fn derived_for_report(&self) -> Self {
Self {
validate_body_schema: false,
..self.clone()
}
}
}
#[derive(Debug)]
pub struct VerifiedContext {
inner: FullContext,
key_status: KeyAuthorization,
verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
head_receipt_stale: Option<bool>,
revocation_discovery_failure: Option<AcdpError>,
}
type VerifyRetrievedResult = Result<
(
KeyAuthorization,
Option<acdp_types::receipt::RegistryReceipt>,
Option<Result<DiscoveryOutcome, AcdpError>>,
),
AcdpError,
>;
impl VerifiedContext {
pub async fn fetch(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
) -> Result<Self, AcdpError> {
Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
}
pub async fn fetch_with_policy(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<Self, AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
let (key_status, verified_receipt, revocation_discovery) =
Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
let revocation_discovery_failure = match revocation_discovery {
Some(Err(e)) => Some(e),
_ => None,
};
Ok(Self {
inner: ctx,
key_status,
verified_receipt,
verified_head_receipt: None,
head_receipt_stale: None,
revocation_discovery_failure,
})
}
pub async fn fetch_current(
client: &RegistryClient,
resolver: &WebResolver,
lineage_id: &acdp_types::primitives::LineageId,
) -> Result<Self, AcdpError> {
Self::fetch_current_with_policy(
client,
resolver,
lineage_id,
&VerificationPolicy::default(),
)
.await
}
pub async fn fetch_current_with_policy(
client: &RegistryClient,
resolver: &WebResolver,
lineage_id: &acdp_types::primitives::LineageId,
policy: &VerificationPolicy,
) -> Result<Self, AcdpError> {
let ctx = client.current(lineage_id).await?;
let served_ctx_id = ctx.body.ctx_id.clone();
let (key_status, verified_receipt, revocation_discovery) =
Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
let revocation_discovery_failure = match revocation_discovery {
Some(Err(e)) => Some(e),
_ => None,
};
let (verified_head_receipt, head_receipt_stale) =
match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
(ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
(ReceiptPolicy::Require, None) => {
return Err(AcdpError::InvalidReceipt(
"policy requires a lineage-head receipt but the /current response \
carries none (registry without the acdp-registry-head-receipts \
profile?)"
.into(),
));
}
(_, Some(value)) => {
let serving_authority = client
.authority()
.unwrap_or_else(|| served_ctx_id.authority().to_string());
let caps = client.capabilities().await?;
let receipt = super::receipt::verify_lineage_head_receipt_value(
value,
lineage_id,
&served_ctx_id,
ctx.body.version,
&ctx.registry_state.status,
true, &serving_authority,
&caps.registry_did,
chrono::Duration::seconds(
policy.lineage_head.max_clock_skew_seconds as i64,
),
resolver,
)
.await?;
let stale = policy.lineage_head.max_age_seconds.map(|max| {
receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
});
(Some(receipt), stale)
}
};
Ok(Self {
inner: ctx,
key_status,
verified_receipt,
verified_head_receipt,
head_receipt_stale,
revocation_discovery_failure,
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "acdp.verify_retrieved",
skip_all,
fields(ctx_id = %expected_ctx_id),
err(Display)
)
)]
async fn verify_retrieved(
client: &RegistryClient,
resolver: &WebResolver,
ctx: &FullContext,
expected_ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> VerifyRetrievedResult {
if ctx.body.ctx_id != *expected_ctx_id {
return Err(AcdpError::ContextIdMismatch {
requested: expected_ctx_id.as_str().to_string(),
served: ctx.body.ctx_id.as_str().to_string(),
});
}
if policy.validate_body_schema {
acdp_validation::validate_body(&ctx.body)?;
}
let verifier = Verifier::new(resolver);
verifier.verify_body_hash(&ctx.body)?;
let serving_authority = client
.authority()
.unwrap_or_else(|| expected_ctx_id.authority().to_string());
let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
(ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
(ReceiptPolicy::Require, None) => {
return Err(AcdpError::InvalidReceipt(
"policy requires a registry receipt but the response carries none \
(registry without the acdp-registry-receipts profile, or a \
pre-receipts context)"
.into(),
));
}
(_, Some(value)) => {
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
&ctx.body.signature.key_id,
&ctx.body.signature.algorithm,
resolver,
)
.await?;
Some(
super::receipt::verify_receipt_value(
value,
expected_ctx_id,
&ctx.body,
&ctx.body.content_hash,
&fingerprint,
&serving_authority,
resolver,
)
.await?,
)
}
};
let (discovered, revocation_discovery, seeded_facts) = match &policy.revocations.discover {
None => {
let agent_id = &ctx.body.agent_id;
let vantage = client.authority();
let seeded_facts = client
.revocation_cache()
.map(|(cache, _freshness)| {
cache.facts_for(agent_id.as_str(), false, vantage.as_deref())
})
.unwrap_or_default();
(Vec::new(), None, seeded_facts)
}
Some(discovery) => {
let agent_id = &ctx.body.agent_id;
let include_attested = discovery.include_registry_attested;
let vantage = client.authority();
let seeded_facts = client
.revocation_cache()
.map(|(cache, _freshness)| {
cache.facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
})
.unwrap_or_default();
let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
let client = client
.with_discovery_budget(budget)
.with_revocation_freshness(discovery.freshness);
let discovery_fut = async {
tokio::try_join!(
super::revocation::find_revocations(&client, resolver, agent_id),
async {
if include_attested {
super::revocation::find_registry_attested_revocations(
&client, resolver, agent_id,
)
.await
} else {
Ok(Vec::new())
}
},
)
};
match tokio::time::timeout(discovery.total_timeout, discovery_fut).await {
Ok(Ok((producer_signed, registry_attested))) => {
let outcome = DiscoveryOutcome {
producer_signed: producer_signed.len(),
registry_attested: if include_attested {
Some(registry_attested.len())
} else {
None
},
};
let mut merged = producer_signed;
merged.extend(registry_attested);
(merged, Some(Ok(outcome)), seeded_facts)
}
Ok(Err(e)) => {
let wrapped = AcdpError::RevocationDiscoveryFailed {
source: Box::new(e),
};
match discovery.on_failure {
DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
DiscoveryFailurePolicy::ProceedWithKnown => {
(Vec::new(), Some(Err(wrapped)), seeded_facts)
}
}
}
Err(_elapsed) => {
let wrapped = AcdpError::RevocationDiscoveryFailed {
source: Box::new(AcdpError::CrossRegistryResolutionFailed(format!(
"revocation auto-discovery exceeded total_timeout={:?}",
discovery.total_timeout
))),
};
match discovery.on_failure {
DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
DiscoveryFailurePolicy::ProceedWithKnown => {
(Vec::new(), Some(Err(wrapped)), seeded_facts)
}
}
}
}
}
};
let effective: Vec<acdp_types::revocation::KeyRevocation> = policy
.revocations
.known
.iter()
.cloned()
.chain(seeded_facts)
.chain(discovered)
.collect();
let revocation_verdict = if effective.is_empty() {
None
} else {
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
&ctx.body.signature.key_id,
&ctx.body.signature.algorithm,
resolver,
)
.await?;
super::revocation::classify_under_revocation(
&effective,
&fingerprint,
verified_receipt.as_ref().map(|r| r.created_at),
)?
};
let key_status = match revocation_verdict {
Some(pre_compromise) => {
if ctx.body.agent_id.as_str().starts_with("did:key:") {
verifier.verify_body_signature(&ctx.body).await?;
} else {
acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
}
pre_compromise
}
None => match verifier.verify_body_signature(&ctx.body).await {
Ok(()) => KeyAuthorization::CurrentlyAuthorized,
Err(AcdpError::KeyNotAuthorized(_))
if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
&& verified_receipt.is_some() =>
{
acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
KeyAuthorization::HistoricallyAuthorized
}
Err(e) => return Err(e),
},
};
if !policy.allow_unknown_status {
if let Some(other) = ctx.registry_state.status.as_other() {
return Err(AcdpError::SchemaViolation(format!(
"policy.allow_unknown_status=false; registry returned '{other}'"
)));
}
}
Ok((key_status, verified_receipt, revocation_discovery))
}
pub async fn fetch_report(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<(Self, VerificationReport), AcdpError> {
Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
}
pub async fn fetch_report_diagnose(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
) -> Result<(Option<Self>, VerificationReport), AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
let mut report = VerificationReport {
body_hash_ok: false,
signature_ok: false,
schema_ok: false,
data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
ctx_id_ok: ctx.body.ctx_id == *ctx_id,
key_status: None,
policy_phase_error: None,
revocation_discovery: None,
};
if policy.validate_body_schema {
match acdp_validation::validate_body_structural(&ctx.body) {
Ok(()) => report.schema_ok = true,
Err(_) => { }
}
} else {
report.schema_ok = true;
}
for dr in &ctx.body.data_refs {
if let Some(emb) = &dr.embedded {
if dr.content_hash.is_some() || emb.content_hash.is_some() {
let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
});
report.data_ref_embedded.push(outcome);
} else {
report.data_ref_embedded.push(Ok(0));
}
} else {
report.data_ref_embedded.push(Ok(0));
}
}
let verifier = Verifier::new(resolver);
report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
for _ in &ctx.body.data_refs {
report.data_ref_external.push(None);
}
let all_top_level_pass =
report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
let verified = if all_top_level_pass {
let outcome = Self::verify_retrieved(
client,
resolver,
&ctx,
ctx_id,
&policy.derived_for_report(),
)
.await; match outcome {
Ok((key_status, verified_receipt, revocation_discovery)) => {
report.key_status = Some(key_status);
let revocation_discovery_failure = match &revocation_discovery {
Some(Err(e)) => Some(e.clone()),
_ => None,
};
report.revocation_discovery = revocation_discovery;
Some(Self {
inner: ctx,
key_status,
verified_receipt,
verified_head_receipt: None,
head_receipt_stale: None,
revocation_discovery_failure,
})
}
Err(e) => {
report.policy_phase_error = Some(e);
None
}
}
} else {
None
};
Ok((verified, report))
}
pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
fetcher: &F,
) -> Result<(Self, VerificationReport), AcdpError> {
Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
}
async fn fetch_report_inner<F: DataRefFetcher>(
client: &RegistryClient,
resolver: &WebResolver,
ctx_id: &CtxId,
policy: &VerificationPolicy,
fetcher: Option<&F>,
) -> Result<(Self, VerificationReport), AcdpError> {
let ctx = client.retrieve(ctx_id).await?;
if ctx.body.ctx_id != *ctx_id {
return Err(AcdpError::ContextIdMismatch {
requested: ctx_id.as_str().to_string(),
served: ctx.body.ctx_id.as_str().to_string(),
});
}
let mut report = VerificationReport {
body_hash_ok: false,
signature_ok: false,
schema_ok: false,
data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
ctx_id_ok: true,
key_status: None,
policy_phase_error: None,
revocation_discovery: None,
};
if policy.validate_body_schema {
acdp_validation::validate_body_structural(&ctx.body)?;
}
report.schema_ok = true;
for dr in &ctx.body.data_refs {
if let Some(emb) = &dr.embedded {
if dr.content_hash.is_some() || emb.content_hash.is_some() {
let outcome = acdp_validation::verify_embedded_hash(dr).and_then(|()| {
acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len())
});
report.data_ref_embedded.push(outcome);
} else {
report.data_ref_embedded.push(Ok(0));
}
} else {
report.data_ref_embedded.push(Ok(0));
}
}
let (key_status, verified_receipt, revocation_discovery) =
Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
.await?;
report.body_hash_ok = true;
report.signature_ok = true;
report.key_status = Some(key_status);
let revocation_discovery_failure = match &revocation_discovery {
Some(Err(e)) => Some(e.clone()),
_ => None,
};
report.revocation_discovery = revocation_discovery;
for dr in &ctx.body.data_refs {
let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
(Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
_ => None,
};
report.data_ref_external.push(slot);
}
Ok((
Self {
inner: ctx,
key_status,
verified_receipt,
verified_head_receipt: None,
head_receipt_stale: None,
revocation_discovery_failure,
},
report,
))
}
pub fn body(&self) -> &acdp_types::body::Body {
&self.inner.body
}
pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
&self.inner.registry_state
}
pub fn full_context(&self) -> &FullContext {
&self.inner
}
pub fn key_status(&self) -> KeyAuthorization {
self.key_status
}
pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
self.verified_receipt.as_ref()
}
pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
self.verified_head_receipt.as_ref()
}
pub fn head_receipt_stale(&self) -> Option<bool> {
self.head_receipt_stale
}
pub fn revocation_discovery_failure(&self) -> Option<&AcdpError> {
self.revocation_discovery_failure.as_ref()
}
pub fn receipt(&self) -> Option<&serde_json::Value> {
self.inner.registry_receipt.as_ref()
}
pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
self.inner.lineage_head_receipt.as_ref()
}
pub async fn verify_receipt(
&self,
resolver: &WebResolver,
) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
let Some(value) = &self.inner.registry_receipt else {
return Ok(None);
};
let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
&self.inner.body.signature.key_id,
&self.inner.body.signature.algorithm,
resolver,
)
.await?;
let receipt = super::receipt::verify_receipt_value(
value,
&self.inner.body.ctx_id,
&self.inner.body,
&self.inner.body.content_hash,
&fingerprint,
self.inner.body.ctx_id.authority(),
resolver,
)
.await?;
Ok(Some(receipt))
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct VerificationReport {
pub body_hash_ok: bool,
pub signature_ok: bool,
pub schema_ok: bool,
pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
pub ctx_id_ok: bool,
pub key_status: Option<KeyAuthorization>,
pub policy_phase_error: Option<AcdpError>,
pub revocation_discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
}
struct NoFetcher;
impl DataRefFetcher for NoFetcher {
async fn fetch(
&self,
_location: &acdp_types::data_ref::Location,
) -> Result<Vec<u8>, AcdpError> {
Err(AcdpError::NotImplemented(
"NoFetcher should never be called — this is a fetch_report sentinel".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::{
DiscoveryFailurePolicy, HistoricalKeyPolicy, ReceiptPolicy, RevocationDiscovery,
RevocationPolicy, VerificationPolicy,
};
use acdp_primitives::error::AcdpError;
use std::time::Duration;
#[test]
fn strict_v0_1_0_preserves_v0_1_0_semantics() {
let strict = VerificationPolicy::strict_v0_1_0();
assert!(strict.validate_body_schema);
assert!(strict.allow_unknown_status);
assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
assert!(
strict.revocations.known.is_empty(),
"a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
);
assert_ne!(
strict,
VerificationPolicy::default(),
"the 0.2 default is receipt-aware; the v0.1.0 profile is not"
);
}
#[test]
fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
const SRC: &str = include_str!("verified.rs");
let policy_prefix = "policy.";
let fields = [
"receipts",
"revocations",
"historical_keys",
"allow_unknown_status",
];
let patterns: Vec<String> = fields
.iter()
.map(|f| format!("{policy_prefix}{f}"))
.collect();
let fn_start = SRC
.find("async fn verify_retrieved(")
.expect("verify_retrieved must exist in verified.rs");
let body_open = fn_start
+ SRC[fn_start..]
.find('{')
.expect("verify_retrieved must have a body");
let mut depth = 0i32;
let mut body_close = None;
for (i, ch) in SRC[body_open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
body_close = Some(body_open + i);
break;
}
}
_ => {}
}
}
let body_close =
body_close.expect("verify_retrieved's matching closing brace must be found");
assert!(
body_close > body_open,
"sanity: verify_retrieved's body must be non-empty"
);
let mut offset = 0usize;
let mut checked_any = false;
for line in SRC.split_inclusive('\n') {
let trimmed = line.trim_start();
let is_comment_line = trimmed.starts_with("//");
if !is_comment_line {
for pattern in &patterns {
let mut search_from = 0usize;
while let Some(rel) = line[search_from..].find(pattern.as_str()) {
let match_col = search_from + rel;
let match_start = offset + match_col;
let before = &line[..match_col];
let in_string_literal = before.matches('"').count() % 2 == 1;
if !in_string_literal {
checked_any = true;
assert!(
match_start >= body_open && match_start < body_close,
"found `{pattern}` outside verify_retrieved's body \
(byte offset {match_start}, line: {line:?}) — every \
authorization-policy-field read must live inside \
verify_retrieved, the sole reader"
);
}
search_from = match_col + pattern.len();
}
}
}
offset += line.len();
}
let mut normalized = String::with_capacity(SRC.len());
let mut orig_offsets: Vec<usize> = Vec::with_capacity(SRC.len());
let mut after_dot = false;
for (i, ch) in SRC.char_indices() {
if ch == '.' {
while let Some(last) = normalized.chars().last() {
if !last.is_whitespace() {
break;
}
normalized.pop();
let new_len = orig_offsets.len() - last.len_utf8();
orig_offsets.truncate(new_len);
}
normalized.push(ch);
orig_offsets.push(i);
after_dot = true;
continue;
}
if after_dot && ch.is_whitespace() {
continue; }
after_dot = false;
normalized.push(ch);
for _ in 0..ch.len_utf8() {
orig_offsets.push(i);
}
}
debug_assert_eq!(normalized.len(), orig_offsets.len());
let is_comment_line_at = |pos: usize| -> bool {
let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
let line_end = SRC[pos..].find('\n').map_or(SRC.len(), |i| pos + i);
SRC[line_start..line_end].trim_start().starts_with("//")
};
let in_string_literal_at = |pos: usize| -> bool {
let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
SRC[line_start..pos].matches('"').count() % 2 == 1
};
for pattern in &patterns {
let mut search_from = 0usize;
while let Some(rel) = normalized[search_from..].find(pattern.as_str()) {
let match_col = search_from + rel;
let orig_start = orig_offsets[match_col];
if !is_comment_line_at(orig_start) && !in_string_literal_at(orig_start) {
checked_any = true;
assert!(
orig_start >= body_open && orig_start < body_close,
"found `{pattern}` (whitespace-normalized) outside \
verify_retrieved's body (original byte offset {orig_start}) \
— every authorization-policy-field read must live inside \
verify_retrieved, the sole reader, even when rustfmt has \
wrapped the field-access chain across multiple lines"
);
}
search_from = match_col + pattern.len();
}
}
assert!(
checked_any,
"sanity: the scan must find at least one non-comment, non-string-literal \
match for at least one pattern (verify_retrieved itself reads these \
fields) — zero hits would mean the patterns are miscomputed, not that \
the invariant holds"
);
}
#[test]
fn revocation_policy_default_is_unchanged() {
let policy = RevocationPolicy::default();
assert!(policy.known.is_empty());
assert!(policy.discover.is_none());
}
#[test]
fn revocation_policy_new_leaves_discovery_off() {
let policy = RevocationPolicy::new(vec![]);
assert!(policy.known.is_empty());
assert!(policy.discover.is_none());
}
#[test]
fn discovery_failure_policy_defaults_to_fail_closed() {
assert_eq!(
DiscoveryFailurePolicy::default(),
DiscoveryFailurePolicy::FailClosed
);
}
#[test]
fn revocation_discovery_constructors_set_the_right_trust_classes() {
let producer_only = RevocationDiscovery::producer_signed_only();
assert!(!producer_only.include_registry_attested);
assert_eq!(producer_only.on_failure, DiscoveryFailurePolicy::FailClosed);
assert_eq!(producer_only.total_timeout, Duration::from_secs(30));
assert_eq!(
producer_only.freshness,
Duration::ZERO,
"issue #257: freshness defaults to ZERO (off) from both named constructors"
);
let all = RevocationDiscovery::all_trust_classes();
assert!(all.include_registry_attested);
assert_eq!(all.on_failure, DiscoveryFailurePolicy::FailClosed);
assert_eq!(all.total_timeout, Duration::from_secs(30));
assert_eq!(all.freshness, Duration::ZERO);
}
#[test]
fn revocation_discovery_has_no_default_by_design() {
}
#[test]
fn revocation_discovery_failed_delegates_is_transient_to_source() {
let transient = AcdpError::RevocationDiscoveryFailed {
source: Box::new(AcdpError::KeyResolutionUnreachable(
"did:web host unreachable".into(),
)),
};
assert!(
transient.is_transient(),
"a transient source must make the wrapper transient too"
);
let permanent = AcdpError::RevocationDiscoveryFailed {
source: Box::new(AcdpError::InvalidSignature("bad signature".into())),
};
assert!(
!permanent.is_transient(),
"a permanent source must make the wrapper permanent too"
);
}
}