use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};
use crate::error::HttpSigError;
#[derive(Debug, Clone)]
pub enum Component {
Method,
Authority,
Path,
Query,
RequestTarget,
Status,
Req(Box<Component>),
Header(String),
}
impl PartialEq for Component {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Method, Self::Method)
| (Self::Authority, Self::Authority)
| (Self::Path, Self::Path)
| (Self::Query, Self::Query)
| (Self::RequestTarget, Self::RequestTarget)
| (Self::Status, Self::Status) => true,
(Self::Req(a), Self::Req(b)) => a == b,
(Self::Header(a), Self::Header(b)) => a.eq_ignore_ascii_case(b),
_ => false,
}
}
}
impl Eq for Component {}
impl Component {
#[must_use]
pub fn header(name: &str) -> Self {
Self::Header(name.to_ascii_lowercase())
}
#[must_use]
pub fn quoted_id(&self) -> String {
match self {
Self::Method => "\"@method\"".to_owned(),
Self::Authority => "\"@authority\"".to_owned(),
Self::Path => "\"@path\"".to_owned(),
Self::Query => "\"@query\"".to_owned(),
Self::RequestTarget => "\"@request-target\"".to_owned(),
Self::Status => "\"@status\"".to_owned(),
Self::Req(inner) => format!("{};req", inner.quoted_id()),
Self::Header(name) => format!("\"{name}\""),
}
}
pub fn from_quoted_id(token: &str) -> Result<Self, HttpSigError> {
if let Some(base) = token.strip_suffix(";req") {
return Ok(Self::Req(Box::new(Self::from_quoted_id(base)?)));
}
let inner = token
.strip_prefix('"')
.and_then(|t| t.strip_suffix('"'))
.ok_or_else(|| HttpSigError::Parse(format!("not a quoted identifier: {token}")))?;
match inner {
"@method" => Ok(Self::Method),
"@authority" => Ok(Self::Authority),
"@path" => Ok(Self::Path),
"@query" => Ok(Self::Query),
"@request-target" => Ok(Self::RequestTarget),
"@status" => Ok(Self::Status),
name if name.starts_with('@') => {
Err(HttpSigError::UnsupportedComponent(inner.to_owned()))
}
name => Ok(Self::Header(name.to_ascii_lowercase())),
}
}
}
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: String,
pub authority: String,
pub path: String,
pub query: Option<String>,
pub headers: Vec<(String, String)>,
}
impl HttpRequest {
pub fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
match component {
Component::Method => Ok(self.method.clone()),
Component::Authority => Ok(self.authority.to_ascii_lowercase()),
Component::Path => Ok(if self.path.is_empty() {
"/".to_owned()
} else {
self.path.clone()
}),
Component::Query => Ok(format!("?{}", self.query.as_deref().unwrap_or(""))),
Component::RequestTarget => Ok(self.request_target()),
Component::Header(name) => self.header_value(name),
Component::Status | Component::Req(_) => {
Err(HttpSigError::UnsupportedComponent(component.quoted_id()))
}
}
}
fn request_target(&self) -> String {
let path = if self.path.is_empty() {
"/"
} else {
&self.path
};
match &self.query {
Some(query) => format!("{path}?{query}"),
None => path.to_owned(),
}
}
fn header_value(&self, name: &str) -> Result<String, HttpSigError> {
header_value(&self.headers, name)
}
}
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy)]
pub struct HttpExchange<'a> {
pub response: &'a HttpResponse,
pub request: &'a HttpRequest,
}
pub trait ComponentSource {
fn component_value(&self, component: &Component) -> Result<String, HttpSigError>;
}
impl ComponentSource for HttpRequest {
fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
HttpRequest::component_value(self, component)
}
}
impl ComponentSource for HttpExchange<'_> {
fn component_value(&self, component: &Component) -> Result<String, HttpSigError> {
match component {
Component::Status => Ok(self.response.status.to_string()),
Component::Req(inner) => self.request.component_value(inner),
Component::Header(name) => header_value(&self.response.headers, name),
other => Err(HttpSigError::UnsupportedComponent(other.quoted_id())),
}
}
}
fn header_value(headers: &[(String, String)], name: &str) -> Result<String, HttpSigError> {
let mut values = headers
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.trim())
.peekable();
if values.peek().is_none() {
return Err(HttpSigError::MissingComponent(name.to_owned()));
}
Ok(values.collect::<Vec<_>>().join(", "))
}
#[must_use]
pub fn content_digest_sha256(body: &[u8]) -> String {
format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body)))
}
#[must_use]
pub fn verify_content_digest(header_value: &str, body: &[u8]) -> bool {
header_value == content_digest_sha256(body)
}
#[cfg(test)]
mod tests {
use super::{content_digest_sha256, verify_content_digest, Component, HttpRequest};
#[test]
fn parses_header_identifiers_case_insensitively() {
let parsed = Component::from_quoted_id("\"Content-Type\"").unwrap();
assert_eq!(parsed, Component::header("content-type"));
}
#[test]
fn joins_repeated_headers_and_trims() {
let request = HttpRequest {
method: "GET".to_owned(),
authority: "EXAMPLE.com".to_owned(),
path: String::new(),
query: None,
headers: vec![
("Accept".to_owned(), " text/plain ".to_owned()),
("accept".to_owned(), "application/json".to_owned()),
],
};
assert_eq!(
request
.component_value(&Component::header("accept"))
.unwrap(),
"text/plain, application/json"
);
assert_eq!(
request.component_value(&Component::Authority).unwrap(),
"example.com"
);
assert_eq!(request.component_value(&Component::Path).unwrap(), "/");
}
#[test]
fn derives_the_request_target() {
let mut request = HttpRequest {
method: "POST".to_owned(),
authority: "example.com".to_owned(),
path: "/foo".to_owned(),
query: Some("param=Value&Pet=dog".to_owned()),
headers: vec![],
};
assert_eq!(
request.component_value(&Component::RequestTarget).unwrap(),
"/foo?param=Value&Pet=dog"
);
request.query = None;
assert_eq!(
request.component_value(&Component::RequestTarget).unwrap(),
"/foo"
);
request.path = String::new();
assert_eq!(
request.component_value(&Component::RequestTarget).unwrap(),
"/"
);
request.query = Some(String::new());
assert_eq!(
request.component_value(&Component::RequestTarget).unwrap(),
"/?"
);
}
#[test]
fn parses_the_request_target_identifier() {
assert_eq!(
Component::from_quoted_id("\"@request-target\"").unwrap(),
Component::RequestTarget
);
assert_eq!(Component::RequestTarget.quoted_id(), "\"@request-target\"");
}
#[test]
fn content_digest_round_trips() {
let body = b"payload";
assert!(verify_content_digest(&content_digest_sha256(body), body));
}
#[test]
fn header_components_compare_case_insensitively() {
assert_eq!(
Component::Header("Content-Type".to_owned()),
Component::header("content-type")
);
assert_ne!(Component::header("a"), Component::header("b"));
}
}