use http::Request;
use crate::error::Error;
use crate::http_shared::collect_canonical_header_value;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Component {
Method,
TargetUri,
Authority,
Scheme,
Path,
Query,
RequestTarget,
Header(String),
}
impl Component {
#[must_use]
pub fn lexical(&self) -> String {
format!(r#""{}""#, self.identifier())
}
#[must_use]
pub fn identifier(&self) -> &str {
match self {
Self::Method => "@method",
Self::TargetUri => "@target-uri",
Self::Authority => "@authority",
Self::Scheme => "@scheme",
Self::Path => "@path",
Self::Query => "@query",
Self::RequestTarget => "@request-target",
Self::Header(name) => name,
}
}
pub fn parse(identifier: &str) -> Result<Self, Error> {
if !identifier.starts_with('@') {
return Ok(Self::Header(identifier.to_ascii_lowercase()));
}
Ok(match identifier {
"@method" => Self::Method,
"@target-uri" => Self::TargetUri,
"@authority" => Self::Authority,
"@scheme" => Self::Scheme,
"@path" => Self::Path,
"@query" => Self::Query,
"@request-target" => Self::RequestTarget,
other => {
return Err(Error::UnsupportedAlgorithm(format!(
"derived component `{other}` is not supported"
)));
}
})
}
}
pub(crate) fn canonical_value<B>(component: &Component, req: &Request<B>) -> Result<String, Error> {
match component {
Component::Method => Ok(req.method().as_str().to_uppercase()),
Component::TargetUri => Ok(target_uri(req)),
Component::Authority => Ok(authority(req)),
Component::Scheme => Ok(scheme(req)),
Component::Path => Ok(req.uri().path().to_owned()),
Component::Query => Ok(query_with_leading_q(req)),
Component::RequestTarget => Ok(request_target(req)),
Component::Header(name) => header_value(req, name),
}
}
fn target_uri<B>(req: &Request<B>) -> String {
let scheme = scheme(req);
let authority = authority(req);
let path_and_query = req
.uri()
.path_and_query()
.map_or_else(|| req.uri().path().to_owned(), ToString::to_string);
format!("{scheme}://{authority}{path_and_query}")
}
fn authority<B>(req: &Request<B>) -> String {
if let Some(auth) = req.uri().authority() {
return auth.as_str().to_ascii_lowercase();
}
req.headers()
.get(http::header::HOST)
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_ascii_lowercase())
.unwrap_or_default()
}
fn scheme<B>(req: &Request<B>) -> String {
req.uri()
.scheme_str()
.map_or_else(|| "https".to_owned(), str::to_ascii_lowercase)
}
fn query_with_leading_q<B>(req: &Request<B>) -> String {
req.uri()
.query()
.map_or_else(|| "?".to_owned(), |q| format!("?{q}"))
}
fn request_target<B>(req: &Request<B>) -> String {
req.uri()
.path_and_query()
.map_or_else(|| req.uri().path().to_owned(), ToString::to_string)
}
fn header_value<B>(req: &Request<B>, lower_name: &str) -> Result<String, Error> {
collect_canonical_header_value(req, lower_name)
.ok_or_else(|| Error::RequiredHeaderAbsent(lower_name.to_owned()))
}
#[allow(
clippy::expect_used,
clippy::unwrap_in_result,
reason = "writing to an owned String via core::fmt::Write is infallible; the Result on write! only exists to satisfy the trait"
)]
pub(crate) fn build_signature_base<B>(
req: &Request<B>,
components: &[Component],
signature_params_inner_list: &str,
) -> Result<String, Error> {
use core::fmt::Write as _;
let mut out = String::new();
let infallible = "writing to an owned String is infallible";
for component in components {
let line = canonical_value(component, req)?;
writeln!(out, "{}: {line}", component.lexical()).expect(infallible);
}
write!(out, r#""@signature-params": {signature_params_inner_list}"#).expect(infallible);
Ok(out)
}
#[cfg(test)]
mod tests {
use http::{Method, Request};
use pretty_assertions::assert_eq;
use super::*;
fn sample() -> Request<Vec<u8>> {
Request::builder()
.method(Method::POST)
.uri("https://example.com/inbox?a=1")
.header("host", "example.com")
.header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
.body(Vec::new())
.expect("valid")
}
#[test]
fn method_is_uppercase() {
assert_eq!(
canonical_value(&Component::Method, &sample()).unwrap(),
"POST"
);
}
#[test]
fn target_uri_includes_scheme_authority_path_and_query() {
assert_eq!(
canonical_value(&Component::TargetUri, &sample()).unwrap(),
"https://example.com/inbox?a=1",
);
}
#[test]
fn authority_is_lowercase() {
let req = Request::builder()
.method(Method::POST)
.uri("https://EXAMPLE.COM/inbox")
.body(Vec::<u8>::new())
.expect("valid");
assert_eq!(
canonical_value(&Component::Authority, &req).unwrap(),
"example.com"
);
}
#[test]
fn path_and_query_are_separate() {
let req = sample();
assert_eq!(canonical_value(&Component::Path, &req).unwrap(), "/inbox");
assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?a=1");
}
#[test]
fn empty_query_canonicalises_to_single_question_mark() {
let req = Request::builder()
.method(Method::POST)
.uri("https://example.com/inbox")
.body(Vec::<u8>::new())
.expect("valid");
assert_eq!(canonical_value(&Component::Query, &req).unwrap(), "?");
}
#[test]
fn request_target_excludes_method_per_rfc9421() {
let req = sample();
assert_eq!(
canonical_value(&Component::RequestTarget, &req).unwrap(),
"/inbox?a=1",
);
}
#[test]
fn request_target_is_just_path_when_query_absent() {
let req = Request::builder()
.method(Method::POST)
.uri("https://example.com/inbox")
.body(Vec::<u8>::new())
.expect("valid");
assert_eq!(
canonical_value(&Component::RequestTarget, &req).unwrap(),
"/inbox",
);
}
#[test]
fn missing_header_reports_required_header_absent() {
let req = sample();
let err = canonical_value(&Component::Header("authorization".into()), &req)
.expect_err("missing header must error");
assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "authorization"));
}
#[test]
fn parse_roundtrips_known_identifiers() {
for ident in [
"@method",
"@target-uri",
"@authority",
"@scheme",
"@path",
"@query",
"@request-target",
"date",
] {
let c = Component::parse(ident).expect("known identifier");
assert_eq!(c.identifier(), ident);
}
}
#[test]
fn parse_rejects_unknown_derived_component() {
let err = Component::parse("@future").expect_err("unknown derived");
assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
}
#[test]
fn full_signature_base_matches_expected_shape() {
let req = sample();
let components = [
Component::Method,
Component::TargetUri,
Component::Header("host".into()),
Component::Header("date".into()),
];
let base = build_signature_base(
&req,
&components,
r#"("@method" "@target-uri" "host" "date");created=1704464900;keyid="kid""#,
)
.unwrap();
assert_eq!(
base,
concat!(
"\"@method\": POST\n",
"\"@target-uri\": https://example.com/inbox?a=1\n",
"\"host\": example.com\n",
"\"date\": Sun, 05 Jan 2014 21:31:40 GMT\n",
"\"@signature-params\": (\"@method\" \"@target-uri\" \"host\" \"date\");created=1704464900;keyid=\"kid\"",
),
);
}
}