use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::{Body, Bytes};
use axum::http::header::{
CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG,
IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, LINK, VARY,
};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode};
use axum::response::Response;
use futures::StreamExt as _;
use http_body_util::BodyExt as _;
#[derive(Debug, Clone, Default)]
pub struct NegotiateConfig {
pub markdown: bool,
pub max_bytes: usize,
pub home_links: Vec<(String, HeaderValue)>,
pub content_signal: Option<HeaderValue>,
pub resource_metadata: Option<HeaderValue>,
pub resource_metadata_from_host: bool,
}
impl NegotiateConfig {
fn home(&self, path: &str) -> Option<usize> {
self.home_links.iter().position(|(home, _)| home == path)
}
}
#[derive(Debug, Clone)]
pub struct NegotiateLayer {
config: Arc<NegotiateConfig>,
}
impl NegotiateLayer {
#[must_use]
pub fn new(config: NegotiateConfig) -> Self {
Self {
config: Arc::new(config),
}
}
}
impl<S> tower::Layer<S> for NegotiateLayer {
type Service = NegotiateService<S>;
fn layer(&self, inner: S) -> Self::Service {
NegotiateService {
inner,
config: Arc::clone(&self.config),
}
}
}
#[derive(Debug, Clone)]
pub struct NegotiateService<S> {
inner: S,
config: Arc<NegotiateConfig>,
}
impl<S> tower::Service<Request<Body>> for NegotiateService<S>
where
S: tower::Service<Request<Body>, Response = Response>,
S::Future: Send + 'static,
{
type Response = Response;
type Error = S::Error;
type Future = NegotiateFuture<S::Future>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<Body>) -> Self::Future {
let is_get = req.method() == Method::GET;
let readable = is_get || req.method() == Method::HEAD;
let flags = Flags {
home: if readable {
self.config.home(req.uri().path())
} else {
None
},
readable,
is_head: !is_get && readable,
};
let accept = (self.config.markdown && readable)
.then(|| accept_values(req.headers()))
.filter(|accept| markdown_possible(accept));
let validators = if accept.is_some() {
Validators::take(req.headers_mut())
} else {
None
};
let host = self
.config
.resource_metadata_from_host
.then(|| super::documents::request_authority(&req));
let page = accept.is_some().then(|| {
req.uri()
.path_and_query()
.map_or_else(|| req.uri().path().to_owned(), ToString::to_string)
});
NegotiateFuture {
inner: self.inner.call(req),
config: Arc::clone(&self.config),
flags,
validators,
host,
page,
accept,
convert: None,
}
}
}
#[derive(Debug, Clone, Copy)]
#[allow(clippy::struct_excessive_bools)] struct Flags {
home: Option<usize>,
readable: bool,
is_head: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Charset {
Utf8,
Ascii,
}
impl Charset {
fn label(value: &str) -> Option<Self> {
let value = value.trim().trim_matches('"');
if ["utf-8", "utf8", "unicode-1-1-utf-8"]
.iter()
.any(|l| value.eq_ignore_ascii_case(l))
{
Some(Self::Utf8)
} else if ["us-ascii", "ascii", "iso646-us", "ansi_x3.4-1968"]
.iter()
.any(|l| value.eq_ignore_ascii_case(l))
{
Some(Self::Ascii)
} else {
None
}
}
}
type ConvertFuture = Pin<Box<dyn Future<Output = Response> + Send>>;
#[derive(Debug)]
struct Validators {
if_none_match: Vec<HeaderValue>,
if_modified_since: Option<HeaderValue>,
}
impl Validators {
fn take(headers: &mut HeaderMap) -> Option<Box<Self>> {
let if_none_match: Vec<HeaderValue> = match headers.entry(IF_NONE_MATCH) {
axum::http::header::Entry::Occupied(e) => e.remove_entry_mult().1.collect(),
axum::http::header::Entry::Vacant(_) => Vec::new(),
};
let if_modified_since = headers.remove(IF_MODIFIED_SINCE);
(!if_none_match.is_empty() || if_modified_since.is_some()).then(|| {
Box::new(Self {
if_none_match,
if_modified_since,
})
})
}
fn apply(&self, res: Response) -> Response {
if matches!(res.status(), StatusCode::OK | StatusCode::PARTIAL_CONTENT)
&& crate::etag::validators_match(
&self.if_none_match,
self.if_modified_since.as_ref(),
res.headers(),
)
{
crate::etag::not_modified_from(res.headers())
} else {
res
}
}
}
pin_project_lite::pin_project! {
pub struct NegotiateFuture<F> {
#[pin]
inner: F,
config: Arc<NegotiateConfig>,
flags: Flags,
validators: Option<Box<Validators>>,
host: Option<(Option<String>, Option<String>)>,
page: Option<String>,
accept: Option<Vec<HeaderValue>>,
convert: Option<ConvertFuture>,
}
}
impl<F, E> Future for NegotiateFuture<F>
where
F: Future<Output = Result<Response, E>>,
{
type Output = Result<Response, E>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
if let Some(convert) = this.convert.as_mut() {
return convert.as_mut().poll(cx).map(Ok);
}
let mut res = std::task::ready!(this.inner.poll(cx))?;
let config = &**this.config;
if res.extensions().get::<super::AeoDocument>().is_some() {
res.headers_mut().remove(axum::http::header::SET_COOKIE);
}
if res.status() == StatusCode::UNAUTHORIZED {
let hint = config.resource_metadata.clone().or_else(|| {
config
.resource_metadata_from_host
.then(|| {
let (host, scheme) = this
.host
.as_ref()
.map_or((None, None), |(h, s)| (h.as_deref(), s.as_deref()));
host_resource_metadata(host, scheme)
})
.flatten()
});
if let Some(hint) = hint {
add_resource_metadata(res.headers_mut(), &hint);
}
}
if res.status().is_success()
&& let Some((_, link)) = this.flags.home.and_then(|i| config.home_links.get(i))
{
res.headers_mut().append(LINK, link.clone());
}
let validators = this.validators.take();
let page = this.page.take();
let checked = |res: Response| match &validators {
Some(v) => v.apply(res),
None => res,
};
if !(config.markdown && this.flags.readable && is_negotiable(&res)) {
let mut res = checked(res);
if config.markdown && this.flags.readable && res.status() == StatusCode::NOT_MODIFIED {
add_vary_accept(res.headers_mut());
}
return Poll::Ready(Ok(res));
}
add_vary_accept(res.headers_mut());
let content_type = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !this
.accept
.as_deref()
.is_some_and(|accept| markdown_wins(accept, content_type))
{
return Poll::Ready(Ok(checked(res)));
}
let body_here = {
use http_body::Body as _;
res.body().size_hint().exact().is_some_and(|n| n > 0)
};
if this.flags.is_head && !body_here {
if known_to_fit(&res, config.max_bytes) {
markdown_headers(res.headers_mut(), config, None);
}
return Poll::Ready(Ok(checked(res)));
}
let config = Arc::clone(this.config);
let mut convert: ConvertFuture = Box::pin(async move {
let res = to_markdown(res, &config, page.as_deref()).await;
match validators {
Some(v) => v.apply(res),
None => res,
}
});
let poll = convert.as_mut().poll(cx);
*this.convert = Some(convert);
poll.map(Ok)
}
}
fn host_resource_metadata(host: Option<&str>, scheme: Option<&str>) -> Option<HeaderValue> {
let origin = super::documents::Origin::resolve_with_scheme(None, host, scheme);
HeaderValue::from_str(&format!(
"Bearer resource_metadata=\"{}\"",
origin.url(super::documents::OAUTH_RESOURCE_PATH)
))
.ok()
}
fn add_resource_metadata(headers: &mut HeaderMap, hint: &HeaderValue) {
use axum::http::header::WWW_AUTHENTICATE;
if !headers.contains_key(WWW_AUTHENTICATE) {
headers.insert(WWW_AUTHENTICATE, hint.clone());
return;
}
let Some(param) = hint.to_str().ok().and_then(|h| h.strip_prefix("Bearer ")) else {
return;
};
let merged: Vec<HeaderValue> = headers
.get_all(WWW_AUTHENTICATE)
.iter()
.map(|v| {
v.to_str()
.ok()
.and_then(|s| with_bearer_param(s, param))
.and_then(|s| HeaderValue::from_str(&s).ok())
.unwrap_or_else(|| v.clone())
})
.collect();
headers.remove(WWW_AUTHENTICATE);
for v in merged {
headers.append(WWW_AUTHENTICATE, v);
}
}
fn with_bearer_param(challenges: &str, param: &str) -> Option<String> {
if challenges
.to_ascii_lowercase()
.contains("resource_metadata")
{
return None;
}
let items: Vec<&str> = split_unquoted(challenges, ',')
.into_iter()
.map(str::trim)
.collect();
let opens = |item: &str| {
let token_end = item
.find(|c: char| c == '=' || c.is_whitespace())
.unwrap_or(item.len());
!item[token_end..].trim_start().starts_with('=')
};
let first = items.iter().position(|item| {
opens(item)
&& item
.split_whitespace()
.next()
.is_some_and(|s| s.eq_ignore_ascii_case("Bearer"))
})?;
let end = items[first + 1..]
.iter()
.position(|item| opens(item))
.map_or(items.len(), |n| first + 1 + n);
let mut out: Vec<String> = items.iter().map(|s| (*s).to_owned()).collect();
if out[first].trim().eq_ignore_ascii_case("Bearer") {
out[first] = format!("Bearer {param}");
} else {
out.insert(end, param.to_owned());
}
Some(out.join(", "))
}
fn markdown_etag(headers: &mut HeaderMap) {
if let Some(etag) = headers.get(ETAG).and_then(|v| v.to_str().ok()) {
let opaque = etag.trim_start_matches("W/").trim_matches('"');
match HeaderValue::from_str(&format!("W/\"{opaque}-md\"")) {
Ok(v) => {
headers.insert(ETAG, v);
}
Err(_) => {
headers.remove(ETAG);
}
}
}
}
#[must_use]
pub fn prefers_markdown(headers: &HeaderMap) -> bool {
markdown_possible(&accept_values(headers))
}
fn accept_values(headers: &HeaderMap) -> Vec<HeaderValue> {
headers
.get_all(axum::http::header::ACCEPT)
.iter()
.cloned()
.collect()
}
fn markdown_possible(accept: &[HeaderValue]) -> bool {
markdown_wins(accept, "text/html")
|| markdown_wins(accept, "text/html; charset=us-ascii")
|| (ranges(accept).any(|(media, params, _)| {
!media.eq_ignore_ascii_case("text/markdown")
&& params
.iter()
.any(|(k, _)| !k.eq_ignore_ascii_case("charset"))
}) && weights(accept, "text/html").0 > 0.0)
}
fn markdown_wins(accept: &[HeaderValue], html_type: &str) -> bool {
let (markdown, html) = weights(accept, html_type);
markdown > 0.0 && markdown >= html
}
fn ranges(accept: &[HeaderValue]) -> impl Iterator<Item = (&str, Vec<(&str, &str)>, f32)> {
accept
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| split_unquoted(v, ','))
.map(|range| {
let parts = split_unquoted(range, ';');
let media = parts.first().copied().unwrap_or("").trim();
let q = accept_weight(parts.iter().skip(1).copied());
let params = parts
.into_iter()
.skip(1)
.map(str::trim)
.filter(|p| !p.is_empty())
.take_while(|p| {
!p.split_once('=')
.is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("q"))
})
.map(|p| {
p.split_once('=')
.map_or((p, ""), |(k, v)| (k.trim(), v.trim().trim_matches('"')))
})
.collect();
(media, params, q)
})
}
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut items = Vec::new();
let (mut start, mut quoted, mut escaped) = (0, false, false);
for (i, c) in s.char_indices() {
match c {
_ if escaped => escaped = false,
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
c if c == sep && !quoted => {
items.push(&s[start..i]);
start = i + c.len_utf8();
}
_ => {}
}
}
items.push(&s[start..]);
items
}
fn type_params(content_type: &str) -> Vec<(&str, &str)> {
let mut params: Vec<(&str, &str)> = split_unquoted(content_type, ';')
.into_iter()
.skip(1)
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.trim(), v.trim().trim_matches('"')))
.collect();
if !params
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("charset"))
{
params.push(("charset", "utf-8"));
}
params
}
fn weights(accept: &[HeaderValue], html_type: &str) -> (f32, f32) {
const MARKDOWN: &[(&str, &str)] = &[("charset", "utf-8"), ("variant", "GFM")];
let html_params = type_params(html_type);
let mut markdown = ((0_u8, 0_usize), 0.0_f32);
let mut html = ((0_u8, 0_usize), 0.0_f32);
let take = |best: &mut ((u8, usize), f32), specificity: (u8, usize), q: f32| {
if specificity > best.0 || (specificity == best.0 && q > best.1) {
*best = (specificity, q);
}
};
for (media, params, q) in ranges(accept) {
let markdown_range = media.eq_ignore_ascii_case("text/markdown");
let level = if markdown_range || media.eq_ignore_ascii_case("text/html") {
3
} else if media.eq_ignore_ascii_case("text/*") {
2
} else if media == "*/*" {
1
} else {
continue;
};
let sent = if markdown_range {
MARKDOWN
} else {
&html_params[..]
};
if !params.iter().all(|&(k, v)| has_param(sent, k, v)) {
continue;
}
let best = if markdown_range {
&mut markdown
} else {
&mut html
};
take(best, (level, params.len()), q);
}
(markdown.1, html.1)
}
fn has_param(sent: &[(&str, &str)], key: &str, value: &str) -> bool {
sent.iter().any(|&(k, v)| {
k.eq_ignore_ascii_case(key)
&& if key.eq_ignore_ascii_case("charset") {
match (Charset::label(value), Charset::label(v)) {
(Some(a), Some(b)) => a == b,
_ => value.eq_ignore_ascii_case(v),
}
} else if key.eq_ignore_ascii_case("variant") {
value.eq_ignore_ascii_case(v)
} else {
value == v
}
})
}
fn accept_weight<'a>(params: impl Iterator<Item = &'a str>) -> f32 {
for param in params {
let Some((key, value)) = param.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("q") {
return value
.trim()
.parse::<f32>()
.map_or(0.0, |q| q.clamp(0.0, 1.0));
}
}
1.0
}
fn is_negotiable(res: &Response) -> bool {
let headers = res.headers();
res.status() == StatusCode::OK
&& !headers.contains_key("signature")
&& !headers.contains_key("signature-input")
&& headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(utf8_html)
&& !headers.contains_key(CONTENT_ENCODING)
&& !headers.contains_key(CONTENT_RANGE)
&& !headers
.get(CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.trim_start()
.to_ascii_lowercase()
.starts_with("attachment")
})
}
fn utf8_html(content_type: &str) -> bool {
let mut parts = split_unquoted(content_type, ';').into_iter();
let html = parts
.next()
.is_some_and(|m| m.trim().eq_ignore_ascii_case("text/html"));
html && parts.all(|param| {
let Some((name, value)) = param.split_once('=') else {
return true;
};
!name.trim().eq_ignore_ascii_case("charset") || Charset::label(value).is_some()
})
}
fn add_vary_accept(headers: &mut HeaderMap) {
let present = headers.get_all(VARY).iter().any(|v| {
v.to_str().is_ok_and(|v| {
v.split(',')
.any(|p| p.trim() == "*" || p.trim().eq_ignore_ascii_case("accept"))
})
});
if !present {
headers.append(VARY, HeaderValue::from_static("Accept"));
}
}
fn content_length(res: &Response) -> Option<u64> {
res.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
}
fn fits(res: &Response, max: usize) -> bool {
use http_body::Body as _;
res.body()
.size_hint()
.exact()
.is_some_and(|len| len <= max as u64)
}
fn known_to_fit(res: &Response, max: usize) -> bool {
use http_body::Body as _;
content_length(res)
.or_else(|| res.body().size_hint().exact().filter(|&n| n > 0))
.is_some_and(|len| len <= max as u64)
}
async fn to_markdown(res: Response, config: &NegotiateConfig, page: Option<&str>) -> Response {
if !fits(&res, config.max_bytes) {
return res;
}
let (mut parts, body) = res.into_parts();
let bytes = match collect_limited(body, config.max_bytes, COLLECT_WITHIN).await {
Ok(bytes) => bytes,
Err(body) => return Response::from_parts(parts, body),
};
let markdown = if bytes.len() > BLOCKING_THRESHOLD {
let html = bytes.clone();
let page = page.map(ToOwned::to_owned);
match crate::time::spawn_blocking(move || {
super::markdown::html_to_markdown_at(&String::from_utf8_lossy(&html), page.as_deref())
})
.await
{
Ok(markdown) => markdown,
Err(_) => return Response::from_parts(parts, Body::from(bytes)),
}
} else {
super::markdown::html_to_markdown_at(&String::from_utf8_lossy(&bytes), page)
};
markdown_headers(
&mut parts.headers,
config,
Some(super::markdown::estimate_tokens(&markdown)),
);
Response::from_parts(parts, Body::from(markdown))
}
const COLLECT_WITHIN: std::time::Duration = std::time::Duration::from_secs(2);
const BLOCKING_THRESHOLD: usize = 256 * 1024;
fn markdown_headers(headers: &mut HeaderMap, config: &NegotiateConfig, tokens: Option<usize>) {
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8; variant=GFM"),
);
headers.remove(CONTENT_LENGTH);
headers.remove(LAST_MODIFIED);
for name in ["content-digest", "repr-digest", "digest", "content-md5"] {
headers.remove(name);
}
headers.remove(axum::http::header::ACCEPT_RANGES);
headers.remove(axum::http::header::TRAILER);
markdown_etag(headers);
if !headers.contains_key(axum::http::header::CACHE_CONTROL) {
headers.insert(
axum::http::header::CACHE_CONTROL,
HeaderValue::from_static("private"),
);
}
if let Some(tokens) = tokens {
headers.insert("x-markdown-tokens", HeaderValue::from(tokens));
}
if let Some(signal) = &config.content_signal {
headers.insert("content-signal", signal.clone());
}
}
async fn collect_limited(
mut body: Body,
limit: usize,
within: std::time::Duration,
) -> Result<Bytes, Body> {
let deadline = tokio::time::Instant::now() + within;
let mut chunks: Vec<Bytes> = Vec::new();
let mut total = 0usize;
loop {
let Ok(frame) = tokio::time::timeout_at(deadline, body.frame()).await else {
return Err(replay(chunks, body));
};
match frame {
None => break,
Some(Ok(frame)) => {
let Ok(data) = frame.into_data() else {
continue;
};
total += data.len();
chunks.push(data);
if total > limit {
return Err(replay(chunks, body));
}
}
Some(Err(err)) => {
let head = futures::stream::iter(chunks.into_iter().map(Ok::<_, axum::Error>));
return Err(Body::from_stream(
head.chain(futures::stream::once(async { Err(err) })),
));
}
}
}
let mut buf = Vec::with_capacity(total);
for chunk in chunks {
buf.extend_from_slice(&chunk);
}
Ok(Bytes::from(buf))
}
fn replay(chunks: Vec<Bytes>, body: Body) -> Body {
let head = futures::stream::iter(
chunks
.into_iter()
.map(|b| Ok::<_, axum::Error>(http_body::Frame::data(b))),
);
let rest = http_body_util::BodyStream::new(body);
Body::new(http_body_util::StreamBody::new(head.chain(rest)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn head_knows_the_length_from_content_length_only() {
let res = |len: Option<&'static str>| {
let mut res = Response::new(Body::empty());
if let Some(len) = len {
res.headers_mut()
.insert(CONTENT_LENGTH, HeaderValue::from_static(len));
}
res
};
assert!(known_to_fit(&res(Some("0")), 10), "an empty page fits");
assert!(known_to_fit(&res(Some("10")), 10));
assert!(!known_to_fit(&res(Some("11")), 10));
assert!(
!known_to_fit(&res(None), 10),
"a stripped body says nothing"
);
assert!(known_to_fit(&Response::new(Body::from("<p>x</p>")), 10));
}
#[test]
fn a_matching_validator_turns_a_partial_response_into_304() {
let mut headers = HeaderMap::new();
headers.insert(IF_NONE_MATCH, HeaderValue::from_static("\"h1\""));
let validators = Validators::take(&mut headers).unwrap();
let partial = |etag: &'static str| {
let mut res = Response::new(Body::from("abc"));
*res.status_mut() = StatusCode::PARTIAL_CONTENT;
res.headers_mut()
.insert(axum::http::header::ETAG, HeaderValue::from_static(etag));
res
};
assert_eq!(validators.apply(partial("\"h1\"")).status(), 304);
assert_eq!(validators.apply(partial("\"h2\"")).status(), 206);
}
#[test]
fn only_utf8_html_is_converted() {
assert!(utf8_html("text/html"));
assert!(utf8_html("text/html; charset=utf-8"));
assert!(utf8_html("Text/HTML; Charset=\"UTF-8\""));
assert!(utf8_html("text/html; charset=us-ascii"));
assert!(!utf8_html("text/html; charset=iso-8859-1"));
assert!(!utf8_html("text/html;charset=windows-1252"));
assert!(!utf8_html("application/json"));
}
#[test]
fn a_signed_response_is_not_converted() {
let html = || {
Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from("<h1>Hi</h1>"))
.unwrap()
};
assert!(is_negotiable(&html()));
for name in ["signature", "signature-input"] {
let mut res = html();
res.headers_mut()
.insert(name, HeaderValue::from_static("sig1=:YQ==:"));
assert!(!is_negotiable(&res), "{name}");
}
}
#[test]
fn markdown_drops_the_html_trailer_declaration() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::TRAILER,
HeaderValue::from_static("content-digest"),
);
markdown_headers(&mut headers, &NegotiateConfig::default(), None);
assert!(!headers.contains_key(axum::http::header::TRAILER));
}
fn accept(v: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
axum::http::header::ACCEPT,
HeaderValue::from_str(v).unwrap(),
);
h
}
fn utf8(headers: &HeaderMap) -> bool {
markdown_wins(&accept_values(headers), "text/html")
}
#[test]
fn accept_ranking() {
assert!(utf8(&accept("text/markdown")));
assert!(utf8(&accept("text/markdown, text/html;q=0.9")));
assert!(utf8(&accept("text/html;q=0.5, text/markdown")));
assert!(!utf8(&accept("text/html, text/markdown;q=0.5")));
assert!(!utf8(&accept("text/markdown;q=0")));
assert!(!utf8(&accept("*/*")));
assert!(!utf8(&accept("text/markdown;q=0.5, */*")));
assert!(!utf8(&accept("text/markdown;q=0.5, text/*")));
assert!(utf8(&accept("text/markdown, */*;q=0.8")));
assert!(!utf8(&accept(
"text/markdown; charset=iso-8859-1, text/html;q=0.5"
)));
assert!(!utf8(&accept("text/markdown;variant=CommonMark")));
assert!(!utf8(&accept("text/markdown;level=1")));
assert!(utf8(&accept(
"text/markdown; charset=\"UTF-8\"; variant=gfm; q=0.9, text/html;q=0.5"
)));
assert!(utf8(&accept("text/markdown;")));
assert!(!utf8(&accept(
"text/markdown;variant=GFM;q=0, text/markdown;q=1, text/html;q=0.5"
)));
assert!(utf8(&accept(
"text/markdown;q=0, text/markdown;variant=GFM;q=1, text/html;q=0.5"
)));
assert!(!utf8(&accept(
"text/html;charset=utf-8;q=1, text/html;q=0, text/markdown;q=0.5"
)));
assert!(utf8(&accept(
"text/html;charset=utf-8;q=0, text/html;q=1, text/markdown;q=0.5"
)));
assert!(utf8(&accept(
"text/html; charset=iso-8859-1, text/markdown;q=0.5"
)));
assert!(!utf8(&accept(
"text/html; charset=utf-8, text/markdown;q=0.5"
)));
assert!(!utf8(&accept(
"text/html;charset=latin1, */*, text/markdown;q=0.5"
)));
assert!(utf8(&accept("text/markdown;Q=0.9, text/html;q=0.5")));
assert!(!utf8(&accept("text/markdown;q=abc")));
assert!(!utf8(&HeaderMap::new()));
}
#[test]
fn the_most_specific_range_sets_the_html_weight() {
assert!(utf8(&accept("text/html;q=0, text/markdown;q=0.5, */*;q=1")));
assert!(utf8(&accept("text/*;q=0.2, text/markdown;q=0.5, */*")));
assert!(!utf8(&accept(
"text/html;q=0.9, text/markdown;q=0.5, */*;q=0.1"
)));
}
#[test]
fn the_html_range_matches_the_page_charset() {
let wins = |a: &str, html: &str| markdown_wins(&accept_values(&accept(a)), html);
let possible = |a: &str| prefers_markdown(&accept(a));
let ascii = "text/html;charset=us-ascii;q=1, text/markdown;q=0.5";
assert!(!wins(ascii, "text/html; charset=us-ascii"));
assert!(!wins(ascii, "text/html; charset=\"US-ASCII\""));
assert!(wins(ascii, "text/html; charset=utf-8"));
assert!(wins(ascii, "text/html"));
assert!(possible(ascii));
let utf8 = "text/html;charset=utf-8;q=1, text/markdown;q=0.5";
assert!(!wins(utf8, "text/html; charset=utf-8"));
assert!(!wins(utf8, "text/html"));
assert!(wins(utf8, "text/html; charset=us-ascii"));
assert!(!possible("text/html, text/markdown;q=0.5"));
}
#[test]
fn charset_labels_are_matched_by_what_they_name() {
let wins = |a: &str, html: &str| markdown_wins(&accept_values(&accept(a)), html);
assert!(utf8(&accept("text/markdown;charset=utf8, text/html;q=0")));
assert!(utf8(&accept(
"text/markdown;charset=\"UTF8\", text/html;q=0.5"
)));
assert!(!wins(
"text/html;charset=utf-8, text/markdown;q=0.5",
"text/html; charset=utf8"
));
let ascii = "text/html;charset=ascii, text/markdown;q=0.5";
assert!(!wins(ascii, "text/html; charset=us-ascii"));
assert!(wins(ascii, "text/html"));
assert!(utf8_html("text/html; charset=ASCII"));
assert!(!utf8_html("text/html; charset=latin1"));
}
#[test]
fn html_range_parameters_match_the_page_type() {
let wins = |a: &str, html: &str| markdown_wins(&accept_values(&accept(a)), html);
let profiled = "text/html;profile=\"https://example.test/p\", text/markdown;q=0.5";
assert!(!wins(
profiled,
"text/html; profile=\"https://example.test/p\""
));
assert!(!wins(
profiled,
"text/html; charset=utf-8; profile=https://example.test/p"
));
let quoted = "text/html;profile=\"a,b;c\";q=1, text/markdown;q=0.5";
assert!(!wins(quoted, "text/html; profile=\"a,b;c\""));
assert!(wins(quoted, "text/html; profile=\"a\""));
assert!(wins(
profiled,
"text/html; profile=\"https://example.test/q\""
));
assert!(wins(profiled, "text/html"));
assert!(prefers_markdown(&accept(profiled)));
}
#[test]
fn localized_home_pages_are_home() {
let link = |s: &'static str| HeaderValue::from_static(s);
let config = NegotiateConfig {
home_links: vec![
("/en".to_owned(), link("<x>; rel=\"alternate\"")),
("/fr".to_owned(), link("<y>; rel=\"alternate\"")),
],
..NegotiateConfig::default()
};
assert_eq!(config.home("/fr"), Some(1));
assert_eq!(config.home("/"), None);
assert_eq!(config.home("/en/x"), None);
}
#[test]
fn validators_are_checked_against_the_representation_sent() {
let mut req = HeaderMap::new();
req.insert(IF_NONE_MATCH, HeaderValue::from_static("W/\"v1-md\""));
req.insert(IF_MODIFIED_SINCE, HeaderValue::from_static("x"));
let v = Validators::take(&mut req).expect("validators");
assert!(req.get(IF_NONE_MATCH).is_none() && req.get(IF_MODIFIED_SINCE).is_none());
let md = |etag: &'static str| {
let mut res = Response::new(Body::from("body"));
res.headers_mut()
.insert(ETAG, HeaderValue::from_static(etag));
res
};
assert_eq!(
v.apply(md("W/\"v1-md\"")).status(),
StatusCode::NOT_MODIFIED
);
assert_eq!(v.apply(md("\"v1\"")).status(), StatusCode::OK);
assert!(Validators::take(&mut HeaderMap::new()).is_none());
}
#[test]
fn resource_metadata_joins_the_handlers_bearer_challenge() {
let p = "resource_metadata=\"https://s/m\"";
assert_eq!(
with_bearer_param("Bearer realm=\"api\", error=\"invalid_token\"", p).as_deref(),
Some(
"Bearer realm=\"api\", error=\"invalid_token\", resource_metadata=\"https://s/m\""
)
);
assert_eq!(
with_bearer_param("Bearer", p).as_deref(),
Some("Bearer resource_metadata=\"https://s/m\"")
);
assert_eq!(
with_bearer_param("Bearer realm=\"a, b\", Basic realm=\"x\"", p).as_deref(),
Some("Bearer realm=\"a, b\", resource_metadata=\"https://s/m\", Basic realm=\"x\"")
);
assert_eq!(with_bearer_param("Basic realm=\"x\"", p), None);
assert_eq!(
with_bearer_param("Bearer resource_metadata=\"https://o\"", p),
None
);
}
#[test]
fn conversion_drops_metadata_about_the_html_bytes() {
let mut h = HeaderMap::new();
for name in [
"content-digest",
"repr-digest",
"digest",
"content-md5",
"accept-ranges",
] {
h.insert(
axum::http::HeaderName::from_static(name),
HeaderValue::from_static("x"),
);
}
let config = NegotiateConfig {
markdown: true,
max_bytes: 1024,
home_links: Vec::new(),
content_signal: None,
resource_metadata: None,
resource_metadata_from_host: false,
};
markdown_headers(&mut h, &config, Some(1));
for name in [
"content-digest",
"repr-digest",
"digest",
"content-md5",
"accept-ranges",
] {
assert!(h.get(name).is_none(), "{name}");
}
}
#[tokio::test]
async fn a_stream_of_unknown_length_stays_html() {
let config = NegotiateConfig {
markdown: true,
max_bytes: 1024,
home_links: Vec::new(),
content_signal: None,
resource_metadata: None,
resource_metadata_from_host: false,
};
let html = || Response::builder().header(CONTENT_TYPE, "text/html; charset=utf-8");
let streamed = html()
.body(Body::from_stream(futures::stream::pending::<
Result<Bytes, std::io::Error>,
>()))
.unwrap();
let res = tokio::time::timeout(
std::time::Duration::from_secs(5),
to_markdown(streamed, &config, None),
)
.await
.expect("not collected");
assert!(
res.headers()[CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/html")
);
let declared = html()
.header(CONTENT_LENGTH, "11")
.body(Body::from_stream(futures::stream::pending::<
Result<Bytes, std::io::Error>,
>()))
.unwrap();
let res = tokio::time::timeout(
std::time::Duration::from_secs(5),
to_markdown(declared, &config, None),
)
.await
.expect("not collected");
assert!(
res.headers()[CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/html")
);
let known = html().body(Body::from("<h1>Hi</h1>")).unwrap();
let res = to_markdown(known, &config, None).await;
assert!(
res.headers()[CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/markdown")
);
}
struct ExactButLazy {
sent: bool,
}
impl http_body::Body for ExactButLazy {
type Data = Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
if self.sent {
return std::task::Poll::Pending;
}
self.sent = true;
std::task::Poll::Ready(Some(Ok(http_body::Frame::data(Bytes::from("<h1>")))))
}
fn size_hint(&self) -> http_body::SizeHint {
http_body::SizeHint::with_exact(11)
}
}
#[tokio::test(start_paused = true)]
async fn an_exact_but_lazy_body_stays_html_after_the_deadline() {
let config = NegotiateConfig {
markdown: true,
max_bytes: 1024,
home_links: Vec::new(),
content_signal: None,
resource_metadata: None,
resource_metadata_from_host: false,
};
let res = Response::builder()
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::new(ExactButLazy { sent: false }))
.unwrap();
let started = tokio::time::Instant::now();
let res = to_markdown(res, &config, None).await;
assert_eq!(started.elapsed(), COLLECT_WITHIN);
assert!(
res.headers()[CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/html")
);
let mut body = res.into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
assert_eq!(first, Bytes::from("<h1>"));
}
#[tokio::test]
async fn collect_limited_keeps_trailers_of_an_oversized_body() {
let mut trailers = HeaderMap::new();
trailers.insert("x-digest", HeaderValue::from_static("abc"));
let frames = futures::stream::iter([
Ok::<_, std::io::Error>(http_body::Frame::data(Bytes::from("aaaa"))),
Ok(http_body::Frame::data(Bytes::from("bbbb"))),
Ok(http_body::Frame::trailers(trailers)),
]);
let body = Body::new(http_body_util::StreamBody::new(frames));
let Err(body) = collect_limited(body, 6, COLLECT_WITHIN).await else {
panic!("over the limit")
};
let collected = body.collect().await.unwrap();
assert_eq!(
collected.trailers().and_then(|t| t.get("x-digest")),
Some(&HeaderValue::from_static("abc"))
);
assert_eq!(&collected.to_bytes()[..], b"aaaabbbb");
}
#[tokio::test]
async fn collect_limited_replays_an_oversized_body() {
let body = Body::from_stream(futures::stream::iter(
["aaaa", "bbbb", "cccc"].map(|s| Ok::<_, std::io::Error>(Bytes::from(s))),
));
let Err(body) = collect_limited(body, 6, COLLECT_WITHIN).await else {
panic!("over the limit")
};
let all = body.collect().await.unwrap().to_bytes();
assert_eq!(&all[..], b"aaaabbbbcccc");
let ok = collect_limited(Body::from("abc"), 6, COLLECT_WITHIN)
.await
.unwrap();
assert_eq!(&ok[..], b"abc");
}
}