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}