pub mod parse;
pub mod publication;
#[cfg(feature = "method-web")]
pub mod web;
#[cfg(feature = "method-webplus")]
pub mod webplus;
#[cfg(feature = "method-webs")]
pub mod webs;
#[cfg(feature = "method-webvh")]
pub mod webvh;
pub use parse::parse_did_method;
pub trait DidMethod: Send + Sync + 'static {
fn name(&self) -> &'static str;
fn content_type(&self) -> &'static str;
fn data_ext(&self) -> &'static str;
fn parse_identifier(&self, did: &str) -> Result<ParsedDid, MethodError>;
fn resolution_url(&self, domain: &str, mnemonic: &str) -> String;
fn validate(&self, data: &[u8]) -> Result<(), MethodError>;
fn apply_update(
&self,
existing: Option<&[u8]>,
new_data: &[u8],
) -> Result<Vec<u8>, MethodError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedDid {
pub method: &'static str,
pub scid: Option<String>,
pub domain: String,
pub path: String,
}
#[derive(Debug, thiserror::Error)]
pub enum MethodError {
#[error("malformed DID identifier: {0}")]
Malformed(String),
#[error("DID method mismatch: expected {expected}, found {found}")]
MethodMismatch {
expected: &'static str,
found: String,
},
#[error("validation failed: {0}")]
Validation(String),
}
#[allow(clippy::needless_return)] pub fn method_by_name(name: &str) -> Option<&'static dyn DidMethod> {
#[cfg(feature = "method-webvh")]
static WEBVH: webvh::Webvh = webvh::Webvh;
#[cfg(feature = "method-web")]
static WEB: web::Web = web::Web;
#[cfg(feature = "method-webs")]
static WEBS: webs::Webs = webs::Webs;
match name {
#[cfg(feature = "method-webvh")]
"webvh" => Some(&WEBVH),
#[cfg(feature = "method-web")]
"web" => Some(&WEB),
#[cfg(feature = "method-webs")]
"webs" => Some(&WEBS),
_ => None,
}
}
pub fn enabled_methods() -> &'static [&'static str] {
&[
#[cfg(feature = "method-webvh")]
"webvh",
#[cfg(feature = "method-web")]
"web",
#[cfg(feature = "method-webs")]
"webs",
]
}
pub fn detect_method(data: &[u8]) -> Option<&'static str> {
let text = std::str::from_utf8(data).ok();
#[cfg(feature = "method-webvh")]
if let Some(text) = text
&& let Some(line) = text.lines().find(|l| !l.trim().is_empty())
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
&& v.get("versionId").is_some()
{
return Some("webvh");
}
#[cfg(feature = "method-webs")]
if webs::Webs.validate(data).is_ok() {
return Some("webs");
}
#[cfg(feature = "method-web")]
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(data)
&& v.get("id")
.and_then(|i| i.as_str())
.is_some_and(|id| id.starts_with("did:web:"))
{
return Some("web");
}
let _ = text;
None
}
#[cfg(test)]
mod tests {
use super::*;
struct NoopMethod;
impl DidMethod for NoopMethod {
fn name(&self) -> &'static str {
"noop"
}
fn content_type(&self) -> &'static str {
"application/octet-stream"
}
fn data_ext(&self) -> &'static str {
"bin"
}
fn parse_identifier(&self, _did: &str) -> Result<ParsedDid, MethodError> {
Err(MethodError::Malformed("noop test impl".into()))
}
fn resolution_url(&self, domain: &str, mnemonic: &str) -> String {
format!("https://{domain}/{mnemonic}/data.bin")
}
fn validate(&self, _data: &[u8]) -> Result<(), MethodError> {
Ok(())
}
fn apply_update(
&self,
_existing: Option<&[u8]>,
new_data: &[u8],
) -> Result<Vec<u8>, MethodError> {
Ok(new_data.to_vec())
}
}
static NOOP: NoopMethod = NoopMethod;
#[test]
fn trait_is_object_safe() {
let m: &'static dyn DidMethod = &NOOP;
assert_eq!(m.name(), "noop");
assert_eq!(m.content_type(), "application/octet-stream");
assert_eq!(m.data_ext(), "bin");
}
#[test]
fn resolution_url_composes_method_specific_path() {
let url = NOOP.resolution_url("example.com", "tenant/user1");
assert_eq!(url, "https://example.com/tenant/user1/data.bin");
}
#[test]
fn parse_identifier_can_surface_malformed() {
let err = NOOP
.parse_identifier("not-a-did")
.expect_err("noop impl rejects everything");
assert!(matches!(err, MethodError::Malformed(_)));
}
#[cfg(feature = "method-webvh")]
#[test]
fn dispatcher_routes_webvh() {
let m = method_by_name("webvh").expect("webvh feature enabled in default build");
assert_eq!(m.name(), "webvh");
}
#[test]
fn dispatcher_returns_none_for_unknown() {
assert!(method_by_name("anything-not-a-method").is_none());
}
#[cfg(feature = "method-web")]
#[test]
fn dispatcher_routes_web() {
let m = method_by_name("web").expect("web feature enabled in default build");
assert_eq!(m.name(), "web");
assert_eq!(m.content_type(), "application/did+json");
}
#[cfg(not(feature = "method-web"))]
#[test]
fn dispatcher_returns_none_for_web_when_feature_off() {
assert!(method_by_name("web").is_none());
}
#[cfg(feature = "method-webvh")]
#[test]
fn enabled_methods_contains_webvh() {
assert!(enabled_methods().contains(&"webvh"));
}
#[cfg(feature = "method-web")]
#[test]
fn enabled_methods_contains_web() {
assert!(enabled_methods().contains(&"web"));
}
#[cfg(not(any(feature = "method-webvh", feature = "method-web")))]
#[test]
fn enabled_methods_is_empty_when_no_method_feature() {
assert!(enabled_methods().is_empty());
}
#[test]
fn parsed_did_round_trips_through_debug() {
let p = ParsedDid {
method: "noop",
scid: Some("Q1Hh3jBb2".into()),
domain: "example.com".into(),
path: "tenant:user1".into(),
};
let dbg = format!("{p:?}");
assert!(dbg.contains("noop"));
assert!(dbg.contains("example.com"));
assert!(dbg.contains("tenant:user1"));
}
#[test]
fn method_error_method_mismatch_carries_both_sides() {
let err = MethodError::MethodMismatch {
expected: "web",
found: "webvh".to_string(),
};
let s = err.to_string();
assert!(s.contains("web"));
assert!(s.contains("webvh"));
}
}
#[cfg(test)]
mod detect_tests {
use super::*;
#[cfg(feature = "method-webs")]
const KERI: &[u8] = include_bytes!("../../tests/fixtures/ENro7uf0.keri.cesr");
const WEBVH_LOG: &[u8] = br#"{"versionId":"1-QmABC","versionTime":"2025-01-01T00:00:00Z","state":{"id":"did:webvh:QmABC:example.com:user1"}}"#;
const WEB_DOC: &[u8] = br#"{"id":"did:web:example.com:user1","verificationMethod":[]}"#;
#[cfg(feature = "method-webvh")]
#[test]
fn detects_webvh_from_a_log_line() {
assert_eq!(detect_method(WEBVH_LOG), Some("webvh"));
}
#[cfg(feature = "method-web")]
#[test]
fn detects_web_from_a_document() {
assert_eq!(detect_method(WEB_DOC), Some("web"));
}
#[cfg(feature = "method-webs")]
#[test]
fn detects_webs_from_a_cesr_stream() {
assert_eq!(detect_method(KERI), Some("webs"));
}
#[cfg(all(feature = "method-webvh", feature = "method-webs"))]
#[test]
fn a_webvh_log_is_never_mistaken_for_a_cesr_stream() {
assert_eq!(detect_method(WEBVH_LOG), Some("webvh"));
assert!(
webs::Webs.validate(WEBVH_LOG).is_err(),
"a webvh log line must not pass the webs stream check",
);
}
#[cfg(all(feature = "method-webs", feature = "method-web"))]
#[test]
fn a_cesr_stream_is_never_mistaken_for_a_web_document() {
assert_eq!(detect_method(KERI), Some("webs"));
}
#[test]
fn returns_none_for_unrecognised_bytes() {
assert_eq!(detect_method(b""), None);
assert_eq!(detect_method(b"not anything"), None);
assert_eq!(detect_method(br#"{"hello":"world"}"#), None);
assert_eq!(detect_method(br#"{"id":"did:key:z6Mk"}"#), None);
}
}