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 match Self::parse(input)? {
119 (_, Some(_)) => Ok(()),
120 (_, None) => Err(MaError::MissingFragment),
121 }
122 }
123
124 pub fn validate_identity(input: &str) -> Result<()> {
126 match Self::parse(input)? {
127 (_, None) => Ok(()),
128 (_, Some(_)) => Err(MaError::UnexpectedFragment),
129 }
130 }
131
132 #[must_use]
134 pub fn is_url(&self) -> bool {
135 self.fragment.is_some()
136 }
137
138 #[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 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
161 let cid = Cid::try_from(input).map_err(|_| MaError::InvalidIdentifier)?;
162 if cid.version() != Version::V1
163 || cid.codec() != LIBP2P_KEY_CODEC
164 || multibase::encode(multibase::Base::Base36Lower, cid.to_bytes()) != input
165 {
166 return Err(MaError::InvalidIdentifier);
167 }
168
169 let peer_id =
170 PeerId::from_multihash(cid.hash().to_owned()).map_err(|_| MaError::InvalidIdentifier)?;
171 let public_key = PublicKey::try_decode_protobuf(peer_id.as_ref().digest())
172 .map_err(|_| MaError::InvalidIdentifier)?;
173 if public_key.try_into_ed25519().is_err() {
174 return Err(MaError::InvalidIdentifier);
175 }
176 Ok(())
177}
178
179fn validate_fragment(input: &str) -> Result<()> {
180 if input.is_empty()
181 || !input
182 .bytes()
183 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
184 {
185 return Err(MaError::InvalidFragment(input.to_string()));
186 }
187 Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 const IDENTIFIER: &str = "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
195 const BARE: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
196 const URL: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr#lobby";
197
198 #[test]
199 fn is_url_with_fragment() {
200 let did = Did::try_from(URL).unwrap();
201 assert!(did.is_url());
202 assert!(!did.is_bare());
203 }
204
205 #[test]
206 fn is_bare_without_fragment() {
207 let did = Did::try_from(BARE).unwrap();
208 assert!(did.is_bare());
209 assert!(!did.is_url());
210 }
211
212 #[test]
213 fn validate_url_accepts_fragment() {
214 assert!(Did::validate_url(URL).is_ok());
215 }
216
217 #[test]
218 fn validate_url_rejects_bare() {
219 assert!(Did::validate_url(BARE).is_err());
220 }
221
222 #[test]
223 fn validate_identity_accepts_bare() {
224 assert!(Did::validate_identity(BARE).is_ok());
225 }
226
227 #[test]
228 fn validate_identity_rejects_fragment() {
229 assert!(Did::validate_identity(URL).is_err());
230 }
231
232 #[test]
233 fn new_url_none_generates_nanoid() {
234 let url = Did::new_url(IDENTIFIER, None::<String>).unwrap();
235 assert!(url.is_url());
236 assert!(!url.fragment.unwrap().is_empty());
237 }
238
239 #[test]
240 fn new_url_accepts_nanoid_fragment() {
241 let url = Did::new_url(IDENTIFIER, Some("bahner")).unwrap();
242 assert_eq!(url.fragment.as_deref(), Some("bahner"));
243 }
244
245 #[test]
246 fn new_url_rejects_invalid_chars() {
247 assert!(Did::new_url(IDENTIFIER, Some("has space")).is_err());
248 assert!(Did::new_url(IDENTIFIER, Some("has.dot")).is_err());
249 assert!(Did::new_url(IDENTIFIER, Some("")).is_err());
250 }
251
252 #[test]
253 fn try_from_accepts_valid_fragment() {
254 let did = Did::try_from(URL).unwrap();
255 assert_eq!(did.fragment.as_deref(), Some("lobby"));
256 }
257
258 #[test]
259 fn rejects_non_ipns_identifier() {
260 assert!(Did::validate("did:ma:k51qzi5uqu5abc").is_err());
261 }
262
263 #[test]
264 fn rejects_non_canonical_ipns_base() {
265 let cid = Cid::try_from(IDENTIFIER).expect("valid CID");
266 let base32 = multibase::encode(multibase::Base::Base32Lower, cid.to_bytes());
267 assert!(Did::validate(&format!("did:ma:{base32}")).is_err());
268 }
269}