use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::Error;
use crate::types::bundle::SchemaPinTrustBundle;
use crate::types::discovery::WellKnownResponse;
use crate::types::revocation::RevocationDocument;
pub trait SchemaResolver: Send + Sync {
fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error>;
fn resolve_revocation(
&self,
domain: &str,
_discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
let _ = domain;
Ok(None)
}
}
#[cfg(feature = "fetch")]
#[async_trait::async_trait]
pub trait AsyncSchemaResolver: Send + Sync {
async fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error>;
async fn resolve_revocation(
&self,
domain: &str,
_discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
let _ = domain;
Ok(None)
}
}
#[cfg(feature = "fetch")]
pub struct WellKnownResolver;
#[cfg(feature = "fetch")]
#[async_trait::async_trait]
impl AsyncSchemaResolver for WellKnownResolver {
async fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error> {
crate::discovery::fetch_well_known(domain).await
}
async fn resolve_revocation(
&self,
_domain: &str,
discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
if let Some(ref endpoint) = discovery.revocation_endpoint {
let doc = crate::revocation::fetch_revocation_document(endpoint).await?;
Ok(Some(doc))
} else {
Ok(None)
}
}
}
pub struct LocalFileResolver {
discovery_dir: PathBuf,
revocation_dir: Option<PathBuf>,
}
impl LocalFileResolver {
pub fn new(discovery_dir: &Path, revocation_dir: Option<&Path>) -> Self {
Self {
discovery_dir: discovery_dir.to_path_buf(),
revocation_dir: revocation_dir.map(|p| p.to_path_buf()),
}
}
}
impl SchemaResolver for LocalFileResolver {
fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error> {
let path = self.discovery_dir.join(format!("{}.json", domain));
let data = std::fs::read_to_string(&path)
.map_err(|e| Error::Discovery(format!("Cannot read {}: {}", path.display(), e)))?;
let doc: WellKnownResponse = serde_json::from_str(&data)?;
Ok(doc)
}
fn resolve_revocation(
&self,
domain: &str,
_discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
let dir = self.revocation_dir.as_ref().unwrap_or(&self.discovery_dir);
let path = dir.join(format!("{}.revocations.json", domain));
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&path)
.map_err(|e| Error::Discovery(format!("Cannot read {}: {}", path.display(), e)))?;
let doc: RevocationDocument = serde_json::from_str(&data)?;
Ok(Some(doc))
}
}
pub struct TrustBundleResolver {
discovery: HashMap<String, WellKnownResponse>,
revocations: HashMap<String, RevocationDocument>,
}
impl TrustBundleResolver {
pub fn new(bundle: &SchemaPinTrustBundle) -> Self {
let mut discovery = HashMap::new();
for doc in &bundle.documents {
discovery.insert(doc.domain.clone(), doc.well_known.clone());
}
let mut revocations = HashMap::new();
for doc in &bundle.revocations {
revocations.insert(doc.domain.clone(), doc.clone());
}
Self {
discovery,
revocations,
}
}
pub fn from_json(json: &str) -> Result<Self, Error> {
let bundle: SchemaPinTrustBundle = serde_json::from_str(json)?;
Ok(Self::new(&bundle))
}
}
impl SchemaResolver for TrustBundleResolver {
fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error> {
self.discovery
.get(domain)
.cloned()
.ok_or_else(|| Error::Discovery(format!("Domain '{}' not in trust bundle", domain)))
}
fn resolve_revocation(
&self,
domain: &str,
_discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
Ok(self.revocations.get(domain).cloned())
}
}
pub struct ChainResolver {
resolvers: Vec<Box<dyn SchemaResolver>>,
}
impl ChainResolver {
pub fn new(resolvers: Vec<Box<dyn SchemaResolver>>) -> Self {
Self { resolvers }
}
}
impl SchemaResolver for ChainResolver {
fn resolve_discovery(&self, domain: &str) -> Result<WellKnownResponse, Error> {
let mut last_err = Error::Discovery("No resolvers configured".to_string());
for resolver in &self.resolvers {
match resolver.resolve_discovery(domain) {
Ok(doc) => return Ok(doc),
Err(e) => last_err = e,
}
}
Err(last_err)
}
fn resolve_revocation(
&self,
domain: &str,
discovery: &WellKnownResponse,
) -> Result<Option<RevocationDocument>, Error> {
for resolver in &self.resolvers {
match resolver.resolve_revocation(domain, discovery) {
Ok(Some(doc)) => return Ok(Some(doc)),
Ok(None) => continue,
Err(_) => continue,
}
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discovery::build_well_known_response;
use crate::revocation::build_revocation_document;
use crate::types::bundle::BundledDiscovery;
fn make_well_known(pem: &str) -> WellKnownResponse {
build_well_known_response(pem, Some("Test"), vec![], "1.2")
}
fn make_bundle(domain: &str) -> SchemaPinTrustBundle {
SchemaPinTrustBundle {
schemapin_bundle_version: "1.2".to_string(),
created_at: "2026-02-10T00:00:00Z".to_string(),
documents: vec![BundledDiscovery {
domain: domain.to_string(),
well_known: make_well_known(
"-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
),
}],
revocations: vec![],
}
}
#[test]
fn test_trust_bundle_resolver_hit() {
let bundle = make_bundle("example.com");
let resolver = TrustBundleResolver::new(&bundle);
let doc = resolver.resolve_discovery("example.com").unwrap();
assert_eq!(doc.schema_version, "1.2");
}
#[test]
fn test_trust_bundle_resolver_miss() {
let bundle = SchemaPinTrustBundle::new("2026-02-10T00:00:00Z");
let resolver = TrustBundleResolver::new(&bundle);
assert!(resolver.resolve_discovery("missing.com").is_err());
}
#[test]
fn test_trust_bundle_resolver_revocation() {
let rev = build_revocation_document("example.com");
let mut bundle = make_bundle("example.com");
bundle.revocations = vec![rev];
let resolver = TrustBundleResolver::new(&bundle);
let disc = resolver.resolve_discovery("example.com").unwrap();
let rev = resolver.resolve_revocation("example.com", &disc).unwrap();
assert!(rev.is_some());
}
#[test]
fn test_trust_bundle_resolver_from_json() {
let bundle = make_bundle("example.com");
let json = serde_json::to_string(&bundle).unwrap();
let resolver = TrustBundleResolver::from_json(&json).unwrap();
assert!(resolver.resolve_discovery("example.com").is_ok());
}
#[test]
fn test_local_file_resolver() {
let dir = tempfile::tempdir().unwrap();
let wk = make_well_known("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----");
let path = dir.path().join("local.example.com.json");
std::fs::write(&path, serde_json::to_string_pretty(&wk).unwrap()).unwrap();
let resolver = LocalFileResolver::new(dir.path(), None);
let resolved = resolver.resolve_discovery("local.example.com").unwrap();
assert_eq!(resolved.schema_version, "1.2");
}
#[test]
fn test_local_file_resolver_missing() {
let dir = tempfile::tempdir().unwrap();
let resolver = LocalFileResolver::new(dir.path(), None);
assert!(resolver.resolve_discovery("missing.com").is_err());
}
#[test]
fn test_local_file_resolver_revocation() {
let dir = tempfile::tempdir().unwrap();
let wk = make_well_known("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----");
let rev = build_revocation_document("local.example.com");
std::fs::write(
dir.path().join("local.example.com.json"),
serde_json::to_string(&wk).unwrap(),
)
.unwrap();
std::fs::write(
dir.path().join("local.example.com.revocations.json"),
serde_json::to_string(&rev).unwrap(),
)
.unwrap();
let resolver = LocalFileResolver::new(dir.path(), None);
let resolved = resolver
.resolve_revocation("local.example.com", &wk)
.unwrap();
assert!(resolved.is_some());
}
#[test]
fn test_chain_resolver_first_wins() {
let bundle_a = make_bundle("a.com");
let bundle_b = make_bundle("b.com");
let chain = ChainResolver::new(vec![
Box::new(TrustBundleResolver::new(&bundle_a)),
Box::new(TrustBundleResolver::new(&bundle_b)),
]);
assert!(chain.resolve_discovery("a.com").is_ok());
assert!(chain.resolve_discovery("b.com").is_ok());
assert!(chain.resolve_discovery("c.com").is_err());
}
#[test]
fn test_chain_resolver_fallthrough() {
let empty = SchemaPinTrustBundle::new("2026-02-10T00:00:00Z");
let has_doc = make_bundle("example.com");
let chain = ChainResolver::new(vec![
Box::new(TrustBundleResolver::new(&empty)),
Box::new(TrustBundleResolver::new(&has_doc)),
]);
let doc = chain.resolve_discovery("example.com").unwrap();
assert_eq!(doc.schema_version, "1.2");
}
}