use std::str::FromStr;
use percent_encoding::percent_decode_str;
use thiserror::Error;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct RequestParams {
pub(crate) resource: String,
pub(crate) rel: Vec<String>,
}
impl FromStr for RequestParams {
type Err = RequestParamsError;
fn from_str(query: &str) -> Result<Self, Self::Err> {
let mut resource = None;
let mut rel = Vec::new();
for parameter in query.split('&').filter(|parameter| !parameter.is_empty()) {
let (key, value) = parameter.split_once('=').unwrap_or((parameter, ""));
let key = decode_query_param(key)?;
let value = decode_query_param(value)?;
match key.as_str() {
"resource" if resource.is_none() => resource = Some(value),
"resource" => return Err(RequestParamsError::MultipleResources),
"rel" => rel.push(value),
_ => {}
}
}
let resource = resource.ok_or(RequestParamsError::MissingResource)?;
Ok(RequestParams { resource, rel })
}
}
fn decode_query_param(value: &str) -> Result<String, RequestParamsError> {
validate_percent_escapes(value)?;
percent_decode_str(value)
.decode_utf8()
.map(|value| value.into_owned())
.map_err(|_| RequestParamsError::InvalidPercentEncoding)
}
fn validate_percent_escapes(value: &str) -> Result<(), RequestParamsError> {
let mut bytes = value.as_bytes().iter();
while let Some(byte) = bytes.next() {
if *byte != b'%' {
continue;
}
let Some(high) = bytes.next() else {
return Err(RequestParamsError::InvalidPercentEncoding);
};
let Some(low) = bytes.next() else {
return Err(RequestParamsError::InvalidPercentEncoding);
};
if !high.is_ascii_hexdigit() || !low.is_ascii_hexdigit() {
return Err(RequestParamsError::InvalidPercentEncoding);
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub(crate) enum RequestParamsError {
#[error("missing resource parameter")]
MissingResource,
#[error("multiple resource parameters")]
MultipleResources,
#[error("invalid percent-encoded query parameter")]
InvalidPercentEncoding,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_percent_encoded_resource() {
let query = "resource=acct%3Abad%40example.org"
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "acct:bad@example.org".to_string(),
rel: Vec::new(),
},
);
}
#[test]
fn preserves_repeated_rel_params() {
let query = "resource=acct%3Acarol%40example.org&rel=profile&rel=avatar"
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "acct:carol@example.org".to_string(),
rel: vec!["profile".to_string(), "avatar".to_string()],
},
);
}
#[test]
fn decodes_percent_encoded_rel_params() {
let rel = "http%3A%2F%2Fwebfinger.example%2Frel%2Fprofile-page";
let query = format!("resource=acct%3Acarol%40example.org&rel={rel}")
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "acct:carol@example.org".to_string(),
rel: vec!["http://webfinger.example/rel/profile-page".to_string()],
},
);
}
#[test]
fn rejects_invalid_utf8_percent_encoded_values() {
let error = "resource=acct%3Acarol%40example.org&rel=%FF"
.parse::<RequestParams>()
.unwrap_err();
assert_eq!(error, RequestParamsError::InvalidPercentEncoding);
}
#[test]
fn rejects_malformed_percent_escape_syntax() {
let error = "resource=acct%3Acarol%40example.org&rel=%GG"
.parse::<RequestParams>()
.unwrap_err();
assert_eq!(error, RequestParamsError::InvalidPercentEncoding);
}
#[test]
fn resource_parameter_order_does_not_matter() {
let query = "rel=profile&resource=acct%3Acarol%40example.org"
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "acct:carol@example.org".to_string(),
rel: vec!["profile".to_string()],
},
);
}
#[test]
fn encoded_delimiters_stay_inside_resource() {
let resource = "https%3A%2F%2Fexample.org%2Fprofile%3Fa%3D1%26b%3D2";
let query = format!("resource={resource}")
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "https://example.org/profile?a=1&b=2".to_string(),
rel: Vec::new(),
},
);
}
#[test]
fn decodes_encoded_percent_once() {
let query = "resource=https://example.org/profile/a%2520b"
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "https://example.org/profile/a%20b".to_string(),
rel: Vec::new(),
},
);
}
#[test]
fn plus_is_not_decoded_as_space() {
let query = "resource=acct%3Acarol+tag%40example.org"
.parse::<RequestParams>()
.unwrap();
assert_eq!(
query,
RequestParams {
resource: "acct:carol+tag@example.org".to_string(),
rel: Vec::new(),
},
);
}
#[test]
fn rejects_multiple_resource_params() {
let error = "resource=acct%3Acarol%40example.org&resource=acct%3Aalice%40example.org"
.parse::<RequestParams>()
.unwrap_err();
assert_eq!(error, RequestParamsError::MultipleResources);
}
#[test]
fn rejects_missing_resource_param() {
let error = "rel=profile".parse::<RequestParams>().unwrap_err();
assert_eq!(error, RequestParamsError::MissingResource);
}
}