use crate::DIDWebVHError;
use chrono::{DateTime, FixedOffset};
use std::fmt::{Display, Formatter};
use std::net::IpAddr;
use url::Url;
type QueryPairs = (Option<String>, Option<DateTime<FixedOffset>>, Option<u32>);
#[derive(Clone, Debug, PartialEq)]
pub enum URLType {
DIDDoc,
WhoIs,
}
#[derive(Clone)]
pub struct WebVHURL {
pub type_: URLType,
pub did_url: String,
pub scid: String,
pub domain: String,
pub port: Option<u16>,
pub path: String,
pub fragment: Option<String>,
pub query: Option<String>,
pub file_name: Option<String>,
pub query_version_id: Option<String>,
pub query_version_time: Option<DateTime<FixedOffset>>,
pub query_version_number: Option<u32>,
}
impl WebVHURL {
pub fn parse_did_url(url: &str) -> Result<WebVHURL, DIDWebVHError> {
let url = if let Some(prefix) = url.strip_prefix("did:webvh:") {
prefix
} else if url.starts_with("did:") {
return Err(DIDWebVHError::UnsupportedMethod(format!(
"Expected did:webvh, got: {}",
url.split(':').take(3).collect::<Vec<_>>().join(":")
)));
} else {
url
};
let (prefix, fragment) = match url.split_once('#') {
Some((prefix, fragment)) => (prefix, Some(fragment.to_string())),
None => (url, None),
};
let (prefix, query) = match prefix.split_once('?') {
Some((prefix, query)) => (prefix, Some(query.to_string())),
None => (url, None),
};
let (query_version_id, query_version_time, query_version_number) =
Self::parse_query(query.as_deref())?;
let parts = prefix.split(':').collect::<Vec<_>>();
if parts.len() < 2 {
return Err(DIDWebVHError::InvalidMethodIdentifier(
"Invalid URL: Must contain SCID and domain".to_string(),
));
}
let scid = parts[0].to_string();
let (domain, port) = match parts[1].split_once("%3A") {
Some((domain, port)) => {
let port = match port.parse::<u16>() {
Ok(port) => port,
Err(err) => {
return Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: Port ({port}) must be a number: {err}",
)));
}
};
(domain.to_string(), Some(port))
}
None => (parts[1].to_string(), None),
};
Self::reject_ip_address(&domain)?;
let mut path = String::new();
let mut file_name = String::new();
for part in parts[2..].iter() {
if part != &"whois" {
path.push('/');
path.push_str(part);
}
}
if path.is_empty() {
path = "/.well-known/".to_string();
} else {
path.push('/');
}
let type_ = if parts.len() > 2 && parts[parts.len() - 1] == "whois" {
if path == "/.well-known/" {
path = "/".to_string()
}
file_name.push_str("whois.vp");
URLType::WhoIs
} else {
file_name.push_str("did.jsonl");
URLType::DIDDoc
};
Ok(WebVHURL {
type_,
did_url: url.to_string(),
scid,
domain,
port,
path,
fragment,
query,
file_name: Some(file_name),
query_version_id,
query_version_time,
query_version_number,
})
}
pub fn parse_url(url: &Url) -> Result<WebVHURL, DIDWebVHError> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(DIDWebVHError::InvalidMethodIdentifier(
"Invalid URL: Must be http or https".to_string(),
));
}
let fragment = url.fragment();
let (query_version_id, query_version_time, query_version_number) =
Self::parse_query(url.query())?;
let Some(domain) = url.domain() else {
if let Some(host) = url.host_str() {
return Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: IP addresses are not allowed, use a domain name instead: {host}",
)));
}
return Err(DIDWebVHError::InvalidMethodIdentifier(
"Invalid URL: Must contain domain".to_string(),
));
};
let port = url.port();
Self::reject_ip_address(domain)?;
let (type_, path, file_name) = if url.path() == "/" {
(
URLType::DIDDoc,
"/.well-known/".to_string(),
Some("did.jsonl".to_string()),
)
} else if url.path().ends_with("/whois") {
(URLType::WhoIs, "/whois.vp".to_string(), None)
} else if url.path().ends_with("/did.jsonl") {
(
URLType::DIDDoc,
url.path()
.to_string()
.trim_end_matches("did.jsonl")
.to_string(),
Some("did.jsonl".to_string()),
)
} else {
(
URLType::DIDDoc,
if url.path().ends_with('/') {
url.path().to_string()
} else {
format!("{}/", url.path())
},
Some("did.jsonl".to_string()),
)
};
Ok(WebVHURL {
type_,
did_url: url.to_string(),
scid: "{SCID}".to_string(),
domain: domain.to_string(),
port,
path,
fragment: fragment.map(|s| s.to_string()),
query: url.query().map(|s| s.to_string()),
file_name,
query_version_id,
query_version_time,
query_version_number,
})
}
fn reject_ip_address(domain: &str) -> Result<(), DIDWebVHError> {
let bare = domain
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(domain);
if bare.parse::<IpAddr>().is_ok() {
return Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: IP addresses are not allowed, use a domain name instead: {domain}",
)));
}
Ok(())
}
fn parse_query(
query: Option<&str>,
) -> Result<QueryPairs, DIDWebVHError> {
if let Some(query) = query {
let mut version_id = None;
let mut version_time = None;
let mut version_number = None;
for parameter in query.split('&') {
if let Some((key, value)) = parameter.split_once('=') {
if key == "versionId" {
version_id = Some(value.to_string());
} else if key == "versionTime" {
version_time = Some(DateTime::parse_from_rfc3339(value)
.map_err(|e| {
DIDWebVHError::DIDError(format!("DID Query parameter (versionTime) is invalid. Must be RFC 3339 compliant: {e}"
))
})?);
} else if key == "versionNumber" {
version_number = Some(str::parse(value).map_err(|e| {
DIDWebVHError::DIDError(format!("DID Query parameter (versionNumber) is invalid. Must be a positive integer: {e}"
))
})?);
}
} else {
return Err(DIDWebVHError::DIDError(format!(
"DID Query parameter ({parameter}) is invalid. Must be in the format key=value."
)));
}
}
let count = [
version_id.is_some(),
version_time.is_some(),
version_number.is_some(),
]
.iter()
.filter(|&&v| v)
.count();
if count > 1 {
return Err(DIDWebVHError::DIDError(
"Only one of versionId, versionTime, or versionNumber may be specified"
.to_string(),
));
}
Ok((version_id, version_time, version_number))
} else {
Ok((None, None, None))
}
}
fn get_http_base_url(&self) -> String {
let mut url_string = String::new();
if self.domain == "localhost" {
url_string.push_str("http://");
} else {
url_string.push_str("https://");
}
url_string.push_str(&self.domain);
if let Some(port) = self.port {
url_string.push_str(&format!(":{port}",));
}
url_string
}
pub fn get_http_url(&self, file_name: Option<&str>) -> Result<Url, DIDWebVHError> {
let mut url_string = self.get_http_base_url();
url_string.push_str(&self.path);
if let Some(file_name) = file_name {
url_string.push_str(file_name);
} else if let Some(file_name) = &self.file_name {
url_string.push_str(file_name);
}
if let Some(query) = &self.query {
url_string.push_str(&format!("?{query}",));
}
if let Some(fragment) = &self.fragment {
url_string.push_str(&format!("#{fragment}",));
}
match Url::parse(&url_string) {
Ok(url) => Ok(url),
Err(err) => Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: {err}",
))),
}
}
pub fn get_http_whois_url(&self) -> Result<Url, DIDWebVHError> {
let mut url_string = self.get_http_base_url();
if self.path == "/.well-known/" {
url_string.push_str("/whois.vp");
} else {
url_string.push_str(&self.path);
url_string.push_str("whois.vp");
}
match Url::parse(&url_string) {
Ok(url) => Ok(url),
Err(err) => Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: {err}",
))),
}
}
pub fn get_http_files_url(&self) -> Result<Url, DIDWebVHError> {
let mut url_string = self.get_http_base_url();
if self.path == "/.well-known/" {
url_string.push('/');
} else {
url_string.push_str(&self.path);
}
match Url::parse(&url_string) {
Ok(url) => Ok(url),
Err(err) => Err(DIDWebVHError::InvalidMethodIdentifier(format!(
"Invalid URL: {err}",
))),
}
}
}
impl WebVHURL {
pub fn to_did_base(&self) -> String {
let mut url_string = String::from("did:webvh:");
url_string.push_str(&self.scid);
url_string.push(':');
url_string.push_str(&self.domain);
if let Some(port) = self.port {
url_string.push_str(&format!("%3A{port}"));
}
if self.path != "/.well-known/" {
url_string.push_str(&self.path.trim_end_matches("/").replace('/', ":"));
}
url_string
}
}
impl Display for WebVHURL {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut url_string = String::new();
url_string.push_str("did:webvh:");
url_string.push_str(&self.scid);
url_string.push(':');
url_string.push_str(&self.domain);
if let Some(port) = self.port {
url_string.push_str(&format!("%3A{port}",));
}
if self.path != "/.well-known/" {
url_string.push_str(&self.path.trim_end_matches("/").replace('/', ":"));
}
if let Some(query) = &self.query {
url_string.push('?');
url_string.push_str(query);
}
if let Some(fragment) = &self.fragment {
url_string.push('#');
url_string.push_str(fragment);
}
write!(f, "{url_string}",)
}
}
#[cfg(test)]
mod tests {
use url::Url;
use crate::{
DIDWebVHError,
url::{URLType, WebVHURL},
};
#[test]
fn wrong_method() {
assert!(WebVHURL::parse_did_url("did:wrong:method").is_err())
}
#[test]
fn url_with_fragment() {
let parsed = match WebVHURL::parse_did_url("did:webvh:scid:example.com#key-fragment") {
Ok(parsed) => parsed,
Err(_) => panic!("Failed to parse URL"),
};
assert_eq!(parsed.fragment, Some("key-fragment".to_string()));
}
#[test]
fn url_with_query() {
let parsed = match WebVHURL::parse_did_url("did:webvh:scid:example.com?versionId=1-xyz") {
Ok(parsed) => parsed,
Err(_) => panic!("Failed to parse URL"),
};
assert_eq!(parsed.query, Some("versionId=1-xyz".to_string()));
}
#[test]
fn url_with_query_version_number() {
let parsed = match WebVHURL::parse_did_url("did:webvh:scid:example.com?versionNumber=13") {
Ok(parsed) => parsed,
Err(_) => panic!("Failed to parse URL"),
};
assert_eq!(parsed.query_version_number, Some(13));
}
#[test]
fn missing_parts() {
assert!(WebVHURL::parse_did_url("did:webvh:domain").is_err());
assert!(WebVHURL::parse_did_url("did:webvh:domain#test").is_err());
}
#[test]
fn url_with_port() {
assert!(WebVHURL::parse_did_url("did:webvh:scid:domain%3A8000").is_ok());
}
#[test]
fn url_with_bad_port() {
assert!(WebVHURL::parse_did_url("did:webvh:scid:domain%3A8bad").is_err());
assert!(WebVHURL::parse_did_url("did:webvh:scid:domain%3A999999").is_err());
}
#[test]
fn url_with_whois() -> Result<(), DIDWebVHError> {
let result = WebVHURL::parse_did_url("did:webvh:scid:domain%3A8000:whois")?;
assert_eq!(result.type_, URLType::WhoIs);
assert_eq!(result.path, "/");
assert_eq!(result.file_name, Some("whois.vp".to_string()));
Ok(())
}
#[test]
fn url_with_whois_path() -> Result<(), DIDWebVHError> {
let result = WebVHURL::parse_did_url("did:webvh:scid:domain%3A8000:custom:path:whois")?;
assert_eq!(result.type_, URLType::WhoIs);
assert_eq!(result.path, "/custom/path/");
assert_eq!(result.file_name, Some("whois.vp".to_string()));
assert_eq!(
result.get_http_whois_url()?.to_string().as_str(),
"https://domain:8000/custom/path/whois.vp"
);
Ok(())
}
#[test]
fn url_with_default_path() -> Result<(), DIDWebVHError> {
let result = WebVHURL::parse_did_url("did:webvh:scid:domain%3A8000")?;
assert_eq!(result.type_, URLType::DIDDoc);
assert_eq!(result.path, "/.well-known/");
assert_eq!(result.file_name, Some("did.jsonl".to_string()));
Ok(())
}
#[test]
fn url_with_custom_path() -> Result<(), DIDWebVHError> {
let result = WebVHURL::parse_did_url("did:webvh:scid:domain%3A8000:custom:path")?;
assert_eq!(result.type_, URLType::DIDDoc);
assert_eq!(result.path, "/custom/path/");
assert_eq!(result.file_name, Some("did.jsonl".to_string()));
Ok(())
}
#[test]
fn to_url_from_basic() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url("did:webvh:scid:example.com")?;
assert_eq!(
webvh.get_http_url(None)?.to_string().as_str(),
"https://example.com/.well-known/did.jsonl"
);
Ok(())
}
#[test]
fn to_url_from_basic_whois() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url("did:webvh:scid:example.com:whois")?;
assert_eq!(
webvh.get_http_url(None)?.to_string().as_str(),
"https://example.com/whois.vp"
);
assert_eq!(
webvh.get_http_whois_url()?.to_string().as_str(),
"https://example.com/whois.vp"
);
Ok(())
}
#[test]
fn to_url_from_complex() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url(
"did:webvh:scid:example.com%3A8080:custom:path?versionId=1-xyz#fragment",
)?;
assert_eq!(
webvh.get_http_url(None)?.to_string().as_str(),
"https://example.com:8080/custom/path/did.jsonl?versionId=1-xyz#fragment"
);
Ok(())
}
#[test]
fn to_did_from_url() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("http://localhost:8000/").unwrap())?;
assert_eq!(webvh.to_string(), "did:webvh:{SCID}:localhost%3A8000");
Ok(())
}
#[test]
fn to_did_from_url_trailing_slash() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost:8000/test/").unwrap())?;
assert_eq!(webvh.to_string(), "did:webvh:{SCID}:localhost%3A8000:test");
assert_eq!(
webvh.get_http_url(None)?.to_string().as_str(),
"http://localhost:8000/test/did.jsonl"
);
Ok(())
}
#[test]
fn to_http_whois_url_default() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/").unwrap())?;
let result = webvh.get_http_whois_url()?;
assert_eq!(result.as_str(), "http://localhost/whois.vp");
Ok(())
}
#[test]
fn to_http_whois_url_path() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/test/path").unwrap())?;
let result = webvh.get_http_whois_url()?;
assert_eq!(result.as_str(), "http://localhost/test/path/whois.vp");
Ok(())
}
#[test]
fn to_http_whois_url_path_trailing_slash() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/test/path/").unwrap())?;
let result = webvh.get_http_whois_url()?;
assert_eq!(result.as_str(), "http://localhost/test/path/whois.vp");
Ok(())
}
#[test]
fn to_http_whois_files_default() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/").unwrap())?;
let result = webvh.get_http_files_url()?;
assert_eq!(result.as_str(), "http://localhost/");
Ok(())
}
#[test]
fn to_http_whois_files_path() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/test/path").unwrap())?;
let result = webvh.get_http_files_url()?;
assert_eq!(result.as_str(), "http://localhost/test/path/");
Ok(())
}
#[test]
fn to_http_whois_files_path_trailing_slash() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_url(&Url::parse("https://localhost/test/path/").unwrap())?;
let result = webvh.get_http_files_url()?;
assert_eq!(result.as_str(), "http://localhost/test/path/");
Ok(())
}
#[test]
fn to_did_base_simple() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url("did:webvh:scid:example.com")?;
assert_eq!(webvh.to_did_base(), "did:webvh:scid:example.com");
Ok(())
}
#[test]
fn to_did_base_with_port() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url("did:webvh:scid:example.com%3A8080")?;
assert_eq!(webvh.to_did_base(), "did:webvh:scid:example.com%3A8080");
Ok(())
}
#[test]
fn to_did_base_with_path() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url("did:webvh:scid:example.com:custom:path")?;
assert_eq!(
webvh.to_did_base(),
"did:webvh:scid:example.com:custom:path"
);
Ok(())
}
#[test]
fn parse_query_version_id_and_version_time_rejects() {
let result = WebVHURL::parse_did_url(
"did:webvh:scid:example.com?versionId=1-xyz&versionTime=2024-01-01T00:00:00Z",
);
let err = result.err().expect("expected error");
assert!(
err.to_string()
.contains("Only one of versionId, versionTime, or versionNumber")
);
}
#[test]
fn parse_query_version_id_and_version_number_rejects() {
let result =
WebVHURL::parse_did_url("did:webvh:scid:example.com?versionId=1-xyz&versionNumber=5");
let err = result.err().expect("expected error");
assert!(
err.to_string()
.contains("Only one of versionId, versionTime, or versionNumber")
);
}
#[test]
fn parse_query_version_time_and_version_number_rejects() {
let result = WebVHURL::parse_did_url(
"did:webvh:scid:example.com?versionTime=2024-01-01T00:00:00Z&versionNumber=5",
);
let err = result.err().expect("expected error");
assert!(
err.to_string()
.contains("Only one of versionId, versionTime, or versionNumber")
);
}
#[test]
fn parse_query_all_three_rejects() {
let result = WebVHURL::parse_did_url(
"did:webvh:scid:example.com?versionId=1-xyz&versionTime=2024-01-01T00:00:00Z&versionNumber=5",
);
let err = result.err().expect("expected error");
assert!(
err.to_string()
.contains("Only one of versionId, versionTime, or versionNumber")
);
}
#[test]
fn to_did_base_strips_query_and_fragment() -> Result<(), DIDWebVHError> {
let webvh = WebVHURL::parse_did_url(
"did:webvh:scid:example.com%3A8080:custom:path?versionId=1-xyz#fragment",
)?;
assert_eq!(
webvh.to_did_base(),
"did:webvh:scid:example.com%3A8080:custom:path"
);
Ok(())
}
#[test]
fn parse_did_url_rejects_ipv4() {
let result = WebVHURL::parse_did_url("did:webvh:scid:192.168.1.1");
let err = result.err().expect("expected error for IPv4 address");
assert!(err.to_string().contains("IP addresses are not allowed"));
}
#[test]
fn parse_did_url_rejects_ipv4_loopback() {
let result = WebVHURL::parse_did_url("did:webvh:scid:127.0.0.1");
let err = result.err().expect("expected error for IPv4 loopback");
assert!(err.to_string().contains("IP addresses are not allowed"));
}
#[test]
fn parse_did_url_rejects_ipv4_with_port() {
let result = WebVHURL::parse_did_url("did:webvh:scid:192.168.1.1%3A8080");
let err = result.err().expect("expected error for IPv4 with port");
assert!(err.to_string().contains("IP addresses are not allowed"));
}
#[test]
fn parse_did_url_allows_localhost() {
assert!(WebVHURL::parse_did_url("did:webvh:scid:localhost").is_ok());
}
#[test]
fn parse_did_url_allows_fqdn() {
assert!(WebVHURL::parse_did_url("did:webvh:scid:example.com").is_ok());
}
#[test]
fn parse_url_rejects_ipv4() {
let url = Url::parse("https://192.168.1.1/").unwrap();
let result = WebVHURL::parse_url(&url);
let err = result.err().expect("expected error for IPv4 address");
assert!(err.to_string().contains("IP addresses are not allowed"));
}
#[test]
fn parse_url_rejects_ipv6() {
let url = Url::parse("https://[::1]/").unwrap();
let result = WebVHURL::parse_url(&url);
let err = result.err().expect("expected error for IPv6 address");
assert!(err.to_string().contains("IP addresses are not allowed"));
}
#[test]
fn parse_url_allows_localhost() -> Result<(), DIDWebVHError> {
let url = Url::parse("http://localhost:8000/").unwrap();
let result = WebVHURL::parse_url(&url)?;
assert_eq!(result.domain, "localhost");
Ok(())
}
#[test]
fn parse_url_allows_fqdn() -> Result<(), DIDWebVHError> {
let url = Url::parse("https://example.com/").unwrap();
let result = WebVHURL::parse_url(&url)?;
assert_eq!(result.domain, "example.com");
Ok(())
}
}