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    #[must_use]
70    pub fn base_id(&self) -> String {
71        format!("{DID_PREFIX}{}", self.ipns)
72    }
73
74    pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
75        Self::new_url(self.ipns.clone(), Some(fragment))
76    }
77
78    #[must_use]
79    pub fn id(&self) -> String {
80        match &self.fragment {
81            Some(fragment) => format!("{}#{fragment}", self.base_id()),
82            None => self.base_id(),
83        }
84    }
85
86    pub fn parse(input: &str) -> Result<(String, Option<String>)> {
87        if input.is_empty() {
88            return Err(MaError::EmptyDid);
89        }
90
91        let stripped = input
92            .strip_prefix(DID_PREFIX)
93            .ok_or(MaError::InvalidDidPrefix)?;
94
95        let parts: Vec<_> = stripped.split('#').collect();
96        match parts.as_slice() {
97            [] | [""] => Err(MaError::MissingIdentifier),
98            [_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
99            [identifier] => {
100                validate_identifier(identifier)?;
101                Ok(((*identifier).to_string(), None))
102            }
103            [identifier, fragment] => {
104                validate_identifier(identifier)?;
105                validate_fragment(fragment)?;
106                Ok(((*identifier).to_string(), Some((*fragment).to_string())))
107            }
108            _ => Err(MaError::InvalidDidFormat),
109        }
110    }
111
112    pub fn validate(input: &str) -> Result<()> {
113        Self::parse(input).map(|_| ())
114    }
115
116    /// Validate that `input` is a DID URL (has a fragment).
117    pub fn validate_url(input: &str) -> Result<()> {
118        match Self::parse(input)? {
119            (_, Some(_)) => Ok(()),
120            (_, None) => Err(MaError::MissingFragment),
121        }
122    }
123
124    /// Validate that `input` is a bare DID identity (no fragment).
125    pub fn validate_identity(input: &str) -> Result<()> {
126        match Self::parse(input)? {
127            (_, None) => Ok(()),
128            (_, Some(_)) => Err(MaError::UnexpectedFragment),
129        }
130    }
131
132    /// True when this DID has a fragment (is a DID URL).
133    #[must_use]
134    pub fn is_url(&self) -> bool {
135        self.fragment.is_some()
136    }
137
138    /// True when this DID has no fragment (bare DID).
139    #[must_use]
140    pub fn is_bare(&self) -> bool {
141        self.fragment.is_none()
142    }
143}
144
145impl TryFrom<&str> for Did {
146    type Error = MaError;
147
148    /// Parse any valid DID URL.  A bare DID (no fragment) is a valid DID URL
149    /// per W3C DID Core ยง3.2 โ€” the fragment is optional.
150    fn try_from(value: &str) -> Result<Self> {
151        let (ipns, fragment) = Self::parse(value)?;
152        Ok(Self { ipns, fragment })
153    }
154}
155
156fn validate_identifier(input: &str) -> Result<()> {
157    if input.is_empty() {
158        return Err(MaError::MissingIdentifier);
159    }
160    // IPNS identifiers are CIDv1 encoded in base36lower or base58btc;
161    // reject anything containing non-alphanumeric characters.
162    if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
163        return Err(MaError::InvalidIdentifier);
164    }
165    Ok(())
166}
167
168/// Lenient fragment validation for parsing incoming data (Postel's law).
169/// Accepts any non-empty string of `[A-Za-z0-9_-]`.
170fn validate_fragment(input: &str) -> Result<()> {
171    if input.is_empty()
172        || !input
173            .bytes()
174            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
175    {
176        return Err(MaError::InvalidFragment(input.to_string()));
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    const BARE: &str = "did:ma:k51qzi5uqu5abc";
186    const URL: &str = "did:ma:k51qzi5uqu5abc#lobby";
187
188    #[test]
189    fn is_url_with_fragment() {
190        let did = Did::try_from(URL).unwrap();
191        assert!(did.is_url());
192        assert!(!did.is_bare());
193    }
194
195    #[test]
196    fn is_bare_without_fragment() {
197        let did = Did::try_from(BARE).unwrap();
198        assert!(did.is_bare());
199        assert!(!did.is_url());
200    }
201
202    #[test]
203    fn validate_url_accepts_fragment() {
204        assert!(Did::validate_url(URL).is_ok());
205    }
206
207    #[test]
208    fn validate_url_rejects_bare() {
209        assert!(Did::validate_url(BARE).is_err());
210    }
211
212    #[test]
213    fn validate_identity_accepts_bare() {
214        assert!(Did::validate_identity(BARE).is_ok());
215    }
216
217    #[test]
218    fn validate_identity_rejects_fragment() {
219        assert!(Did::validate_identity(URL).is_err());
220    }
221
222    #[test]
223    fn new_url_none_generates_nanoid() {
224        let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
225        assert!(url.is_url());
226        assert!(!url.fragment.unwrap().is_empty());
227    }
228
229    #[test]
230    fn new_url_accepts_nanoid_fragment() {
231        let url = Did::new_url("k51qzi5uqu5abc", Some("bahner")).unwrap();
232        assert_eq!(url.fragment.as_deref(), Some("bahner"));
233    }
234
235    #[test]
236    fn new_url_rejects_invalid_chars() {
237        assert!(Did::new_url("k51qzi5uqu5abc", Some("has space")).is_err());
238        assert!(Did::new_url("k51qzi5uqu5abc", Some("has.dot")).is_err());
239        assert!(Did::new_url("k51qzi5uqu5abc", Some("")).is_err());
240    }
241
242    #[test]
243    fn try_from_lenient_accepts_non_nanoid_fragment() {
244        // Postel's law: generous in what we receive
245        let did = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
246        assert_eq!(did.fragment.as_deref(), Some("lobby"));
247    }
248}