1use cid::{Cid, Version};
2use libp2p_identity::{PeerId, PublicKey};
3use nanoid::nanoid;
4
5use crate::error::{MaError, MaResult as Result};
6
7pub const DID_PREFIX: &str = "did:ma:";
8const LIBP2P_KEY_CODEC: u64 = 0x72;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub struct Did {
35 pub ipns: String,
36 pub fragment: Option<String>,
39}
40
41impl Did {
42 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 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 pub fn validate_url(input: &str) -> Result<()> {
118 Self::validate(input)
119 }
120
121 pub fn validate_resource(input: &str) -> Result<()> {
123 match Self::parse(input)? {
124 (_, Some(_)) => Ok(()),
125 (_, None) => Err(MaError::MissingFragment),
126 }
127 }
128
129 pub fn validate_identity(input: &str) -> Result<()> {
131 match Self::parse(input)? {
132 (_, None) => Ok(()),
133 (_, Some(_)) => Err(MaError::UnexpectedFragment),
134 }
135 }
136
137 #[must_use]
139 pub fn is_url(&self) -> bool {
140 self.fragment.is_some()
141 }
142
143 #[must_use]
145 pub fn is_bare(&self) -> bool {
146 self.fragment.is_none()
147 }
148}
149
150impl TryFrom<&str> for Did {
151 type Error = MaError;
152
153 fn try_from(value: &str) -> Result<Self> {
156 let (ipns, fragment) = Self::parse(value)?;
157 Ok(Self { ipns, fragment })
158 }
159}
160
161fn validate_identifier(input: &str) -> Result<()> {
162 if input.is_empty() {
163 return Err(MaError::MissingIdentifier);
164 }
165
166 let cid = Cid::try_from(input).map_err(|_| MaError::InvalidIdentifier)?;
167 if cid.version() != Version::V1
168 || cid.codec() != LIBP2P_KEY_CODEC
169 || multibase::encode(multibase::Base::Base36Lower, cid.to_bytes()) != input
170 {
171 return Err(MaError::InvalidIdentifier);
172 }
173
174 let peer_id =
175 PeerId::from_multihash(cid.hash().to_owned()).map_err(|_| MaError::InvalidIdentifier)?;
176 let public_key = PublicKey::try_decode_protobuf(peer_id.as_ref().digest())
177 .map_err(|_| MaError::InvalidIdentifier)?;
178 if public_key.try_into_ed25519().is_err() {
179 return Err(MaError::InvalidIdentifier);
180 }
181 Ok(())
182}
183
184fn validate_fragment(input: &str) -> Result<()> {
185 if input.is_empty()
186 || !input
187 .bytes()
188 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
189 {
190 return Err(MaError::InvalidFragment(input.to_string()));
191 }
192 Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 const IDENTIFIER: &str = "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
200 const BARE: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
201 const URL: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr#lobby";
202
203 #[test]
204 fn is_url_with_fragment() {
205 let did = Did::try_from(URL).unwrap();
206 assert!(did.is_url());
207 assert!(!did.is_bare());
208 }
209
210 #[test]
211 fn is_bare_without_fragment() {
212 let did = Did::try_from(BARE).unwrap();
213 assert!(did.is_bare());
214 assert!(!did.is_url());
215 }
216
217 #[test]
218 fn validate_url_accepts_fragment() {
219 assert!(Did::validate_url(URL).is_ok());
220 }
221
222 #[test]
223 fn validate_url_accepts_bare() {
224 assert!(Did::validate_url(BARE).is_ok());
225 }
226
227 #[test]
228 fn validate_resource_requires_fragment() {
229 assert!(Did::validate_resource(URL).is_ok());
230 assert!(Did::validate_resource(BARE).is_err());
231 }
232
233 #[test]
234 fn validate_identity_accepts_bare() {
235 assert!(Did::validate_identity(BARE).is_ok());
236 }
237
238 #[test]
239 fn validate_identity_rejects_fragment() {
240 assert!(Did::validate_identity(URL).is_err());
241 }
242
243 #[test]
244 fn new_url_none_generates_nanoid() {
245 let url = Did::new_url(IDENTIFIER, None::<String>).unwrap();
246 assert!(url.is_url());
247 assert!(!url.fragment.unwrap().is_empty());
248 }
249
250 #[test]
251 fn new_url_accepts_nanoid_fragment() {
252 let url = Did::new_url(IDENTIFIER, Some("bahner")).unwrap();
253 assert_eq!(url.fragment.as_deref(), Some("bahner"));
254 }
255
256 #[test]
257 fn new_url_rejects_invalid_chars() {
258 assert!(Did::new_url(IDENTIFIER, Some("has space")).is_err());
259 assert!(Did::new_url(IDENTIFIER, Some("has.dot")).is_err());
260 assert!(Did::new_url(IDENTIFIER, Some("")).is_err());
261 }
262
263 #[test]
264 fn try_from_accepts_valid_fragment() {
265 let did = Did::try_from(URL).unwrap();
266 assert_eq!(did.fragment.as_deref(), Some("lobby"));
267 }
268
269 #[test]
270 fn rejects_non_ipns_identifier() {
271 assert!(Did::validate("did:ma:k51qzi5uqu5abc").is_err());
272 }
273
274 #[test]
275 fn rejects_non_canonical_ipns_base() {
276 let cid = Cid::try_from(IDENTIFIER).expect("valid CID");
277 let base32 = multibase::encode(multibase::Base::Base32Lower, cid.to_bytes());
278 assert!(Did::validate(&format!("did:ma:{base32}")).is_err());
279 }
280}