Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions arvik-core/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ where
}

fn call(&self, req: Request, state: S) -> BoxFuture<'static, Response> {
// NOTE (audit C1): `H::Future` is a generic associated type here, so
// the compiler cannot see it is already `Pin<Box<dyn Future>>` (every
// blanket impl boxes). The re-box below is therefore inherent on
// stable Rust without specialization — same cost axum's HandlerService
// pays. Eliminating it needs the Handler-trait state-by-ref migration
// (audit O1/R3-5).
let handler = self.handler.clone();
let fut = handler.call(req, state);
Box::pin(fut)
Expand Down
30 changes: 24 additions & 6 deletions arvik-core/src/into_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,21 @@ impl IntoResponse for StatusCode {
impl IntoResponse for String {
fn into_response(self) -> Response {
ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/plain; charset=utf-8"),
)
.body(Body::from(self))
}
}

impl IntoResponse for &'static str {
fn into_response(self) -> Response {
ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/plain; charset=utf-8"),
)
.body(Body::from(self))
}
}
Expand All @@ -174,7 +180,10 @@ impl IntoResponse for &'static str {
impl IntoResponse for Bytes {
fn into_response(self) -> Response {
ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "application/octet-stream")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/octet-stream"),
)
.body(Body::from(self))
}
}
Expand Down Expand Up @@ -421,13 +430,19 @@ impl<T: serde::Serialize> IntoResponse for Json<T> {
let mut writer = BytesMut::with_capacity(128).writer();
match serde_json::to_writer(&mut writer, &self.0) {
Ok(()) => ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/json"),
)
.body(Body::from_bytes(writer.into_inner().freeze())),
Err(err) => {
tracing::error!("JSON serialization failed: {err}");
ResponseBuilder::new()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/json"),
)
.body(Body::from(r#"{"error":"Serialization failed","code":500}"#))
}
}
Expand Down Expand Up @@ -455,7 +470,10 @@ pub struct Html<T>(pub T);
impl<T: Into<String>> IntoResponse for Html<T> {
fn into_response(self) -> Response {
ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/html; charset=utf-8"),
)
.body(Body::from(self.0.into()))
}
}
Expand Down
19 changes: 14 additions & 5 deletions arvik-core/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ impl ResponseBuilder {
///
/// Sets `Content-Type: text/html; charset=utf-8`.
pub fn html(self, html: impl Into<String>) -> Response {
self.header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(html.into()))
self.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/html; charset=utf-8"),
)
.body(Body::from(html.into()))
}

/// Build a plain text response from static data.
Expand All @@ -135,8 +138,11 @@ impl ResponseBuilder {
///
/// Sets `Content-Type: text/plain; charset=utf-8`.
pub fn text(self, text: impl Into<String>) -> Response {
self.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(text.into()))
self.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/plain; charset=utf-8"),
)
.body(Body::from(text.into()))
}

/// Build a response with an empty body.
Expand All @@ -155,7 +161,10 @@ impl Default for ResponseBuilder {
fn internal_error_response() -> Response {
http::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("text/plain; charset=utf-8"),
)
.body(Body::from("Internal Server Error"))
.expect("static fallback response is valid")
}
Expand Down
28 changes: 23 additions & 5 deletions arvik-extract/src/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ use crate::state::FromRef;
// Shared cookie parsing helper
// ---------------------------------------------------------------------------

/// Memoization key for the parsed `Cookie` header (audit C15). Handlers that
/// take several cookie-jar extractors parse the header once instead of once
/// per extractor.
#[derive(Clone)]
struct CachedCookies(std::sync::Arc<InnerJar>);

/// Return this request's parsed cookie jar, parsing (and caching) on first
/// use. Callers still get an owned jar so mutation keeps working unchanged.
fn request_cookie_jar(parts: &mut RequestParts) -> InnerJar {
if let Some(cached) = parts.extensions().get::<CachedCookies>() {
return (*cached.0).clone();
}
let jar = parse_cookie_header(parts);
parts
.extensions_mut()
.insert(CachedCookies(std::sync::Arc::new(jar.clone())));
jar
}

/// Parse the `Cookie` header of a request into an inner `cookie::CookieJar`.
fn parse_cookie_header(parts: &RequestParts) -> InnerJar {
let mut jar = InnerJar::new();
Expand Down Expand Up @@ -166,9 +185,8 @@ impl<S: Send + Sync> FromRequestParts<S> for CookieJar {
parts: &mut RequestParts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(CookieJar {
inner: parse_cookie_header(parts),
})
let inner = request_cookie_jar(parts);
Ok(CookieJar { inner })
}
}

Expand Down Expand Up @@ -246,7 +264,7 @@ where
state: &S,
) -> Result<Self, Self::Rejection> {
let key = Key::from_ref(state);
let inner = parse_cookie_header(parts);
let inner = request_cookie_jar(parts);
Ok(SignedCookieJar { inner, key })
}
}
Expand Down Expand Up @@ -319,7 +337,7 @@ where
state: &S,
) -> Result<Self, Self::Rejection> {
let key = Key::from_ref(state);
let inner = parse_cookie_header(parts);
let inner = request_cookie_jar(parts);
Ok(PrivateCookieJar { inner, key })
}
}
Expand Down
34 changes: 29 additions & 5 deletions arvik-extract/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,37 @@ where

impl<T: serde::Serialize> IntoResponse for Json<T> {
fn into_response(self) -> Response {
let mut writer = BytesMut::with_capacity(128).writer();
let mut writer = BytesMut::with_capacity(512).writer();
match serde_json::to_writer(&mut writer, &self.0) {
Ok(()) => ResponseBuilder::new()
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/json"),
)
.body(Body::from_bytes(writer.into_inner().freeze())),
Err(err) => {
let body = format!("{{\"error\":\"JSON serialization failed: {}\"}}", err);
ResponseBuilder::new()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/json"),
)
.body(Body::from(body))
}
}
}
}

