Skip to main content

didbot_schema/format/
uri.rs

1//! The `uri` string format: a generic URI, checked loosely on purpose.
2//!
3//! atproto's `uri` format is not RFC 3986 parsing. It is the deliberately
4//! permissive rule that a URI has a scheme, a colon, and something after the
5//! colon, with no whitespace anywhere and a length bound. Anything stricter
6//! would refuse schemes nobody has registered yet, and the field exists to
7//! carry links a consumer will hand to something that does understand them.
8//!
9//! What it does catch is the failure that actually happens: a bare
10//! `example.com` or `127.0.0.1` written into a field that a reader will treat
11//! as dereferenceable.
12
13/// Longest a URI may be, in bytes.
14pub const MAX_URI_LENGTH: usize = 8 * 1024;
15
16/// Why a string is not a usable URI.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum UriError {
19    /// Empty.
20    #[error("uri is empty")]
21    Empty,
22    /// Over [`MAX_URI_LENGTH`] bytes.
23    #[error("uri is {0} bytes, over the limit of {MAX_URI_LENGTH}")]
24    TooLong(usize),
25    /// Whitespace appeared, anywhere.
26    #[error("uri contains whitespace")]
27    Whitespace,
28    /// No colon, so there is no scheme at all.
29    #[error("uri has no scheme")]
30    NoScheme,
31    /// The scheme is empty, starts with a non-letter, or holds a character
32    /// outside `A-Za-z0-9+-.`.
33    #[error("uri scheme {0:?} is not a valid scheme")]
34    BadScheme(String),
35    /// Nothing followed the scheme, or nothing followed a `//` authority
36    /// marker.
37    #[error("uri has a scheme and nothing after it")]
38    EmptyPath,
39}
40
41/// Checks a string against the generic URI rules.
42///
43/// ```
44/// use didbot_schema::format::uri;
45/// assert!(uri::validate("https://example.com/path?q=1#frag").is_ok());
46/// assert!(uri::validate("did:key:zQ3sh").is_ok());
47/// assert!(uri::validate("content-type:text/plain").is_ok());
48/// assert!(uri::validate("example.com").is_err()); // no scheme
49/// assert!(uri::validate("http:").is_err());       // nothing after the scheme
50/// ```
51pub fn validate(value: &str) -> Result<(), UriError> {
52    if value.is_empty() {
53        return Err(UriError::Empty);
54    }
55    if value.len() > MAX_URI_LENGTH {
56        return Err(UriError::TooLong(value.len()));
57    }
58    if value.chars().any(char::is_whitespace) {
59        return Err(UriError::Whitespace);
60    }
61    let (scheme, rest) = value.split_once(':').ok_or(UriError::NoScheme)?;
62    // RFC 3986 ยง3.1: a scheme begins with a letter, which is what rules out
63    // `127.0.0.1` and `12345` while leaving `content-type` and
64    // `microsoft.windows.camera` alone.
65    let mut characters = scheme.chars();
66    let opens_correctly = characters.next().is_some_and(|ch| ch.is_ascii_alphabetic());
67    let body_is_clean =
68        characters.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
69    if !(opens_correctly && body_is_clean) {
70        return Err(UriError::BadScheme(scheme.to_owned()));
71    }
72    // A `//` introduces an authority; what follows it must be the authority
73    // and not another slash.
74    let after_authority = rest.strip_prefix("//").unwrap_or(rest);
75    if after_authority.is_empty() || after_authority.starts_with('/') {
76        return Err(UriError::EmptyPath);
77    }
78    Ok(())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn a_scheme_alone_is_not_a_uri() {
87        assert_eq!(validate("http:"), Err(UriError::EmptyPath));
88        assert_eq!(validate("http://"), Err(UriError::EmptyPath));
89    }
90
91    #[test]
92    fn hostnames_and_addresses_are_not_uris() {
93        // Neither holds a colon at all, so neither has a scheme to judge.
94        assert_eq!(validate("example.com"), Err(UriError::NoScheme));
95        assert_eq!(validate("127.0.0.1"), Err(UriError::NoScheme));
96        // With a colon, the leading digit is what disqualifies it.
97        assert!(matches!(
98            validate("127.0.0.1:8080"),
99            Err(UriError::BadScheme(_))
100        ));
101    }
102
103    #[test]
104    fn the_length_bound_is_on_bytes() {
105        let long = format!("https://example.com/{}", "x".repeat(MAX_URI_LENGTH));
106        assert!(matches!(validate(&long), Err(UriError::TooLong(_))));
107    }
108}