use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;
use serve_net::{Method, Verdict};
use crate::app::Shared;
use crate::error::Error;
use crate::router::Captures;
use crate::{Output, Request, RequestId, Result, Stage, TemplateId, View, url};
#[derive(Default)]
pub(crate) struct Out {
pub(crate) status: u16,
pub(crate) names: Vec<u8>,
pub(crate) values: Vec<u8>,
pub(crate) spans: Vec<(u32, u32, u32, u32)>,
pub(crate) body: Vec<u8>,
pub(crate) invalid: bool,
pub(crate) has_type: bool,
}
const FRAMING: [&str; 5] = [
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"upgrade",
];
impl Out {
pub(crate) fn reset(&mut self) {
self.status = 200;
self.names.clear();
self.values.clear();
self.spans.clear();
self.body.clear();
self.invalid = false;
self.has_type = false;
}
pub(crate) fn wipe(&mut self) {
for v in [&mut self.names, &mut self.values, &mut self.body] {
v.iter_mut().for_each(|b| *b = 0);
std::hint::black_box(&v);
}
}
pub(crate) fn header(&mut self, name: &str, value: &[u8]) {
let ok_name = !name.is_empty()
&& name.bytes().all(is_tchar)
&& !FRAMING.iter().any(|f| f.eq_ignore_ascii_case(name));
let ok_value = value
.iter()
.all(|&b| b == b'\t' || (b >= 0x20 && b != 0x7f));
if !ok_name || !ok_value || self.spans.len() >= 64 {
self.invalid = true;
return;
}
if name.eq_ignore_ascii_case("content-type") {
self.has_type = true;
}
let (ns, vs) = (self.names.len() as u32, self.values.len() as u32);
self.names
.extend(name.bytes().map(|b| b.to_ascii_lowercase()));
self.values.extend_from_slice(value);
self.spans
.push((ns, self.names.len() as u32, vs, self.values.len() as u32));
}
pub(crate) fn has(&self, name: &str) -> bool {
self.spans
.iter()
.any(|&(s, e, _, _)| &self.names[s as usize..e as usize] == name.as_bytes())
}
}
const fn is_tchar(b: u8) -> bool {
matches!(b,
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'~')
}
pub(crate) fn is_local_target(p: &str) -> bool {
let b = p.as_bytes();
b.first() == Some(&b'/')
&& !matches!(b.get(1), Some(b'/' | b'\\'))
&& b.iter().all(|&c| (0x21..0x7f).contains(&c) && c != b'\\')
}
pub struct Cx<'r, I> {
pub(crate) req: Request<'r>,
pub(crate) who: &'r I,
pub(crate) caps: Captures,
pub(crate) names: &'r [Box<str>],
pub(crate) out: &'r mut Out,
pub(crate) shared: &'r Shared,
pub(crate) template: Option<TemplateId>,
}
impl<'r, I> Cx<'r, I> {
#[must_use]
pub fn request(&self) -> &Request<'r> {
&self.req
}
#[must_use]
pub fn method(&self) -> Method {
self.req.method()
}
#[must_use]
pub fn path(&self) -> &'r str {
self.req.path
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&'r [u8]> {
self.req.header(name)
}
#[must_use]
pub fn identity(&self) -> &'r I {
self.who
}
#[must_use]
pub fn id(&self) -> RequestId {
self.req.id()
}
#[must_use]
pub fn param(&self, name: &str) -> Option<Cow<'r, str>> {
let i = self.names.iter().position(|n| n.as_ref() == name)?;
let (s, e) = self.caps.get(i)?;
Some(url::decode(&self.req.path[s..e]))
}
pub fn param_as<T: FromStr>(&self, name: &str) -> Result<T> {
self.param(name)
.and_then(|v| v.parse().ok())
.ok_or_else(|| Error::not_found("route.param"))
}
pub fn query(&self, name: &str) -> Result<Option<Cow<'r, str>>> {
let Some(q) = self.req.query() else {
return Ok(None);
};
let mut found = None;
for pair in q.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
if decode_query(k).as_deref() != Some(name) {
continue;
}
if found.is_some() {
return Err(Error::bad_request("query.duplicate"));
}
found = Some(decode_query(v).ok_or_else(|| Error::bad_request("query.invalid"))?);
}
Ok(found)
}
pub fn body(&self) -> Result<&'r [u8]> {
Ok(self.req.http.body_bytes())
}
pub fn status(&mut self, status: u16) -> &mut Self {
self.out.status = status;
self
}
pub fn set_header(&mut self, name: &str, value: impl AsRef<[u8]>) -> &mut Self {
self.out.header(name, value.as_ref());
self
}
pub fn body_bytes(&mut self, bytes: &[u8]) -> &mut Self {
self.out.body.clear();
self.out.body.extend_from_slice(bytes);
self
}
pub fn html(&mut self, status: u16, html: &str) -> &mut Self {
self.content_type("text/html; charset=utf-8");
self.status(status).body_bytes(html.as_bytes())
}
pub fn text(&mut self, status: u16, text: &str) -> &mut Self {
self.content_type("text/plain; charset=utf-8");
self.status(status).body_bytes(text.as_bytes())
}
pub fn set_cookie(&mut self, cookie: &cookie_net::SetCookie<'_>) -> &mut Self {
let line = cookie.encode();
self.out.header("set-cookie", line.as_bytes());
self
}
pub fn not_modified(&mut self, tag: &str) -> bool {
let Ok(current) = parse_net::EntityTag::strong(tag.as_bytes()) else {
return false;
};
let quoted = format!("\"{tag}\"");
self.out.header("etag", quoted.as_bytes());
if !matches!(self.method(), Method::Get | Method::Head) {
return false;
}
let matched = self
.req
.header("if-none-match")
.is_some_and(|v| parse_net::if_none_match(v, ¤t).unwrap_or(false));
if matched {
self.out.status = 304;
self.out.body.clear();
}
matched
}
fn content_type(&mut self, value: &str) {
if !self.out.has_type {
self.out.header("content-type", value.as_bytes());
}
}
#[must_use]
pub fn url(&self, path: &str) -> String {
let base = self.shared.base.as_ref();
if path == "/" && !base.is_empty() {
format!("{base}/")
} else {
format!("{base}{path}")
}
}
pub fn see_other(&mut self, path: &str) -> Result {
self.redirect(303, path)
}
pub fn redirect(&mut self, status: u16, path: &str) -> Result {
if !matches!(status, 301 | 302 | 303 | 307 | 308) {
return Err(Error::internal("redirect.status"));
}
if !is_local_target(path) {
return Err(Error::internal("redirect.not_local"));
}
let location = self.url(path);
self.out.header("location", location.as_bytes());
self.out.status = status;
self.out.body.clear();
Ok(())
}
pub fn render(&mut self, view: &dyn View) -> Result {
let t = self
.template
.ok_or_else(|| Error::internal("render.no_template"))?;
self.render_id(t, view)
}
pub fn render_named(&mut self, name: &str, view: &dyn View) -> Result {
let engine = self
.shared
.render
.as_deref()
.ok_or_else(|| Error::internal("render.no_engine"))?;
let t = engine
.template(name)
.ok_or_else(|| Error::internal("render.no_template"))?;
self.render_id(t, view)
}
fn render_id(&mut self, t: TemplateId, view: &dyn View) -> Result {
let engine = self
.shared
.render
.as_deref()
.ok_or_else(|| Error::internal("render.no_engine"))?;
if let Some(cp) = &self.shared.checkpoint {
match cp.check(self.req.env, Stage::BeforeRender) {
Verdict::Pass => {}
Verdict::Deny { status } => return Err(Error::new(status, "checkpoint.denied")),
_ => return Err(Error::forbidden("checkpoint.killed")),
}
}
self.out.body.clear();
let mut output = Output::new(&mut self.out.body, self.shared.page_limit);
let rendered = engine.render(t, view, &mut output);
if let Err(e) = rendered {
self.out.body.clear();
return Err(e.into());
}
self.content_type("text/html; charset=utf-8");
Ok(())
}
}
impl<I> fmt::Debug for Cx<'_, I> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Cx")
.field("request", &self.req)
.field("status", &self.out.status)
.finish_non_exhaustive()
}
}
fn decode_query(s: &str) -> Option<Cow<'_, str>> {
if !s.contains(['%', '+']) {
return Some(Cow::Borrowed(s));
}
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' => {
let h = b.get(i + 1).and_then(|&c| (c as char).to_digit(16))?;
let l = b.get(i + 2).and_then(|&c| (c as char).to_digit(16))?;
out.push(((h << 4) | l) as u8);
i += 3;
}
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8(out).ok().map(Cow::Owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_local_targets() {
for ok in ["/", "/a", "/a/b?c=d", "/a%20b"] {
assert!(is_local_target(ok), "{ok}");
}
for bad in [
"",
"a",
"//evil.example",
"/\\evil.example",
"https://x",
"/a\r\nx",
"/a b",
"/a\\b",
] {
assert!(!is_local_target(bad), "{bad:?}");
}
}
#[test]
fn test_decode_query() {
assert_eq!(decode_query("a+b%21").as_deref(), Some("a b!"));
assert_eq!(decode_query("plain").as_deref(), Some("plain"));
assert_eq!(decode_query("%zz"), None);
assert_eq!(decode_query("%ff"), None);
}
#[test]
fn test_out_refuses_bad_fields() {
let mut o = Out::default();
o.header("x-ok", b"v");
assert!(!o.invalid);
o.header("content-length", b"1");
assert!(o.invalid);
let mut o = Out::default();
o.header("x", b"a\r\nb");
assert!(o.invalid);
let mut o = Out::default();
o.header("bad name", b"v");
assert!(o.invalid);
let mut o = Out::default();
o.header("Content-Type", b"text/x");
assert!(o.has_type && o.has("content-type"));
}
}