Skip to main content

ojak/portable/
uri.rs

1//! Identifiers of portable objects (FEP-ef61): `ap` URIs, whose authority is
2//! a DID rather than a host.
3//!
4//! One identifier has several spellings. [`ApUri::parse`] reads each of them,
5//! and [`ApUri::canonical`] is the one they are compared by:
6//!
7//!  -  `ap://did:key:z6Mk…/path`, canonical;
8//!  -  `ap+ef61://…`, or the DID's colons percent-encoded;
9//!  -  the compatible form at a gateway,
10//!     `https://gateway.example/.well-known/apgateway/did:key:z6Mk…/path`.
11//!
12//! The query of an `ap` URI is not part of the identifier. It carries
13//! location hints, `@gateway` parameters naming where the object may be
14//! fetched, which are kept as [`ApUri::gateways`].
15
16use alloc::string::{String, ToString};
17use alloc::vec::Vec;
18use core::fmt;
19
20/// The well-known path a gateway serves portable objects under.
21pub const GATEWAY_PATH: &str = "/.well-known/apgateway/";
22
23/// A portable object's identifier.
24#[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    /// Two `ap` URIs are the same identifier when their canonical forms are;
34    /// where they say the object may be found is not part of it.
35    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    /// Read `iri` in any of its spellings; `None` when it is not a portable
44    /// object's identifier.
45    #[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                // The DID runs to the first `/`: a DID has no slash of its
58                // own, and a portable path always starts with one.
59                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        // The path is required, and opaque, but it is a path: something
84        // after its `/`, and not an authority of its own, which would make
85        // `ap://did:key:…//host/x` a second identifier for a host.
86        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            // `@gateway` names one gateway; `gateways`, the form FEP-ef61 had
91            // before it, a comma-separated list.
92            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    /// The identifier `path` names under `did`; `None` when `did` is not a
117    /// DID or `path` does not start with `/`.
118    #[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    /// The DID that controls the object, which is its origin.
127    #[must_use]
128    pub fn did(&self) -> &str {
129        &self.did
130    }
131
132    /// The path under the DID, starting with `/`.
133    #[must_use]
134    pub fn path(&self) -> &str {
135        &self.path
136    }
137
138    /// The fragment, without its `#`.
139    #[must_use]
140    pub fn fragment(&self) -> Option<&str> {
141        self.fragment.as_deref()
142    }
143
144    /// Where the identifier says the object may be fetched: its `@gateway`
145    /// hints, or the gateway a compatible identifier was served by.
146    #[must_use]
147    pub fn gateways(&self) -> &[String] {
148        &self.gateways
149    }
150
151    /// `ap://did/path#fragment`: the form identifiers are compared in, and
152    /// the one Ojak writes.
153    #[must_use]
154    pub fn canonical(&self) -> String {
155        self.spelled(&self.did)
156    }
157
158    /// The canonical form with the DID's colons percent-encoded: the spelling
159    /// an RFC 3986 parser accepts, for where a type needs an IRI.
160    #[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    /// The canonical form with `gateways` as location hints, as a reference
175    /// to another actor is written.
176    #[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    /// Where `gateway`, an origin such as `https://server.example`, serves
192    /// this object: the compatible identifier, without its fragment.
193    #[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/// Whether `iri`, in any spelling, identifies a portable object.
211#[must_use]
212pub fn is_portable(iri: &str) -> bool {
213    ApUri::parse(iri).is_some()
214}
215
216/// Whether two identifiers name the same portable object, however each is
217/// spelled.
218#[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/// The DID a DID URL is under: `did:method:id`, without path, query or
227/// fragment. `None` when it is not a DID URL.
228#[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
235/// `did:method:id` as DID Core writes one: a lower-case method, and an id
236/// of letters, digits, `.`, `-`, `_`, percent-encodings and `:`, not ending
237/// in `:`.
238fn 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
258/// Whether `path` would name another object once it is a URL's: the path of
259/// an `ap` URI is opaque, but at a gateway it is an `https` URL's, and a URL
260/// parser removes tabs and line breaks, reads `\` as `/`, and resolves `.`
261/// and `..` segments, `%2e` spelled or not. `ap://did:key:…/a/../b` would be
262/// fetched, served and delivered to as `/b`.
263fn 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/// Whether `gateway` is what FEP-ef61 allows as one: an `http` or `https`
272/// URI with a host and no path, query or fragment, `https://server.example`,
273/// or with the one `/` of an empty path.
274#[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
315/// Encode everything but RFC 3986's unreserved characters, as a query value
316/// holding a URI has to be.
317fn 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        // The list FEP-ef61 had before `@gateway`.
381        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}