1use alloc::string::{String, ToString};
17use alloc::vec::Vec;
18use core::fmt;
19
20pub const GATEWAY_PATH: &str = "/.well-known/apgateway/";
22
23#[derive(Clone, Debug)]
25pub struct ApUri {
26 did: String,
27 path: String,
28 fragment: Option<String>,
29 gateways: Vec<String>,
30}
31
32impl PartialEq for ApUri {
33 fn eq(&self, other: &Self) -> bool {
36 self.did == other.did && self.path == other.path && self.fragment == other.fragment
37 }
38}
39
40impl Eq for ApUri {}
41
42impl ApUri {
43 #[must_use]
46 pub fn parse(iri: &str) -> Option<Self> {
47 let (scheme, rest) = iri.split_once("://")?;
48 match scheme.to_ascii_lowercase().as_str() {
49 "ap" | "ap+ef61" => {
50 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
51 Self::from_parts(&percent_decode(&rest[..end]), &rest[end..], Vec::new())
52 }
53 "http" | "https" => {
54 let slash = rest.find('/')?;
55 let (host, path) = rest.split_at(slash);
56 let after = path.strip_prefix(GATEWAY_PATH)?;
57 let end = after.find(['/', '?', '#']).unwrap_or(after.len());
60 let gateway = alloc::format!("{scheme}://{host}").to_ascii_lowercase();
61 Self::from_parts(
62 &percent_decode(&after[..end]),
63 &after[end..],
64 alloc::vec![gateway],
65 )
66 }
67 _ => None,
68 }
69 }
70
71 fn from_parts(did: &str, rest: &str, mut gateways: Vec<String>) -> Option<Self> {
72 if !is_did(did) {
73 return None;
74 }
75 let (rest, fragment) = match rest.split_once('#') {
76 Some((rest, fragment)) => (rest, Some(fragment.to_string())),
77 None => (rest, None),
78 };
79 let (path, query) = match rest.split_once('?') {
80 Some((path, query)) => (path, Some(query)),
81 None => (rest, None),
82 };
83 if !path.starts_with('/') || path.len() == 1 || path.starts_with("//") || lossy(path) {
87 return None;
88 }
89 for parameter in query.into_iter().flat_map(|query| query.split('&')) {
90 let hinted = if let Some(gateway) = parameter.strip_prefix("@gateway=") {
93 alloc::vec![percent_decode(gateway)]
94 } else if let Some(list) = parameter.strip_prefix("gateways=") {
95 percent_decode(list)
96 .split(',')
97 .map(str::to_string)
98 .collect()
99 } else {
100 Vec::new()
101 };
102 for gateway in hinted {
103 if is_gateway(&gateway) && !gateways.contains(&gateway) {
104 gateways.push(gateway);
105 }
106 }
107 }
108 Some(Self {
109 did: did.to_string(),
110 path: path.to_string(),
111 fragment,
112 gateways,
113 })
114 }
115
116 #[must_use]
119 pub fn new(did: &str, path: &str) -> Option<Self> {
120 if path.contains(['?', '#']) {
121 return None;
122 }
123 Self::from_parts(did, path, Vec::new())
124 }
125
126 #[must_use]
128 pub fn did(&self) -> &str {
129 &self.did
130 }
131
132 #[must_use]
134 pub fn path(&self) -> &str {
135 &self.path
136 }
137
138 #[must_use]
140 pub fn fragment(&self) -> Option<&str> {
141 self.fragment.as_deref()
142 }
143
144 #[must_use]
147 pub fn gateways(&self) -> &[String] {
148 &self.gateways
149 }
150
151 #[must_use]
154 pub fn canonical(&self) -> String {
155 self.spelled(&self.did)
156 }
157
158 #[must_use]
161 pub fn encoded(&self) -> String {
162 self.spelled(&self.did.replace(':', "%3A"))
163 }
164
165 fn spelled(&self, authority: &str) -> String {
166 let mut uri = alloc::format!("ap://{authority}{}", self.path);
167 if let Some(fragment) = &self.fragment {
168 uri.push('#');
169 uri.push_str(fragment);
170 }
171 uri
172 }
173
174 #[must_use]
177 pub fn with_hints(&self, gateways: &[&str]) -> String {
178 let mut uri = alloc::format!("ap://{}{}", self.did, self.path);
179 for (index, gateway) in gateways.iter().enumerate() {
180 uri.push(if index == 0 { '?' } else { '&' });
181 uri.push_str("@gateway=");
182 uri.push_str(&percent_encode(gateway));
183 }
184 if let Some(fragment) = &self.fragment {
185 uri.push('#');
186 uri.push_str(fragment);
187 }
188 uri
189 }
190
191 #[must_use]
194 pub fn at_gateway(&self, gateway: &str) -> String {
195 alloc::format!(
196 "{}{GATEWAY_PATH}{}{}",
197 gateway.trim_end_matches('/'),
198 self.did,
199 self.path
200 )
201 }
202}
203
204impl fmt::Display for ApUri {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.write_str(&self.canonical())
207 }
208}
209
210#[must_use]
212pub fn is_portable(iri: &str) -> bool {
213 ApUri::parse(iri).is_some()
214}
215
216#[must_use]
219pub fn same_object(a: &str, b: &str) -> bool {
220 match (ApUri::parse(a), ApUri::parse(b)) {
221 (Some(a), Some(b)) => a == b,
222 _ => false,
223 }
224}
225
226#[must_use]
229pub fn did_of(did_url: &str) -> Option<&str> {
230 let end = did_url.find(['/', '?', '#']).unwrap_or(did_url.len());
231 let did = &did_url[..end];
232 is_did(did).then_some(did)
233}
234
235fn is_did(did: &str) -> bool {
239 let mut parts = did.splitn(3, ':');
240 let (Some("did"), Some(method), Some(id)) = (parts.next(), parts.next(), parts.next()) else {
241 return false;
242 };
243 let bytes = id.as_bytes();
244 !method.is_empty()
245 && method
246 .bytes()
247 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
248 && !id.is_empty()
249 && !id.ends_with(':')
250 && bytes.iter().enumerate().all(|(index, &byte)| match byte {
251 b'%' => bytes
252 .get(index + 1..index + 3)
253 .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)),
254 byte => byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':'),
255 })
256}
257
258fn lossy(path: &str) -> bool {
264 path.contains(['\\', '\t', '\n', '\r'])
265 || path.split('/').any(|segment| {
266 let segment = segment.to_ascii_lowercase().replace("%2e", ".");
267 segment == "." || segment == ".."
268 })
269}
270
271#[must_use]
275pub fn is_gateway(gateway: &str) -> bool {
276 let Some((scheme, rest)) = gateway.split_once("://") else {
277 return false;
278 };
279 let authority = rest.strip_suffix('/').unwrap_or(rest);
280 matches!(scheme, "http" | "https")
281 && !authority.is_empty()
282 && !authority.contains(['/', '\\', '?', '#', '@'])
283}
284
285fn percent_decode(text: &str) -> String {
286 let bytes = text.as_bytes();
287 let mut out = Vec::with_capacity(bytes.len());
288 let mut index = 0;
289 while index < bytes.len() {
290 if bytes[index] == b'%'
291 && let (Some(high), Some(low)) = (
292 bytes.get(index + 1).and_then(|byte| hex(*byte)),
293 bytes.get(index + 2).and_then(|byte| hex(*byte)),
294 )
295 {
296 out.push(high * 16 + low);
297 index += 3;
298 continue;
299 }
300 out.push(bytes[index]);
301 index += 1;
302 }
303 String::from_utf8(out).unwrap_or_else(|_| text.to_string())
304}
305
306fn hex(byte: u8) -> Option<u8> {
307 match byte {
308 b'0'..=b'9' => Some(byte - b'0'),
309 b'a'..=b'f' => Some(byte - b'a' + 10),
310 b'A'..=b'F' => Some(byte - b'A' + 10),
311 _ => None,
312 }
313}
314
315fn percent_encode(text: &str) -> String {
318 let mut out = String::with_capacity(text.len());
319 for byte in text.bytes() {
320 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
321 out.push(byte as char);
322 } else {
323 out.push_str(&alloc::format!("%{byte:02X}"));
324 }
325 }
326 out
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 const DID: &str = "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2";
334
335 #[test]
336 fn every_spelling_is_one_identifier() {
337 let canonical = alloc::format!("ap://{DID}/actor");
338 let uri = ApUri::parse(&canonical).unwrap();
339 assert_eq!(uri.did(), DID);
340 assert_eq!(uri.path(), "/actor");
341 assert_eq!(uri.canonical(), canonical);
342 for spelling in [
343 alloc::format!("ap+ef61://{DID}/actor"),
344 alloc::format!("AP://{}/actor", DID.replace(':', "%3A")),
345 alloc::format!("ap://{}/actor", DID.replace(':', "%3a")),
346 alloc::format!("https://Server.example/.well-known/apgateway/{DID}/actor"),
347 alloc::format!(
348 "ap://{DID}/actor?@gateway=https%3A%2F%2Fserver1.example&@gateway=https%3A%2F%2Fserver2.example"
349 ),
350 ] {
351 let other = ApUri::parse(&spelling).unwrap();
352 assert_eq!(other, uri, "{spelling}");
353 assert_eq!(other.canonical(), canonical, "{spelling}");
354 }
355 }
356
357 #[test]
358 fn hints_are_where_to_look_and_not_the_identifier() {
359 let uri = ApUri::parse(&alloc::format!(
360 "ap://{DID}/actor?@gateway=https%3A%2F%2Fserver1.example&other=1&@gateway=https%3A%2F%2Fserver2.example&@gateway=https%3A%2F%2Fbad.example%2Fpath"
361 ))
362 .unwrap();
363 assert_eq!(
364 uri.gateways(),
365 ["https://server1.example", "https://server2.example"]
366 );
367 let written = uri.with_hints(&["https://server1.example"]);
368 assert_eq!(
369 written,
370 alloc::format!("ap://{DID}/actor?@gateway=https%3A%2F%2Fserver1.example")
371 );
372 assert_eq!(ApUri::parse(&written).unwrap(), uri);
373
374 let compatible = ApUri::parse(&alloc::format!(
375 "https://g.example/.well-known/apgateway/{DID}/x"
376 ))
377 .unwrap();
378 assert_eq!(compatible.gateways(), ["https://g.example"]);
379
380 let listed = ApUri::parse(&alloc::format!(
382 "ap://{DID}/actor?gateways=https%3A%2F%2Fserver1.example,https%3A%2F%2Fserver2.example"
383 ))
384 .unwrap();
385 assert_eq!(
386 listed.gateways(),
387 ["https://server1.example", "https://server2.example"]
388 );
389 assert_eq!(listed, uri);
390 }
391
392 #[test]
393 fn a_gateway_serves_the_compatible_form() {
394 let uri = ApUri::parse(&alloc::format!("ap://{DID}/objects/1#part")).unwrap();
395 assert_eq!(uri.fragment(), Some("part"));
396 assert_eq!(
397 uri.at_gateway("https://g.example/"),
398 alloc::format!("https://g.example/.well-known/apgateway/{DID}/objects/1")
399 );
400 assert_eq!(
401 uri.encoded(),
402 alloc::format!("ap://{}/objects/1#part", DID.replace(':', "%3A"))
403 );
404 }
405
406 #[test]
407 fn what_is_not_portable() {
408 for iri in [
409 "https://a.example/users/alice",
410 "https://a.example/.well-known/apgateway/",
411 "https://a.example/.well-known/apgateway/not-a-did/x",
412 "ap://did:key:z6Mk",
413 "ap://did:key:z6Mk?x=1",
414 "ap://did::z6Mk/actor",
415 "ap://did:KEY:z6Mk/actor",
416 "ap://host.example/actor",
417 "ap://did:key:z6Mk/",
418 "ap://did:key:z6Mk//host.example/actor",
419 "ap://did:key:z6Mk@evil.example/actor",
420 "ap://did:key:z6Mk%5C@evil.example/actor",
421 "ap://did:key:z6Mk%20x/actor",
422 "ap://did:key:z6Mk:/actor",
423 "ap://did:key:z6Mk%zz/actor",
424 "ap://did:key:z6Mk/a/../b",
425 "ap://did:key:z6Mk/a/./b",
426 "ap://did:key:z6Mk/a/%2E%2e/b",
427 "ap://did:key:z6Mk/a/.%2e",
428 "ap://did:key:z6Mk/..",
429 "ap://did:key:z6Mk/a\\..\\b",
430 "ap://did:key:z6Mk/a/.\t./b",
431 "https://g.example/.well-known/apgateway/did:key:z6Mk/a/../b",
432 "did:key:z6Mk",
433 "",
434 ] {
435 assert!(ApUri::parse(iri).is_none(), "{iri}");
436 }
437 }
438
439 #[test]
440 fn a_dot_that_is_not_a_segment_is_part_of_the_path() {
441 let uri = ApUri::parse(&alloc::format!("ap://{DID}/a/..b/c./.d")).unwrap();
442 assert_eq!(uri.path(), "/a/..b/c./.d");
443 }
444
445 #[test]
446 fn a_gateway_is_an_origin() {
447 for gateway in [
448 "https://server.example",
449 "https://server.example/",
450 "http://127.0.0.1:8080",
451 ] {
452 assert!(is_gateway(gateway), "{gateway}");
453 }
454 for gateway in [
455 "https://server.example/path",
456 "https://server.example//",
457 "https://server.example?",
458 "https://server.example/#",
459 "https://user@server.example",
460 "https://",
461 "https:///",
462 "ftp://server.example",
463 "server.example",
464 ] {
465 assert!(!is_gateway(gateway), "{gateway}");
466 }
467 }
468
469 #[test]
470 fn a_did_url_is_under_its_did() {
471 assert_eq!(did_of(&alloc::format!("{DID}#z6Mk")), Some(DID));
472 assert_eq!(did_of(DID), Some(DID));
473 assert_eq!(did_of("https://a.example#key"), None);
474 assert!(same_object(
475 &alloc::format!("ap://{DID}/a"),
476 &alloc::format!("https://g.example/.well-known/apgateway/{DID}/a")
477 ));
478 assert!(!same_object(
479 &alloc::format!("ap://{DID}/a"),
480 &alloc::format!("ap://{DID}/b")
481 ));
482 }
483}