forked from
smokesignal.events/atproto-plc
Rust and WASM did-method-plc tools and structures
1//! DID (Decentralized Identifier) types and validation for did:plc
2
3use crate::encoding::is_valid_base32;
4use crate::error::{PlcError, Result};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::str::FromStr;
8
9/// The prefix for all did:plc identifiers
10pub const DID_PLC_PREFIX: &str = "did:plc:";
11
12/// The length of the identifier portion (24 characters)
13pub const IDENTIFIER_LENGTH: usize = 24;
14
15/// The total length of a valid did:plc string (32 characters)
16pub const TOTAL_LENGTH: usize = 32; // "did:plc:" (8) + identifier (24)
17
18/// Represents a validated did:plc identifier.
19///
20/// A did:plc consists of the prefix "did:plc:" followed by exactly 24
21/// lowercase base32 characters (using alphabet abcdefghijklmnopqrstuvwxyz234567).
22///
23/// # Examples
24///
25/// ```
26/// use atproto_plc::Did;
27///
28/// let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")?;
29/// assert_eq!(did.identifier(), "ewvi7nxzyoun6zhxrhs64oiz");
30/// # Ok::<(), atproto_plc::PlcError>(())
31/// ```
32///
33/// # Format
34///
35/// The identifier is derived from the SHA-256 hash of the genesis operation,
36/// base32-encoded and truncated to 24 characters.
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct Did {
39 /// The full did:plc:xyz... string
40 full: String,
41 /// The 24-character identifier portion
42 identifier: String,
43}
44
45impl Did {
46 /// Parse and validate a DID string
47 ///
48 /// # Errors
49 ///
50 /// Returns `PlcError::InvalidDidFormat` if:
51 /// - The string doesn't start with "did:plc:"
52 /// - The total length isn't exactly 32 characters
53 /// - The identifier portion contains invalid base32 characters
54 ///
55 /// # Examples
56 ///
57 /// ```
58 /// use atproto_plc::Did;
59 ///
60 /// let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")?;
61 /// assert!(did.is_valid());
62 /// # Ok::<(), atproto_plc::PlcError>(())
63 /// ```
64 pub fn parse(s: &str) -> Result<Self> {
65 Self::validate_format(s)?;
66
67 let identifier = s[DID_PLC_PREFIX.len()..].to_string();
68
69 Ok(Self {
70 full: s.to_string(),
71 identifier,
72 })
73 }
74
75 /// Create a DID from a validated identifier (without the "did:plc:" prefix)
76 ///
77 /// # Errors
78 ///
79 /// Returns `PlcError::InvalidDidFormat` if the identifier is not exactly 24 characters
80 /// or contains invalid base32 characters
81 pub fn from_identifier(identifier: &str) -> Result<Self> {
82 if identifier.len() != IDENTIFIER_LENGTH {
83 return Err(PlcError::InvalidDidFormat(format!(
84 "Identifier must be exactly {} characters, got {}",
85 IDENTIFIER_LENGTH,
86 identifier.len()
87 )));
88 }
89
90 if !is_valid_base32(identifier) {
91 return Err(PlcError::InvalidDidFormat(
92 "Identifier contains invalid base32 characters".to_string(),
93 ));
94 }
95
96 Ok(Self {
97 full: format!("{}{}", DID_PLC_PREFIX, identifier),
98 identifier: identifier.to_string(),
99 })
100 }
101
102 /// Get the 24-character identifier portion (without "did:plc:" prefix)
103 ///
104 /// # Examples
105 ///
106 /// ```
107 /// use atproto_plc::Did;
108 ///
109 /// let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")?;
110 /// assert_eq!(did.identifier(), "ewvi7nxzyoun6zhxrhs64oiz");
111 /// # Ok::<(), atproto_plc::PlcError>(())
112 /// ```
113 pub fn identifier(&self) -> &str {
114 &self.identifier
115 }
116
117 /// Get the full DID string including "did:plc:" prefix
118 ///
119 /// # Examples
120 ///
121 /// ```
122 /// use atproto_plc::Did;
123 ///
124 /// let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")?;
125 /// assert_eq!(did.as_str(), "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
126 /// # Ok::<(), atproto_plc::PlcError>(())
127 /// ```
128 pub fn as_str(&self) -> &str {
129 &self.full
130 }
131
132 /// Check if this DID is valid
133 ///
134 /// Since DIDs can only be constructed through validation,
135 /// this always returns `true`
136 pub fn is_valid(&self) -> bool {
137 true
138 }
139
140 /// Validate the format of a DID string without constructing a Did instance
141 ///
142 /// # Errors
143 ///
144 /// Returns `PlcError::InvalidDidFormat` if validation fails
145 fn validate_format(s: &str) -> Result<()> {
146 // Check prefix
147 if !s.starts_with(DID_PLC_PREFIX) {
148 return Err(PlcError::InvalidDidFormat(format!(
149 "DID must start with '{}', got '{}'",
150 DID_PLC_PREFIX,
151 s.chars().take(8).collect::<String>()
152 )));
153 }
154
155 // Check exact length
156 if s.len() != TOTAL_LENGTH {
157 return Err(PlcError::InvalidDidFormat(format!(
158 "DID must be exactly {} characters, got {}",
159 TOTAL_LENGTH,
160 s.len()
161 )));
162 }
163
164 // Extract and validate identifier
165 let identifier = &s[DID_PLC_PREFIX.len()..];
166
167 if !is_valid_base32(identifier) {
168 return Err(PlcError::InvalidDidFormat(format!(
169 "Identifier contains invalid base32 characters. Valid alphabet: abcdefghijklmnopqrstuvwxyz234567"
170 )));
171 }
172
173 Ok(())
174 }
175}
176
177impl fmt::Display for Did {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 write!(f, "{}", self.full)
180 }
181}
182
183impl FromStr for Did {
184 type Err = PlcError;
185
186 fn from_str(s: &str) -> Result<Self> {
187 Self::parse(s)
188 }
189}
190
191impl Serialize for Did {
192 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
193 where
194 S: serde::Serializer,
195 {
196 serializer.serialize_str(&self.full)
197 }
198}
199
200impl<'de> Deserialize<'de> for Did {
201 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
202 where
203 D: serde::Deserializer<'de>,
204 {
205 let s = String::deserialize(deserializer)?;
206 Self::parse(&s).map_err(serde::de::Error::custom)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_valid_did() {
216 let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz").unwrap();
217 assert_eq!(did.identifier(), "ewvi7nxzyoun6zhxrhs64oiz");
218 assert_eq!(did.as_str(), "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
219 assert!(did.is_valid());
220 }
221
222 #[test]
223 fn test_invalid_prefix() {
224 assert!(Did::parse("did:web:example.com").is_err());
225 assert!(Did::parse("DID:PLC:ewvi7nxzyoun6zhxrhs64oiz").is_err());
226 }
227
228 #[test]
229 fn test_invalid_length() {
230 assert!(Did::parse("did:plc:tooshort").is_err());
231 assert!(Did::parse("did:plc:wayyyyyyyyyyyyyyyyyyyyyyytooooooolong").is_err());
232 }
233
234 #[test]
235 fn test_invalid_characters() {
236 // Contains 0, 1, 8, 9 which are not in base32 alphabet
237 assert!(Did::parse("did:plc:012345678901234567890123").is_err());
238 // Contains uppercase
239 assert!(Did::parse("did:plc:EWVI7NXZYOUN6ZHXRHS64OIZ").is_err());
240 }
241
242 #[test]
243 fn test_from_identifier() {
244 let did = Did::from_identifier("ewvi7nxzyoun6zhxrhs64oiz").unwrap();
245 assert_eq!(did.as_str(), "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
246 }
247
248 #[test]
249 fn test_display() {
250 let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz").unwrap();
251 assert_eq!(format!("{}", did), "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
252 }
253
254 #[test]
255 fn test_serialization() {
256 let did = Did::parse("did:plc:ewvi7nxzyoun6zhxrhs64oiz").unwrap();
257 let json = serde_json::to_string(&did).unwrap();
258 assert_eq!(json, "\"did:plc:ewvi7nxzyoun6zhxrhs64oiz\"");
259
260 let deserialized: Did = serde_json::from_str(&json).unwrap();
261 assert_eq!(did, deserialized);
262 }
263}