use nanoid::nanoid;
use crate::error::{MaError, Result};
pub const DID_PREFIX: &str = "did:ma:";
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Did {
pub ipns: String,
pub fragment: Option<String>,
}
impl Did {
pub fn new_identity(ipns: impl Into<String>) -> Result<Self> {
let ipns = ipns.into();
validate_identifier(&ipns)?;
Ok(Self {
ipns,
fragment: None,
})
}
pub fn new_url(ipns: impl Into<String>, fragment: Option<impl Into<String>>) -> Result<Self> {
let frag = match fragment {
Some(f) => f.into(),
None => nanoid!(),
};
let ipns = ipns.into();
validate_identifier(&ipns)?;
validate_fragment(&frag)?;
Ok(Self {
ipns,
fragment: Some(frag),
})
}
pub fn base_id(&self) -> String {
format!("{DID_PREFIX}{}", self.ipns)
}
pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
Self::new_url(self.ipns.clone(), Some(fragment))
}
pub fn id(&self) -> String {
match &self.fragment {
Some(fragment) => format!("{}#{fragment}", self.base_id()),
None => self.base_id(),
}
}
pub fn parse(input: &str) -> Result<(String, Option<String>)> {
if input.is_empty() {
return Err(MaError::EmptyDid);
}
let stripped = input
.strip_prefix(DID_PREFIX)
.ok_or(MaError::InvalidDidPrefix)?;
let parts: Vec<_> = stripped.split('#').collect();
match parts.as_slice() {
[] => Err(MaError::MissingIdentifier),
[_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
[""] => Err(MaError::MissingIdentifier),
[identifier] => {
validate_identifier(identifier)?;
Ok(((*identifier).to_string(), None))
}
[identifier, fragment] => {
validate_identifier(identifier)?;
validate_fragment(fragment)?;
Ok(((*identifier).to_string(), Some((*fragment).to_string())))
}
_ => Err(MaError::InvalidDidFormat),
}
}
pub fn validate(input: &str) -> Result<()> {
Self::parse(input).map(|_| ())
}
pub fn validate_url(input: &str) -> Result<()> {
match Self::parse(input)? {
(_, Some(_)) => Ok(()),
(_, None) => Err(MaError::MissingFragment),
}
}
pub fn validate_identity(input: &str) -> Result<()> {
match Self::parse(input)? {
(_, None) => Ok(()),
(_, Some(_)) => Err(MaError::UnexpectedFragment),
}
}
pub fn is_url(&self) -> bool {
self.fragment.is_some()
}
pub fn is_bare(&self) -> bool {
self.fragment.is_none()
}
}
impl TryFrom<&str> for Did {
type Error = MaError;
fn try_from(value: &str) -> Result<Self> {
let (ipns, fragment) = Self::parse(value)?;
Ok(Self { ipns, fragment })
}
}
fn validate_identifier(input: &str) -> Result<()> {
if input.is_empty() {
return Err(MaError::MissingIdentifier);
}
if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(MaError::InvalidIdentifier);
}
Ok(())
}
fn validate_fragment(input: &str) -> Result<()> {
if input.is_empty()
|| !input
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(MaError::InvalidFragment(input.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const BARE: &str = "did:ma:k51qzi5uqu5abc";
const URL: &str = "did:ma:k51qzi5uqu5abc#lobby";
#[test]
fn is_url_with_fragment() {
let did = Did::try_from(URL).unwrap();
assert!(did.is_url());
assert!(!did.is_bare());
}
#[test]
fn is_bare_without_fragment() {
let did = Did::try_from(BARE).unwrap();
assert!(did.is_bare());
assert!(!did.is_url());
}
#[test]
fn validate_url_accepts_fragment() {
assert!(Did::validate_url(URL).is_ok());
}
#[test]
fn validate_url_rejects_bare() {
assert!(Did::validate_url(BARE).is_err());
}
#[test]
fn validate_identity_accepts_bare() {
assert!(Did::validate_identity(BARE).is_ok());
}
#[test]
fn validate_identity_rejects_fragment() {
assert!(Did::validate_identity(URL).is_err());
}
#[test]
fn new_url_none_generates_nanoid() {
let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
assert!(url.is_url());
assert!(!url.fragment.unwrap().is_empty());
}
#[test]
fn new_url_accepts_nanoid_fragment() {
let url = Did::new_url("k51qzi5uqu5abc", Some("bahner")).unwrap();
assert_eq!(url.fragment.as_deref(), Some("bahner"));
}
#[test]
fn new_url_rejects_invalid_chars() {
assert!(Did::new_url("k51qzi5uqu5abc", Some("has space")).is_err());
assert!(Did::new_url("k51qzi5uqu5abc", Some("has.dot")).is_err());
assert!(Did::new_url("k51qzi5uqu5abc", Some("")).is_err());
}
#[test]
fn try_from_lenient_accepts_non_nanoid_fragment() {
let did = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
assert_eq!(did.fragment.as_deref(), Some("lobby"));
}
}