Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm
1/// see https://atproto.com/specs/did#at-protocol-did-identifier-syntax
2/// this parser is intentinonally lax: it should accept all valid DIDs, and
3/// may accept some invalid DIDs.
4///
5/// at the moment this implementation might also be quite bad and incomplete
6pub fn parse_did(s: &str) -> Option<String> {
7 // for now, just working through the rules laid out in the docs in order,
8 // without much regard for efficiency for now.
9
10 // newer specs say max 2048 chars
11 if s.len() > 2048 {
12 return None;
13 }
14
15 // The entire URI is made up of a subset of ASCII, containing letters (A-Z, a-z),
16 // digits (0-9), period, underscore, colon, percent sign, or hyphen (._:%-)
17 if !s
18 .chars()
19 .all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | ':' | '%' | '-'))
20 {
21 return None;
22 }
23
24 // The URI is case-sensitive
25 // -> (nothing to check)
26
27 // The URI starts with lowercase `did:`
28 let unprefixed = s.strip_prefix("did:")?;
29
30 // The method segment is one or more lowercase letters (a-z), followed by :
31 let (method, identifier) = unprefixed.split_once(':')?;
32 if !method.chars().all(|c| c.is_ascii_lowercase()) {
33 return None;
34 }
35
36 // The remainder of the URI (the identifier) may contain any of the above-allowed
37 // ASCII characters, except for percent-sign (%)
38 // -> ok, ugh, gotta know our encoding context for this
39
40 // The URI (and thus the remaining identifier) may not end in ':'.
41 if identifier.ends_with(':') {
42 return None;
43 }
44
45 // Percent-sign (%) is used for "percent encoding" in the identifier section, and
46 // must always be followed by two hex characters
47 // -> again incoding context (bleh)
48
49 // Query (?) and fragment (#) sections are allowed in DID URIs, but not in DID
50 // identifiers. In the context of atproto, the query and fragment parts are not
51 // allowed.
52 // -> disallow here -- the uri decoder should already split them out first.
53
54 // DID identifiers do not generally have a maximum length restriction, but in the
55 // context of atproto, there is an initial hard limit of 2 KB.
56 // -> we're in atproto, so sure, let's enforce it. (would be sensible to do this
57 // -> first but we're following doc order)
58 if s.len() > (2 * 2_usize.pow(10)) {
59 return None;
60 }
61
62 // -> it's not actually written in the spec, but by example in the spec, the
63 // -> identifier cannot be empty
64 if identifier.is_empty() {
65 return None;
66 }
67
68 Some(s.to_string())
69 // the only normalization we might want would be percent-decoding, but we
70 // probably leave that to the uri decoder
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn test_did_too_long() {
79 let long = concat!(
80 "did:long:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
81 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
82 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
83 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
84 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
85 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
86 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
87 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
88 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
89 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
90 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
91 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
92 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
93 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
94 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
95 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
96 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
97 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
98 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
99 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
100 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
101 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
102 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
103 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
104 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
105 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
106 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
107 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
108 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
109 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
110 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
111 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
112 );
113 assert_eq!(parse_did(long), None);
114 }
115
116 #[test]
117 fn test_did_parse() {
118 for (case, expected, detail) in vec![
119 ("", None, "empty str"),
120 (" ", None, "whitespace str"),
121 ("z", None, "not a did"),
122 ("did:plc", None, "no identifier separator colon"),
123 ("did:plc:", None, "missing identifier"),
124 (
125 "did:web:bad-example.com",
126 Some("did:web:bad-example.com"),
127 "web did",
128 ),
129 (
130 "did:plc:hdhoaan3xa3jiuq4fg4mefid",
131 Some("did:plc:hdhoaan3xa3jiuq4fg4mefid"),
132 "plc did",
133 ),
134 (
135 "DID:plc:hdhoaan3xa3jiuq4fg4mefid",
136 None,
137 "'did:' prefix must be lowercase",
138 ),
139 (
140 "did:ok:z",
141 Some("did:ok:z"),
142 "unknown did methods are allowed",
143 ),
144 ("did:BAD:z", None, "non-lowercase methods are not allowed"),
145 ("did:bad:z$z", None, "invalid chars are not allowed"),
146 (
147 "did:ok:z:z",
148 Some("did:ok:z:z"),
149 "colons are allowed in identifier",
150 ),
151 ("did:bad:z:", None, "colons not are allowed at the end"),
152 ("did:bad:z?q=y", None, "queries are not allowed in atproto"),
153 ("did:bad:z#a", None, "anchors are not allowed in atproto"),
154 ] {
155 assert_eq!(parse_did(case), expected.map(|s| s.to_string()), "{detail}");
156 }
157 }
158
159 #[test]
160 fn test_doc_exmples_atproto() {
161 // https://atproto.com/specs/did#at-protocol-did-identifier-syntax
162 for case in ["did:plc:z72i7hdynmk6r22z27h6tvur", "did:web:blueskyweb.xyz"] {
163 assert!(parse_did(case).is_some(), "should pass: {case}")
164 }
165 }
166
167 #[test]
168 fn test_doc_exmples_lexicon() {
169 // https://atproto.com/specs/did#at-protocol-did-identifier-syntax
170 for case in [
171 "did:method:val:two",
172 "did:m:v",
173 "did:method::::val",
174 "did:method:-:_:.",
175 "did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N",
176 ] {
177 assert!(parse_did(case).is_some(), "should pass: {case}")
178 }
179 }
180
181 #[test]
182 fn test_doc_exmples_invalid() {
183 // https://atproto.com/specs/did#at-protocol-did-identifier-syntax
184 for case in [
185 "did:METHOD:val",
186 "did:m123:val",
187 "DID:method:val",
188 "did:method:",
189 "did:method:val/two",
190 "did:method:val?two",
191 "did:method:val#two",
192 ] {
193 assert!(parse_did(case).is_none(), "should fail: {case}")
194 }
195 }
196}