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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 1.2.0 - 2026-07-29
## Unreleased

### Added

- Built-in WebSocket support behind rkt's optional `ws` feature, available as
`rkt::ws`. The `rkt_ws` crate remains a deprecated compatibility shim for one
full minor release cycle.

### Fixed

- Multipart file parts sent without a `Content-Type` are no longer corrupted by
UTF-8 decoding. A part with a `filename` is now treated as data, defaulting to
`application/octet-stream`.

## 1.2.0 - 2026-07-29

### Added
Comment thread
martynp marked this conversation as resolved.

- Support for the [PROXY protocol] (v1 and v2) behind the new `proxy-proto`
crate feature. Setting the `proxy_protocol` configuration option (env
`ROCKET_PROXY_PROTOCOL`) to `true` requires every connection to begin with a
Expand Down
14 changes: 8 additions & 6 deletions core/lib/src/form/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use crate::{Data, Request};
/// A form field with a string value.
///
/// rkt preprocesses all form fields into either [`ValueField`]s or
/// [`DataField`]s. All fields from url-encoded forms, and fields without
/// Content-Types from multipart forms, are preprocessed as a `ValueField`.
/// [`DataField`]s. All fields from url-encoded forms, and multipart fields with
/// neither a `Content-Type` nor a file name, are preprocessed as a `ValueField`.
#[derive(Debug, Clone)]
pub struct ValueField<'r> {
/// The (decoded) name of the form field.
Expand All @@ -22,16 +22,18 @@ pub struct ValueField<'r> {
/// A multipart form field with an underlying data stream.
///
/// rkt preprocesses all form fields into either [`ValueField`]s or
/// [`DataField`]s. Multipart form fields with a `Content-Type` are preprocessed
/// as a `DataField`. The underlying data is _not_ read into memory, but
/// instead, streamable from the contained [`Data`] structure.
/// [`DataField`]s. Multipart form fields with a `Content-Type`, or with a file
/// name in their `Content-Disposition`, are preprocessed as a `DataField`. The
/// underlying data is _not_ read into memory, but instead, streamable from the
/// contained [`Data`] structure.
pub struct DataField<'r, 'i> {
/// The (decoded) name of the form field.
pub name: NameView<'r>,
/// The form fields's file name.
pub file_name: Option<&'r FileName>,
/// The form field's Content-Type, as submitted, which may or may not
/// reflect on `data`.
/// reflect on `data`. A file field that submitted no `Content-Type` is
/// reported as [`ContentType::Binary`], the default for a file part.
pub content_type: ContentType,
/// The request in which the form field was submitted.
pub request: &'r Request<'i>,
Expand Down
6 changes: 3 additions & 3 deletions core/lib/src/form/from_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ use crate::http::uncased::AsUncased;
/// are then pushed to the parser in [`FromForm::push_value()`] or
/// [`FromForm::push_data()`], respectively. Both url-encoded forms and
/// multipart forms are supported. All url-encoded form fields are preprocessed
/// as [`ValueField`]s. Multipart form fields with Content-Types are processed
/// as [`DataField`]s while those without a set Content-Type are processed as
/// [`ValueField`]s. `ValueField` field names and values are percent-decoded.
/// as [`ValueField`]s. Multipart form fields with a Content-Type, or with a file
/// name, are processed as [`DataField`]s while those with neither are processed
/// as [`ValueField`]s. `ValueField` field names and values are percent-decoded.
///
/// [field wire format]: crate::form#field-wire-format
///
Expand Down
14 changes: 12 additions & 2 deletions core/lib/src/form/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use multer::Multipart;

use crate::data::{Data, Limits, Outcome};
use crate::form::prelude::*;
use crate::http::{RawStr, Status};
use crate::http::{ContentType, RawStr, Status};
use crate::request::{local_cache_once, Request};

type Result<'r, T> = std::result::Result<T, Error<'r>>;
Expand Down Expand Up @@ -148,9 +148,19 @@ impl<'r, 'i> MultipartParser<'r, 'i> {
}
};

// A field with a content-type is data; one without is "value".
// A field with a content-type or a file name is data; one without is
// "value". RFC 7578 §4.4 defaults a part to `text/plain`, which would
// lossily decode a file's bytes as text, so a file part that declared no
// content-type falls back to binary here — the type that section
// recommends for file data that isn't otherwise labeled.
trace!(?field, "multipart field");
let content_type = field.content_type().and_then(|m| m.as_ref().parse().ok());
let content_type = match (content_type, field.file_name()) {
(Some(content_type), _) => Some(content_type),
(None, Some(_)) => Some(ContentType::Binary),
Comment thread
martynp marked this conversation as resolved.
(None, None) => None,
};

let field = if let Some(content_type) = content_type {
let (name, file_name) = match (field.name(), field.file_name()) {
(None, None) => ("", None),
Expand Down
5 changes: 4 additions & 1 deletion core/lib/src/fs/temp_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,10 @@ impl<'v> TempFile<'v> {
///
/// A multipart data form field can optionally specify the content-type of a
/// file. A browser will typically sniff the file's extension to set the
/// content-type. This method returns that value, if it was specified.
/// content-type. This method returns that value, if it was specified. A file
/// field that specified none reports [`ContentType::Binary`], the default
/// for a file part, so an omitted header is indistinguishable from an
/// explicit `application/octet-stream`.
///
/// ```rust
/// #[macro_use] extern crate rkt;
Expand Down
62 changes: 62 additions & 0 deletions core/lib/tests/multipart-no-content-type-issue-2934.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#[macro_use]
extern crate rkt;

use rkt::form::Form;
use rkt::fs::TempFile;
use rkt::http::ContentType;
use rkt::tokio::io::AsyncReadExt;

#[derive(FromForm)]
struct Upload<'r> {
file: TempFile<'r>,
note: &'r str,
}

#[rkt::post("/", data = "<form>")]
async fn upload(form: Form<Upload<'_>>) -> Vec<u8> {
let mut bytes = form.note.as_bytes().to_vec();
bytes.push(b'|');

// A file part that declared no `Content-Type` defaults to binary.
let content_type = form.file.content_type().map(|ct| ct.to_string());
bytes.extend(content_type.unwrap_or_else(|| "none".into()).as_bytes());
bytes.push(b'|');

// `open()` reads buffered and on-disk uploads alike.
let mut stream = form.file.open().await.unwrap();
stream.read_to_end(&mut bytes).await.unwrap();
bytes
}

/// A file part with a `filename` but no `Content-Type` must be delivered
/// byte-for-byte, not lossily decoded as UTF-8. See issue #2934.
#[test]
fn file_part_without_content_type_preserves_bytes() {
use rkt::local::blocking::Client;

let payload: Vec<u8> = (0u8..=255).collect();

let mut body = Vec::new();
body.extend(b"--X-BOUNDARY\r\n");
body.extend(b"Content-Disposition: form-data; name=\"note\"\r\n\r\n");
body.extend(b"hello\r\n");
body.extend(b"--X-BOUNDARY\r\n");
body.extend(b"Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n\r\n");
body.extend(&payload);
body.extend(b"\r\n--X-BOUNDARY--\r\n");

let client = Client::debug_with(routes![upload]).unwrap();
let response = client
.post("/")
.header(
"multipart/form-data; boundary=X-BOUNDARY"
.parse::<ContentType>()
.unwrap(),
)
.body(body)
.dispatch();

let mut expected = b"hello|application/octet-stream|".to_vec();
expected.extend(&payload);
assert_eq!(response.into_bytes().unwrap(), expected);
}
Loading