use std::fmt;
use std::str::FromStr;
use http::Uri;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ResourceError {
#[error("resource must be an absolute URI")]
RelativeReference,
#[error("resource contains invalid URI characters")]
InvalidCharacters,
#[error("resource contains invalid percent encoding")]
InvalidPercentEncoding,
#[error(transparent)]
InvalidHttpUri(#[from] http::uri::InvalidUri),
#[error("HTTP and HTTPS resources must include an authority")]
MissingHttpAuthority,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Resource {
text: String,
host: Option<String>,
}
impl Resource {
pub fn as_str(&self) -> &str {
&self.text
}
pub fn uri(&self) -> Option<Uri> {
Uri::try_from(self.as_str()).ok()
}
pub fn host(&self) -> Option<&str> {
self.host.as_deref()
}
}
impl fmt::Display for Resource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl AsRef<str> for Resource {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for Resource {
type Err = ResourceError;
fn from_str(resource: &str) -> Result<Self, Self::Err> {
let host = validate_resource(resource)?;
Ok(Self {
text: resource.to_string(),
host,
})
}
}
impl TryFrom<String> for Resource {
type Error = ResourceError;
fn try_from(resource: String) -> Result<Self, Self::Error> {
let host = validate_resource(&resource)?;
Ok(Self {
text: resource,
host,
})
}
}
impl TryFrom<&str> for Resource {
type Error = ResourceError;
fn try_from(resource: &str) -> Result<Self, Self::Error> {
resource.parse()
}
}
fn validate_resource(resource: &str) -> Result<Option<String>, ResourceError> {
let Some(scheme) = scheme(resource) else {
return Err(ResourceError::RelativeReference);
};
if !resource.is_ascii() {
return Err(ResourceError::InvalidCharacters);
}
validate_uri_characters(resource)?;
validate_percent_escapes(resource)?;
if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
if !resource[scheme.len()..].starts_with("://") {
return Err(ResourceError::MissingHttpAuthority);
}
let uri = Uri::try_from(resource).map_err(ResourceError::InvalidHttpUri)?;
let Some(host) = uri.host() else {
return Err(ResourceError::MissingHttpAuthority);
};
return Ok(Some(host.to_string()));
}
Ok(None)
}
fn validate_percent_escapes(resource: &str) -> Result<(), ResourceError> {
let mut bytes = resource.as_bytes().iter();
while let Some(byte) = bytes.next() {
if *byte != b'%' {
continue;
}
let Some(high) = bytes.next() else {
return Err(ResourceError::InvalidPercentEncoding);
};
let Some(low) = bytes.next() else {
return Err(ResourceError::InvalidPercentEncoding);
};
if !high.is_ascii_hexdigit() || !low.is_ascii_hexdigit() {
return Err(ResourceError::InvalidPercentEncoding);
}
}
Ok(())
}
fn validate_uri_characters(resource: &str) -> Result<(), ResourceError> {
if resource.bytes().all(is_uri_character) {
Ok(())
} else {
Err(ResourceError::InvalidCharacters)
}
}
fn is_uri_character(byte: u8) -> bool {
matches!(
byte,
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'.'
| b'_'
| b'~'
| b':'
| b'/'
| b'?'
| b'#'
| b'['
| b']'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b'%'
)
}
fn scheme(resource: &str) -> Option<&str> {
let mut bytes = resource.bytes();
let first = bytes.next()?;
if !first.is_ascii_alphabetic() {
return None;
}
for (index, byte) in bytes.enumerate() {
match byte {
b':' => return Some(&resource[..index + 1]),
b'/' | b'?' | b'#' => return None,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {}
_ => return None,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_acct_resource() {
let resource = "acct:carol@example.com".parse::<Resource>().unwrap();
assert_eq!(resource.as_str(), "acct:carol@example.com");
}
#[test]
fn accepts_https_resource() {
let resource = "https://example.org/users/carol"
.parse::<Resource>()
.unwrap();
assert_eq!(resource.as_str(), "https://example.org/users/carol");
assert_eq!(resource.host(), Some("example.org"));
}
#[test]
fn accepts_scheme_specific_resource() {
let resource = "urn:example:animal:ferret:nose"
.parse::<Resource>()
.unwrap();
assert_eq!(resource.as_str(), "urn:example:animal:ferret:nose");
}
#[test]
fn rejects_relative_resource_references() {
for resource in ["carol", "/relative", "../x", ""] {
let error = resource.parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::RelativeReference),
"expected relative-resource error for {resource:?}, got {error:?}",
);
}
}
#[test]
fn rejects_non_ascii_resource_text() {
let error = "acct:carolé@example.org".parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::InvalidCharacters),
"expected invalid-character error, got {error:?}",
);
}
#[test]
fn rejects_invalid_raw_uri_characters() {
for resource in [
"acct:carol{bad}@example.org",
"acct:carol|bad@example.org",
"acct:carol^bad@example.org",
"acct:carol`bad@example.org",
] {
let error = resource.parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::InvalidCharacters),
"expected invalid-character error for {resource:?}, got {error:?}",
);
}
}
#[test]
fn accepts_percent_encoded_invalid_raw_characters() {
let resource = "acct:carol%7Bbad%7D@example.org"
.parse::<Resource>()
.unwrap();
assert_eq!(resource.as_str(), "acct:carol%7Bbad%7D@example.org");
}
#[test]
fn rejects_malformed_resource_percent_escape() {
let error = "acct:carol%GG@example.org".parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::InvalidPercentEncoding),
"expected invalid-percent-encoding error, got {error:?}",
);
}
#[test]
fn rejects_http_resources_without_authority() {
for resource in ["http:foo", "https:foo", "http:/example.org/path"] {
let error = resource.parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::MissingHttpAuthority),
"expected missing-authority error for {resource:?}, got {error:?}",
);
}
}
#[test]
fn rejects_invalid_https_authority_with_uppercase_scheme() {
let error = "HTTPS://[::1".parse::<Resource>().unwrap_err();
assert!(
matches!(error, ResourceError::InvalidHttpUri(_)),
"expected invalid-authority error, got {error:?}",
);
}
}