/// Case-insensitive `starts_with`; returns the remainder.
fn strip_ascii_case_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(&value[prefix.len()..])
} else {
None
}
}

/// Check if the Content-Type header indicates JSON.
fn json_content_type(headers: &http::HeaderMap) -> bool {
let content_type = match headers.get(http::header::CONTENT_TYPE) {
Expand All @@ -101,10 +116,16 @@ fn json_content_type(headers: &http::HeaderMap) -> bool {
Err(_) => return false,
};

// Fast path for the exact constant — avoids a full Mime parse per request.
// Fast paths for the exact constant and its most common parameterized
// form — avoid a full Mime parse per request.
if content_type.eq_ignore_ascii_case("application/json") {
return true;
}
if let Some(rest) = strip_ascii_case_prefix(content_type, "application/json")
&& (rest.is_empty() || rest.starts_with(';') || rest.trim_start().starts_with(';'))
{
return true;
}

// Parameterized / suffixed variants (e.g. application/vnd.api+json,
// application/json;charset=utf-8) need a real parse.
Expand All @@ -129,7 +150,10 @@ mod body_limit_tests {
http::Request::builder()
.method(http::Method::POST)
.uri("/")
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::CONTENT_TYPE,
http::header::HeaderValue::from_static("application/json"),
)
.body(body)
.unwrap(),
)
Expand Down
104 changes: 97 additions & 7 deletions arvik-extract/src/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ impl Field {
};

let std_file = temp.as_file().try_clone().map_err(MultipartError::Io)?;
let mut writer = tokio::fs::File::from_std(std_file);
// Buffered: chunked uploads otherwise pay one blocking-pool write
// syscall per network chunk (audit C15).
let mut writer =
tokio::io::BufWriter::with_capacity(64 * 1024, tokio::fs::File::from_std(std_file));
let mut bytes_written = 0_u64;
let metadata = self.metadata.clone();

Expand Down Expand Up @@ -696,14 +699,44 @@ fn validate_multipart_request(
.and_then(|v| v.to_str().ok())
.ok_or(MultipartRejection::InvalidContentType)?;

match multer::parse_boundary(content_type) {
Ok(boundary) => Ok(boundary),
Err(multer::Error::NoBoundary) => Err(MultipartRejection::MissingBoundary),
Err(multer::Error::NoMultipart | multer::Error::DecodeContentType(_)) => {
Err(MultipartRejection::InvalidContentType)
extract_boundary(content_type)
}

/// Extract the `boundary` parameter without a full `mime::Mime` parse
/// (audit C15). Semantics mirror what multer got from the mime crate:
/// media type must be exactly `multipart/form-data` (no suffix), parameter
/// names are case-insensitive, values may be quoted, and the FIRST boundary
/// parameter wins.
fn extract_boundary(content_type: &str) -> Result<String, MultipartRejection> {
let mut segments = content_type.split(';');
if !segments
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("multipart/form-data")
{
return Err(MultipartRejection::InvalidContentType);
}

let mut boundary = None;
for param in segments {
let Some(eq) = param.find('=') else {
continue;
};
if !param[..eq].trim().eq_ignore_ascii_case("boundary") {
continue;
}
let value = param[eq + 1..].trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(value);
if boundary.is_none() && !value.is_empty() {
boundary = Some(value.to_string());
}
Err(error) => Err(MultipartRejection::MultipartError(error.to_string())),
}

boundary.ok_or(MultipartRejection::MissingBoundary)
}

fn build_multer_constraints(constraints: MultipartConstraints) -> multer::Constraints {
Expand Down Expand Up @@ -747,3 +780,60 @@ impl Stream for BodyStream {
}
}
}

#[cfg(test)]
mod boundary_tests {
use super::*;

#[test]
fn extracts_plain_quoted_and_case_variants() {
assert_eq!(
extract_boundary("multipart/form-data; boundary=XyZ123").unwrap(),
"XyZ123"
);
assert_eq!(
extract_boundary("multipart/form-data; boundary=\"quoted-value\"").unwrap(),
"quoted-value"
);
assert_eq!(
extract_boundary("MULTIPART/FORM-DATA; BOUNDARY=upper").unwrap(),
"upper"
);
// Whitespace around the parameter and value is tolerated.
assert_eq!(
extract_boundary("multipart/form-data ; boundary = spaced ").unwrap(),
"spaced"
);
// First boundary parameter wins (mime-crate semantics).
assert_eq!(
extract_boundary("multipart/form-data; boundary=first; boundary=second").unwrap(),
"first"
);
// Other parameters are skipped.
assert_eq!(
extract_boundary("multipart/form-data; charset=utf-8; boundary=late").unwrap(),
"late"
);
}

#[test]
fn rejects_non_multipart_and_missing_boundary() {
assert!(matches!(
extract_boundary("application/json; boundary=x"),
Err(MultipartRejection::InvalidContentType)
));
// Suffixed subtypes were rejected by multer too.
assert!(matches!(
extract_boundary("multipart/form-data+xml; boundary=x"),
Err(MultipartRejection::InvalidContentType)
));
assert!(matches!(
extract_boundary("multipart/form-data"),
Err(MultipartRejection::MissingBoundary)
));
assert!(matches!(
extract_boundary("multipart/form-data; boundary="),
Err(MultipartRejection::MissingBoundary)
));
}
}
Loading
Loading