use std::fmt::Write as _;
use axum::body::{Body, Bytes};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use crate::http::security::NO_SNIFF;
use crate::storage::error::StorageError;
use crate::storage::sniff::{SNIFF_BYTES, SniffedType, sniff};
use crate::storage::{Disk, SafeFilename, StoragePath};
pub const OCTET_STREAM: &str = "application/octet-stream";
pub const DOWNLOAD_CSP: &str = "default-src 'none'; sandbox";
#[non_exhaustive]
pub struct Attachment {
body: Body,
content_type: &'static str,
content_length: Option<u64>,
filename: Option<String>,
}
impl std::fmt::Debug for Attachment {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Attachment")
.field("content_type", &self.content_type)
.field("content_length", &self.content_length)
.field("filename", &self.filename)
.finish_non_exhaustive()
}
}
impl Attachment {
#[must_use]
pub fn from_bytes(bytes: impl Into<Bytes>) -> Self {
let bytes = bytes.into();
let content_type = media_type(sniff(&bytes));
let content_length = Some(bytes.len() as u64);
Self {
body: Body::from(bytes),
content_type,
content_length,
filename: None,
}
}
pub async fn from_disk(disk: &Disk, path: &StoragePath) -> Result<Self, StorageError> {
use futures::StreamExt as _;
let length = disk.stat(path).await?.content_length();
let reader = disk.reader(path).await?;
let head_len = length.min(SNIFF_BYTES as u64);
let head: Bytes = reader.read(0..head_len).await?.to_bytes();
let content_type = media_type(sniff(&head));
let body = if head.len() as u64 >= length {
Body::from(head)
} else {
let tail = reader.into_bytes_stream(head.len() as u64..).await?;
Body::from_stream(
futures::stream::once(async move { Ok::<Bytes, std::io::Error>(head) }).chain(tail),
)
};
Ok(Self {
body,
content_type,
content_length: Some(length),
filename: None,
})
}
#[must_use]
pub fn with_filename(mut self, filename: &SafeFilename) -> Self {
self.filename = Some(filename.to_string());
self
}
#[must_use]
pub fn with_content_type(mut self, sniffed: SniffedType) -> Self {
self.content_type = sniffed.mime();
self
}
#[must_use]
pub fn content_type(&self) -> &'static str {
self.content_type
}
}
impl IntoResponse for Attachment {
fn into_response(self) -> Response {
let mut response = Response::new(self.body);
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(self.content_type),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static(NO_SNIFF),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(DOWNLOAD_CSP),
);
headers.insert(
header::CONTENT_DISPOSITION,
HeaderValue::try_from(content_disposition(self.filename.as_deref()))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
if let Some(length) = self.content_length
&& let Ok(value) = HeaderValue::try_from(length.to_string())
{
headers.insert(header::CONTENT_LENGTH, value);
}
response
}
}
const SCRIPTABLE: &[&str] = &[
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"text/xml",
"application/xml",
];
fn media_type(sniffed: Option<SniffedType>) -> &'static str {
match sniffed.map(|kind| kind.mime()) {
None => OCTET_STREAM,
Some(mime) if SCRIPTABLE.contains(&mime) => OCTET_STREAM,
Some(mime) => mime,
}
}
fn content_disposition(filename: Option<&str>) -> String {
let Some(filename) = filename else {
return "attachment".to_string();
};
let mut value = String::from("attachment; filename=\"");
value.push_str(&ascii_fallback(filename));
value.push('"');
if !filename.is_ascii() {
value.push_str("; filename*=UTF-8''");
value.push_str(&rfc5987_encode(filename));
}
value
}
fn ascii_fallback(filename: &str) -> String {
filename
.chars()
.map(|character| match character {
'"' | '\\' => '_',
character if character.is_ascii_graphic() || character == ' ' => character,
_ => '_',
})
.collect()
}
fn rfc5987_encode(filename: &str) -> String {
const UNRESERVED: &[u8] = b"!#$&+-.^_`|~";
let mut encoded = String::with_capacity(filename.len());
for byte in filename.as_bytes() {
if byte.is_ascii_alphanumeric() || UNRESERVED.contains(byte) {
encoded.push(*byte as char);
} else {
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::{AllowedExtensions, Storage, StorageConfig};
const PNG: &[u8] = &[
0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R',
];
fn header_value(response: &Response, name: header::HeaderName) -> Option<String> {
response
.headers()
.get(name)
.map(|value| value.to_str().expect("an ASCII header").to_string())
}
fn safe(name: &str) -> SafeFilename {
let allowed = AllowedExtensions::images()
.with("pdf")
.expect("pdf is valid")
.with("txt")
.expect("txt is valid");
SafeFilename::parse(name, &allowed).expect("a storable name")
}
#[test]
fn every_download_is_an_attachment_that_will_not_be_sniffed() {
let response = Attachment::from_bytes(PNG.to_vec()).into_response();
assert_eq!(
header_value(&response, header::CONTENT_DISPOSITION).as_deref(),
Some("attachment")
);
assert_eq!(
header_value(&response, header::X_CONTENT_TYPE_OPTIONS).as_deref(),
Some("nosniff")
);
assert_eq!(
header_value(&response, header::CONTENT_SECURITY_POLICY).as_deref(),
Some(DOWNLOAD_CSP)
);
}
#[test]
fn the_media_type_comes_from_the_bytes() {
let response = Attachment::from_bytes(PNG.to_vec()).into_response();
assert_eq!(
header_value(&response, header::CONTENT_TYPE).as_deref(),
Some("image/png")
);
}
#[test]
fn unrecognized_bytes_decline_to_say_what_they_are() {
let response = Attachment::from_bytes(b"<?php echo 1; ?>".to_vec()).into_response();
assert_eq!(
header_value(&response, header::CONTENT_TYPE).as_deref(),
Some(OCTET_STREAM)
);
}
#[test]
fn an_html_upload_is_never_served_as_a_document() {
let response = Attachment::from_bytes(b"<html><script>alert(1)</script>".to_vec())
.with_filename(&safe("notes.txt"))
.into_response();
assert_eq!(
header_value(&response, header::CONTENT_TYPE).as_deref(),
Some(OCTET_STREAM)
);
assert!(
header_value(&response, header::CONTENT_DISPOSITION)
.expect("a disposition")
.starts_with("attachment;")
);
}
#[test]
fn an_ascii_filename_needs_only_the_plain_form() {
assert_eq!(
content_disposition(Some("report.pdf")),
"attachment; filename=\"report.pdf\""
);
}
#[test]
fn a_vietnamese_filename_is_sent_in_both_forms() {
let value = content_disposition(Some("báo-cáo.pdf"));
assert!(
value.starts_with("attachment; filename=\"b_o-c_o.pdf\""),
"the ASCII fallback keeps the shape: {value}"
);
assert!(
value.ends_with("; filename*=UTF-8''b%C3%A1o-c%C3%A1o.pdf"),
"the extended form keeps the name: {value}"
);
}
#[test]
fn a_quote_cannot_close_the_quoted_string() {
let value = content_disposition(Some("a\"; x=\"b\\c.txt"));
assert_eq!(value, "attachment; filename=\"a_; x=_b_c.txt\"");
assert!(HeaderValue::try_from(value).is_ok());
}
#[test]
fn a_newline_cannot_reach_the_header() {
let value = content_disposition(Some("a\r\nSet-Cookie: x=1.txt"));
assert!(!value.contains('\r') && !value.contains('\n'), "{value}");
assert!(HeaderValue::try_from(value).is_ok());
}
#[test]
fn the_disposition_is_always_a_valid_header_value() {
for name in [
"a.txt",
"báo cáo.pdf",
"😀.png",
"a\u{7f}b.txt",
"very long name with spaces and (parens).pdf",
] {
let value = content_disposition(Some(name));
assert!(HeaderValue::try_from(&value).is_ok(), "{name} -> {value}");
}
}
#[tokio::test]
async fn an_object_is_streamed_off_the_disk_with_its_sniffed_type() {
let root = tempfile::tempdir().expect("a temporary directory");
let config =
StorageConfig::fs(root.path().to_string_lossy().into_owned()).expect("a valid root");
let storage = Storage::connect(config).await.expect("the disk connects");
let disk = storage.default_disk();
let mut object = PNG.to_vec();
object.extend(std::iter::repeat_n(b'x', SNIFF_BYTES * 3));
let path = StoragePath::new("ab/cd/object.png").expect("a valid key");
disk.put(&path, &object).await.expect("the write succeeds");
let response = Attachment::from_disk(&disk, &path)
.await
.expect("the object is readable")
.with_filename(&safe("photo.png"))
.into_response();
assert_eq!(
header_value(&response, header::CONTENT_TYPE).as_deref(),
Some("image/png")
);
assert_eq!(
header_value(&response, header::CONTENT_LENGTH).as_deref(),
Some(object.len().to_string().as_str())
);
let served = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("the body is readable");
assert_eq!(
served.as_ref(),
object.as_slice(),
"no byte was lost at the seam"
);
}
#[tokio::test]
async fn an_object_shorter_than_the_sniff_window_survives_intact() {
let root = tempfile::tempdir().expect("a temporary directory");
let config =
StorageConfig::fs(root.path().to_string_lossy().into_owned()).expect("a valid root");
let storage = Storage::connect(config).await.expect("the disk connects");
let disk = storage.default_disk();
let path = StoragePath::new("small.png").expect("a valid key");
disk.put(&path, PNG).await.expect("the write succeeds");
let response = Attachment::from_disk(&disk, &path)
.await
.expect("the object is readable")
.into_response();
let served = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("the body is readable");
assert_eq!(served.as_ref(), PNG);
}
}