Skip to main content

ma_did/
did.rs

1use nanoid::nanoid;
2
3use crate::error::{MaError, Result};
4
5pub const DID_PREFIX: &str = "did:ma:";
6
7/// A parsed `did:ma:` identifier.
8///
9/// Without a fragment this is a bare DID: `did:ma:<ipns>`.
10/// With a fragment it becomes a DID URL: `did:ma:<ipns>#<fragment>`.
11///
12/// Constructors enforce strict fragment validation (strict in what we send).
13/// Parsing via `try_from` is lenient (generous in what we receive).
14///
15/// # Examples
16///
17/// ```
18/// use ma_did::Did;
19///
20/// // Bare DID (identity)
21/// let id = Did::new_identity("k51qzi5uqu5abc").unwrap();
22/// assert!(id.is_bare());
23/// assert_eq!(id.base_id(), "did:ma:k51qzi5uqu5abc");
24///
25/// // DID URL with auto-generated fragment
26/// let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
27/// assert!(url.is_url());
28///
29/// // Parse incoming DID URL (lenient)
30/// let parsed = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
31/// assert_eq!(parsed.fragment.as_deref(), Some("lobby"));
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub struct Did {
35    pub ipns: String,
36    /// Local atom/inbox name (for example an avatar inbox in a world).
37    /// In practice this often matches a Kubo key name, but this coupling is loose.
38    pub fragment: Option<String>,
39}
40
41impl Did {
42    /// Create a bare DID (`did:ma:<ipns>`) with no fragment.
43    pub fn new_identity(ipns: impl Into<String>) -> Result<Self> {
44        let ipns = ipns.into();
45        validate_identifier(&ipns)?;
46        Ok(Self {
47            ipns,
48            fragment: None,
49        })
50    }
51
52    /// Create a DID URL (`did:ma:<ipns>#<fragment>`).
53    /// If `fragment` is `None`, a nanoid is generated automatically.
54    /// Provided fragments are validated as nanoids (`[A-Za-z0-9_-]+`).
55    pub fn new_url(ipns: impl Into<String>, fragment: Option<impl Into<String>>) -> Result<Self> {
56        let frag = match fragment {
57            Some(f) => f.into(),
58            None => nanoid!(),
59        };
60        let ipns = ipns.into();
61        validate_identifier(&ipns)?;
62        validate_fragment(&frag)?;
63        Ok(Self {
64            ipns,
65            fragment: Some(frag),
66        })
67    }
68
69    pub fn base_id(&self) -> String {
70        format!("{DID_PREFIX}{}", self.ipns)
71    }
72
73    pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
74        Self::new_url(self.ipns.clone(), Some(fragment))
75    }
76
77    pub fn id(&self) -> String {
78        match &self.fragment {
79            Some(fragment) => format!("{}#{fragment}", self.base_id()),
80            None => self.base_id(),
81        }
82    }
83
84    pub fn parse(input: &str) -> Result<(String, Option<String>)> {
85        if input.is_empty() {
86            return Err(MaError::EmptyDid);
87        }
88
89        let stripped = input
90            .strip_prefix(DID_PREFIX)
91            .ok_or(MaError::InvalidDidPrefix)?;
92
93        let parts: Vec<_> = stripped.split('#').collect();
94        match parts.as_slice() {
95            [] => Err(MaError::MissingIdentifier),
96            [_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
97            [""] => Err(MaError::MissingIdentifier),
98            [identifier] => {
99                validate_identifier(identifier)?;
100                Ok(((*identifier).to_string(), None))
101            }
102            [identifier, fragment] => {
103                validate_identifier(identifier)?;
104                validate_fragment(fragment)?;
105                Ok(((*identifier).to_string(), Some((*fragment).to_string())))
106            }
107            _ => Err(MaError::InvalidDidFormat),
108        }
109    }
110
111    pub fn validate(input: &str) -> Result<()> {
112        Self::parse(input).map(|_| ())
113    }
114
115    /// Validate that `input` is a DID URL (has a fragment).
116    pub fn validate_url(input: &str) -> Result<()> {
117        match Self::parse(input)? {
118            (_, Some(_)) => Ok(()),
119            (_, None) => Err(MaError::MissingFragment),
120        }
121    }
122
123    /// Validate that `input` is a bare DID identity (no fragment).
124    pub fn validate_identity(input: &str) -> Result<()> {
125        match Self::parse(input)? {
126            (_, None) => Ok(()),
127            (_, Some(_)) => Err(MaError::UnexpectedFragment),
128        }
129    }
130
131    /// True when this DID has a fragment (is a DID URL).
132    pub fn is_url(&self) -> bool {
133        self.fragment.is_some()
134    }
135
136    /// True when this DID has no fragment (bare DID).
137    pub fn is_bare(&self) -> bool {
138        self.fragment.is_none()
139    }
140}
141
142impl TryFrom<&str> for Did {
143    type Error = MaError;
144
145    /// Parse any valid DID URL.  A bare DID (no fragment) is a valid DID URL
146    /// per W3C DID Core ยง3.2 โ€” the fragment is optional.
147    fn try_from(value: &str) -> Result<Self> {
148        let (ipns, fragment) = Self::parse(value)?;
149        Ok(Self { ipns, fragment })
150    }
151}
152
153fn validate_identifier(input: &str) -> Result<()> {
154    if input.is_empty() {
155        return Err(MaError::MissingIdentifier);
156    }
157    // IPNS identifiers are CIDv1 encoded in base36lower or base58btc;
158    // reject anything containing non-alphanumeric characters.
159    if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
160        return Err(MaError::InvalidIdentifier);
161    }
162    Ok(())
163}
164
165/// Lenient fragment validation for parsing incoming data (Postel's law).
166/// Accepts any non-empty string of `[A-Za-z0-9_-]`.
167fn validate_fragment(input: &str) -> Result<()> {
168    if input.is_empty()
169        || !input
170            .bytes()
171            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
172    {
173        return Err(MaError::InvalidFragment(input.to_string()));
174    }
175    Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    const BARE: &str = "did:ma:k51qzi5uqu5abc";
183    const URL: &str = "did:ma:k51qzi5uqu5abc#lobby";
184
185    #[test]
186    fn is_url_with_fragment() {
187        let did = Did::try_from(URL).unwrap();
188        assert!(did.is_url());
189        assert!(!did.is_bare());
190    }
191
192    #[test]
193    fn is_bare_without_fragment() {
194        let did = Did::try_from(BARE).unwrap();
195        assert!(did.is_bare());
196        assert!(!did.is_url());
197    }
198
199    #[test]
200    fn validate_url_accepts_fragment() {
201        assert!(Did::validate_url(URL).is_ok());
202    }
203
204    #[test]
205    fn validate_url_rejects_bare() {
206        assert!(Did::validate_url(BARE).is_err());
207    }
208
209    #[test]
210    fn validate_identity_accepts_bare() {
211        assert!(Did::validate_identity(BARE).is_ok());
212    }
213
214    #[test]
215    fn validate_identity_rejects_fragment() {
216        assert!(Did::validate_identity(URL).is_err());
217    }
218
219    #[test]
220    fn new_url_none_generates_nanoid() {
221        let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
222        assert!(url.is_url());
223        assert!(!url.fragment.unwrap().is_empty());
224    }
225
226    #[test]
227    fn new_url_accepts_nanoid_fragment() {
228        let url = Did::new_url("k51qzi5uqu5abc", Some("bahner")).unwrap();
229        assert_eq!(url.fragment.as_deref(), Some("bahner"));
230    }
231
232    #[test]
233    fn new_url_rejects_invalid_chars() {
234        assert!(Did::new_url("k51qzi5uqu5abc", Some("has space")).is_err());
235        assert!(Did::new_url("k51qzi5uqu5abc", Some("has.dot")).is_err());
236        assert!(Did::new_url("k51qzi5uqu5abc", Some("")).is_err());
237    }
238
239    #[test]
240    fn try_from_lenient_accepts_non_nanoid_fragment() {
241        // Postel's law: generous in what we receive
242        let did = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
243        assert_eq!(did.fragment.as_deref(), Some("lobby"));
244    }
245}