use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
pub const SCID_VH_PREFIX: &str = "did:scid:vh:1:";
pub const WEBVH_PREFIX: &str = "did:webvh:";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[non_exhaustive]
pub enum DidMethod {
#[default]
Webvh,
Scid,
}
impl DidMethod {
pub fn as_str(&self) -> &'static str {
match self {
DidMethod::Webvh => "webvh",
DidMethod::Scid => "scid",
}
}
}
impl fmt::Display for DidMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for DidMethod {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"webvh" | "did:webvh" => Ok(DidMethod::Webvh),
"scid" | "did:scid" => Ok(DidMethod::Scid),
other => Err(format!(
"unknown DID method '{other}', expected 'webvh' or 'scid'"
)),
}
}
}
pub fn is_scid(did: &str) -> bool {
did.starts_with("did:scid:")
}
pub fn is_durable(did: &str) -> bool {
did.starts_with(WEBVH_PREFIX) || did.starts_with(SCID_VH_PREFIX)
}
pub fn is_network_resolved(did: &str) -> bool {
did.starts_with("did:web:") || is_durable(did)
}
fn split_src(did_url: &str) -> Option<(&str, &str, &str)> {
if !is_scid(did_url) {
return None;
}
let (did, rest) = did_url.split_once('?')?;
let (query, fragment) = match rest.find('#') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let src = query.strip_prefix("src=")?;
if src.is_empty() || src.contains('&') {
return None;
}
Some((did, src, fragment))
}
pub fn strip_src(did_url: &str) -> Cow<'_, str> {
match split_src(did_url) {
Some((did, _, "")) => Cow::Borrowed(did),
Some((did, _, fragment)) => Cow::Owned(format!("{did}{fragment}")),
None => Cow::Borrowed(did_url),
}
}
pub fn src(did_url: &str) -> Option<&str> {
split_src(did_url).map(|(_, src, _)| src)
}
pub fn identity(reference: &str) -> String {
let did = reference.split('#').next().unwrap_or(reference);
strip_src(did).into_owned()
}
pub fn same_reference(a: &str, b: &str) -> bool {
a == b || strip_src(a) == strip_src(b)
}
pub fn scid_forms(webvh_did: &str) -> Option<(String, String)> {
let rest = webvh_did.strip_prefix(WEBVH_PREFIX)?;
let rest = rest.split('#').next().unwrap_or(rest);
let (scid, tail) = rest.split_once(':')?;
if scid.is_empty() || tail.is_empty() {
return None;
}
let bare = format!("{SCID_VH_PREFIX}{scid}");
let url = format!("{bare}?src={}", tail.replace(':', "/"));
Some((bare, url))
}
pub fn webvh_did(did_url: &str) -> Option<String> {
let did_url = did_url.split('#').next().unwrap_or(did_url);
let scid = did_url.strip_prefix(SCID_VH_PREFIX)?.split('?').next()?;
if scid.is_empty() || scid.contains(':') {
return None;
}
let source = src(did_url)?;
let source = source
.strip_prefix("https://")
.or_else(|| source.strip_prefix("http://"))
.unwrap_or(source)
.trim_end_matches('/');
let (host, path) = match source.split_once('/') {
Some((h, p)) => (h, Some(p)),
None => (source, None),
};
let host = match host.split_once(':') {
Some((h, port)) if !h.is_empty() && port.parse::<u16>().is_ok() => {
format!("{h}%3A{port}")
}
Some(_) => return None,
None => host.to_string(),
};
if host.is_empty() {
return None;
}
let mut out = format!("{WEBVH_PREFIX}{scid}:{host}");
if let Some(path) = path.filter(|p| !p.is_empty()) {
if path.split('/').any(str::is_empty) {
return None;
}
out.push(':');
out.push_str(&path.replace('/', ":"));
}
Some(out)
}
pub fn scid_of(did: &str) -> Option<&str> {
let did = did.split('#').next().unwrap_or(did);
if let Some(rest) = did.strip_prefix(SCID_VH_PREFIX) {
let scid = rest.split('?').next().unwrap_or(rest);
return (!scid.is_empty()).then_some(scid);
}
let rest = did.strip_prefix(WEBVH_PREFIX)?;
let (scid, _) = rest.split_once(':')?;
(!scid.is_empty()).then_some(scid)
}
pub fn presented_reference(identity: &str, reference: &str) -> Option<String> {
let identity = strip_src(identity);
let scid = identity.strip_prefix(SCID_VH_PREFIX)?;
if scid.is_empty() || scid.contains(['?', '#', ':']) {
return None;
}
let (did, fragment) = reference.split_once('#')?;
if !did.starts_with(WEBVH_PREFIX) || scid_of(did) != Some(scid) {
return None;
}
Some(format!("{identity}#{fragment}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn method_parses_and_prints() {
assert_eq!("webvh".parse::<DidMethod>().unwrap(), DidMethod::Webvh);
assert_eq!("SCID".parse::<DidMethod>().unwrap(), DidMethod::Scid);
assert_eq!("did:scid".parse::<DidMethod>().unwrap(), DidMethod::Scid);
assert!("key".parse::<DidMethod>().is_err());
assert_eq!(DidMethod::default(), DidMethod::Webvh);
assert_eq!(serde_json::to_value(DidMethod::Scid).unwrap(), "scid");
}
#[test]
fn durable_identities() {
assert!(is_durable("did:webvh:Qm:example.com"));
assert!(is_durable("did:scid:vh:1:Qm"));
assert!(is_durable("did:scid:vh:1:Qm?src=example.com"));
assert!(!is_durable("did:scid:ke:1:Qm?src=example.com"));
assert!(!is_durable("did:key:z6Mk"));
assert!(is_network_resolved("did:web:example.com"));
assert!(!is_network_resolved("did:peer:2.Ez"));
}
#[test]
fn src_is_a_locator() {
assert_eq!(
strip_src("did:scid:vh:1:Qm?src=example.com/a#key-1"),
"did:scid:vh:1:Qm#key-1"
);
assert_eq!(
src("did:scid:vh:1:Qm?src=example.com/a"),
Some("example.com/a")
);
assert_eq!(
identity("did:scid:vh:1:Qm?src=example.com/a#key-1"),
"did:scid:vh:1:Qm"
);
assert_eq!(
identity("did:webvh:Qm:example.com#key-1"),
"did:webvh:Qm:example.com"
);
for unchanged in [
"did:scid:vh:1:Qm?src=a&versionId=1",
"did:webvh:Qm:example.com?versionId=1",
] {
assert_eq!(strip_src(unchanged), unchanged);
}
assert!(same_reference(
"did:scid:vh:1:Qm?src=a.example#key-1",
"did:scid:vh:1:Qm#key-1"
));
assert!(!same_reference(
"did:scid:vh:1:Qm#key-1",
"did:webvh:Qm:a.example#key-1"
));
}
#[test]
fn forms_of_a_log() {
assert_eq!(
scid_forms("did:webvh:Qm:example.com:alice"),
Some((
"did:scid:vh:1:Qm".to_string(),
"did:scid:vh:1:Qm?src=example.com/alice".to_string()
))
);
assert_eq!(
scid_forms("did:webvh:Qm:localhost%3A8000").unwrap().1,
"did:scid:vh:1:Qm?src=localhost%3A8000"
);
assert_eq!(scid_forms("did:webvh:Qm"), None);
assert_eq!(scid_forms("did:key:z6Mk"), None);
assert_eq!(scid_of("did:scid:vh:1:Qm?src=a#k"), Some("Qm"));
assert_eq!(scid_of("did:webvh:Qm:a"), Some("Qm"));
}
#[test]
fn a_did_url_names_its_log() {
assert_eq!(
webvh_did("did:scid:vh:1:Qm?src=example.com/alice").as_deref(),
Some("did:webvh:Qm:example.com:alice")
);
for lenient in [
"did:scid:vh:1:Qm?src=https://localhost:8000/a/",
"did:scid:vh:1:Qm?src=localhost%3A8000/a",
"did:scid:vh:1:Qm?src=localhost:8000/a#key-1",
] {
assert_eq!(
webvh_did(lenient).as_deref(),
Some("did:webvh:Qm:localhost%3A8000:a"),
"{lenient}"
);
}
for none in [
"did:scid:vh:1:Qm",
"did:scid:ke:1:Qm?src=example.com",
"did:scid:vh:1:Qm?src=example.com:notaport",
"did:scid:vh:1:Qm?src=example.com//a",
"did:webvh:Qm:example.com",
] {
assert_eq!(webvh_did(none), None, "{none}");
}
let (_, url) = scid_forms("did:webvh:Qm:localhost%3A8000:a:b").unwrap();
assert_eq!(
webvh_did(&url).as_deref(),
Some("did:webvh:Qm:localhost%3A8000:a:b")
);
}
#[test]
fn webvh_references_present_under_the_scid_identity() {
assert_eq!(
presented_reference("did:scid:vh:1:Qm", "did:webvh:Qm:example.com#key-1").as_deref(),
Some("did:scid:vh:1:Qm#key-1")
);
assert_eq!(
presented_reference(
"did:scid:vh:1:Qm?src=example.com",
"did:webvh:Qm:example.com#key-1"
)
.as_deref(),
Some("did:scid:vh:1:Qm#key-1")
);
assert_eq!(
presented_reference("did:scid:vh:1:Qm", "did:webvh:QmOther:example.com#key-1"),
None
);
assert_eq!(
presented_reference("did:webvh:Qm:example.com", "did:webvh:Qm:example.com#key-1"),
None
);
}
}