Merge branch 'main' into compress-ws-deflate

# Conflicts:
#	Cargo.lock
#	actix-http/CHANGES.md
#	awc/src/ws.rs
This commit is contained in:
Yuki Okushi 2026-04-19 14:55:07 +09:00
commit 6497364332
161 changed files with 9239 additions and 1961 deletions

View File

@ -3,6 +3,6 @@ disallowed-names = [
"e", # no single letter error bindings
]
disallowed-methods = [
{ path = "std::cell::RefCell::default()", reason = "prefer explicit inner type default" },
{ path = "std::rc::Rc::default()", reason = "prefer explicit inner type default" },
{ path = "std::cell::RefCell::default()", reason = "prefer explicit inner type default (remove allow-invalid when rust-lang/rust-clippy/#8581 is fixed)", allow-invalid = true },
{ path = "std::rc::Rc::default()", reason = "prefer explicit inner type default (remove allow-invalid when rust-lang/rust-clippy/#8581 is fixed)", allow-invalid = true },
]

View File

@ -2,11 +2,14 @@ version: "0.2"
words:
- actix
- addrs
- ALPN
- bytestring
- httparse
- msrv
- MSRV
- realip
- rustls
- rustup
- serde
- uring
- webpki
- zstd

2
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
# These are supported funding model platforms
github: [robjtede]
github: [robjtede, JohnTitor]

43
.github/labeler.yml vendored Normal file
View File

@ -0,0 +1,43 @@
A-files:
- changed-files:
- any-glob-to-any-file: actix-files/**
A-http:
- changed-files:
- any-glob-to-any-file: actix-http/**
A-http-test:
- changed-files:
- any-glob-to-any-file: actix-http-test/**
A-multipart:
- changed-files:
- any-glob-to-any-file: actix-multipart/**
A-multipart-derive:
- changed-files:
- any-glob-to-any-file: actix-multipart-derive/**
A-router:
- changed-files:
- any-glob-to-any-file: actix-router/**
A-test:
- changed-files:
- any-glob-to-any-file: actix-test/**
A-web:
- changed-files:
- any-glob-to-any-file: actix-web/**
A-web-actors:
- changed-files:
- any-glob-to-any-file: actix-web-actors/**
A-web-codegen:
- changed-files:
- any-glob-to-any-file: actix-web-codegen/**
A-awc:
- changed-files:
- any-glob-to-any-file: awc/**

View File

@ -2,7 +2,7 @@ name: Benchmark
on:
push:
branches: [master]
branches: [main]
permissions:
contents: read
@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
run: |

View File

@ -2,7 +2,7 @@ name: CI (post-merge)
on:
push:
branches: [master]
branches: [main]
permissions:
contents: read
@ -28,11 +28,11 @@ jobs:
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install nasm
if: matrix.target.os == 'windows-latest'
uses: ilammy/setup-nasm@v1.5.2
uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2
- name: Install OpenSSL
if: matrix.target.os == 'windows-latest'
@ -44,12 +44,12 @@ jobs:
echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV
- name: Install Rust (${{ matrix.version.name }})
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: ${{ matrix.version.version }}
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
@ -71,19 +71,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Free Disk Space
run: ./scripts/free-disk-space.sh
- name: Setup mold linker
uses: rui314/setup-mold@v1
uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
- name: Install just, cargo-hack
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just,cargo-hack

View File

@ -6,7 +6,7 @@ on:
merge_group:
types: [checks_requested]
push:
branches: [master]
branches: [main]
permissions:
contents: read
@ -18,7 +18,7 @@ concurrency:
jobs:
read_msrv:
name: Read MSRV
uses: actions-rust-lang/msrv/.github/workflows/msrv.yml@v0.1.0
uses: actions-rust-lang/msrv/.github/workflows/msrv.yml@b95a3a81b0efee6438b858b41a84aff627e01351 # v0.1.1
build_and_test:
needs: read_msrv
@ -39,11 +39,11 @@ jobs:
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install nasm
if: matrix.target.os == 'windows-latest'
uses: ilammy/setup-nasm@v1.5.2
uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2
- name: Install OpenSSL
if: matrix.target.os == 'windows-latest'
@ -56,15 +56,15 @@ jobs:
- name: Setup mold linker
if: matrix.target.os == 'ubuntu-latest'
uses: rui314/setup-mold@v1
uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1
- name: Install Rust (${{ matrix.version.name }})
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: ${{ matrix.version.version }}
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
@ -79,25 +79,29 @@ jobs:
run: just check-default
- name: tests
timeout-minutes: 60
timeout-minutes: 30
run: just test
- name: CI cache clean
run: cargo-ci-cache-clean
- name: deny check
if: matrix.version.name == 'stable' && matrix.target.os == 'ubuntu-latest'
uses: EmbarkStudios/cargo-deny-action@175dc7fd4fb85ec8f46948fb98f44db001149081 # v2.0.16
io-uring:
name: io-uring tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: nightly
- name: tests (io-uring)
timeout-minutes: 60
timeout-minutes: 30
run: >
sudo bash -c "ulimit -Sl 512 && ulimit -Hl 512 && PATH=$PATH:/usr/share/rust/.cargo/bin && RUSTUP_TOOLCHAIN=stable cargo test --lib --tests -p=actix-files --all-features"
@ -105,15 +109,15 @@ jobs:
name: doc tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: nightly
- name: Install just
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just

View File

@ -2,7 +2,7 @@ name: Coverage
on:
push:
branches: [master]
branches: [main]
permissions:
contents: read
@ -15,16 +15,16 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: nightly
components: llvm-tools
- name: Install just, cargo-llvm-cov, cargo-nextest
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just,cargo-llvm-cov,cargo-nextest
@ -32,7 +32,7 @@ jobs:
run: just test-coverage-codecov
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5.4.2
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: codecov.json
fail_ci_if_error: true

16
.github/workflows/labeler.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
labeler:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1

View File

@ -15,10 +15,10 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: nightly
components: rustfmt
@ -33,15 +33,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
components: clippy
- name: Check with Clippy
uses: giraffate/clippy-action@v1.0.1
uses: giraffate/clippy-action@13b9d32482f25d29ead141b79e7e04e7900281e0 # v1.0.1
with:
reporter: github-pr-check
github_token: ${{ secrets.GITHUB_TOKEN }}
@ -52,10 +52,10 @@ jobs:
lint-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: nightly
components: rust-docs
@ -69,20 +69,20 @@ jobs:
if: false # rustdoc mismatch currently
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (${{ vars.RUST_VERSION_EXTERNAL_TYPES }})
uses: actions-rust-lang/setup-rust-toolchain@v1.12.0
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }}
- name: Install just
uses: taiki-e/install-action@v2.50.10
uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7
with:
tool: just
- name: Install cargo-check-external-types
uses: taiki-e/cache-cargo-install-action@v2.1.1
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
with:
tool: cargo-check-external-types

86
.github/workflows/semver-checks.yml vendored Normal file
View File

@ -0,0 +1,86 @@
name: Semver Checks
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
semver-checks:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
toolchain: stable
- name: Install cargo-semver-checks
uses: taiki-e/install-action@7a562dfa955aa2e4d5b0fd6ebd57ff9715c07b0b # v2.73.0
with:
tool: cargo-semver-checks
- name: Run cargo semver-checks
id: semver
shell: bash
run: |
set -o pipefail
output_file="$(mktemp)"
cargo semver-checks \
--workspace \
--release-type=patch \
--baseline-rev "${{ github.event.pull_request.base.sha }}" \
2>&1 | tee "$output_file"
status=$?
semver_type=patch
if grep -q "semver requires new major version" "$output_file"; then
semver_type=major
elif grep -q "semver requires new minor version" "$output_file"; then
semver_type=minor
elif grep -q "semver requires new patch version" "$output_file"; then
semver_type=patch
fi
{
echo "exit_code=$status"
echo "output_file=$output_file"
echo "semver_type=$semver_type"
} >> "$GITHUB_OUTPUT"
exit 0
- name: Summarize cargo semver-checks output
if: always() && steps.semver.outcome != 'skipped'
shell: bash
run: |
summary_file="${{ steps.semver.outputs.output_file }}"
status="${{ steps.semver.outputs.exit_code }}"
{
echo "## cargo semver-checks"
echo
echo "- Base SHA: \`${{ github.event.pull_request.base.sha }}\`"
echo "- Head SHA: \`${{ github.event.pull_request.head.sha }}\`"
echo "- Required release: \`${{ steps.semver.outputs.semver_type }}\`"
echo "- cargo semver-checks exit code: \`$status\`"
echo
echo "<details><summary>Command output</summary>"
echo
echo '```text'
sed -n '1,200p' "$summary_file"
total_lines="$(wc -l < "$summary_file")"
if [ "$total_lines" -gt 200 ]; then
echo
echo "[truncated; showing first 200 of ${total_lines} lines]"
fi
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"

1917
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,6 @@ members = [
"actix-multipart-derive",
"actix-router",
"actix-test",
"actix-web-actors",
"actix-web-codegen",
"actix-web",
"awc",
@ -19,7 +18,7 @@ homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-web"
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.75"
rust-version = "1.88"
[profile.dev]
# Disabling debug info speeds up builds a bunch and we don't rely on it for debugging that much.
@ -39,7 +38,6 @@ actix-multipart-derive = { path = "actix-multipart-derive" }
actix-router = { path = "actix-router" }
actix-test = { path = "actix-test" }
actix-web = { path = "actix-web" }
actix-web-actors = { path = "actix-web-actors" }
actix-web-codegen = { path = "actix-web-codegen" }
awc = { path = "awc" }

View File

@ -2,6 +2,45 @@
## Unreleased
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
- Fix handling of `bytes=0-`
- Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748]
- Fix invalid `Content-Encoding: identity` header in `NamedFile` range responses. [#3191]
[#2615]: https://github.com/actix/actix-web/pull/2615
[#2748]: https://github.com/actix/actix-web/issues/2748
[#3191]: https://github.com/actix/actix-web/issues/3191
## 0.6.10
### Security Notice
We addressed 2 vulnerabilities in this release:
- Do not panic with empty Range header.
- Avoid serving CWD on invalid `Files::new` inputs.
We encourage updating your `actix-files` version as soon as possible.
### Other changes
- Minimum supported Rust version (MSRV) is now 1.88.
- `PathBufWrap` & `UriSegmentError` made public. [#3694]
[#3694]: https://github.com/actix/actix-web/pull/3694
## 0.6.9
- Correct `derive_more` dependency feature requirements.
## 0.6.8
- Add `Files::with_permanent_redirect()` method.
- Change default redirect status code to 307 Temporary Redirect.
## 0.6.7
- Add `{Files, NamedFile}::read_mode_threshold()` methods to allow faster synchronous reads of small files.
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.6.6

View File

@ -1,6 +1,6 @@
[package]
name = "actix-files"
version = "0.6.6"
version = "0.6.10"
authors = ["Nikolay Kim <fafhrd91@gmail.com>", "Rob Ede <robjtede@icloud.com>"]
description = "Static file serving for Actix Web"
keywords = ["actix", "http", "async", "futures"]
@ -24,7 +24,7 @@ actix-web = { version = "4", default-features = false }
bitflags = "2"
bytes = "1"
derive_more = { version = "2", features = ["display", "error", "from"] }
derive_more = { version = "2", features = ["deref", "deref_mut", "display", "error", "from"] }
futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] }
http-range = "0.1.4"
log = "0.4"
@ -37,13 +37,14 @@ v_htmlescape = "0.15.5"
# experimental-io-uring
[target.'cfg(target_os = "linux")'.dependencies]
tokio-uring = { version = "0.5", optional = true, features = ["bytes"] }
actix-server = { version = "2.4", optional = true } # ensure matching tokio-uring versions
actix-server = { version = "2.4", optional = true } # ensure matching tokio-uring versions
[dev-dependencies]
actix-rt = "2.7"
actix-test = "0.1"
actix-web = "4"
env_logger = "0.11"
filetime = "0.2"
tempfile = "3.2"
[lints]

View File

@ -3,11 +3,11 @@
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-files?label=latest)](https://crates.io/crates/actix-files)
[![Documentation](https://docs.rs/actix-files/badge.svg?version=0.6.6)](https://docs.rs/actix-files/0.6.6)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
[![Documentation](https://docs.rs/actix-files/badge.svg?version=0.6.9)](https://docs.rs/actix-files/0.6.9)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![License](https://img.shields.io/crates/l/actix-files.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-files/0.6.6/status.svg)](https://deps.rs/crate/actix-files/0.6.6)
[![dependency status](https://deps.rs/crate/actix-files/0.6.9/status.svg)](https://deps.rs/crate/actix-files/0.6.9)
[![Download](https://img.shields.io/crates/d/actix-files.svg)](https://crates.io/crates/actix-files)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x)

View File

@ -14,6 +14,12 @@ use pin_project_lite::pin_project;
use super::named::File;
#[derive(Debug, Clone, Copy)]
pub(crate) enum ReadMode {
Sync,
Async,
}
pin_project! {
/// Adapter to read a `std::file::File` in chunks.
#[doc(hidden)]
@ -24,6 +30,7 @@ pin_project! {
state: ChunkedReadFileState<Fut>,
counter: u64,
callback: F,
read_mode: ReadMode,
}
}
@ -57,6 +64,7 @@ pub(crate) fn new_chunked_read(
size: u64,
offset: u64,
file: File,
read_mode_threshold: u64,
) -> impl Stream<Item = Result<Bytes, Error>> {
ChunkedReadFile {
size,
@ -69,31 +77,50 @@ pub(crate) fn new_chunked_read(
},
counter: 0,
callback: chunked_read_file_callback,
read_mode: if size < read_mode_threshold {
ReadMode::Sync
} else {
ReadMode::Async
},
}
}
#[cfg(not(feature = "experimental-io-uring"))]
async fn chunked_read_file_callback(
fn chunked_read_file_callback_sync(
mut file: File,
offset: u64,
max_bytes: usize,
) -> Result<(File, Bytes), Error> {
) -> Result<(File, Bytes), io::Error> {
use io::{Read as _, Seek as _};
let res = actix_web::web::block(move || {
let mut buf = Vec::with_capacity(max_bytes);
let mut buf = Vec::with_capacity(max_bytes);
file.seek(io::SeekFrom::Start(offset))?;
file.seek(io::SeekFrom::Start(offset))?;
let n_bytes = file.by_ref().take(max_bytes as u64).read_to_end(&mut buf)?;
let n_bytes = file.by_ref().take(max_bytes as u64).read_to_end(&mut buf)?;
if n_bytes == 0 {
Err(io::Error::from(io::ErrorKind::UnexpectedEof))
} else {
Ok((file, Bytes::from(buf)))
if n_bytes == 0 {
Err(io::Error::from(io::ErrorKind::UnexpectedEof))
} else {
Ok((file, Bytes::from(buf)))
}
}
#[cfg(not(feature = "experimental-io-uring"))]
#[inline]
async fn chunked_read_file_callback(
file: File,
offset: u64,
max_bytes: usize,
read_mode: ReadMode,
) -> Result<(File, Bytes), Error> {
let res = match read_mode {
ReadMode::Sync => chunked_read_file_callback_sync(file, offset, max_bytes)?,
ReadMode::Async => {
actix_web::web::block(move || chunked_read_file_callback_sync(file, offset, max_bytes))
.await??
}
})
.await??;
};
Ok(res)
}
@ -171,7 +198,7 @@ where
#[cfg(not(feature = "experimental-io-uring"))]
impl<F, Fut> Stream for ChunkedReadFile<F, Fut>
where
F: Fn(File, u64, usize) -> Fut,
F: Fn(File, u64, usize, ReadMode) -> Fut,
Fut: Future<Output = Result<(File, Bytes), Error>>,
{
type Item = Result<Bytes, Error>;
@ -193,7 +220,7 @@ where
.take()
.expect("ChunkedReadFile polled after completion");
let fut = (this.callback)(file, offset, max_bytes);
let fut = (this.callback)(file, offset, max_bytes, *this.read_mode);
this.state
.project_replace(ChunkedReadFileState::Future { fut });

View File

@ -21,6 +21,7 @@ impl ResponseError for FilesError {
}
}
/// Error which can occur with parsing/validating a request-uri path
#[derive(Debug, PartialEq, Eq, Display)]
#[non_exhaustive]
pub enum UriSegmentError {

View File

@ -41,6 +41,7 @@ pub struct Files {
index: Option<String>,
show_index: bool,
redirect_to_slash: bool,
with_permanent_redirect: bool,
default: Rc<RefCell<Option<Rc<HttpNewService>>>>,
renderer: Rc<DirectoryRenderer>,
mime_override: Option<Rc<MimeOverride>>,
@ -49,6 +50,8 @@ pub struct Files {
use_guards: Option<Rc<dyn Guard>>,
guards: Vec<Rc<dyn Guard>>,
hidden_files: bool,
try_compressed: bool,
read_mode_threshold: u64,
}
impl fmt::Debug for Files {
@ -64,6 +67,7 @@ impl Clone for Files {
index: self.index.clone(),
show_index: self.show_index,
redirect_to_slash: self.redirect_to_slash,
with_permanent_redirect: self.with_permanent_redirect,
default: self.default.clone(),
renderer: self.renderer.clone(),
file_flags: self.file_flags,
@ -73,6 +77,8 @@ impl Clone for Files {
use_guards: self.use_guards.clone(),
guards: self.guards.clone(),
hidden_files: self.hidden_files,
try_compressed: self.try_compressed,
read_mode_threshold: self.read_mode_threshold,
}
}
}
@ -92,6 +98,9 @@ impl Files {
/// If the mount path is set as the root path `/`, services registered after this one will
/// be inaccessible. Register more specific handlers and services first.
///
/// If `serve_from` cannot be canonicalized at startup, an error is logged and the original
/// path is preserved. Requests will return `404 Not Found` until the path exists.
///
/// `Files` utilizes the existing Tokio thread-pool for blocking filesystem operations.
/// The number of running threads is adjusted over time as needed, up to a maximum of 512 times
/// the number of server [workers](actix_web::HttpServer::workers), by default.
@ -101,7 +110,8 @@ impl Files {
Ok(canon_dir) => canon_dir,
Err(_) => {
log::error!("Specified path is not a directory: {:?}", orig_dir);
PathBuf::new()
// Preserve original path so requests don't fall back to CWD.
orig_dir
}
};
@ -111,6 +121,7 @@ impl Files {
index: None,
show_index: false,
redirect_to_slash: false,
with_permanent_redirect: false,
default: Rc::new(RefCell::new(None)),
renderer: Rc::new(directory_listing),
mime_override: None,
@ -119,6 +130,8 @@ impl Files {
use_guards: None,
guards: Vec::new(),
hidden_files: false,
try_compressed: false,
read_mode_threshold: 0,
}
}
@ -141,6 +154,14 @@ impl Files {
self
}
/// Redirect with permanent redirect status code (308).
///
/// By default redirect with temporary redirect status code (307).
pub fn with_permanent_redirect(mut self) -> Self {
self.with_permanent_redirect = true;
self
}
/// Set custom directory renderer.
pub fn files_listing_renderer<F>(mut self, f: F) -> Self
where
@ -192,10 +213,14 @@ impl Files {
self
}
/// Set index file
/// Sets index file for directory requests.
///
/// Shows specific index file for directories instead of
/// showing files listing.
/// When a directory is requested, this value is appended to the directory's path on disk.
/// Therefore, the index file path is relative to the served directory (the `serve_from` path
/// passed to [`Files::new`]) and should not include the `serve_from` prefix.
///
/// For example, to serve `./static/index.html` when mounting `Files::new("/", "./static")`,
/// configure it as `.index_file("index.html")` (not `.index_file("./static/index.html")`).
///
/// If the index file is not found, files listing is shown as a fallback if
/// [`Files::show_files_listing()`] is set.
@ -204,6 +229,23 @@ impl Files {
self
}
/// Sets the size threshold that determines file read mode (sync/async).
///
/// When a file is smaller than the threshold (bytes), the reader will use synchronous
/// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the
/// main thread.
///
/// Tweaking this value according to your expected usage may lead to significant performance
/// gains (or losses in other handlers, if `size` is too high).
///
/// When the `experimental-io-uring` crate feature is enabled, file reads are always async.
///
/// Default is 0, meaning all files are read asynchronously.
pub fn read_mode_threshold(mut self, size: u64) -> Self {
self.read_mode_threshold = size;
self
}
/// Specifies whether to use ETag or not.
///
/// Default is true.
@ -316,6 +358,15 @@ impl Files {
self.hidden_files = true;
self
}
/// Attempts to search for a suitable pre-compressed version of a file on disk before falling
/// back to the uncompressed version.
///
/// Currently, `.gz`, `.br`, and `.zst` files are supported.
pub fn try_compressed(mut self) -> Self {
self.try_compressed = true;
self
}
}
impl HttpServiceFactory for Files {
@ -367,6 +418,9 @@ impl ServiceFactory<ServiceRequest> for Files {
file_flags: self.file_flags,
guards: self.use_guards.clone(),
hidden_files: self.hidden_files,
try_compressed: self.try_compressed,
size_threshold: self.read_mode_threshold,
with_permanent_redirect: self.with_permanent_redirect,
};
if let Some(ref default) = *self.default.borrow() {

View File

@ -14,7 +14,7 @@
#![warn(missing_docs, missing_debug_implementations)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::path::Path;
@ -37,13 +37,12 @@ mod range;
mod service;
pub use self::{
chunked::ChunkedReadFile, directory::Directory, files::Files, named::NamedFile,
range::HttpRange, service::FilesService,
chunked::ChunkedReadFile, directory::Directory, error::UriSegmentError, files::Files,
named::NamedFile, path_buf::PathBufWrap, range::HttpRange, service::FilesService,
};
use self::{
directory::{directory_listing, DirectoryRenderer},
error::FilesError,
path_buf::PathBufWrap,
};
type HttpService = BoxService<ServiceRequest, ServiceResponse, Error>;
@ -471,6 +470,24 @@ mod tests {
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
}
#[actix_rt::test]
async fn test_named_file_empty_range_headers() {
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
for range in ["", "bytes="] {
let response = srv
.get("/tests/test.binary")
.insert_header((header::RANGE, range))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
let content_range = response.headers().get(header::CONTENT_RANGE).unwrap();
assert_eq!(content_range.to_str().unwrap(), "bytes */100");
}
}
#[actix_rt::test]
async fn test_named_file_content_range_headers() {
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
@ -496,6 +513,30 @@ mod tests {
assert_eq!(content_range.to_str().unwrap(), "bytes */100");
}
#[actix_rt::test]
async fn test_named_file_range_header_from_zero_to_end_returns_partial_content() {
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
let response = srv
.get("/tests/test.binary")
.insert_header((header::RANGE, "bytes=0-"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
let content_range = response.headers().get(header::CONTENT_RANGE).unwrap();
assert_eq!(content_range.to_str().unwrap(), "bytes 0-99/100");
let content_length = response.headers().get(header::CONTENT_LENGTH).unwrap();
assert_eq!(content_length.to_str().unwrap(), "100");
// Should be no transfer-encoding
let transfer_encoding = response.headers().get(header::TRANSFER_ENCODING);
assert!(transfer_encoding.is_none());
}
#[actix_rt::test]
async fn test_named_file_content_length_headers() {
let srv = actix_test::start(|| App::new().service(Files::new("/", ".")));
@ -736,7 +777,21 @@ mod tests {
.await;
let req = TestRequest::with_uri("/tests").to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
// should redirect if index present with permanent redirect
let srv = test::init_service(
App::new().service(
Files::new("/", ".")
.index_file("test.png")
.redirect_to_slash_directory()
.with_permanent_redirect(),
),
)
.await;
let req = TestRequest::with_uri("/tests").to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
// should redirect if files listing is enabled
let srv = test::init_service(
@ -749,7 +804,7 @@ mod tests {
.await;
let req = TestRequest::with_uri("/tests").to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
// should not redirect if the path is wrong
let req = TestRequest::with_uri("/not_existing").to_request();
@ -767,6 +822,16 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_rt::test]
async fn test_static_files_bad_directory_does_not_serve_cwd_files() {
let service = Files::new("/", "./missing").new_service(()).await.unwrap();
let req = TestRequest::with_uri("/Cargo.toml").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_rt::test]
async fn test_default_handler_file_missing() {
let st = Files::new("/", ".")

View File

@ -14,7 +14,7 @@ use actix_web::{
http::{
header::{
self, Charset, ContentDisposition, ContentEncoding, DispositionParam, DispositionType,
ExtendedValue, HeaderValue,
ExtendedValue,
},
StatusCode,
},
@ -80,6 +80,7 @@ pub struct NamedFile {
pub(crate) content_type: Mime,
pub(crate) content_disposition: ContentDisposition,
pub(crate) encoding: Option<ContentEncoding>,
pub(crate) read_mode_threshold: u64,
}
#[cfg(not(feature = "experimental-io-uring"))]
@ -90,6 +91,55 @@ pub(crate) use tokio_uring::fs::File;
use super::chunked;
pub(crate) fn get_content_type_and_disposition(
path: &Path,
) -> Result<(mime::Mime, ContentDisposition), io::Error> {
let filename = match path.file_name() {
Some(name) => name.to_string_lossy(),
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Provided path has no filename",
));
}
};
let ct = mime_guess::from_path(path).first_or_octet_stream();
let disposition = match ct.type_() {
mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline,
mime::APPLICATION => match ct.subtype() {
mime::JAVASCRIPT | mime::JSON => DispositionType::Inline,
name if name == "wasm" || name == "xhtml" => DispositionType::Inline,
_ => DispositionType::Attachment,
},
_ => DispositionType::Attachment,
};
// replace special characters in filenames which could occur on some filesystems
let filename_s = filename
.replace('\n', "%0A") // \n line break
.replace('\x0B', "%0B") // \v vertical tab
.replace('\x0C', "%0C") // \f form feed
.replace('\r', "%0D"); // \r carriage return
let mut parameters = vec![DispositionParam::Filename(filename_s)];
if !filename.is_ascii() {
parameters.push(DispositionParam::FilenameExt(ExtendedValue {
charset: Charset::Ext(String::from("UTF-8")),
language_tag: None,
value: filename.into_owned().into_bytes(),
}))
}
let cd = ContentDisposition {
disposition,
parameters,
};
Ok((ct, cd))
}
impl NamedFile {
/// Creates an instance from a previously opened file.
///
@ -116,52 +166,7 @@ impl NamedFile {
// Get the name of the file and use it to construct default Content-Type
// and Content-Disposition values
let (content_type, content_disposition) = {
let filename = match path.file_name() {
Some(name) => name.to_string_lossy(),
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Provided path has no filename",
));
}
};
let ct = mime_guess::from_path(&path).first_or_octet_stream();
let disposition = match ct.type_() {
mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline,
mime::APPLICATION => match ct.subtype() {
mime::JAVASCRIPT | mime::JSON => DispositionType::Inline,
name if name == "wasm" || name == "xhtml" => DispositionType::Inline,
_ => DispositionType::Attachment,
},
_ => DispositionType::Attachment,
};
// replace special characters in filenames which could occur on some filesystems
let filename_s = filename
.replace('\n', "%0A") // \n line break
.replace('\x0B', "%0B") // \v vertical tab
.replace('\x0C', "%0C") // \f form feed
.replace('\r', "%0D"); // \r carriage return
let mut parameters = vec![DispositionParam::Filename(filename_s)];
if !filename.is_ascii() {
parameters.push(DispositionParam::FilenameExt(ExtendedValue {
charset: Charset::Ext(String::from("UTF-8")),
language_tag: None,
value: filename.into_owned().into_bytes(),
}))
}
let cd = ContentDisposition {
disposition,
parameters,
};
(ct, cd)
};
let (content_type, content_disposition) = get_content_type_and_disposition(&path)?;
let md = {
#[cfg(not(feature = "experimental-io-uring"))]
@ -200,6 +205,7 @@ impl NamedFile {
encoding,
status_code: StatusCode::OK,
flags: Flags::default(),
read_mode_threshold: 0,
})
}
@ -353,6 +359,23 @@ impl NamedFile {
self
}
/// Sets the size threshold that determines file read mode (sync/async).
///
/// When a file is smaller than the threshold (bytes), the reader will use synchronous
/// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the
/// main thread.
///
/// Tweaking this value according to your expected usage may lead to significant performance
/// gains (or losses in other handlers, if `size` is too high).
///
/// When the `experimental-io-uring` crate feature is enabled, file reads are always async.
///
/// Default is 0, meaning all files are read asynchronously.
pub fn read_mode_threshold(mut self, size: u64) -> Self {
self.read_mode_threshold = size;
self
}
/// Specifies whether to return `ETag` header in response.
///
/// Default is true.
@ -382,7 +405,9 @@ impl NamedFile {
/// Creates an `ETag` in a format is similar to Apache's.
pub(crate) fn etag(&self) -> Option<header::EntityTag> {
self.modified.as_ref().map(|mtime| {
let mtime = self.modified?;
Some({
let ino = {
#[cfg(unix)]
{
@ -398,22 +423,50 @@ impl NamedFile {
}
};
let dur = mtime
.duration_since(UNIX_EPOCH)
.expect("modification time must be after epoch");
// Don't panic for pre-epoch modification times. Encode the timestamp as seconds and
// sub-second nanoseconds relative to the UNIX epoch, allowing negative values.
let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) {
Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
Err(err) => {
let dur = err.duration();
// For timestamps before the epoch, represent the time as a negative seconds
// offset with positive nanoseconds (like POSIX timespec).
if dur.subsec_nanos() == 0 {
(-(dur.as_secs() as i64), 0)
} else {
(
-(dur.as_secs() as i64) - 1,
1_000_000_000 - dur.subsec_nanos(),
)
}
}
};
header::EntityTag::new_strong(format!(
"{:x}:{:x}:{:x}:{:x}",
ino,
self.md.len(),
dur.as_secs(),
dur.subsec_nanos()
secs as u64,
nanos
))
})
}
pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
self.modified.map(|mtime| mtime.into())
let mtime = self.modified?;
// avoid panic in `httpdate` crate when formatting as an HTTP date
// see: https://github.com/actix/actix-web/issues/2748
//
// httpdate supports dates in range [1970, 9999); see:
// https://github.com/seanmonstar/httpdate/blob/v1.0.3/src/date.rs
let dur = mtime.duration_since(UNIX_EPOCH).ok()?;
if dur.as_secs() >= 253_402_300_800 {
return None;
}
Some(mtime.into())
}
/// Creates an `HttpResponse` with file as a streaming body.
@ -440,7 +493,8 @@ impl NamedFile {
res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
}
let reader = chunked::new_chunked_read(self.md.len(), 0, self.file);
let reader =
chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold);
return res.streaming(reader);
}
@ -526,34 +580,18 @@ impl NamedFile {
let mut length = self.md.len();
let mut offset = 0;
let mut ranged_req = false;
// check for range header
if let Some(ranges) = req.headers().get(header::RANGE) {
if let Ok(ranges_header) = ranges.to_str() {
if let Ok(ranges) = HttpRange::parse(ranges_header, length) {
length = ranges[0].length;
offset = ranges[0].start;
// When a Content-Encoding header is present in a 206 partial content response
// for video content, it prevents browser video players from starting playback
// before loading the whole video and also prevents seeking.
//
// See: https://github.com/actix/actix-web/issues/2815
//
// The assumption of this fix is that the video player knows to not send an
// Accept-Encoding header for this request and that downstream middleware will
// not attempt compression for requests without it.
//
// TODO: Solve question around what to do if self.encoding is set and partial
// range is requested. Reject request? Ignoring self.encoding seems wrong, too.
// In practice, it should not come up.
if req.headers().contains_key(&header::ACCEPT_ENCODING) {
// don't allow compression middleware to modify partial content
res.insert_header((
header::CONTENT_ENCODING,
HeaderValue::from_static("identity"),
));
}
if let Some(range) = HttpRange::parse(ranges_header, length)
.ok()
.and_then(|ranges| ranges.first().copied())
{
ranged_req = true;
length = range.length;
offset = range.start;
res.insert_header((
header::CONTENT_RANGE,
@ -577,9 +615,9 @@ impl NamedFile {
.map_into_boxed_body();
}
let reader = chunked::new_chunked_read(length, offset, self.file);
let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold);
if offset != 0 || length != self.md.len() {
if ranged_req {
res.status(StatusCode::PARTIAL_CONTENT);
}
@ -687,3 +725,14 @@ impl HttpServiceFactory for NamedFile {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_files_use_inline_content_disposition() {
let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
assert_eq!(cd.disposition, DispositionType::Inline);
}
}

View File

@ -8,8 +8,11 @@ use actix_web::{dev::Payload, FromRequest, HttpRequest};
use crate::error::UriSegmentError;
/// Secure Path Traversal Guard
///
/// This struct parses a request-uri [`PathBuf`](std::path::PathBuf)
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct PathBufWrap(PathBuf);
pub struct PathBufWrap(PathBuf);
impl FromStr for PathBufWrap {
type Err = UriSegmentError;
@ -20,6 +23,37 @@ impl FromStr for PathBufWrap {
}
impl PathBufWrap {
/// Parse a safe path from the unprocessed tail of a supplied
/// [`HttpRequest`](actix_web::HttpRequest), given the choice of allowing hidden files to be
/// considered valid segments.
///
/// This uses [`HttpRequest::match_info`](actix_web::HttpRequest::match_info) and
/// [`Path::unprocessed`](actix_web::dev::Path::unprocessed), which returns the part of the
/// path not matched by route patterns. This is useful for mounted services (eg. `Files`),
/// where only the tail should be parsed.
///
/// Path traversal is guarded by this method.
#[inline]
pub fn parse_unprocessed_req(
req: &HttpRequest,
hidden_files: bool,
) -> Result<Self, UriSegmentError> {
Self::parse_path(req.match_info().unprocessed(), hidden_files)
}
/// Parse a safe path from the full request path of a supplied
/// [`HttpRequest`](actix_web::HttpRequest), given the choice of allowing hidden files to be
/// considered valid segments.
///
/// This uses [`HttpRequest::path`](actix_web::HttpRequest::path), and is more appropriate
/// for non-mounted handlers that want the entire request path.
///
/// Path traversal is guarded by this method.
#[inline]
pub fn parse_req_path(req: &HttpRequest, hidden_files: bool) -> Result<Self, UriSegmentError> {
Self::parse_path(req.path(), hidden_files)
}
/// Parse a path, giving the choice of allowing hidden files to be considered valid segments.
///
/// Path traversal is guarded by this method.
@ -91,6 +125,7 @@ impl FromRequest for PathBufWrap {
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Uses the unprocessed tail of the request path and disallows hidden files.
ready(req.match_info().unprocessed().parse())
}
}

View File

@ -45,7 +45,7 @@ pub struct HttpRange {
impl HttpRange {
/// Parses Range HTTP header string as per RFC 2616.
///
/// `header` is HTTP Range header (e.g. `bytes=bytes=0-9`).
/// `header` is HTTP Range header (e.g. `bytes=0-9`).
/// `size` is full size of response (file).
pub fn parse(header: &str, size: u64) -> Result<Vec<HttpRange>, ParseRangeErr> {
let ranges =
@ -294,16 +294,11 @@ mod tests {
let res = HttpRange::parse(header, size);
if res.is_err() {
if let Err(err) = res {
if expected.is_empty() {
continue;
} else {
panic!(
"parse({}, {}) returned error {:?}",
header,
size,
res.unwrap_err()
);
panic!("parse({header}, {size}) returned error {err:?}");
}
}

View File

@ -1,4 +1,9 @@
use std::{fmt, io, ops::Deref, path::PathBuf, rc::Rc};
use std::{
fmt, io,
ops::Deref,
path::{Path, PathBuf},
rc::Rc,
};
use actix_web::{
body::BoxBody,
@ -39,6 +44,9 @@ pub struct FilesServiceInner {
pub(crate) file_flags: named::Flags,
pub(crate) guards: Option<Rc<dyn Guard>>,
pub(crate) hidden_files: bool,
pub(crate) try_compressed: bool,
pub(crate) size_threshold: u64,
pub(crate) with_permanent_redirect: bool,
}
impl fmt::Debug for FilesServiceInner {
@ -62,7 +70,12 @@ impl FilesService {
}
}
fn serve_named_file(&self, req: ServiceRequest, mut named_file: NamedFile) -> ServiceResponse {
fn serve_named_file_with_encoding(
&self,
req: ServiceRequest,
mut named_file: NamedFile,
encoding: header::ContentEncoding,
) -> ServiceResponse {
if let Some(ref mime_override) = self.mime_override {
let new_disposition = mime_override(&named_file.content_type.type_());
named_file.content_disposition.disposition = new_disposition;
@ -70,10 +83,36 @@ impl FilesService {
named_file.flags = self.file_flags;
let (req, _) = req.into_parts();
let res = named_file.into_response(&req);
let mut res = named_file
.read_mode_threshold(self.size_threshold)
.into_response(&req);
let header_value = match encoding {
header::ContentEncoding::Brotli => Some("br"),
header::ContentEncoding::Gzip => Some("gzip"),
header::ContentEncoding::Zstd => Some("zstd"),
header::ContentEncoding::Identity => None,
// Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here
_ => unreachable!(),
};
if let Some(header_value) = header_value {
res.headers_mut().insert(
header::CONTENT_ENCODING,
header::HeaderValue::from_static(header_value),
);
// Response representation varies by Accept-Encoding when serving pre-compressed assets.
res.headers_mut().append(
header::VARY,
header::HeaderValue::from_static("accept-encoding"),
);
}
ServiceResponse::new(req, res)
}
fn serve_named_file(&self, req: ServiceRequest, named_file: NamedFile) -> ServiceResponse {
self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity)
}
fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse {
let dir = Directory::new(self.directory.clone(), path);
@ -134,6 +173,15 @@ impl Service<ServiceRequest> for FilesService {
// full file path
let path = this.directory.join(&path_on_disk);
// Try serving pre-compressed file even if the uncompressed file doesn't exist yet.
// Still handle directories (index/listing) through the normal branch below.
if this.try_compressed && !path.is_dir() {
if let Some((named_file, encoding)) = find_compressed(&req, &path).await {
return Ok(this.serve_named_file_with_encoding(req, named_file, encoding));
}
}
if let Err(err) = path.canonicalize() {
return this.handle_err(err, req).await;
}
@ -145,16 +193,30 @@ impl Service<ServiceRequest> for FilesService {
{
let redirect_to = format!("{}/", req.path());
return Ok(req.into_response(
HttpResponse::Found()
.insert_header((header::LOCATION, redirect_to))
.finish(),
));
let response = if this.with_permanent_redirect {
HttpResponse::PermanentRedirect()
} else {
HttpResponse::TemporaryRedirect()
}
.insert_header((header::LOCATION, redirect_to))
.finish();
return Ok(req.into_response(response));
}
match this.index {
Some(ref index) => {
let named_path = path.join(index);
if this.try_compressed {
if let Some((named_file, encoding)) =
find_compressed(&req, &named_path).await
{
return Ok(
this.serve_named_file_with_encoding(req, named_file, encoding)
);
}
}
// fallback to the uncompressed version
match NamedFile::open_async(named_path).await {
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
Err(_) if this.show_index => Ok(this.show_index(req, path)),
@ -169,20 +231,91 @@ impl Service<ServiceRequest> for FilesService {
}
} else {
match NamedFile::open_async(&path).await {
Ok(mut named_file) => {
if let Some(ref mime_override) = this.mime_override {
let new_disposition = mime_override(&named_file.content_type.type_());
named_file.content_disposition.disposition = new_disposition;
}
named_file.flags = this.file_flags;
let (req, _) = req.into_parts();
let res = named_file.into_response(&req);
Ok(ServiceResponse::new(req, res))
}
Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
Err(err) => this.handle_err(err, req).await,
}
}
})
}
}
/// Flate doesn't have an accepted file extension, so it is not included here.
const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[
header::ContentEncoding::Brotli,
header::ContentEncoding::Gzip,
header::ContentEncoding::Zstd,
header::ContentEncoding::Identity,
];
/// Searches disk for an acceptable alternate encoding of the content at the given path, as
/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with
/// the most appropriate supported encoding, if any exist.
async fn find_compressed(
req: &ServiceRequest,
original_path: &Path,
) -> Option<(NamedFile, header::ContentEncoding)> {
use actix_web::HttpMessage;
use header::{AcceptEncoding, ContentEncoding, Encoding};
// Retrieve the content type and content disposition based on the original filename. If we
// can't get these successfully, don't even try to find a compressed file.
let (content_type, content_disposition) =
match crate::named::get_content_type_and_disposition(original_path) {
Ok(values) => values,
Err(_) => return None,
};
let accept_encoding = req.get_header::<AcceptEncoding>()?;
let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS
.iter()
.copied()
.map(Encoding::Known)
.collect::<Vec<_>>();
// Only move the original content-type/disposition into the chosen compressed file once.
let mut content_type = Some(content_type);
let mut content_disposition = Some(content_disposition);
loop {
// Select next acceptable encoding (honouring q=0 rejections) from remaining supported set.
let chosen = accept_encoding.negotiate(supported.iter())?;
let encoding = match chosen {
Encoding::Known(enc) => enc,
// No supported encoding should ever be unknown here.
Encoding::Unknown(_) => return None,
};
// Identity indicates there is no acceptable pre-compressed representation.
if encoding == ContentEncoding::Identity {
return None;
}
let extension = match encoding {
ContentEncoding::Brotli => ".br",
ContentEncoding::Gzip => ".gz",
ContentEncoding::Zstd => ".zst",
ContentEncoding::Identity => unreachable!(),
// Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here.
_ => unreachable!(),
};
let mut compressed_path = original_path.to_owned();
let mut filename = compressed_path.file_name()?.to_owned();
filename.push(extension);
compressed_path.set_file_name(filename);
match NamedFile::open_async(&compressed_path).await {
Ok(mut named_file) => {
named_file.content_type = content_type.take().unwrap();
named_file.content_disposition = content_disposition.take().unwrap();
return Some((named_file, encoding));
}
// Ignore errors while searching disk for a suitable encoding.
Err(_) => {
supported.retain(|enc| enc != &chosen);
}
}
}
}

View File

@ -36,6 +36,136 @@ async fn test_utf8_file_contents() {
);
}
#[actix_web::test]
async fn test_compression_encodings() {
use actix_web::body::MessageBody;
let utf8_txt_len = std::fs::metadata("./tests/utf8.txt").unwrap().len();
let utf8_txt_br_len = std::fs::metadata("./tests/utf8.txt.br").unwrap().len();
let utf8_txt_gz_len = std::fs::metadata("./tests/utf8.txt.gz").unwrap().len();
let srv =
test::init_service(App::new().service(Files::new("/", "./tests").try_compressed())).await;
// Select the requested encoding when present
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("gzip"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(
res.headers().get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("gzip")),
);
assert_eq!(
res.headers().get(header::VARY),
Some(&HeaderValue::from_static("accept-encoding")),
);
assert_eq!(
res.into_body().size(),
actix_web::body::BodySize::Sized(utf8_txt_gz_len),
);
// Select the highest priority encoding
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("gzip;q=0.6,br;q=0.8,*"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(
res.headers().get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("br")),
);
assert_eq!(
res.headers().get(header::VARY),
Some(&HeaderValue::from_static("accept-encoding")),
);
assert_eq!(
res.into_body().size(),
actix_web::body::BodySize::Sized(utf8_txt_br_len),
);
// Request encoding that doesn't exist on disk and fallback to no encoding
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("zstd"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,);
assert_eq!(
res.into_body().size(),
actix_web::body::BodySize::Sized(utf8_txt_len),
);
// Do not select an encoding explicitly refused via q=0
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("zstd;q=1, gzip;q=0"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,);
assert_eq!(
res.into_body().size(),
actix_web::body::BodySize::Sized(utf8_txt_len),
);
// Can still request a compressed file directly
let req = TestRequest::with_uri("/utf8.txt.gz").to_request();
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("application/gzip")),
);
assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,);
// Don't try compressed files
let srv = test::init_service(App::new().service(Files::new("/", "./tests"))).await;
let mut req = TestRequest::with_uri("/utf8.txt").to_request();
req.headers_mut().insert(
header::ACCEPT_ENCODING,
header::HeaderValue::from_static("gzip"),
);
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/plain; charset=utf-8")),
);
assert_eq!(res.headers().get(header::CONTENT_ENCODING), None);
}
#[actix_web::test]
async fn partial_range_response_encoding() {
let srv = test::init_service(App::new().default_service(web::to(|| async {
@ -51,15 +181,12 @@ async fn partial_range_response_encoding() {
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
// range request with accept-encoding returns a content-encoding header
// range request with accept-encoding still returns no content-encoding header
let req = TestRequest::with_uri("/")
.append_header((header::RANGE, "bytes=10-20"))
.append_header((header::ACCEPT_ENCODING, "identity"))
.append_header((header::ACCEPT_ENCODING, "gzip"))
.to_request();
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
res.headers().get(header::CONTENT_ENCODING).unwrap(),
"identity"
);
assert!(!res.headers().contains_key(header::CONTENT_ENCODING));
}

View File

@ -0,0 +1,49 @@
use std::time::UNIX_EPOCH;
use actix_files::NamedFile;
use actix_web::{
http::{header, StatusCode},
test, web, App,
};
use filetime::{set_file_mtime, FileTime};
use tempfile::tempdir;
#[actix_web::test]
async fn serves_file_with_pre_epoch_mtime() {
let dir = tempdir().unwrap();
let path = dir.path().join("pre_epoch.txt");
std::fs::write(&path, b"hello").unwrap();
// set mtime to before UNIX epoch; this used to panic during ETag/Last-Modified generation
set_file_mtime(&path, FileTime::from_unix_time(-60, 0)).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(
mtime < UNIX_EPOCH,
"fixture mtime should be before UNIX_EPOCH"
);
let srv = {
let path = path.clone();
test::init_service(App::new().default_service(web::to(move || {
let path = path.clone();
async move { NamedFile::open_async(path).await.unwrap() }
})))
.await
};
let req = test::TestRequest::with_uri("/").to_request();
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
// ETag is still generated even for pre-epoch times.
assert!(res.headers().contains_key(header::ETAG));
// HTTP-date formatting in the httpdate crate does not support pre-epoch times.
assert!(!res.headers().contains_key(header::LAST_MODIFIED));
let body = test::read_body(res).await;
assert_eq!(&body[..], b"hello");
}

Binary file not shown.

Binary file not shown.

View File

@ -2,7 +2,7 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.72.
- Minimum supported Rust version (MSRV) is now 1.88.
## 3.2.0

View File

@ -53,7 +53,7 @@ serde = "1"
serde_json = "1"
serde_urlencoded = "0.7"
slab = "0.4"
socket2 = "0.5"
socket2 = "0.6"
tls-openssl = { version = "0.10.55", package = "openssl", optional = true }
tokio = { version = "1.38.2", features = ["sync"] }

View File

@ -4,7 +4,7 @@
[![crates.io](https://img.shields.io/crates/v/actix-http-test?label=latest)](https://crates.io/crates/actix-http-test)
[![Documentation](https://docs.rs/actix-http-test/badge.svg?version=3.2.0)](https://docs.rs/actix-http-test/3.2.0)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http-test)
<br>
[![Dependency Status](https://deps.rs/crate/actix-http-test/3.2.0/status.svg)](https://deps.rs/crate/actix-http-test/3.2.0)

View File

@ -2,7 +2,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "openssl")]
extern crate tls_openssl as openssl;

View File

@ -3,6 +3,52 @@
## Unreleased
- Add DEFLATE compression support for WebSocket.
- When configured, gracefully close HTTP/1 connections after early responses to unread request bodies. [#3967]
- Update `foldhash` dependency to `0.2`.
[#3967]: https://github.com/actix/actix-web/issues/3967
## 3.12.1
**Notice: This release contains a security fix. Users are encouraged to update to this version ASAP.**
- SECURITY: Reject HTTP/1 requests with ambiguous request framing from `Content-Length` and `Transfer-Encoding` headers to prevent request smuggling.
- Encode the HTTP/1 `Connection: Upgrade` header in Camel-Case when camel-case header formatting is enabled.[#3953]
- Fix `HeaderMap` iterators' `len()` and `size_hint()` implementations for multi-value headers.
- Update `rand` dependency to `0.10`.
- Update `sha1` dependency to `0.11`.
- Add `ServiceConfigBuilder::h1_write_buffer_size()` and `HttpServiceBuilder::h1_write_buffer_size()`.
[#3953]: https://github.com/actix/actix-web/pull/3953
## 3.12.0
- Minimum supported Rust version (MSRV) is now 1.88.
- Increase default HTTP/2 flow control window sizes. [#3638]
- Expose configuration methods to improve upload throughput. [#3638]
- Fix truncated body ending without error when connection closed abnormally. [#3067]
- Add config/method for `TCP_NODELAY`. [#3918]
- Do not compress 206 Partial Content responses. [#3191]
- Fix lingering sockets and client stalls when responding early to dropped chunked request payloads. [#2972]
[#3638]: https://github.com/actix/actix-web/issues/3638
[#3067]: https://github.com/actix/actix-web/pull/3067
[#3918]: https://github.com/actix/actix-web/pull/3918
[#3191]: https://github.com/actix/actix-web/issues/3191
[#2972]: https://github.com/actix/actix-web/issues/2972
## 3.11.2
- Properly wake Payload receivers when feeding errors or EOF.
- Add `ServiceConfigBuilder` type to facilitate future configuration extensions.
- Add a configuration option to allow/disallow half closed connections in HTTP/1. This defaults to allow, reverting the change made in 3.11.1.
- Shutdown connections when HTTP Responses are written without reading full Requests.
## 3.11.1
- Prevent more hangs after client disconnects.
- More malformed WebSocket frames are now gracefully rejected.
- Using `TestRequest::set_payload()` now sets a Content-Length header.
## 3.11.0

View File

@ -1,6 +1,6 @@
[package]
name = "actix-http"
version = "3.11.0"
version = "3.12.1"
authors = ["Nikolay Kim <fafhrd91@gmail.com>", "Rob Ede <robjtede@icloud.com>"]
description = "HTTP types and services for the Actix ecosystem"
keywords = ["actix", "http", "framework", "async", "futures"]
@ -17,7 +17,6 @@ edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
features = [
"http2",
"ws",
@ -105,7 +104,7 @@ bytes = "1"
bytestring = "1"
derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] }
encoding_rs = "0.8"
foldhash = "0.1"
foldhash = "0.2"
futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] }
http = "0.2.7"
httparse = "1.5.1"
@ -121,13 +120,13 @@ tokio-util = { version = "0.7", features = ["io", "codec"] }
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
# http2
h2 = { version = "0.3.26", optional = true }
h2 = { version = "0.3.27", optional = true }
# websockets
base64 = { version = "0.22", optional = true }
local-channel = { version = "0.1", optional = true }
rand = { version = "0.9", optional = true }
sha1 = { version = "0.10", optional = true }
rand = { version = "0.10.1", optional = true }
sha1 = { version = "0.11", optional = true }
# openssl/rustls
actix-tls = { version = "3.4", default-features = false, optional = true }
@ -142,6 +141,7 @@ actix-http-test = { version = "3", features = ["openssl"] }
actix-server = "2"
actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23-webpki-roots"] }
actix-web = "4"
awc = { version = "3", default-features = false, features = ["openssl"] }
async-stream = "0.3"
criterion = { version = "0.5", features = ["html_reports"] }
@ -152,14 +152,14 @@ memchr = "2.4"
once_cell = "1.21"
rcgen = "0.13"
regex = "1.3"
rustls-pemfile = "2"
rustls-pki-types = "1.13.1"
rustversion = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
static_assertions = "1"
tls-openssl = { package = "openssl", version = "0.10.55" }
tls-rustls_023 = { package = "rustls", version = "0.23" }
tokio = { version = "1.38.2", features = ["net", "rt", "macros"] }
tokio = { version = "1.38.2", features = ["net", "rt", "macros", "sync"] }
[lints]
workspace = true

View File

@ -5,11 +5,11 @@
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-http?label=latest)](https://crates.io/crates/actix-http)
[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.11.0)](https://docs.rs/actix-http/3.11.0)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.12.0)](https://docs.rs/actix-http/3.12.0)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-http/3.11.0/status.svg)](https://deps.rs/crate/actix-http/3.11.0)
[![dependency status](https://deps.rs/crate/actix-http/3.12.0/status.svg)](https://deps.rs/crate/actix-http/3.12.0)
[![Download](https://img.shields.io/crates/d/actix-http.svg)](https://crates.io/crates/actix-http)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x)

View File

@ -45,25 +45,14 @@ async fn main() -> io::Result<()> {
fn rustls_config() -> rustls::ServerConfig {
let rcgen::CertifiedKey { cert, key_pair } =
rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap();
let cert_file = cert.pem();
let key_file = key_pair.serialize_pem();
let cert_file = &mut io::BufReader::new(cert_file.as_bytes());
let key_file = &mut io::BufReader::new(key_file.as_bytes());
let cert_chain = rustls_pemfile::certs(cert_file)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let mut keys = rustls_pemfile::pkcs8_private_keys(key_file)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let cert_chain = vec![cert.der().clone()];
let key_der = rustls_pki_types::PrivateKeyDer::Pkcs8(
rustls_pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
cert_chain,
rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)),
)
.with_single_cert(cert_chain, key_der)
.unwrap();
const H1_ALPN: &[u8] = b"http/1.1";

View File

@ -82,29 +82,16 @@ impl Stream for Heartbeat {
}
fn tls_config() -> rustls::ServerConfig {
use std::io::BufReader;
use rustls_pemfile::{certs, pkcs8_private_keys};
let rcgen::CertifiedKey { cert, key_pair } =
rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap();
let cert_file = cert.pem();
let key_file = key_pair.serialize_pem();
let cert_file = &mut BufReader::new(cert_file.as_bytes());
let key_file = &mut BufReader::new(key_file.as_bytes());
let cert_chain = certs(cert_file).collect::<Result<Vec<_>, _>>().unwrap();
let mut keys = pkcs8_private_keys(key_file)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let cert_chain = vec![cert.der().clone()];
let key_der = rustls_pki_types::PrivateKeyDer::Pkcs8(
rustls_pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
cert_chain,
rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)),
)
.with_single_cert(cert_chain, key_der)
.unwrap();
config.alpn_protocols.push(b"http/1.1".to_vec());

View File

@ -5,9 +5,12 @@ use actix_service::{IntoServiceFactory, Service, ServiceFactory};
use crate::{
body::{BoxBody, MessageBody},
config::{
DEFAULT_H1_WRITE_BUFFER_SIZE, DEFAULT_H2_CONN_WINDOW_SIZE, DEFAULT_H2_STREAM_WINDOW_SIZE,
},
h1::{self, ExpectHandler, H1Service, UpgradeHandler},
service::HttpService,
ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfig,
ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfigBuilder,
};
/// An HTTP service builder.
@ -17,8 +20,13 @@ pub struct HttpServiceBuilder<T, S, X = ExpectHandler, U = UpgradeHandler> {
keep_alive: KeepAlive,
client_request_timeout: Duration,
client_disconnect_timeout: Duration,
tcp_nodelay: Option<bool>,
secure: bool,
local_addr: Option<net::SocketAddr>,
h1_allow_half_closed: bool,
h1_write_buffer_size: usize,
h2_conn_window_size: u32,
h2_stream_window_size: u32,
expect: X,
upgrade: Option<U>,
on_connect_ext: Option<Rc<ConnectCallback<T>>>,
@ -38,8 +46,13 @@ where
keep_alive: KeepAlive::default(),
client_request_timeout: Duration::from_secs(5),
client_disconnect_timeout: Duration::ZERO,
tcp_nodelay: None,
secure: false,
local_addr: None,
h1_allow_half_closed: true,
h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE,
h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE,
h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE,
// dispatcher parts
expect: ExpectHandler,
@ -118,12 +131,65 @@ where
self
}
/// Sets `TCP_NODELAY` value on accepted TCP connections.
pub fn tcp_nodelay(mut self, nodelay: bool) -> Self {
self.tcp_nodelay = Some(nodelay);
self
}
#[doc(hidden)]
#[deprecated(since = "3.0.0", note = "Renamed to `client_disconnect_timeout`.")]
pub fn client_disconnect(self, dur: Duration) -> Self {
self.client_disconnect_timeout(dur)
}
/// Sets whether HTTP/1 connections should support half-closures.
///
/// Clients can choose to shutdown their writer-side of the connection after completing their
/// request and while waiting for the server response. Setting this to `false` will cause the
/// server to abort the connection handling as soon as it detects an EOF from the client.
///
/// The default behavior is to allow, i.e. `true`
pub fn h1_allow_half_closed(mut self, allow: bool) -> Self {
self.h1_allow_half_closed = allow;
self
}
/// Sets the maximum response write buffer size for HTTP/1 connections.
///
/// Once the response buffer reaches this size, the dispatcher flushes it to the I/O stream.
///
/// The default value is 32 KiB.
///
/// # Panics
///
/// Panics if `size` is 0.
pub fn h1_write_buffer_size(mut self, size: usize) -> Self {
assert!(
size > 0,
"HTTP/1 write buffer size must be greater than zero"
);
self.h1_write_buffer_size = size;
self
}
/// Sets initial stream-level flow control window size for HTTP/2 connections.
///
/// See [`ServiceConfigBuilder::h2_initial_window_size`] for more details.
pub fn h2_initial_window_size(mut self, size: u32) -> Self {
self.h2_stream_window_size = size;
self
}
/// Sets initial connection-level flow control window size for HTTP/2 connections.
///
/// See [`ServiceConfigBuilder::h2_initial_connection_window_size`] for more details.
pub fn h2_initial_connection_window_size(mut self, size: u32) -> Self {
self.h2_conn_window_size = size;
self
}
/// Provide service for `EXPECT: 100-Continue` support.
///
/// Service get called with request that contains `EXPECT` header.
@ -140,8 +206,13 @@ where
keep_alive: self.keep_alive,
client_request_timeout: self.client_request_timeout,
client_disconnect_timeout: self.client_disconnect_timeout,
tcp_nodelay: self.tcp_nodelay,
secure: self.secure,
local_addr: self.local_addr,
h1_allow_half_closed: self.h1_allow_half_closed,
h1_write_buffer_size: self.h1_write_buffer_size,
h2_conn_window_size: self.h2_conn_window_size,
h2_stream_window_size: self.h2_stream_window_size,
expect: expect.into_factory(),
upgrade: self.upgrade,
on_connect_ext: self.on_connect_ext,
@ -164,8 +235,13 @@ where
keep_alive: self.keep_alive,
client_request_timeout: self.client_request_timeout,
client_disconnect_timeout: self.client_disconnect_timeout,
tcp_nodelay: self.tcp_nodelay,
secure: self.secure,
local_addr: self.local_addr,
h1_allow_half_closed: self.h1_allow_half_closed,
h1_write_buffer_size: self.h1_write_buffer_size,
h2_conn_window_size: self.h2_conn_window_size,
h2_stream_window_size: self.h2_stream_window_size,
expect: self.expect,
upgrade: Some(upgrade.into_factory()),
on_connect_ext: self.on_connect_ext,
@ -195,13 +271,18 @@ where
S::InitError: fmt::Debug,
S::Response: Into<Response<B>>,
{
let cfg = ServiceConfig::new(
self.keep_alive,
self.client_request_timeout,
self.client_disconnect_timeout,
self.secure,
self.local_addr,
);
let cfg = ServiceConfigBuilder::new()
.keep_alive(self.keep_alive)
.client_request_timeout(self.client_request_timeout)
.client_disconnect_timeout(self.client_disconnect_timeout)
.tcp_nodelay(self.tcp_nodelay)
.secure(self.secure)
.local_addr(self.local_addr)
.h1_allow_half_closed(self.h1_allow_half_closed)
.h1_write_buffer_size(self.h1_write_buffer_size)
.h2_initial_window_size(self.h2_stream_window_size)
.h2_initial_connection_window_size(self.h2_conn_window_size)
.build();
H1Service::with_config(cfg, service.into_factory())
.expect(self.expect)
@ -220,13 +301,18 @@ where
B: MessageBody + 'static,
{
let cfg = ServiceConfig::new(
self.keep_alive,
self.client_request_timeout,
self.client_disconnect_timeout,
self.secure,
self.local_addr,
);
let cfg = ServiceConfigBuilder::new()
.keep_alive(self.keep_alive)
.client_request_timeout(self.client_request_timeout)
.client_disconnect_timeout(self.client_disconnect_timeout)
.tcp_nodelay(self.tcp_nodelay)
.secure(self.secure)
.local_addr(self.local_addr)
.h1_allow_half_closed(self.h1_allow_half_closed)
.h1_write_buffer_size(self.h1_write_buffer_size)
.h2_initial_window_size(self.h2_stream_window_size)
.h2_initial_connection_window_size(self.h2_conn_window_size)
.build();
crate::h2::H2Service::with_config(cfg, service.into_factory())
.on_connect_ext(self.on_connect_ext)
@ -242,13 +328,18 @@ where
B: MessageBody + 'static,
{
let cfg = ServiceConfig::new(
self.keep_alive,
self.client_request_timeout,
self.client_disconnect_timeout,
self.secure,
self.local_addr,
);
let cfg = ServiceConfigBuilder::new()
.keep_alive(self.keep_alive)
.client_request_timeout(self.client_request_timeout)
.client_disconnect_timeout(self.client_disconnect_timeout)
.tcp_nodelay(self.tcp_nodelay)
.secure(self.secure)
.local_addr(self.local_addr)
.h1_allow_half_closed(self.h1_allow_half_closed)
.h1_write_buffer_size(self.h1_write_buffer_size)
.h2_initial_window_size(self.h2_stream_window_size)
.h2_initial_connection_window_size(self.h2_conn_window_size)
.build();
HttpService::with_config(cfg, service.into_factory())
.expect(self.expect)

View File

@ -1,5 +1,5 @@
use std::{
net,
net::SocketAddr,
rc::Rc,
time::{Duration, Instant},
};
@ -8,8 +8,136 @@ use bytes::BytesMut;
use crate::{date::DateService, KeepAlive};
/// Default HTTP/2 initial connection-level flow control window size.
///
/// Matches awc's defaults to avoid poor throughput on high-BDP links.
pub(crate) const DEFAULT_H2_CONN_WINDOW_SIZE: u32 = 1024 * 1024 * 2; // 2MiB
/// Default HTTP/2 initial stream-level flow control window size.
///
/// Matches awc's defaults to avoid poor throughput on high-BDP links.
pub(crate) const DEFAULT_H2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024; // 1MiB
/// Default HTTP/1 response write buffer size.
pub(crate) const DEFAULT_H1_WRITE_BUFFER_SIZE: usize = 32_768;
/// A builder for creating a [`ServiceConfig`]
#[derive(Default, Debug)]
pub struct ServiceConfigBuilder {
inner: Inner,
}
impl ServiceConfigBuilder {
/// Creates a new, default, [`ServiceConfigBuilder`]
///
/// It uses the following default values:
///
/// - [`KeepAlive::default`] for the connection keep-alive setting
/// - 5 seconds for the client request timeout
/// - 0 seconds for the client shutdown timeout
/// - secure value of `false`
/// - [`None`] for the local address setting
/// - Allow for half closed HTTP/1 connections
pub fn new() -> Self {
Self::default()
}
/// Sets the `secure` attribute for this configuration
pub fn secure(mut self, secure: bool) -> Self {
self.inner.secure = secure;
self
}
/// Sets the local address for this configuration
pub fn local_addr(mut self, local_addr: Option<SocketAddr>) -> Self {
self.inner.local_addr = local_addr;
self
}
/// Sets connection keep-alive setting
pub fn keep_alive(mut self, keep_alive: KeepAlive) -> Self {
self.inner.keep_alive = keep_alive;
self
}
/// Sets the timeout for the client to finish sending the head of its first request
pub fn client_request_timeout(mut self, timeout: Duration) -> Self {
self.inner.client_request_timeout = timeout;
self
}
/// Sets the timeout for cleanly disconnecting from the client after connection shutdown has
/// started
pub fn client_disconnect_timeout(mut self, timeout: Duration) -> Self {
self.inner.client_disconnect_timeout = timeout;
self
}
/// Sets `TCP_NODELAY` preference for accepted TCP connections.
pub fn tcp_nodelay(mut self, nodelay: Option<bool>) -> Self {
self.inner.tcp_nodelay = nodelay;
self
}
/// Sets whether HTTP/1 connections should support half-closures.
///
/// Clients can choose to shutdown their writer-side of the connection after completing their
/// request and while waiting for the server response. Setting this to `false` will cause the
/// server to abort the connection handling as soon as it detects an EOF from the client
pub fn h1_allow_half_closed(mut self, allow: bool) -> Self {
self.inner.h1_allow_half_closed = allow;
self
}
/// Sets the maximum response write buffer size for HTTP/1 connections.
///
/// Once the response buffer reaches this size, the dispatcher flushes it to the I/O stream.
///
/// The default value is 32 KiB.
///
/// # Panics
///
/// Panics if `size` is 0.
pub fn h1_write_buffer_size(mut self, size: usize) -> Self {
assert!(
size > 0,
"HTTP/1 write buffer size must be greater than zero"
);
self.inner.h1_write_buffer_size = size;
self
}
/// Sets initial stream-level flow control window size for HTTP/2 connections.
///
/// Higher values can improve upload performance on high-latency links at the cost of higher
/// worst-case memory usage per connection.
///
/// The default value is 1MiB.
pub fn h2_initial_window_size(mut self, size: u32) -> Self {
self.inner.h2_stream_window_size = size;
self
}
/// Sets initial connection-level flow control window size for HTTP/2 connections.
///
/// Higher values can improve upload performance on high-latency links at the cost of higher
/// worst-case memory usage per connection.
///
/// The default value is 2MiB.
pub fn h2_initial_connection_window_size(mut self, size: u32) -> Self {
self.inner.h2_conn_window_size = size;
self
}
/// Builds a [`ServiceConfig`] from this [`ServiceConfigBuilder`] instance
pub fn build(self) -> ServiceConfig {
ServiceConfig(Rc::new(self.inner))
}
}
/// HTTP service configuration.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct ServiceConfig(Rc<Inner>);
#[derive(Debug)]
@ -18,19 +146,30 @@ struct Inner {
client_request_timeout: Duration,
client_disconnect_timeout: Duration,
secure: bool,
local_addr: Option<std::net::SocketAddr>,
local_addr: Option<SocketAddr>,
tcp_nodelay: Option<bool>,
date_service: DateService,
h1_allow_half_closed: bool,
h1_write_buffer_size: usize,
h2_conn_window_size: u32,
h2_stream_window_size: u32,
}
impl Default for ServiceConfig {
impl Default for Inner {
fn default() -> Self {
Self::new(
KeepAlive::default(),
Duration::from_secs(5),
Duration::ZERO,
false,
None,
)
Self {
keep_alive: KeepAlive::default(),
client_request_timeout: Duration::from_secs(5),
client_disconnect_timeout: Duration::ZERO,
secure: false,
local_addr: None,
tcp_nodelay: None,
date_service: DateService::new(),
h1_allow_half_closed: true,
h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE,
h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE,
h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE,
}
}
}
@ -41,7 +180,7 @@ impl ServiceConfig {
client_request_timeout: Duration,
client_disconnect_timeout: Duration,
secure: bool,
local_addr: Option<net::SocketAddr>,
local_addr: Option<SocketAddr>,
) -> ServiceConfig {
ServiceConfig(Rc::new(Inner {
keep_alive: keep_alive.normalize(),
@ -49,7 +188,12 @@ impl ServiceConfig {
client_disconnect_timeout,
secure,
local_addr,
tcp_nodelay: None,
date_service: DateService::new(),
h1_allow_half_closed: true,
h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE,
h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE,
h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE,
}))
}
@ -63,7 +207,7 @@ impl ServiceConfig {
///
/// Returns `None` for connections via UDS (Unix Domain Socket).
#[inline]
pub fn local_addr(&self) -> Option<net::SocketAddr> {
pub fn local_addr(&self) -> Option<SocketAddr> {
self.0.local_addr
}
@ -100,6 +244,35 @@ impl ServiceConfig {
(timeout != Duration::ZERO).then(|| self.now() + timeout)
}
/// Whether HTTP/1 connections should support half-closures.
///
/// Clients can choose to shutdown their writer-side of the connection after completing their
/// request and while waiting for the server response. If this configuration is `false`, the
/// server will abort the connection handling as soon as it detects an EOF from the client
pub fn h1_allow_half_closed(&self) -> bool {
self.0.h1_allow_half_closed
}
/// HTTP/1 response write buffer size (in bytes).
pub fn h1_write_buffer_size(&self) -> usize {
self.0.h1_write_buffer_size
}
/// Returns configured `TCP_NODELAY` setting for accepted TCP connections.
pub fn tcp_nodelay(&self) -> Option<bool> {
self.0.tcp_nodelay
}
/// HTTP/2 initial stream-level flow control window size (in bytes).
pub fn h2_initial_window_size(&self) -> u32 {
self.0.h2_stream_window_size
}
/// HTTP/2 initial connection-level flow control window size (in bytes).
pub fn h2_initial_connection_window_size(&self) -> u32 {
self.0.h2_conn_window_size
}
pub(crate) fn now(&self) -> Instant {
self.0.date_service.now()
}

View File

@ -70,6 +70,7 @@ impl<B: MessageBody> Encoder<B> {
let should_encode = !(head.headers().contains_key(&CONTENT_ENCODING)
|| head.status == StatusCode::SWITCHING_PROTOCOLS
|| head.status == StatusCode::NO_CONTENT
|| head.status == StatusCode::PARTIAL_CONTENT
|| encoding == ContentEncoding::Identity);
let body = match body.try_into_bytes() {

View File

@ -237,4 +237,18 @@ mod tests {
assert_eq!(*req.method(), Method::POST);
assert!(req.chunked().unwrap());
}
#[actix_rt::test]
async fn test_http_request_rejects_content_length_and_chunked() {
let mut codec = Codec::default();
let mut buf = BytesMut::from(
"POST /test HTTP/1.1\r\n\
content-length: 11\r\n\
transfer-encoding: chunked\r\n\r\n\
0\r\n\r\n\
GET /test2 HTTP/1.1\r\n\r\n",
);
assert!(codec.decode(&mut buf).is_err());
}
}

View File

@ -275,6 +275,23 @@ impl MessageType for Request {
// convert headers
let mut length = msg.set_headers(&src.split_to(len).freeze(), &headers[..h_len], ver)?;
if msg.head().headers.contains_key(header::TRANSFER_ENCODING) {
if ver == Version::HTTP_10 {
debug!("Transfer-Encoding is not allowed in HTTP/1.0 requests");
return Err(ParseError::Header);
}
if !crate::HttpMessage::chunked(&msg)? {
debug!("request Transfer-Encoding must be chunked");
return Err(ParseError::Header);
}
if msg.head().headers.contains_key(header::CONTENT_LENGTH) {
debug!("both Content-Length and Transfer-Encoding are set");
return Err(ParseError::Header);
}
}
// disallow HTTP/1.0 POST requests that do not contain a Content-Length headers
// see https://datatracker.ietf.org/doc/html/rfc1945#section-7.2.2
if ver == Version::HTTP_10 && method == Method::POST && length.is_none() {
@ -1116,18 +1133,57 @@ mod tests {
#[test]
fn hrs_cl_and_te_http10() {
// in HTTP/1.0 transfer encoding is simply ignored so it's fine to have both
let mut buf = BytesMut::from(
expect_parse_err!(&mut BytesMut::from(
"GET / HTTP/1.0\r\n\
Host: example.com\r\n\
Content-Length: 3\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
000",
);
));
}
parse_ready!(&mut buf);
#[test]
fn hrs_cl_and_chunked_te_http11() {
expect_parse_err!(&mut BytesMut::from(
"POST / HTTP/1.1\r\n\
Host: example.com\r\n\
Content-Length: 3\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
0\r\n\
\r\n",
));
expect_parse_err!(&mut BytesMut::from(
"POST / HTTP/1.1\r\n\
Host: example.com\r\n\
Transfer-Encoding: chunked\r\n\
Content-Length: 3\r\n\
\r\n\
0\r\n\
\r\n",
));
}
#[test]
fn hrs_identity_te_http11() {
expect_parse_err!(&mut BytesMut::from(
"POST / HTTP/1.1\r\n\
Host: example.com\r\n\
Transfer-Encoding: identity\r\n\
\r\n\
0\r\n",
));
expect_parse_err!(&mut BytesMut::from(
"POST / HTTP/1.1\r\n\
Host: example.com\r\n\
Content-Length: 3\r\n\
Transfer-Encoding: identity\r\n\
\r\n\
0\r\n",
));
}
#[test]
@ -1165,14 +1221,16 @@ mod tests {
}
#[test]
fn transfer_encoding_agrees() {
fn hrs_chunked_te_http11() {
let mut buf = BytesMut::from(
"GET /test HTTP/1.1\r\n\
Host: example.com\r\n\
Content-Length: 3\r\n\
Transfer-Encoding: identity\r\n\
Transfer-Encoding: chunked\r\n\
\r\n\
0\r\n",
1\r\n\
a\r\n\
0\r\n\
\r\n",
);
let mut reader = MessageDecoder::<Request>::default();
@ -1180,6 +1238,6 @@ mod tests {
let mut pl = pl.unwrap();
let chunk = pl.decode(&mut buf).unwrap().unwrap();
assert_eq!(chunk, PayloadItem::Chunk(Bytes::from_static(b"0\r\n")));
assert_eq!(chunk, PayloadItem::Chunk(Bytes::from_static(b"a")));
}
}

View File

@ -31,7 +31,7 @@ use crate::{
config::ServiceConfig,
error::{DispatchError, ParseError, PayloadError},
service::HttpFlow,
Error, Extensions, OnConnectData, Request, Response, StatusCode,
ConnectionType, Error, Extensions, HttpMessage, OnConnectData, Request, Response, StatusCode,
};
const LW_BUFFER_SIZE: usize = 1024;
@ -58,6 +58,9 @@ bitflags! {
/// Set if write-half is disconnected.
const WRITE_DISCONNECT = 0b0010_0000;
/// Set while gracefully closing a connection after an early response.
const LINGER = 0b0100_0000;
}
}
@ -157,6 +160,8 @@ pin_project! {
pub(super) state: State<S, B, X>,
// when Some(_) dispatcher is in state of receiving request payload
payload: Option<PayloadSender>,
// true when current request uses chunked transfer encoding (drainable when payload is dropped)
payload_drainable: bool,
messages: VecDeque<DispatcherMessage>,
head_timer: TimerState,
@ -166,6 +171,7 @@ pin_project! {
pub(super) io: Option<T>,
read_buf: BytesMut,
write_buf: BytesMut,
h1_write_buffer_size: usize,
codec: Codec,
}
}
@ -269,6 +275,7 @@ where
state: State::None,
payload: None,
payload_drainable: false,
messages: VecDeque::new(),
head_timer: TimerState::new(config.client_request_deadline().is_some()),
@ -278,6 +285,7 @@ where
io: Some(io),
read_buf: BytesMut::with_capacity(HW_BUFFER_SIZE),
write_buf: BytesMut::with_capacity(HW_BUFFER_SIZE),
h1_write_buffer_size: config.h1_write_buffer_size(),
codec: Codec::new(config),
},
},
@ -308,7 +316,10 @@ where
if self.flags.contains(Flags::READ_DISCONNECT) {
false
} else if let Some(ref info) = self.payload {
info.need_read(cx) == PayloadStatus::Read
matches!(
info.need_read(cx),
PayloadStatus::Read | PayloadStatus::Dropped
)
} else {
true
}
@ -355,6 +366,65 @@ where
io.poll_flush(cx)
}
fn enter_linger(mut self: Pin<&mut Self>) {
let this = self.as_mut().project();
this.flags.remove(Flags::KEEP_ALIVE);
this.flags.insert(Flags::LINGER | Flags::FINISHED);
}
fn ensure_linger_timer(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> bool {
let this = self.as_mut().project();
if matches!(this.shutdown_timer, TimerState::Active { .. }) {
return true;
}
if let Some(deadline) = this.config.client_disconnect_deadline() {
this.shutdown_timer
.set_and_init(cx, sleep_until(deadline.into()), line!());
true
} else {
false
}
}
fn poll_linger(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Result<Poll<()>, DispatchError> {
if self.as_mut().poll_flush(cx)?.is_pending() {
return Ok(Poll::Pending);
}
if !self.as_mut().ensure_linger_timer(cx) {
let this = self.as_mut().project();
this.flags.remove(Flags::LINGER);
this.flags.insert(Flags::SHUTDOWN);
return Ok(Poll::Ready(()));
}
loop {
let should_disconnect = self.as_mut().read_available(cx)?;
let this = self.as_mut().project();
let mut progressed = false;
if !this.read_buf.is_empty() {
this.read_buf.clear();
progressed = true;
}
if should_disconnect {
this.flags.remove(Flags::LINGER);
this.flags.insert(Flags::READ_DISCONNECT | Flags::SHUTDOWN);
return Ok(Poll::Ready(()));
}
if !progressed {
return Ok(Poll::Pending);
}
}
}
fn send_response_inner(
self: Pin<&mut Self>,
res: Response<()>,
@ -379,36 +449,90 @@ where
fn send_response(
mut self: Pin<&mut Self>,
res: Response<()>,
mut res: Response<()>,
body: B,
) -> Result<(), DispatchError> {
let close_after_response = {
let this = self.as_mut().project();
should_close_after_response(this.payload.as_ref(), *this.payload_drainable)
};
if close_after_response {
res.head_mut().set_connection_type(ConnectionType::Close);
}
let size = self.as_mut().send_response_inner(res, &body)?;
let mut this = self.project();
this.state.set(match size {
match size {
BodySize::None | BodySize::Sized(0) => {
this.flags.insert(Flags::FINISHED);
State::None
let this = self.as_mut().project();
if close_after_response {
if this.config.client_disconnect_deadline().is_some() {
drop(this);
self.as_mut().enter_linger();
} else {
self.as_mut()
.project()
.flags
.insert(Flags::SHUTDOWN | Flags::FINISHED);
}
} else {
this.flags.insert(Flags::FINISHED);
}
self.as_mut().project().state.set(State::None);
}
_ => State::SendPayload { body },
});
_ => self
.as_mut()
.project()
.state
.set(State::SendPayload { body }),
}
Ok(())
}
fn send_error_response(
mut self: Pin<&mut Self>,
res: Response<()>,
mut res: Response<()>,
body: BoxBody,
) -> Result<(), DispatchError> {
let close_after_response = {
let this = self.as_mut().project();
should_close_after_response(this.payload.as_ref(), *this.payload_drainable)
};
if close_after_response {
res.head_mut().set_connection_type(ConnectionType::Close);
}
let size = self.as_mut().send_response_inner(res, &body)?;
let mut this = self.project();
this.state.set(match size {
match size {
BodySize::None | BodySize::Sized(0) => {
this.flags.insert(Flags::FINISHED);
State::None
let this = self.as_mut().project();
if close_after_response {
if this.config.client_disconnect_deadline().is_some() {
drop(this);
self.as_mut().enter_linger();
} else {
self.as_mut()
.project()
.flags
.insert(Flags::SHUTDOWN | Flags::FINISHED);
}
} else {
this.flags.insert(Flags::FINISHED);
}
self.as_mut().project().state.set(State::None);
}
_ => State::SendErrorPayload { body },
});
_ => self
.as_mut()
.project()
.state
.set(State::SendErrorPayload { body }),
}
Ok(())
}
@ -455,8 +579,11 @@ where
// all messages are dealt with
None => {
// start keep-alive if last request allowed it
this.flags.set(Flags::KEEP_ALIVE, this.codec.keep_alive());
// start keep-alive only if request payload is fully read/drained
this.flags.set(
Flags::KEEP_ALIVE,
this.payload.is_none() && this.codec.keep_alive(),
);
return Ok(PollResponse::DoNothing);
}
@ -493,7 +620,7 @@ where
StateProj::SendPayload { mut body } => {
// keep populate writer buffer until buffer size limit hit,
// get blocked or finished.
while this.write_buf.len() < super::payload::MAX_BUFFER_SIZE {
while this.write_buf.len() < *this.h1_write_buffer_size {
match body.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(item))) => {
this.codec
@ -503,10 +630,33 @@ where
Poll::Ready(None) => {
this.codec.encode(Message::Chunk(None), this.write_buf)?;
// if we have not yet pipelined to the next request, then
// this.payload was the payload for the request we just finished
// responding to. We can check to see if we finished reading it
// yet, and if not, shutdown the connection.
let close_after_response = should_close_after_response(
this.payload.as_ref(),
*this.payload_drainable,
);
let not_pipelined = this.messages.is_empty();
// payload stream finished.
// set state to None and handle next message
this.state.set(State::None);
this.flags.insert(Flags::FINISHED);
if not_pipelined && close_after_response {
if this.config.client_disconnect_deadline().is_some() {
drop(this);
self.as_mut().enter_linger();
} else {
self.as_mut()
.project()
.flags
.insert(Flags::SHUTDOWN | Flags::FINISHED);
}
} else {
this.flags.insert(Flags::FINISHED);
}
continue 'res;
}
@ -532,7 +682,7 @@ where
// keep populate writer buffer until buffer size limit hit,
// get blocked or finished.
while this.write_buf.len() < super::payload::MAX_BUFFER_SIZE {
while this.write_buf.len() < *this.h1_write_buffer_size {
match body.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(item))) => {
this.codec
@ -542,10 +692,33 @@ where
Poll::Ready(None) => {
this.codec.encode(Message::Chunk(None), this.write_buf)?;
// payload stream finished
// if we have not yet pipelined to the next request, then
// this.payload was the payload for the request we just finished
// responding to. We can check to see if we finished reading it
// yet, and if not, shutdown the connection.
let close_after_response = should_close_after_response(
this.payload.as_ref(),
*this.payload_drainable,
);
let not_pipelined = this.messages.is_empty();
// payload stream finished.
// set state to None and handle next message
this.state.set(State::None);
this.flags.insert(Flags::FINISHED);
if not_pipelined && close_after_response {
if this.config.client_disconnect_deadline().is_some() {
drop(this);
self.as_mut().enter_linger();
} else {
self.as_mut()
.project()
.flags
.insert(Flags::SHUTDOWN | Flags::FINISHED);
}
} else {
this.flags.insert(Flags::FINISHED);
}
continue 'res;
}
@ -710,12 +883,13 @@ where
match this.codec.message_type() {
// request has no payload
MessageType::None => {}
MessageType::None => *this.payload_drainable = false,
// Request is upgradable. Add upgrade message and break.
// Everything remaining in read buffer will be handed to
// upgraded Request.
MessageType::Stream if this.flow.upgrade.is_some() => {
*this.payload_drainable = false;
this.messages.push_back(DispatcherMessage::Upgrade(req));
break;
}
@ -730,6 +904,7 @@ where
let (sender, payload) = Payload::create(false);
*req.payload() = crate::Payload::H1 { payload };
*this.payload = Some(sender);
*this.payload_drainable = req.chunked().unwrap_or(false);
}
}
@ -759,6 +934,7 @@ where
Message::Chunk(None) => {
if let Some(mut payload) = this.payload.take() {
payload.feed_eof();
*this.payload_drainable = false;
} else {
error!("Internal server error: unexpected eof");
this.flags.insert(Flags::READ_DISCONNECT);
@ -900,14 +1076,20 @@ where
let this = self.as_mut().project();
if let TimerState::Active { timer } = this.shutdown_timer {
debug_assert!(
this.flags.contains(Flags::SHUTDOWN),
"shutdown flag should be set when timer is active",
this.flags.intersects(Flags::LINGER | Flags::SHUTDOWN),
"shutdown or linger flag should be set when timer is active",
);
// timed-out during shutdown; drop connection
if timer.as_mut().poll(cx).is_ready() {
trace!("timed-out during shutdown");
return Err(DispatchError::DisconnectTimeout);
if this.flags.contains(Flags::LINGER) {
trace!("timed-out during linger; shutting down connection");
this.flags.remove(Flags::LINGER);
this.flags.insert(Flags::SHUTDOWN);
this.shutdown_timer.clear(line!());
} else {
trace!("timed-out during shutdown");
return Err(DispatchError::DisconnectTimeout);
}
}
}
@ -961,23 +1143,14 @@ where
//
// A Request head too large to parse is only checked on `httparse::Status::Partial`.
match this.payload {
// When dispatcher has a payload the responsibility of wake ups is shifted to
// `h1::payload::Payload` unless the payload is needing a read, in which case it
// might not have access to the waker and could result in the dispatcher
// getting stuck until timeout.
//
// Reason:
// Self wake up when there is payload would waste poll and/or result in
// over read.
//
// Case:
// When payload is (partial) dropped by user there is no need to do
// read anymore. At this case read_buf could always remain beyond
// MAX_BUFFER_SIZE and self wake up would be busy poll dispatcher and
// waste resources.
Some(ref p) if p.need_read(cx) != PayloadStatus::Read => {}
_ => cx.waker().wake_by_ref(),
match this.payload.as_ref().map(|p| p.need_read(cx)) {
// Payload consumer is alive but applying backpressure. Wait for its waker.
Some(PayloadStatus::Pause) => {}
// Consumer dropped means drain/discard mode; keep polling to make progress.
Some(PayloadStatus::Dropped) | Some(PayloadStatus::Read) | None => {
cx.waker().wake_by_ref()
}
}
return Ok(false);
@ -991,7 +1164,11 @@ where
match tokio_util::io::poll_read_buf(io.as_mut(), cx, this.read_buf) {
Poll::Ready(Ok(n)) => {
this.flags.remove(Flags::FINISHED);
// When draining a dropped request payload, keep FINISHED set so the
// disconnect/keep-alive decision can be made once the payload is fully drained.
if !this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) {
this.flags.remove(Flags::FINISHED);
}
if n == 0 {
return Ok(true);
@ -1078,7 +1255,15 @@ where
inner.as_mut().poll_timers(cx)?;
let poll = if inner.flags.contains(Flags::SHUTDOWN) {
let poll = if inner.flags.contains(Flags::LINGER) {
match inner.as_mut().poll_linger(cx)? {
Poll::Ready(()) => {
cx.waker().wake_by_ref();
Poll::Pending
}
Poll::Pending => Poll::Pending,
}
} else if inner.flags.contains(Flags::SHUTDOWN) {
if inner.flags.contains(Flags::WRITE_DISCONNECT) {
Poll::Ready(Ok(()))
} else {
@ -1118,6 +1303,7 @@ where
let inner = inner.as_mut().project();
inner.flags.insert(Flags::READ_DISCONNECT);
if let Some(mut payload) = inner.payload.take() {
payload.set_error(PayloadError::Incomplete(None));
payload.feed_eof();
}
};
@ -1181,8 +1367,16 @@ where
let inner_p = inner.as_mut().project();
let state_is_none = inner_p.state.is_none();
// read half is closed; we do not process any responses
if inner_p.flags.contains(Flags::READ_DISCONNECT) && state_is_none {
// If the read-half is closed, we start the shutdown procedure if either is
// true:
//
// - state is [`State::None`], which means that we're done with request
// processing, so if the client closed its writer-side it means that it won't
// send more requests.
// - The user requested to not allow half-closures
if inner_p.flags.contains(Flags::READ_DISCONNECT)
&& (!inner_p.config.h1_allow_half_closed() || state_is_none)
{
trace!("read half closed; start shutdown");
inner_p.flags.insert(Flags::SHUTDOWN);
}
@ -1197,6 +1391,7 @@ where
// disconnect if keep-alive is not enabled
if inner_p.flags.contains(Flags::FINISHED)
&& !inner_p.flags.contains(Flags::KEEP_ALIVE)
&& inner_p.payload.is_none()
{
inner_p.flags.remove(Flags::FINISHED);
inner_p.flags.insert(Flags::SHUTDOWN);
@ -1216,6 +1411,9 @@ where
inner_p.shutdown_timer,
);
if inner_p.flags.intersects(Flags::LINGER | Flags::SHUTDOWN) {
cx.waker().wake_by_ref();
}
Poll::Pending
};
@ -1227,6 +1425,13 @@ where
}
}
fn should_close_after_response(payload: Option<&PayloadSender>, payload_drainable: bool) -> bool {
let payload_unfinished = payload.is_some();
let drain_payload = payload.is_some_and(|pl| pl.is_dropped()) && payload_drainable;
payload_unfinished && !drain_payload
}
#[allow(dead_code)]
fn trace_timer_states(
label: &str,

View File

@ -1,7 +1,19 @@
use std::{future::Future, str, task::Poll, time::Duration};
use std::{
cell::Cell,
future::Future,
io,
pin::Pin,
rc::Rc,
str,
task::{Context, Poll},
time::Duration,
};
use actix_codec::Framed;
use actix_rt::{pin, time::sleep};
use actix_rt::{
pin,
time::{sleep, timeout},
};
use actix_service::{fn_service, Service};
use actix_utils::future::{ready, Ready};
use bytes::{Buf, Bytes, BytesMut};
@ -9,7 +21,7 @@ use futures_util::future::lazy;
use super::dispatcher::{Dispatcher, DispatcherState, DispatcherStateProj, Flags};
use crate::{
body::MessageBody,
body::{BoxBody, MessageBody},
config::ServiceConfig,
h1::{Codec, ExpectHandler, UpgradeHandler},
service::HttpFlow,
@ -17,6 +29,131 @@ use crate::{
Error, HttpMessage, KeepAlive, Method, OnConnectData, Request, Response, StatusCode,
};
struct YieldService;
impl Service<Request> for YieldService {
type Response = Response<BoxBody>;
type Error = Response<BoxBody>;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
actix_service::always_ready!();
fn call(&self, _: Request) -> Self::Future {
Box::pin(async {
// Yield twice because the dispatcher can poll the service twice per dispatcher's poll:
// once in `handle_request` and another in `poll_response`
actix_rt::task::yield_now().await;
actix_rt::task::yield_now().await;
Ok(Response::ok())
})
}
}
struct ReadyChunkBody {
chunk_polls: Rc<Cell<usize>>,
remaining: usize,
chunk_len: usize,
}
impl ReadyChunkBody {
fn new(chunk_polls: Rc<Cell<usize>>, remaining: usize, chunk_len: usize) -> Self {
Self {
chunk_polls,
remaining,
chunk_len,
}
}
}
impl MessageBody for ReadyChunkBody {
type Error = Error;
fn size(&self) -> crate::body::BodySize {
crate::body::BodySize::Stream
}
fn poll_next(
mut self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<Option<Result<Bytes, Self::Error>>> {
if self.remaining == 0 {
return Poll::Ready(None);
}
self.remaining -= 1;
self.chunk_polls.set(self.chunk_polls.get() + 1);
Poll::Ready(Some(Ok(Bytes::from(vec![b'x'; self.chunk_len]))))
}
}
struct PendingOnceWriteBuf {
io: TestBuffer,
block_next_write: bool,
}
impl PendingOnceWriteBuf {
fn new<T>(data: T) -> Self
where
T: Into<BytesMut>,
{
Self {
io: TestBuffer::new(data),
block_next_write: true,
}
}
}
impl io::Read for PendingOnceWriteBuf {
fn read(&mut self, dst: &mut [u8]) -> Result<usize, io::Error> {
self.io.read(dst)
}
}
impl io::Write for PendingOnceWriteBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.io.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.io.flush()
}
}
impl actix_codec::AsyncRead for PendingOnceWriteBuf {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut actix_codec::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.io).poll_read(cx, buf)
}
}
impl actix_codec::AsyncWrite for PendingOnceWriteBuf {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
if self.block_next_write {
self.block_next_write = false;
cx.waker().wake_by_ref();
return Poll::Pending;
}
Pin::new(&mut self.io).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.io).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.io).poll_shutdown(cx)
}
}
fn find_slice(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
memchr::memmem::find(&haystack[from..], needle)
}
@ -58,6 +195,11 @@ fn drop_payload_service() -> impl Service<Request, Response = Response<&'static
})
}
fn ignore_payload_service(
) -> impl Service<Request, Response = Response<&'static str>, Error = Error> {
fn_service(|_req: Request| ready(Ok::<_, Error>(Response::with_body(StatusCode::OK, "ok"))))
}
fn echo_payload_service() -> impl Service<Request, Response = Response<Bytes>, Error = Error> {
fn_service(|mut req: Request| {
Box::pin(async move {
@ -74,6 +216,18 @@ fn echo_payload_service() -> impl Service<Request, Response = Response<Bytes>, E
})
}
fn ready_chunk_body_service(
chunk_polls: Rc<Cell<usize>>,
chunk_count: usize,
chunk_len: usize,
) -> impl Service<Request, Response = Response<ReadyChunkBody>, Error = Error> {
fn_service(move |_req: Request| {
ready(Ok::<_, Error>(Response::ok().set_body(
ReadyChunkBody::new(chunk_polls.clone(), chunk_count, chunk_len),
)))
})
}
#[actix_rt::test]
async fn late_request() {
let mut buf = TestBuffer::empty();
@ -509,6 +663,205 @@ async fn pipelining_ok_then_ok() {
.await;
}
#[actix_rt::test]
async fn early_response_with_payload_lingers_before_closing() {
lazy(|cx| {
let buf = TestSeqBuffer::new(http_msg(
r"
GET /unfinished HTTP/1.1
Content-Length: 2
",
));
let cfg = ServiceConfig::new(
KeepAlive::Os,
Duration::from_millis(1),
Duration::from_millis(1),
false,
None,
);
let services = HttpFlow::new(echo_path_service(), ExpectHandler, None);
let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new(
buf.clone(),
services,
cfg,
None,
OnConnectData::default(),
);
pin!(h1);
assert!(matches!(&h1.inner, DispatcherState::Normal { .. }));
match h1.as_mut().poll(cx) {
Poll::Pending => {}
Poll::Ready(res) => panic!("should still be lingering: {:?}", res),
}
// polls: initial
assert_eq!(h1.poll_count, 1);
let mut res = buf.take_write_buf().to_vec();
stabilize_date_header(&mut res);
let res = &res[..];
let exp = b"\
HTTP/1.1 200 OK\r\n\
content-length: 11\r\n\
connection: close\r\n\
date: Thu, 01 Jan 1970 12:34:56 UTC\r\n\r\n\
/unfinished\
";
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(res),
String::from_utf8_lossy(exp)
);
buf.close_read();
assert!(h1.as_mut().poll(cx).is_pending());
assert!(h1.as_mut().poll(cx).is_ready());
})
.await;
}
#[actix_rt::test]
async fn buffered_upload_ignored_by_handler_should_not_shutdown_immediately() {
lazy(|cx| {
let buf = TestSeqBuffer::new(http_msg(
r"
POST / HTTP/1.1
Content-Length: 8
ab
",
));
let cfg = ServiceConfig::new(
KeepAlive::Os,
Duration::from_millis(1),
Duration::from_millis(1),
false,
None,
);
let services = HttpFlow::new(ignore_payload_service(), ExpectHandler, None);
let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new(
buf.clone(),
services,
cfg,
None,
OnConnectData::default(),
);
pin!(h1);
assert!(matches!(&h1.inner, DispatcherState::Normal { .. }));
match h1.as_mut().poll(cx) {
Poll::Pending => {}
Poll::Ready(res) => panic!("closed connection early: {:?}", res),
}
let mut res = BytesMut::from(buf.take_write_buf().as_ref());
stabilize_date_header(&mut res);
let res = &res[..];
let exp = http_msg(
r"
HTTP/1.1 200 OK
content-length: 2
connection: close
date: Thu, 01 Jan 1970 12:34:56 UTC
ok
",
);
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(res),
String::from_utf8_lossy(&exp)
);
buf.close_read();
assert!(h1.as_mut().poll(cx).is_pending());
assert!(h1.as_mut().poll(cx).is_ready());
})
.await;
}
#[actix_rt::test]
async fn lingering_timeout_uses_graceful_shutdown() {
let buf = TestSeqBuffer::new(
"\
POST / HTTP/1.1\r\n\
Content-Length: 8\r\n\
\r\n\
ab\
",
);
let cfg = ServiceConfig::new(
KeepAlive::Disabled,
Duration::ZERO,
Duration::from_millis(1),
false,
None,
);
let services = HttpFlow::new(ignore_payload_service(), ExpectHandler, None);
let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new(
buf.clone(),
services,
cfg,
None,
OnConnectData::default(),
);
assert!(matches!(
timeout(Duration::from_millis(100), h1).await,
Ok(Ok(()))
));
let mut res = buf.take_write_buf().to_vec();
stabilize_date_header(&mut res);
let res = &res[..];
let exp = b"\
HTTP/1.1 200 OK\r\n\
content-length: 2\r\n\
connection: close\r\n\
date: Thu, 01 Jan 1970 12:34:56 UTC\r\n\r\n\
ok\
";
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(res),
String::from_utf8_lossy(exp)
);
}
#[actix_rt::test]
async fn pipelining_ok_then_bad() {
lazy(|cx| {
@ -791,7 +1144,7 @@ async fn handler_drop_payload() {
r"
POST /drop-payload HTTP/1.1
Content-Length: 3
abc
",
));
@ -924,6 +1277,265 @@ async fn handler_drop_payload() {
.await;
}
// Handler drops request payload without reading it. Server should keep reading and discarding the
// rest of the request body so clients that do not read the response until they've finished
// writing the request (like `requests` in Python) do not deadlock.
// ref. https://github.com/actix/actix-web/issues/2972
#[actix_rt::test]
async fn handler_drop_payload_drains_body() {
let _ = env_logger::try_init();
let mut buf = TestSeqBuffer::new(http_msg(
r"
POST /drop-payload HTTP/1.1
Transfer-Encoding: chunked
",
));
let services = HttpFlow::new(
drop_payload_service(),
ExpectHandler,
None::<UpgradeHandler>,
);
let h1 = Dispatcher::new(
buf.clone(),
services,
ServiceConfig::default(),
None,
OnConnectData::default(),
);
pin!(h1);
lazy(|cx| {
assert!(h1.as_mut().poll(cx).is_pending());
let mut res = BytesMut::from(buf.take_write_buf().as_ref());
stabilize_date_header(&mut res);
let res = &res[..];
let exp = http_msg(
r"
HTTP/1.1 200 OK
content-length: 15
date: Thu, 01 Jan 1970 12:34:56 UTC
payload dropped
",
);
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(res),
String::from_utf8_lossy(&exp)
);
})
.await;
// stream a body larger than the dispatcher read buffer limit; it should still be drained
// (read + decoded + discarded) without stalling.
for _ in 0..32 {
let data = vec![b'a'; 8192];
let mut chunk = BytesMut::new();
chunk.extend_from_slice(format!("{:x}\r\n", data.len()).as_bytes());
chunk.extend_from_slice(&data);
chunk.extend_from_slice(b"\r\n");
buf.extend_read_buf(chunk);
lazy(|cx| {
assert!(h1.as_mut().poll(cx).is_pending());
assert!(buf.take_write_buf().is_empty());
assert!(buf.read_buf().is_empty());
})
.await;
}
// terminating chunk
buf.extend_read_buf(b"0\r\n\r\n");
lazy(|cx| {
assert!(h1.as_mut().poll(cx).is_pending());
assert!(buf.take_write_buf().is_empty());
assert!(buf.read_buf().is_empty());
})
.await;
// connection should be able to accept another request after draining the previous body
buf.extend_read_buf(http_msg("GET /drop-payload HTTP/1.1"));
lazy(|cx| {
assert!(h1.as_mut().poll(cx).is_pending());
let mut res = BytesMut::from(buf.take_write_buf().as_ref());
stabilize_date_header(&mut res);
let res = &res[..];
let exp = http_msg(
r"
HTTP/1.1 200 OK
content-length: 15
date: Thu, 01 Jan 1970 12:34:56 UTC
payload dropped
",
);
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(res),
String::from_utf8_lossy(&exp)
);
})
.await;
}
#[actix_rt::test]
async fn allow_half_closed() {
let buf = TestSeqBuffer::new(http_msg("GET / HTTP/1.1"));
buf.close_read();
let services = HttpFlow::new(YieldService, ExpectHandler, None::<UpgradeHandler>);
let mut cx = Context::from_waker(futures_util::task::noop_waker_ref());
let disptacher = Dispatcher::new(
buf.clone(),
services,
ServiceConfig::default(),
None,
OnConnectData::default(),
);
pin!(disptacher);
assert!(disptacher.as_mut().poll(&mut cx).is_pending());
assert_eq!(disptacher.poll_count, 1);
assert!(disptacher.as_mut().poll(&mut cx).is_ready());
assert_eq!(disptacher.poll_count, 3);
let mut res = BytesMut::from(buf.take_write_buf().as_ref());
stabilize_date_header(&mut res);
let exp = http_msg(
r"
HTTP/1.1 200 OK
content-length: 0
date: Thu, 01 Jan 1970 12:34:56 UTC
",
);
assert_eq!(
res,
exp,
"\nexpected response not in write buffer:\n\
response: {:?}\n\
expected: {:?}",
String::from_utf8_lossy(&res),
String::from_utf8_lossy(&exp)
);
let DispatcherStateProj::Normal { inner } = disptacher.as_mut().project().inner.project()
else {
panic!("End dispatcher state should be Normal");
};
assert!(inner.state.is_none());
}
#[actix_rt::test]
async fn disallow_half_closed() {
use crate::{config::ServiceConfigBuilder, h1::dispatcher::State};
let buf = TestSeqBuffer::new(http_msg("GET / HTTP/1.1"));
buf.close_read();
let services = HttpFlow::new(YieldService, ExpectHandler, None::<UpgradeHandler>);
let config = ServiceConfigBuilder::new()
.h1_allow_half_closed(false)
.build();
let mut cx = Context::from_waker(futures_util::task::noop_waker_ref());
let disptacher = Dispatcher::new(
buf.clone(),
services,
config,
None,
OnConnectData::default(),
);
pin!(disptacher);
assert!(disptacher.as_mut().poll(&mut cx).is_pending());
assert_eq!(disptacher.poll_count, 1);
assert!(disptacher.as_mut().poll(&mut cx).is_ready());
assert_eq!(disptacher.poll_count, 2);
let res = BytesMut::from(buf.take_write_buf().as_ref());
assert!(res.is_empty());
let DispatcherStateProj::Normal { inner } = disptacher.as_mut().project().inner.project()
else {
panic!("End dispatcher state should be Normal");
};
assert!(matches!(inner.state, State::ServiceCall { .. }))
}
#[actix_rt::test]
async fn h1_write_buffer_size_limits_buffering() {
let request = "GET /stream HTTP/1.1\r\nConnection: close\r\n\r\n";
let default_polls = Rc::new(Cell::new(0));
let default_services = HttpFlow::new(
ready_chunk_body_service(default_polls.clone(), 8, 1024),
ExpectHandler,
None::<UpgradeHandler>,
);
let default_io = PendingOnceWriteBuf::new(request);
let default_dispatcher = Dispatcher::new(
default_io,
default_services,
ServiceConfig::default(),
None,
OnConnectData::default(),
);
pin!(default_dispatcher);
let mut cx = Context::from_waker(futures_util::task::noop_waker_ref());
assert!(default_dispatcher.as_mut().poll(&mut cx).is_pending());
assert_eq!(default_polls.get(), 8);
let custom_polls = Rc::new(Cell::new(0));
let custom_services = HttpFlow::new(
ready_chunk_body_service(custom_polls.clone(), 8, 1024),
ExpectHandler,
None::<UpgradeHandler>,
);
let custom_io = PendingOnceWriteBuf::new(request);
let custom_dispatcher = Dispatcher::new(
custom_io,
custom_services,
crate::config::ServiceConfigBuilder::new()
.h1_write_buffer_size(1024)
.build(),
None,
OnConnectData::default(),
);
pin!(custom_dispatcher);
assert!(custom_dispatcher.as_mut().poll(&mut cx).is_pending());
assert_eq!(custom_polls.get(), 1);
}
#[actix_rt::test]
#[should_panic(expected = "HTTP/1 write buffer size must be greater than zero")]
async fn h1_write_buffer_size_rejects_zero() {
let _ = crate::config::ServiceConfigBuilder::new().h1_write_buffer_size(0);
}
fn http_msg(msg: impl AsRef<str>) -> BytesMut {
let mut msg = msg
.as_ref()

View File

@ -111,7 +111,13 @@ pub(crate) trait MessageType: Sized {
// Connection
match conn_type {
ConnectionType::Upgrade => dst.put_slice(b"connection: upgrade\r\n"),
ConnectionType::Upgrade => {
if camel_case {
dst.put_slice(b"Connection: Upgrade\r\n")
} else {
dst.put_slice(b"connection: upgrade\r\n")
}
}
ConnectionType::KeepAlive if version < Version::HTTP_11 => {
if camel_case {
dst.put_slice(b"Connection: keep-alive\r\n")
@ -580,6 +586,16 @@ mod tests {
assert!(data.contains("Date: date\r\n"));
assert!(data.contains("Upgrade-Insecure-Requests: 1\r\n"));
let _ = head.encode_headers(
&mut bytes,
Version::HTTP_11,
BodySize::None,
ConnectionType::Upgrade,
&ServiceConfig::default(),
);
let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap();
assert!(data.contains("Connection: Upgrade\r\n"));
let _ = head.encode_headers(
&mut bytes,
Version::HTTP_11,

View File

@ -133,6 +133,11 @@ impl PayloadSender {
PayloadStatus::Dropped
}
}
#[inline]
pub fn is_dropped(&self) -> bool {
self.inner.strong_count() == 0
}
}
#[derive(Debug)]
@ -176,11 +181,7 @@ impl Inner {
/// Register future waiting data from payload.
/// Waker would be used in `Inner::wake`
fn register(&mut self, cx: &Context<'_>) {
if self
.task
.as_ref()
.map_or(true, |w| !cx.waker().will_wake(w))
{
if self.task.as_ref().is_none_or(|w| !cx.waker().will_wake(w)) {
self.task = Some(cx.waker().clone());
}
}
@ -191,7 +192,7 @@ impl Inner {
if self
.io_task
.as_ref()
.map_or(true, |w| !cx.waker().will_wake(w))
.is_none_or(|w| !cx.waker().will_wake(w))
{
self.io_task = Some(cx.waker().clone());
}
@ -200,11 +201,13 @@ impl Inner {
#[inline]
fn set_error(&mut self, err: PayloadError) {
self.err = Some(err);
self.wake();
}
#[inline]
fn feed_eof(&mut self) {
self.eof = true;
self.wake();
}
#[inline]
@ -253,8 +256,13 @@ impl Inner {
#[cfg(test)]
mod tests {
use std::{task::Poll, time::Duration};
use actix_rt::time::timeout;
use actix_utils::future::poll_fn;
use futures_util::{FutureExt, StreamExt};
use static_assertions::{assert_impl_all, assert_not_impl_any};
use tokio::sync::oneshot;
use super::*;
@ -263,6 +271,67 @@ mod tests {
assert_impl_all!(Inner: Unpin, Send, Sync);
const WAKE_TIMEOUT: Duration = Duration::from_secs(2);
fn prepare_waking_test(
mut payload: Payload,
expected: Option<Result<(), ()>>,
) -> (oneshot::Receiver<()>, actix_rt::task::JoinHandle<()>) {
let (tx, rx) = oneshot::channel();
let handle = actix_rt::spawn(async move {
// Make sure to poll once to set the waker
poll_fn(|cx| {
assert!(payload.poll_next_unpin(cx).is_pending());
Poll::Ready(())
})
.await;
tx.send(()).unwrap();
// actix-rt is single-threaded, so this won't race with `rx.await`
let mut pend_once = false;
poll_fn(|_| {
if pend_once {
Poll::Ready(())
} else {
// Return pending without storing wakers, we already did on the previous
// `poll_fn`, now this task will only continue if the `sender` wakes us
pend_once = true;
Poll::Pending
}
})
.await;
let got = payload.next().now_or_never().unwrap();
match expected {
Some(Ok(_)) => assert!(got.unwrap().is_ok()),
Some(Err(_)) => assert!(got.unwrap().is_err()),
None => assert!(got.is_none()),
}
});
(rx, handle)
}
#[actix_rt::test]
async fn wake_on_error() {
let (mut sender, payload) = Payload::create(false);
let (rx, handle) = prepare_waking_test(payload, Some(Err(())));
rx.await.unwrap();
sender.set_error(PayloadError::Incomplete(None));
timeout(WAKE_TIMEOUT, handle).await.unwrap().unwrap();
}
#[actix_rt::test]
async fn wake_on_eof() {
let (mut sender, payload) = Payload::create(false);
let (rx, handle) = prepare_waking_test(payload, None);
rx.await.unwrap();
sender.feed_eof();
timeout(WAKE_TIMEOUT, handle).await.unwrap().unwrap();
}
#[actix_rt::test]
async fn test_unread_data() {
let (_, mut payload) = Payload::create(false);

View File

@ -11,7 +11,7 @@ use actix_rt::time::{sleep_until, Sleep};
use bytes::Bytes;
use futures_core::{ready, Stream};
use h2::{
server::{handshake, Connection, Handshake},
server::{Builder, Connection, Handshake},
RecvStream,
};
@ -61,8 +61,13 @@ pub(crate) fn handshake_with_timeout<T>(io: T, config: &ServiceConfig) -> Handsh
where
T: AsyncRead + AsyncWrite + Unpin,
{
let mut builder = Builder::new();
builder
.initial_window_size(config.h2_initial_window_size())
.initial_connection_window_size(config.h2_initial_connection_window_size());
HandshakeWithTimeout {
handshake: handshake(io),
handshake: builder.handshake(io),
timer: config
.client_request_deadline()
.map(|deadline| Box::pin(sleep_until(deadline.into()))),

View File

@ -25,6 +25,16 @@ use crate::{
ConnectCallback, OnConnectData, Request, Response,
};
#[inline]
fn desired_nodelay(tcp_nodelay: Option<bool>) -> Option<bool> {
tcp_nodelay
}
#[inline]
fn set_nodelay(stream: &TcpStream, nodelay: bool) {
let _ = stream.set_nodelay(nodelay);
}
/// `ServiceFactory` implementation for HTTP/2 transport
pub struct H2Service<T, S, B> {
srv: S,
@ -82,8 +92,13 @@ where
Error = DispatchError,
InitError = S::InitError,
> {
fn_factory(|| {
ready(Ok::<_, S::InitError>(fn_service(|io: TcpStream| {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
fn_factory(move || {
ready(Ok::<_, S::InitError>(fn_service(move |io: TcpStream| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(&io, nodelay);
}
let peer_addr = io.peer_addr().ok();
ready(Ok::<_, DispatchError>((io, peer_addr)))
})))
@ -126,12 +141,17 @@ mod openssl {
Error = TlsError<SslError, DispatchError>,
InitError = S::InitError,
> {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
Acceptor::new(acceptor)
.map_init_err(|_| {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(io.get_ref(), nodelay);
}
let peer_addr = io.get_ref().peer_addr().ok();
(io, peer_addr)
})
@ -173,6 +193,7 @@ mod rustls_0_20 {
Error = TlsError<io::Error, DispatchError>,
InitError = S::InitError,
> {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
let mut protos = vec![b"h2".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -182,7 +203,10 @@ mod rustls_0_20 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
(io, peer_addr)
})
@ -224,6 +248,7 @@ mod rustls_0_21 {
Error = TlsError<io::Error, DispatchError>,
InitError = S::InitError,
> {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
let mut protos = vec![b"h2".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -233,7 +258,10 @@ mod rustls_0_21 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
(io, peer_addr)
})
@ -275,6 +303,7 @@ mod rustls_0_22 {
Error = TlsError<io::Error, DispatchError>,
InitError = S::InitError,
> {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
let mut protos = vec![b"h2".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -284,7 +313,10 @@ mod rustls_0_22 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
(io, peer_addr)
})
@ -326,6 +358,7 @@ mod rustls_0_23 {
Error = TlsError<io::Error, DispatchError>,
InitError = S::InitError,
> {
let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay());
let mut protos = vec![b"h2".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -335,7 +368,10 @@ mod rustls_0_23 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
if let Some(nodelay) = tcp_nodelay {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
(io, peer_addr)
})

View File

@ -537,7 +537,7 @@ impl HeaderMap {
/// assert!(pairs.contains(&(&header::SET_COOKIE, &HeaderValue::from_static("two=2"))));
/// ```
pub fn iter(&self) -> Iter<'_> {
Iter::new(self.inner.iter())
Iter::new(self.inner.iter(), self.len())
}
/// An iterator over all contained header names.
@ -626,7 +626,8 @@ impl HeaderMap {
/// assert!(map.is_empty());
/// ```
pub fn drain(&mut self) -> Drain<'_> {
Drain::new(self.inner.drain())
let len = self.len();
Drain::new(self.inner.drain(), len)
}
}
@ -638,7 +639,8 @@ impl IntoIterator for HeaderMap {
#[inline]
fn into_iter(self) -> Self::IntoIter {
IntoIter::new(self.inner.into_iter())
let len = self.len();
IntoIter::new(self.inner.into_iter(), len)
}
}
@ -648,7 +650,7 @@ impl<'a> IntoIterator for &'a HeaderMap {
#[inline]
fn into_iter(self) -> Self::IntoIter {
Iter::new(self.inner.iter())
Iter::new(self.inner.iter(), self.len())
}
}
@ -760,14 +762,16 @@ pub struct Iter<'a> {
inner: hash_map::Iter<'a, HeaderName, Value>,
multi_inner: Option<(&'a HeaderName, &'a SmallVec<[HeaderValue; 4]>)>,
multi_idx: usize,
remaining: usize,
}
impl<'a> Iter<'a> {
fn new(iter: hash_map::Iter<'a, HeaderName, Value>) -> Self {
fn new(iter: hash_map::Iter<'a, HeaderName, Value>, remaining: usize) -> Self {
Self {
inner: iter,
multi_idx: 0,
multi_inner: None,
remaining,
}
}
}
@ -781,6 +785,7 @@ impl<'a> Iterator for Iter<'a> {
match vals.get(self.multi_idx) {
Some(val) => {
self.multi_idx += 1;
self.remaining -= 1;
return Some((name, val));
}
None => {
@ -800,9 +805,7 @@ impl<'a> Iterator for Iter<'a> {
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
// take inner lower bound
// make no attempt at an upper bound
(self.inner.size_hint().0, None)
(self.remaining, Some(self.remaining))
}
}
@ -818,14 +821,16 @@ pub struct Drain<'a> {
inner: hash_map::Drain<'a, HeaderName, Value>,
multi_inner: Option<(Option<HeaderName>, SmallVec<[HeaderValue; 4]>)>,
multi_idx: usize,
remaining: usize,
}
impl<'a> Drain<'a> {
fn new(iter: hash_map::Drain<'a, HeaderName, Value>) -> Self {
fn new(iter: hash_map::Drain<'a, HeaderName, Value>, remaining: usize) -> Self {
Self {
inner: iter,
multi_inner: None,
multi_idx: 0,
remaining,
}
}
}
@ -838,6 +843,7 @@ impl Iterator for Drain<'_> {
if let Some((ref mut name, ref mut vals)) = self.multi_inner {
if !vals.is_empty() {
// OPTIMIZE: array removals
self.remaining -= 1;
return Some((name.take(), vals.remove(0)));
} else {
// no more items in value iterator; reset state
@ -855,9 +861,7 @@ impl Iterator for Drain<'_> {
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
// take inner lower bound
// make no attempt at an upper bound
(self.inner.size_hint().0, None)
(self.remaining, Some(self.remaining))
}
}
@ -872,13 +876,15 @@ impl iter::FusedIterator for Drain<'_> {}
pub struct IntoIter {
inner: hash_map::IntoIter<HeaderName, Value>,
multi_inner: Option<(HeaderName, smallvec::IntoIter<[HeaderValue; 4]>)>,
remaining: usize,
}
impl IntoIter {
fn new(inner: hash_map::IntoIter<HeaderName, Value>) -> Self {
fn new(inner: hash_map::IntoIter<HeaderName, Value>, remaining: usize) -> Self {
Self {
inner,
multi_inner: None,
remaining,
}
}
}
@ -891,6 +897,7 @@ impl Iterator for IntoIter {
if let Some((ref name, ref mut vals)) = self.multi_inner {
match vals.next() {
Some(val) => {
self.remaining -= 1;
return Some((name.clone(), val));
}
None => {
@ -909,9 +916,7 @@ impl Iterator for IntoIter {
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
// take inner lower bound
// make no attempt at an upper bound
(self.inner.size_hint().0, None)
(self.remaining, Some(self.remaining))
}
}
@ -1160,6 +1165,40 @@ mod tests {
assert!(vals.next().is_none());
}
#[test]
fn iter_len_counts_values() {
let mut map = HeaderMap::new();
map.append(header::SET_COOKIE, HeaderValue::from_static("a=1"));
map.append(header::SET_COOKIE, HeaderValue::from_static("b=2"));
map.append(header::SET_COOKIE, HeaderValue::from_static("c=3"));
assert_eq!(map.iter().count(), 3);
assert_eq!(map.iter().len(), 3);
}
#[test]
fn into_iter_len_counts_values() {
let mut map = HeaderMap::new();
map.append(header::SET_COOKIE, HeaderValue::from_static("a=1"));
map.append(header::SET_COOKIE, HeaderValue::from_static("b=2"));
map.append(header::SET_COOKIE, HeaderValue::from_static("c=3"));
assert_eq!(map.clone().into_iter().count(), 3);
assert_eq!(map.into_iter().len(), 3);
}
#[test]
fn drain_len_counts_values() {
let mut map = HeaderMap::new();
map.append(header::SET_COOKIE, HeaderValue::from_static("a=1"));
map.append(header::SET_COOKIE, HeaderValue::from_static("b=2"));
map.append(header::SET_COOKIE, HeaderValue::from_static("c=3"));
let mut drained = map.clone();
assert_eq!(map.drain().count(), 3);
assert_eq!(drained.drain().len(), 3);
}
fn owned_pair<'a>((name, val): (&'a HeaderName, &'a HeaderValue)) -> (HeaderName, HeaderValue) {
(name.clone(), val.clone())
}

View File

@ -28,7 +28,7 @@
)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
pub use http::{uri, uri::Uri, Method, StatusCode, Version};
@ -64,7 +64,7 @@ pub use self::payload::PayloadStream;
pub use self::service::TlsAcceptorConfig;
pub use self::{
builder::HttpServiceBuilder,
config::ServiceConfig,
config::{ServiceConfig, ServiceConfigBuilder},
error::Error,
extensions::Extensions,
header::ContentEncoding,

View File

@ -61,14 +61,15 @@ impl RequestHead {
&mut self.headers
}
/// Is to uppercase headers with Camel-Case.
/// Default is `false`
/// Returns whether headers should be sent in Camel-Case.
///
/// Default is `false`.
#[inline]
pub fn camel_case_headers(&self) -> bool {
self.flags.contains(Flags::CAMEL_CASE)
}
/// Set `true` to send headers which are formatted as Camel-Case.
/// Sets whether to send headers formatted as Camel-Case.
#[inline]
pub fn set_camel_case_headers(&mut self, val: bool) {
if val {

View File

@ -24,6 +24,16 @@ use crate::{
h1, ConnectCallback, OnConnectData, Protocol, Request, Response, ServiceConfig,
};
#[inline]
fn desired_nodelay(tcp_nodelay: Option<bool>) -> Option<bool> {
tcp_nodelay
}
#[inline]
fn set_nodelay(stream: &TcpStream, nodelay: bool) {
let _ = stream.set_nodelay(nodelay);
}
/// A [`ServiceFactory`] for HTTP/1.1 and HTTP/2 connections.
///
/// Use [`build`](Self::build) to begin constructing service. Also see [`HttpServiceBuilder`].
@ -202,7 +212,13 @@ where
self,
) -> impl ServiceFactory<TcpStream, Config = (), Response = (), Error = DispatchError, InitError = ()>
{
fn_service(|io: TcpStream| async {
let tcp_nodelay = self.cfg.tcp_nodelay();
fn_service(move |io: TcpStream| async move {
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(&io, nodelay);
}
let peer_addr = io.peer_addr().ok();
Ok((io, Protocol::Http1, peer_addr))
})
@ -216,6 +232,8 @@ where
self,
) -> impl ServiceFactory<TcpStream, Config = (), Response = (), Error = DispatchError, InitError = ()>
{
let tcp_nodelay = self.cfg.tcp_nodelay();
fn_service(move |io: TcpStream| async move {
// subset of HTTP/2 preface defined by RFC 9113 §3.4
// this subset was chosen to maximize likelihood that peeking only once will allow us to
@ -233,6 +251,10 @@ where
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(&io, nodelay);
}
let peer_addr = io.peer_addr().ok();
Ok((io, proto, peer_addr))
})
@ -322,6 +344,7 @@ mod openssl {
Error = TlsError<SslError, DispatchError>,
InitError = (),
> {
let tcp_nodelay = self.cfg.tcp_nodelay();
let mut acceptor = Acceptor::new(acceptor);
if let Some(handshake_timeout) = tls_acceptor_config.handshake_timeout {
@ -333,7 +356,7 @@ mod openssl {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.map(|io: TlsStream<TcpStream>| {
.map(move |io: TlsStream<TcpStream>| {
let proto = if let Some(protos) = io.ssl().selected_alpn_protocol() {
if protos.windows(2).any(|window| window == b"h2") {
Protocol::Http2
@ -344,6 +367,10 @@ mod openssl {
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(io.get_ref(), nodelay);
}
let peer_addr = io.get_ref().peer_addr().ok();
(io, proto, peer_addr)
})
@ -415,6 +442,7 @@ mod rustls_0_20 {
Error = TlsError<io::Error, DispatchError>,
InitError = (),
> {
let tcp_nodelay = self.cfg.tcp_nodelay();
let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -430,7 +458,7 @@ mod rustls_0_20 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.and_then(|io: TlsStream<TcpStream>| async {
.and_then(move |io: TlsStream<TcpStream>| async move {
let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() {
if protos.windows(2).any(|window| window == b"h2") {
Protocol::Http2
@ -440,6 +468,11 @@ mod rustls_0_20 {
} else {
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
Ok((io, proto, peer_addr))
})
@ -511,6 +544,7 @@ mod rustls_0_21 {
Error = TlsError<io::Error, DispatchError>,
InitError = (),
> {
let tcp_nodelay = self.cfg.tcp_nodelay();
let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -526,7 +560,7 @@ mod rustls_0_21 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.and_then(|io: TlsStream<TcpStream>| async {
.and_then(move |io: TlsStream<TcpStream>| async move {
let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() {
if protos.windows(2).any(|window| window == b"h2") {
Protocol::Http2
@ -536,6 +570,11 @@ mod rustls_0_21 {
} else {
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
Ok((io, proto, peer_addr))
})
@ -607,6 +646,7 @@ mod rustls_0_22 {
Error = TlsError<io::Error, DispatchError>,
InitError = (),
> {
let tcp_nodelay = self.cfg.tcp_nodelay();
let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -622,7 +662,7 @@ mod rustls_0_22 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.and_then(|io: TlsStream<TcpStream>| async {
.and_then(move |io: TlsStream<TcpStream>| async move {
let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() {
if protos.windows(2).any(|window| window == b"h2") {
Protocol::Http2
@ -632,6 +672,11 @@ mod rustls_0_22 {
} else {
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
Ok((io, proto, peer_addr))
})
@ -703,6 +748,7 @@ mod rustls_0_23 {
Error = TlsError<io::Error, DispatchError>,
InitError = (),
> {
let tcp_nodelay = self.cfg.tcp_nodelay();
let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
protos.extend_from_slice(&config.alpn_protocols);
config.alpn_protocols = protos;
@ -718,7 +764,7 @@ mod rustls_0_23 {
unreachable!("TLS acceptor service factory does not error on init")
})
.map_err(TlsError::into_service_error)
.and_then(|io: TlsStream<TcpStream>| async {
.and_then(move |io: TlsStream<TcpStream>| async move {
let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() {
if protos.windows(2).any(|window| window == b"h2") {
Protocol::Http2
@ -728,6 +774,11 @@ mod rustls_0_23 {
} else {
Protocol::Http1
};
if let Some(nodelay) = desired_nodelay(tcp_nodelay) {
set_nodelay(io.get_ref().0, nodelay);
}
let peer_addr = io.get_ref().0.peer_addr().ok();
Ok((io, proto, peer_addr))
})

View File

@ -11,7 +11,7 @@ use std::{
use actix_codec::{AsyncRead, AsyncWrite, ReadBuf};
use bytes::{Bytes, BytesMut};
use http::{Method, Uri, Version};
use http::{header, Method, Uri, Version};
use crate::{
header::{HeaderMap, TryIntoHeaderPair},
@ -98,9 +98,13 @@ impl TestRequest {
}
/// Set request payload.
///
/// This sets the `Content-Length` header with the size of `data`.
pub fn set_payload(&mut self, data: impl Into<Bytes>) -> &mut Self {
let mut payload = crate::h1::Payload::empty();
payload.unread_data(data.into());
let bytes = data.into();
self.insert_header((header::CONTENT_LENGTH, bytes.len()));
payload.unread_data(bytes);
parts(&mut self.0).payload = Some(payload.into());
self
}
@ -271,6 +275,7 @@ impl TestSeqBuffer {
{
Self(Rc::new(RefCell::new(TestSeqInner {
read_buf: data.into(),
read_closed: false,
write_buf: BytesMut::new(),
err: None,
})))
@ -289,36 +294,59 @@ impl TestSeqBuffer {
Ref::map(self.0.borrow(), |inner| &inner.write_buf)
}
pub fn take_write_buf(&self) -> Bytes {
self.0.borrow_mut().write_buf.split().freeze()
}
pub fn err(&self) -> Ref<'_, Option<io::Error>> {
Ref::map(self.0.borrow(), |inner| &inner.err)
}
/// Add data to read buffer.
///
/// # Panics
///
/// Panics if called after [`TestSeqBuffer::close_read`] has been called
pub fn extend_read_buf<T: AsRef<[u8]>>(&mut self, data: T) {
self.0
.borrow_mut()
.read_buf
.extend_from_slice(data.as_ref())
let mut inner = self.0.borrow_mut();
if inner.read_closed {
panic!("Tried to extend the read buffer after calling close_read");
}
inner.read_buf.extend_from_slice(data.as_ref())
}
/// Closes the [`AsyncRead`]/[`Read`] part of this test buffer.
///
/// The current data in the buffer will still be returned by a call to read/poll_read, however,
/// after the buffer is empty, it will return `Ok(0)` to signify the EOF condition
pub fn close_read(&self) {
self.0.borrow_mut().read_closed = true;
}
}
pub struct TestSeqInner {
read_buf: BytesMut,
read_closed: bool,
write_buf: BytesMut,
err: Option<io::Error>,
}
impl io::Read for TestSeqBuffer {
fn read(&mut self, dst: &mut [u8]) -> Result<usize, io::Error> {
if self.0.borrow().read_buf.is_empty() {
if self.0.borrow().err.is_some() {
Err(self.0.borrow_mut().err.take().unwrap())
let mut inner = self.0.borrow_mut();
if inner.read_buf.is_empty() {
if let Some(err) = inner.err.take() {
Err(err)
} else if inner.read_closed {
Ok(0)
} else {
Err(io::Error::new(io::ErrorKind::WouldBlock, ""))
}
} else {
let size = std::cmp::min(self.0.borrow().read_buf.len(), dst.len());
let b = self.0.borrow_mut().read_buf.split_to(size);
let size = std::cmp::min(inner.read_buf.len(), dst.len());
let b = inner.read_buf.split_to(size);
dst[..size].copy_from_slice(&b);
Ok(size)
}

View File

@ -98,11 +98,21 @@ impl Parser {
Some(res) => res,
};
let frame_len = match idx.checked_add(length) {
Some(len) => len,
None => return Err(ProtocolError::Overflow),
};
// not enough data
if src.len() < idx + length {
if src.len() < frame_len {
let min_length = min(length, max_size);
if src.capacity() < idx + min_length {
src.reserve(idx + min_length - src.capacity());
let required_cap = match idx.checked_add(min_length) {
Some(cap) => cap,
None => return Err(ProtocolError::Overflow),
};
if src.capacity() < required_cap {
src.reserve(required_cap - src.capacity());
}
return Ok(None);
}
@ -440,4 +450,14 @@ mod tests {
Parser::write_close(&mut buf, None, RsvBits::empty(), false);
assert_eq!(&buf[..], &vec![0x88, 0x00][..]);
}
#[test]
fn test_parse_length_overflow() {
let buf: [u8; 14] = [
0x0a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xeb, 0x0e, 0x8f,
];
let mut buf = BytesMut::from(&buf[..]);
let result = Parser::parse(&mut buf, true, 65536);
assert!(matches!(result, Err(ProtocolError::Overflow)));
}
}

View File

@ -149,10 +149,16 @@ async fn h2_content_length() {
{
let req = srv.request(Method::HEAD, srv.surl("/0")).send();
req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
let req = srv.request(Method::GET, srv.surl("/0")).send();
req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
let req = srv.request(Method::GET, srv.surl("/1")).send();
let response = req.await.unwrap();

View File

@ -1,10 +1,11 @@
#![cfg(feature = "rustls-0_23")]
extern crate tls_openssl as openssl;
extern crate tls_rustls_023 as rustls;
use std::{
convert::Infallible,
io::{self, BufReader, Write},
io::{self, Write},
net::{SocketAddr, TcpStream as StdTcpStream},
sync::Arc,
task::Poll,
@ -18,16 +19,17 @@ use actix_http::{
Error, HttpService, Method, Request, Response, StatusCode, TlsAcceptorConfig, Version,
};
use actix_http_test::test_server;
use actix_rt::pin;
use actix_rt::{net::TcpStream as RtTcpStream, pin};
use actix_service::{fn_factory_with_config, fn_service};
use actix_tls::connect::rustls_0_23::webpki_roots_cert_store;
use actix_tls::{accept::rustls_0_23::TlsStream, connect::rustls_0_23::webpki_roots_cert_store};
use actix_utils::future::{err, ok, poll_fn};
use awc::{Client, Connector};
use bytes::{Bytes, BytesMut};
use derive_more::{Display, Error};
use futures_core::{ready, Stream};
use futures_util::stream::once;
use rustls::{pki_types::ServerName, ServerConfig as RustlsServerConfig};
use rustls_pemfile::{certs, pkcs8_private_keys};
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
async fn load_body<S>(stream: S) -> Result<BytesMut, PayloadError>
where
@ -51,34 +53,49 @@ where
Ok(buf)
}
fn tls_config() -> RustlsServerConfig {
fn tls_config_with_alpn(protocols: &[&[u8]]) -> RustlsServerConfig {
let rcgen::CertifiedKey { cert, key_pair } =
rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap();
let cert_file = cert.pem();
let key_file = key_pair.serialize_pem();
let cert_file = &mut BufReader::new(cert_file.as_bytes());
let key_file = &mut BufReader::new(key_file.as_bytes());
let cert_chain = certs(cert_file).collect::<Result<Vec<_>, _>>().unwrap();
let mut keys = pkcs8_private_keys(key_file)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let cert_chain = vec![cert.der().clone()];
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
let mut config = RustlsServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
cert_chain,
rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)),
)
.with_single_cert(cert_chain, key_der)
.unwrap();
config.alpn_protocols.push(HTTP1_1_ALPN_PROTOCOL.to_vec());
config.alpn_protocols.push(H2_ALPN_PROTOCOL.to_vec());
config.alpn_protocols = protocols.iter().map(|proto| proto.to_vec()).collect();
config
}
fn tls_config() -> RustlsServerConfig {
tls_config_with_alpn(&[HTTP1_1_ALPN_PROTOCOL, H2_ALPN_PROTOCOL])
}
fn tls_config_h1() -> RustlsServerConfig {
tls_config_with_alpn(&[HTTP1_1_ALPN_PROTOCOL])
}
fn tls_config_h2() -> RustlsServerConfig {
tls_config_with_alpn(&[H2_ALPN_PROTOCOL])
}
fn h1_client() -> Client {
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
let mut builder = SslConnector::builder(SslMethod::tls()).unwrap();
builder.set_verify(SslVerifyMode::NONE);
builder.set_alpn_protos(b"\x08http/1.1").unwrap();
let connector = Connector::new()
.conn_lifetime(Duration::from_secs(0))
.timeout(Duration::from_millis(30_000))
.openssl(builder.build());
Client::builder().connector(connector).finish()
}
pub fn get_negotiated_alpn_protocol(
addr: SocketAddr,
client_alpn_protocol: &[u8],
@ -106,53 +123,56 @@ pub fn get_negotiated_alpn_protocol(
#[actix_rt::test]
async fn h1() -> io::Result<()> {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.h1(|_| ok::<_, Error>(Response::ok()))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h1())
})
.await;
let response = srv.sget("/").send().await.unwrap();
let response = h1_client().get(srv.surl("/")).send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h2() -> io::Result<()> {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.h2(|_| ok::<_, Error>(Response::ok()))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h1_1() -> io::Result<()> {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.h1(|req: Request| {
assert!(req.peer_addr().is_some());
assert_eq!(req.version(), Version::HTTP_11);
ok::<_, Error>(Response::ok())
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h1())
})
.await;
let response = srv.sget("/").send().await.unwrap();
let response = h1_client().get(srv.surl("/")).send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h2_1() -> io::Result<()> {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.finish(|req: Request| {
assert!(req.peer_addr().is_some());
@ -160,7 +180,7 @@ async fn h2_1() -> io::Result<()> {
ok::<_, Error>(Response::ok())
})
.rustls_0_23_with_config(
tls_config(),
tls_config_h2(),
TlsAcceptorConfig::default().handshake_timeout(Duration::from_secs(5)),
)
})
@ -168,6 +188,51 @@ async fn h2_1() -> io::Result<()> {
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h2_tcp_nodelay_override_true() -> io::Result<()> {
let mut srv = test_server(move || {
HttpService::build()
.tcp_nodelay(true)
.on_connect_ext(|io: &TlsStream<RtTcpStream>, data| {
data.insert(io.get_ref().0.nodelay().unwrap());
})
.h2(|req: Request| {
assert_eq!(req.conn_data::<bool>(), Some(&true));
ok::<_, Error>(Response::ok())
})
.rustls_0_23(tls_config_h2())
})
.await;
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h2_tcp_nodelay_override_false() -> io::Result<()> {
let mut srv = test_server(move || {
HttpService::build()
.tcp_nodelay(false)
.on_connect_ext(|io: &TlsStream<RtTcpStream>, data| {
data.insert(io.get_ref().0.nodelay().unwrap());
})
.h2(|req: Request| {
assert_eq!(req.conn_data::<bool>(), Some(&false));
ok::<_, Error>(Response::ok())
})
.rustls_0_23(tls_config_h2())
})
.await;
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
@ -180,7 +245,7 @@ async fn h2_body1() -> io::Result<()> {
let body = load_body(req.take_payload()).await?;
Ok::<_, Error>(Response::ok().set_body(body))
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -189,12 +254,13 @@ async fn h2_body1() -> io::Result<()> {
let body = srv.load_body(response).await.unwrap();
assert_eq!(&body, data.as_bytes());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn h2_content_length() {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.h2(|req: Request| {
let indx: usize = req.uri().path()[1..].parse().unwrap();
@ -206,7 +272,7 @@ async fn h2_content_length() {
];
ok::<_, Infallible>(Response::new(statuses[indx]))
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -219,13 +285,19 @@ async fn h2_content_length() {
let req = srv
.request(Method::HEAD, srv.surl(&format!("/{}", i)))
.send();
let _response = req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
// assert_eq!(response.headers().get(&header), None);
let req = srv
.request(Method::GET, srv.surl(&format!("/{}", i)))
.send();
let _response = req.await.expect_err("should timeout on recv 1xx frame");
actix_rt::time::timeout(Duration::from_secs(15), req)
.await
.expect("request future stalled on recv 1xx frame")
.expect_err("should timeout on recv 1xx frame");
// assert_eq!(response.headers().get(&header), None);
}
@ -246,6 +318,8 @@ async fn h2_content_length() {
assert_eq!(response.headers().get(&header), Some(&value));
}
}
srv.stop().await;
}
#[actix_rt::test]
@ -278,7 +352,7 @@ async fn h2_headers() {
}
ok::<_, Infallible>(config.body(data.clone()))
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -288,6 +362,7 @@ async fn h2_headers() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert_eq!(bytes, Bytes::from(data2));
srv.stop().await;
}
const STR: &str = "Hello World Hello World Hello World Hello World Hello World \
@ -317,7 +392,7 @@ async fn h2_body2() {
let mut srv = test_server(move || {
HttpService::build()
.h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR)))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -327,6 +402,7 @@ async fn h2_body2() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert_eq!(bytes, Bytes::from_static(STR.as_ref()));
srv.stop().await;
}
#[actix_rt::test]
@ -334,7 +410,7 @@ async fn h2_head_empty() {
let mut srv = test_server(move || {
HttpService::build()
.finish(|_| ok::<_, Infallible>(Response::ok().set_body(STR)))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -353,6 +429,7 @@ async fn h2_head_empty() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert!(bytes.is_empty());
srv.stop().await;
}
#[actix_rt::test]
@ -360,7 +437,7 @@ async fn h2_head_binary() {
let mut srv = test_server(move || {
HttpService::build()
.h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR)))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -378,14 +455,15 @@ async fn h2_head_binary() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert!(bytes.is_empty());
srv.stop().await;
}
#[actix_rt::test]
async fn h2_head_binary2() {
let srv = test_server(move || {
let mut srv = test_server(move || {
HttpService::build()
.h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR)))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -399,6 +477,8 @@ async fn h2_head_binary2() {
.unwrap();
assert_eq!(format!("{}", STR.len()), len.to_str().unwrap());
}
srv.stop().await;
}
#[actix_rt::test]
@ -411,7 +491,7 @@ async fn h2_body_length() {
Response::ok().set_body(SizedStream::new(STR.len() as u64, body)),
)
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -421,6 +501,7 @@ async fn h2_body_length() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert_eq!(bytes, Bytes::from_static(STR.as_ref()));
srv.stop().await;
}
#[actix_rt::test]
@ -435,7 +516,7 @@ async fn h2_body_chunked_explicit() {
.body(BodyStream::new(body)),
)
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -448,6 +529,7 @@ async fn h2_body_chunked_explicit() {
// decode
assert_eq!(bytes, Bytes::from_static(STR.as_ref()));
srv.stop().await;
}
#[actix_rt::test]
@ -464,7 +546,7 @@ async fn h2_response_http_error_handling() {
)
}))
}))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -477,6 +559,7 @@ async fn h2_response_http_error_handling() {
bytes,
Bytes::from_static(b"error processing HTTP: failed to parse header value")
);
srv.stop().await;
}
#[derive(Debug, Display, Error)]
@ -494,7 +577,7 @@ async fn h2_service_error() {
let mut srv = test_server(move || {
HttpService::build()
.h2(|_| err::<Response<BoxBody>, _>(BadRequest))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -504,6 +587,7 @@ async fn h2_service_error() {
// read response
let bytes = srv.load_body(response).await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"error"));
srv.stop().await;
}
#[actix_rt::test]
@ -511,16 +595,17 @@ async fn h1_service_error() {
let mut srv = test_server(move || {
HttpService::build()
.h1(|_| err::<Response<BoxBody>, _>(BadRequest))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h1())
})
.await;
let response = srv.sget("/").send().await.unwrap();
let response = h1_client().get(srv.surl("/")).send().await.unwrap();
assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
// read response
let bytes = srv.load_body(response).await.unwrap();
assert_eq!(bytes, Bytes::from_static(b"error"));
srv.stop().await;
}
const H2_ALPN_PROTOCOL: &[u8] = b"h2";
@ -529,8 +614,8 @@ const CUSTOM_ALPN_PROTOCOL: &[u8] = b"custom";
#[actix_rt::test]
async fn alpn_h1() -> io::Result<()> {
let srv = test_server(move || {
let mut config = tls_config();
let mut srv = test_server(move || {
let mut config = tls_config_h1();
config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec());
HttpService::build()
.h1(|_| ok::<_, Error>(Response::ok()))
@ -543,16 +628,17 @@ async fn alpn_h1() -> io::Result<()> {
Some(CUSTOM_ALPN_PROTOCOL.to_vec())
);
let response = srv.sget("/").send().await.unwrap();
let response = h1_client().get(srv.surl("/")).send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn alpn_h2() -> io::Result<()> {
let srv = test_server(move || {
let mut config = tls_config();
let mut srv = test_server(move || {
let mut config = tls_config_h2();
config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec());
HttpService::build()
.h2(|_| ok::<_, Error>(Response::ok()))
@ -572,12 +658,13 @@ async fn alpn_h2() -> io::Result<()> {
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}
#[actix_rt::test]
async fn alpn_h2_1() -> io::Result<()> {
let srv = test_server(move || {
let mut srv = test_server(move || {
let mut config = tls_config();
config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec());
HttpService::build()
@ -602,5 +689,6 @@ async fn alpn_h2_1() -> io::Result<()> {
let response = srv.sget("/").send().await.unwrap();
assert!(response.status().is_success());
srv.stop().await;
Ok(())
}

View File

@ -10,13 +10,16 @@ use actix_http::{
header, Error, HttpService, KeepAlive, Request, Response, StatusCode, Version,
};
use actix_http_test::test_server;
use actix_rt::{net::TcpStream, time::sleep};
use actix_rt::{
net::TcpStream,
time::{sleep, timeout},
};
use actix_service::fn_service;
use actix_utils::future::{err, ok, ready};
use bytes::Bytes;
use derive_more::{Display, Error};
use futures_util::{stream::once, FutureExt as _, StreamExt as _};
use rand::Rng as _;
use rand::RngExt as _;
use regex::Regex;
#[actix_rt::test]
@ -443,6 +446,60 @@ async fn content_length() {
srv.stop().await;
}
#[actix_rt::test]
async fn content_length_truncated() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut srv = test_server(|| {
HttpService::build()
.h1(|mut req: Request| async move {
let expected_length: usize = req.uri().path()[1..].parse().unwrap();
let mut payload = req.take_payload();
let mut length = 0;
let mut seen_error = false;
while let Some(chunk) = payload.next().await {
match chunk {
Ok(b) => length += b.len(),
Err(_) => {
seen_error = true;
break;
}
}
}
if seen_error {
return Result::<_, Infallible>::Ok(Response::bad_request());
}
assert_eq!(length, expected_length, "length must match when no error");
Result::<_, Infallible>::Ok(Response::ok())
})
.tcp()
})
.await;
let addr = srv.addr();
let mut buf = [0; 12];
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"POST /10000 HTTP/1.1\r\nContent-Length: 10000\r\n\r\ndata_truncated")
.await
.unwrap();
conn.shutdown().await.unwrap();
conn.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"HTTP/1.1 400");
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"POST /4 HTTP/1.1\r\nContent-Length: 4\r\n\r\ndata")
.await
.unwrap();
conn.shutdown().await.unwrap();
conn.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"HTTP/1.1 200");
srv.stop().await;
}
#[actix_rt::test]
async fn h1_headers() {
let data = STR.repeat(10);
@ -899,3 +956,68 @@ async fn h2c_auto() {
srv.stop().await;
}
#[actix_rt::test]
async fn h2_flow_control_window_sizes() {
let mut srv = test_server(|| {
HttpService::build()
.keep_alive(KeepAlive::Disabled)
.finish(|mut req: Request| async move {
while let Some(item) = req.take_payload().next().await {
item?;
}
Ok::<_, Error>(Response::ok())
})
.tcp_auto_h2c()
})
.await;
let tcp = TcpStream::connect(srv.addr()).await.unwrap();
let mut builder = h2::client::Builder::new();
builder.max_send_buffer_size(4 * 1024 * 1024);
let (h2, connection) = builder.handshake(tcp).await.unwrap();
tokio::spawn(async move { connection.await.unwrap() });
let mut h2 = h2.ready().await.unwrap();
let request = ::http::Request::builder()
.method("POST")
.uri("/")
.body(())
.unwrap();
let (response, mut send) = h2.send_request(request, false).unwrap();
// request more than the default 64KiB. if server is advertising larger flow control windows,
// we should get at least 1MiB assigned.
send.reserve_capacity(2 * 1024 * 1024);
let cap = timeout(Duration::from_secs(2), async {
loop {
let cap = std::future::poll_fn(|cx| send.poll_capacity(cx))
.await
.expect("request stream closed before flow control capacity became available")
.expect("failed polling flow control capacity");
if cap >= 1024 * 1024 {
break cap;
}
}
})
.await
.expect("timed out waiting for flow control capacity");
assert!(
cap >= 1024 * 1024,
"expected >= 1MiB send capacity, got {cap}"
);
send.send_data(Bytes::new(), true).unwrap();
let res = response.await.unwrap();
assert!(res.status().is_success());
srv.stop().await;
}

View File

@ -2,6 +2,10 @@
## Unreleased
## 0.8.0
- Minimum supported Rust version (MSRV) is now 1.88.
## 0.7.0
- Minimum supported Rust version (MSRV) is now 1.72.

View File

@ -1,6 +1,6 @@
[package]
name = "actix-multipart-derive"
version = "0.7.0"
version = "0.8.0"
authors = ["Jacob Halsey <jacob@jhalsey.com>"]
description = "Multipart form derive macro for Actix Web"
keywords = ["http", "web", "framework", "async", "futures"]
@ -11,7 +11,6 @@ edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[lib]
@ -25,7 +24,7 @@ quote = "1"
syn = "2"
[dev-dependencies]
actix-multipart = "0.7"
actix-multipart = "0.8"
actix-web = "4"
rustversion-msrv = "0.100"
trybuild = "1"

View File

@ -5,11 +5,11 @@
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-multipart-derive?label=latest)](https://crates.io/crates/actix-multipart-derive)
[![Documentation](https://docs.rs/actix-multipart-derive/badge.svg?version=0.7.0)](https://docs.rs/actix-multipart-derive/0.7.0)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
[![Documentation](https://docs.rs/actix-multipart-derive/badge.svg?version=0.8.0)](https://docs.rs/actix-multipart-derive/0.8.0)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart-derive.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.7.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.7.0)
[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.8.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.8.0)
[![Download](https://img.shields.io/crates/d/actix-multipart-derive.svg)](https://crates.io/crates/actix-multipart-derive)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x)

View File

@ -4,7 +4,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![allow(clippy::disallowed_names)] // false positives in some macro expansions
use std::collections::HashSet;
@ -16,19 +16,14 @@ use proc_macro2::Ident;
use quote::quote;
use syn::{parse_macro_input, Type};
#[derive(FromMeta)]
#[derive(Default, FromMeta)]
enum DuplicateField {
#[default]
Ignore,
Deny,
Replace,
}
impl Default for DuplicateField {
fn default() -> Self {
Self::Ignore
}
}
#[derive(FromDeriveInput, Default)]
#[darling(attributes(multipart), default)]
struct MultipartFormAttrs {
@ -232,7 +227,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS
::actix_multipart::MultipartError::UnknownField(field.name().unwrap().to_string())
))
} else {
quote!(::std::result::Result::Ok(()))
quote!(::actix_multipart::form::discard_field(field, limits).await)
};
// Value for duplicate action
@ -294,7 +289,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::std::result::Result<(), ::actix_multipart::MultipartError>> + 't>> {
match field.name().unwrap() {
#handle_field_impl
_ => return ::std::boxed::Box::pin(::std::future::ready(#unknown_field_result)),
_ => return ::std::boxed::Box::pin(async move { #unknown_field_result }),
}
}

View File

@ -1,10 +1,10 @@
error: Could not parse size limit `2 bytes`: couldn't parse "bytes" into a known SI unit, couldn't parse unit of "bytes"
error: Could not parse size limit `2 bytes`: couldn't parse "bytes" into a known SI unit, Failed to parse unit "byt..."
--> tests/trybuild/size-limit-parse-fail.rs:6:5
|
6 | description: Text<String>,
| ^^^^^^^^^^^
error: Could not parse size limit `2 megabytes`: couldn't parse "megabytes" into a known SI unit, couldn't parse unit of "megabytes"
error: Could not parse size limit `2 megabytes`: couldn't parse "megabytes" into a known SI unit, Failed to parse unit "meg..."
--> tests/trybuild/size-limit-parse-fail.rs:12:5
|
12 | description: Text<String>,

View File

@ -2,7 +2,18 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.75.
## 0.8.0
- Add multi-field multipart payload builders to `actix_multipart::test`. [#3575]
- Add `MultipartForm` support for `Option<Vec<T>>` fields. [#3577]
- Bound internal multipart parser buffering to prevent unbounded memory growth on malformed bodies.
- behavior change notice: There's now a cap for buffering (64KB). It can be changed with `MultipartConfig::buffer_limit`.
- Fix user-triggerable panic when parsing multipart boundaries.
- Minimum supported Rust version (MSRV) is now 1.88.
- Update `rand` dependency to `0.10`.
[#3577]: https://github.com/actix/actix-web/pull/3577
[#3575]: https://github.com/actix/actix-web/issues/3575
## 0.7.2

View File

@ -1,6 +1,6 @@
[package]
name = "actix-multipart"
version = "0.7.2"
version = "0.8.0"
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Jacob Halsey <jacob@jhalsey.com>",
@ -14,7 +14,6 @@ license.workspace = true
edition.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
all-features = true
[package.metadata.cargo_check_external_types]
@ -38,7 +37,7 @@ derive = ["actix-multipart-derive"]
tempfile = ["dep:tempfile", "tokio/fs"]
[dependencies]
actix-multipart-derive = { version = "=0.7.0", optional = true }
actix-multipart-derive = { version = "=0.8.0", optional = true }
actix-utils = "3"
actix-web = { version = "4", default-features = false }
@ -50,7 +49,7 @@ local-waker = "0.1"
log = "0.4"
memchr = "2.5"
mime = "0.3"
rand = "0.9"
rand = "0.10.1"
serde = "1"
serde_json = "1"
serde_plain = "1"

View File

@ -3,11 +3,11 @@
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-multipart?label=latest)](https://crates.io/crates/actix-multipart)
[![Documentation](https://docs.rs/actix-multipart/badge.svg?version=0.7.2)](https://docs.rs/actix-multipart/0.7.2)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
[![Documentation](https://docs.rs/actix-multipart/badge.svg?version=0.8.0)](https://docs.rs/actix-multipart/0.8.0)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-multipart/0.7.2/status.svg)](https://deps.rs/crate/actix-multipart/0.7.2)
[![dependency status](https://deps.rs/crate/actix-multipart/0.8.0/status.svg)](https://deps.rs/crate/actix-multipart/0.8.0)
[![Download](https://img.shields.io/crates/d/actix-multipart.svg)](https://crates.io/crates/actix-multipart)
[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x)
@ -24,9 +24,10 @@ Due to additional requirements for `multipart/form-data` requests, the higher le
## Examples
```rust
use actix_web::{post, App, HttpServer, Responder};
use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
use actix_multipart::form::{
json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig,
};
use actix_web::{middleware::Logger, post, App, HttpServer, Responder};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
@ -36,25 +37,37 @@ struct Metadata {
#[derive(Debug, MultipartForm)]
struct UploadForm {
// Note: the form is also subject to the global limits configured using `MultipartFormConfig`.
#[multipart(limit = "100MB")]
file: TempFile,
json: MpJson<Metadata>,
}
#[post("/videos")]
pub async fn post_video(MultipartForm(form): MultipartForm<UploadForm>) -> impl Responder {
async fn post_video(MultipartForm(form): MultipartForm<UploadForm>) -> impl Responder {
format!(
"Uploaded file {}, with size: {}",
form.json.name, form.file.size
"Uploaded file {}, with size: {}\ntemporary file ({}) was deleted\n",
form.json.name,
form.file.size,
form.file.file.path().display(),
)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(move || App::new().service(post_video))
.bind(("127.0.0.1", 8080))?
.run()
.await
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
HttpServer::new(move || {
App::new()
.service(post_video)
.wrap(Logger::default())
// Also increase the global total limit to 100MiB.
.app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024))
})
.workers(2)
.bind(("127.0.0.1", 8080))?
.run()
.await
}
```
@ -71,4 +84,4 @@ curl -v --request POST \
<!-- cargo-rdme end -->
[More available in the examples repo &rarr;](https://github.com/actix/examples/tree/master/forms/multipart)
[More available in the examples repo &rarr;](https://github.com/actix/examples/tree/main/forms/multipart)

View File

@ -1,4 +1,6 @@
use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
use actix_multipart::form::{
json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig,
};
use actix_web::{middleware::Logger, post, App, HttpServer, Responder};
use serde::Deserialize;
@ -9,6 +11,7 @@ struct Metadata {
#[derive(Debug, MultipartForm)]
struct UploadForm {
// Note: the form is also subject to the global limits configured using `MultipartFormConfig`.
#[multipart(limit = "100MB")]
file: TempFile,
json: MpJson<Metadata>,
@ -28,9 +31,15 @@ async fn post_video(MultipartForm(form): MultipartForm<UploadForm>) -> impl Resp
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
HttpServer::new(move || App::new().service(post_video).wrap(Logger::default()))
.workers(2)
.bind(("127.0.0.1", 8080))?
.run()
.await
HttpServer::new(move || {
App::new()
.service(post_video)
.wrap(Logger::default())
// Also increase the global total limit to 100MiB.
.app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024))
})
.workers(2)
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View File

@ -82,7 +82,9 @@ where
) -> Self::Future {
if state.contains_key(&field.form_field_name) {
match duplicate_field {
DuplicateField::Ignore => return Box::pin(ready(Ok(()))),
DuplicateField::Ignore => {
return Box::pin(async move { discard_field(field, limits).await });
}
DuplicateField::Deny => {
return Box::pin(ready(Err(MultipartError::DuplicateField(
@ -159,7 +161,9 @@ where
) -> Self::Future {
if state.contains_key(&field.form_field_name) {
match duplicate_field {
DuplicateField::Ignore => return Box::pin(ready(Ok(()))),
DuplicateField::Ignore => {
return Box::pin(async move { discard_field(field, limits).await });
}
DuplicateField::Deny => {
return Box::pin(ready(Err(MultipartError::DuplicateField(
@ -187,6 +191,45 @@ where
}
}
impl<'t, T> FieldGroupReader<'t> for Option<Vec<T>>
where
T: FieldReader<'t>,
{
type Future = LocalBoxFuture<'t, Result<(), MultipartError>>;
fn handle_field(
req: &'t HttpRequest,
field: Field,
limits: &'t mut Limits,
state: &'t mut State,
_duplicate_field: DuplicateField,
) -> Self::Future {
let field_name = field.name().unwrap().to_string();
Box::pin(async move {
let vec = state
.entry(field_name)
.or_insert_with(|| Box::<Vec<T>>::default())
.downcast_mut::<Vec<T>>()
.unwrap();
let item = T::read_field(req, field, limits).await?;
vec.push(item);
Ok(())
})
}
fn from_state(name: &str, state: &'t mut State) -> Result<Self, MultipartError> {
if let Some(boxed_vec) = state.remove(name) {
let vec = *boxed_vec.downcast::<Vec<T>>().unwrap();
Ok(Some(vec))
} else {
Ok(None)
}
}
}
/// Trait that allows a type to be used in the [`struct@MultipartForm`] extractor.
///
/// You should use the [`macro@MultipartForm`] macro to derive this for your struct.
@ -273,6 +316,16 @@ impl Limits {
}
}
/// Drain a field that will not be retained while still accounting for form limits.
#[doc(hidden)]
pub async fn discard_field(mut field: Field, limits: &mut Limits) -> Result<(), MultipartError> {
while let Some(chunk) = field.try_next().await? {
limits.try_consume_limits(chunk.len(), false)?;
}
Ok(())
}
/// Typed `multipart/form-data` extractor.
///
/// To extract typed data from a multipart stream, the inner type `T` must implement the
@ -506,6 +559,40 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
/// Test `Option<Vec>` fields.
#[derive(MultipartForm)]
struct TestOptionVec {
list1: Option<Vec<Text<String>>>,
list2: Option<Vec<Text<String>>>,
}
async fn test_option_vec_route(form: MultipartForm<TestOptionVec>) -> impl Responder {
let form = form.into_inner();
let strings = form
.list1
.unwrap()
.into_iter()
.map(|s| s.into_inner())
.collect::<Vec<_>>();
assert_eq!(strings, vec!["value1", "value2", "value3"]);
assert!(form.list2.is_none());
HttpResponse::Ok().finish()
}
#[actix_rt::test]
async fn test_option_vec() {
let srv =
actix_test::start(|| App::new().route("/", web::post().to(test_option_vec_route)));
let mut form = multipart::Form::default();
form.add_text("list1", "value1");
form.add_text("list1", "value2");
form.add_text("list1", "value3");
let response = send_form(&srv, form, "/").await;
assert_eq!(response.status(), StatusCode::OK);
}
/// Test the `rename` field attribute.
#[derive(MultipartForm)]
struct TestFieldRenaming {
@ -637,6 +724,32 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[actix_rt::test]
async fn test_discarded_fields_count_towards_total_limit() {
let srv = actix_test::start(|| {
App::new()
.route("/unknown", web::post().to(test_upload_limits_memory))
.route("/duplicate", web::post().to(test_duplicate_ignore_route))
.app_data(
MultipartFormConfig::default()
.memory_limit(usize::MAX)
.total_limit(20),
)
});
let mut form = multipart::Form::default();
form.add_text("field", "7 bytes");
form.add_text("unknown", "this string is 28 bytes long");
let response = send_form(&srv, form, "/unknown").await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let mut form = multipart::Form::default();
form.add_text("field", "first_value");
form.add_text("field", "this string is 28 bytes long");
let response = send_form(&srv, form, "/duplicate").await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
/// Test the Limits.
#[derive(MultipartForm)]
struct TestMemoryUploadLimits {

View File

@ -13,7 +13,7 @@
//! ```no_run
//! use actix_web::{post, App, HttpServer, Responder};
//!
//! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
//! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig};
//! use serde::Deserialize;
//!
//! #[derive(Debug, Deserialize)]
@ -23,6 +23,7 @@
//!
//! #[derive(Debug, MultipartForm)]
//! struct UploadForm {
//! // Note: the form is also subject to the global limits configured using `MultipartFormConfig`.
//! #[multipart(limit = "100MB")]
//! file: TempFile,
//! json: MpJson<Metadata>,
@ -38,10 +39,15 @@
//!
//! #[actix_web::main]
//! async fn main() -> std::io::Result<()> {
//! HttpServer::new(move || App::new().service(post_video))
//! .bind(("127.0.0.1", 8080))?
//! .run()
//! .await
//! HttpServer::new(move || {
//! App::new()
//! .service(post_video)
//! // Also increase the global total limit to 100MiB.
//! .app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024))
//! })
//! .bind(("127.0.0.1", 8080))?
//! .run()
//! .await
//! }
//! ```
//!
@ -58,7 +64,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
// This allows us to use the actix_multipart_derive within this crate's tests
#[cfg(test)]
@ -76,5 +82,5 @@ pub mod test;
pub use self::{
error::Error as MultipartError,
field::{Field, LimitExceeded},
multipart::Multipart,
multipart::{Multipart, MultipartConfig},
};

View File

@ -11,7 +11,7 @@ use actix_web::{
dev,
error::{ParseError, PayloadError},
http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue},
web::Bytes,
web::{self, Bytes},
HttpRequest,
};
use futures_core::stream::Stream;
@ -20,7 +20,7 @@ use mime::Mime;
use crate::{
error::Error,
field::InnerField,
payload::{PayloadBuffer, PayloadRef},
payload::{PayloadBuffer, PayloadRef, DEFAULT_BUFFER_LIMIT},
safety::Safety,
Field,
};
@ -44,6 +44,46 @@ enum Flow {
Error(Option<Error>),
}
/// [`Multipart`] extractor configuration.
///
/// Add to your app data to have it picked up by [`Multipart`] extractors.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct MultipartConfig {
buffer_limit: usize,
}
impl MultipartConfig {
/// Creates a default multipart extractor configuration.
pub fn new() -> Self {
DEFAULT_CONFIG
}
/// Sets maximum internal parser buffer size. By default this limit is 64 KiB.
pub fn buffer_limit(mut self, buffer_limit: usize) -> Self {
self.buffer_limit = buffer_limit;
self
}
/// Extracts multipart config from app data. Check both `T` and `Data<T>`, in that order, and
/// fall back to the default multipart config.
fn from_req(req: &HttpRequest) -> &Self {
req.app_data::<Self>()
.or_else(|| req.app_data::<web::Data<Self>>().map(|d| d.as_ref()))
.unwrap_or(&DEFAULT_CONFIG)
}
}
static DEFAULT_CONFIG: MultipartConfig = MultipartConfig {
buffer_limit: DEFAULT_BUFFER_LIMIT,
};
impl Default for MultipartConfig {
fn default() -> Self {
Self::new()
}
}
impl Multipart {
/// Creates multipart instance from parts.
pub fn new<S>(headers: &HeaderMap, stream: S) -> Self
@ -58,8 +98,15 @@ impl Multipart {
/// Creates multipart instance from parts.
pub(crate) fn from_req(req: &HttpRequest, payload: &mut dev::Payload) -> Self {
let config = MultipartConfig::from_req(req);
match Self::find_ct_and_boundary(req.headers()) {
Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, payload.take()),
Ok((ct, boundary)) => Self::from_ct_and_boundary_with_buffer_limit(
ct,
boundary,
payload.take(),
config.buffer_limit,
),
Err(err) => Self::from_error(err),
}
}
@ -84,18 +131,39 @@ impl Multipart {
.as_str()
.to_owned();
if boundary.is_empty() {
return Err(Error::BoundaryMissing);
}
Ok((content_type, boundary))
}
/// Constructs a new multipart reader from given Content-Type, boundary, and stream.
pub(crate) fn from_ct_and_boundary<S>(ct: Mime, boundary: String, stream: S) -> Multipart
where
S: Stream<Item = Result<Bytes, PayloadError>> + 'static,
{
Self::from_ct_and_boundary_with_buffer_limit(
ct,
boundary,
stream,
DEFAULT_CONFIG.buffer_limit,
)
}
fn from_ct_and_boundary_with_buffer_limit<S>(
ct: Mime,
boundary: String,
stream: S,
buffer_limit: usize,
) -> Multipart
where
S: Stream<Item = Result<Bytes, PayloadError>> + 'static,
{
Multipart {
safety: Safety::new(),
flow: Flow::InFlight(Inner {
payload: PayloadRef::new(PayloadBuffer::new(stream)),
payload: PayloadRef::new(PayloadBuffer::new_with_limit(stream, buffer_limit)),
content_type: ct,
boundary,
state: State::FirstBoundary,
@ -239,6 +307,10 @@ impl Inner {
/// - `Ok(None)` - boundary not found, more data needs reading
/// - `Err(BoundaryMissing)` - multipart boundary is missing
fn read_boundary(payload: &mut PayloadBuffer, boundary: &str) -> Result<Option<bool>, Error> {
if boundary.is_empty() {
return Err(Error::BoundaryMissing);
}
// TODO: need to read epilogue
let chunk = match payload.readline_or_eof()? {
// TODO: this might be okay as a let Some() else return Ok(None)
@ -249,34 +321,21 @@ impl Inner {
const BOUNDARY_MARKER: &[u8] = b"--";
const LINE_BREAK: &[u8] = b"\r\n";
let boundary_len = boundary.len();
if chunk.len() < boundary_len + 2 + 2
|| !chunk.starts_with(BOUNDARY_MARKER)
|| &chunk[2..boundary_len + 2] != boundary.as_bytes()
{
let Some(chunk) = chunk.as_ref().strip_prefix(BOUNDARY_MARKER) else {
return Err(Error::BoundaryMissing);
}
};
// chunk facts:
// - long enough to contain boundary + 2 markers or 1 marker and line-break
// - starts with boundary marker
// - chunk contains correct boundary
let Some(chunk) = chunk.strip_prefix(boundary.as_bytes()) else {
return Err(Error::BoundaryMissing);
};
if &chunk[boundary_len + 2..] == LINE_BREAK {
if chunk == LINE_BREAK {
// boundary is followed by line-break, indicating more fields to come
return Ok(Some(false));
}
// boundary is followed by marker
if &chunk[boundary_len + 2..boundary_len + 4] == BOUNDARY_MARKER
&& (
// chunk is exactly boundary len + 2 markers
chunk.len() == boundary_len + 2 + 2
// final boundary is allowed to end with a line-break
|| &chunk[boundary_len + 4..] == LINE_BREAK
)
{
if chunk == BOUNDARY_MARKER || chunk == b"--\r\n" {
return Ok(Some(true));
}
@ -287,7 +346,12 @@ impl Inner {
payload: &mut PayloadBuffer,
boundary: &str,
) -> Result<Option<bool>, Error> {
if boundary.is_empty() {
return Err(Error::BoundaryMissing);
}
let mut eof = false;
let boundary = boundary.as_bytes();
loop {
match payload.readline()? {
@ -295,19 +359,17 @@ impl Inner {
if chunk.is_empty() {
return Err(Error::BoundaryMissing);
}
if chunk.len() < boundary.len() {
let Some(line) = chunk.as_ref().strip_suffix(b"\r\n") else {
continue;
}
if &chunk[..2] == b"--" && &chunk[2..chunk.len() - 2] == boundary.as_bytes() {
break;
} else {
if chunk.len() < boundary.len() + 2 {
continue;
};
if let Some(line) = line.strip_prefix(b"--") {
if line == boundary {
break;
}
let b: &[u8] = boundary.as_ref();
if &chunk[..boundary.len()] == b
&& &chunk[boundary.len()..boundary.len() + 2] == b"--"
{
if line.strip_suffix(b"--") == Some(boundary) {
eof = true;
break;
}
@ -589,11 +651,226 @@ mod tests {
(bytes, headers)
}
fn create_header(content_type: &'static str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static(content_type),
);
headers
}
fn create_multipart_with_buffer_limit(
body: impl Stream<Item = Result<Bytes, PayloadError>> + 'static,
buffer_limit: usize,
) -> Multipart {
Multipart::from_ct_and_boundary_with_buffer_limit(
"multipart/mixed; boundary=\"a\"".parse().unwrap(),
"a".to_owned(),
body,
buffer_limit,
)
}
#[actix_rt::test]
async fn empty_boundary_does_not_panic() {
let payload = stream::once(async { Ok(Bytes::from_static(b"\n")) });
let ct = "multipart/mixed; boundary=\"a\"".parse().unwrap();
let mut multipart = Multipart::from_ct_and_boundary(ct, String::new(), payload);
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::BoundaryMissing));
}
#[actix_rt::test]
async fn short_line_with_one_byte_boundary_does_not_panic() {
let bytes = Bytes::from_static(b"\n");
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("multipart/mixed; boundary=\"a\""),
);
let payload = stream::once(async { Ok(bytes) });
let mut multipart = Multipart::new(&headers, payload);
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::Incomplete));
}
#[actix_rt::test]
async fn short_final_boundary_with_one_byte_boundary_does_not_panic() {
let bytes = Bytes::from_static(b"--\n");
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("multipart/mixed; boundary=\"a\""),
);
let payload = stream::once(async { Ok(bytes) });
let mut multipart = Multipart::new(&headers, payload);
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::Incomplete));
}
#[actix_rt::test]
async fn one_byte_boundary_parses_valid_body() {
let bytes = Bytes::from_static(
b"preamble\r\n\
--a\r\n\
Content-Type: text/plain\r\n\
Content-Length: 3\r\n\
\r\n\
one\r\n\
--a\r\n\
Content-Type: text/plain\r\n\
Content-Length: 3\r\n\
\r\n\
two\r\n\
--a--\r\n",
);
let headers = create_header("multipart/mixed; boundary=\"a\"");
let payload = stream::once(async { Ok(bytes) });
let mut multipart = Multipart::new(&headers, payload);
let mut field = multipart.next().await.unwrap().unwrap();
assert_eq!(get_whole_field(&mut field).await, "one");
drop(field);
let mut field = multipart.next().await.unwrap().unwrap();
assert_eq!(get_whole_field(&mut field).await, "two");
drop(field);
assert!(multipart.next().await.is_none());
}
#[actix_rt::test]
async fn one_byte_boundary_parses_when_split_across_chunks() {
let bytes = Bytes::from_static(
b"x\r\n\
--a\r\n\
Content-Type: text/plain\r\n\
Content-Length: 4\r\n\
\r\n\
data\r\n\
--a--\r\n",
);
let headers = create_header("multipart/mixed; boundary=\"a\"");
let payload = stream::iter(bytes)
.map(|byte| Ok(Bytes::copy_from_slice(&[byte])))
.interleave_pending();
let mut multipart = Multipart::new(&headers, payload);
let mut field = multipart.next().await.unwrap().unwrap();
assert_eq!(get_whole_field(&mut field).await, "data");
drop(field);
assert!(multipart.next().await.is_none());
}
#[actix_rt::test]
async fn short_preamble_lines_before_boundary_are_skipped() {
let bytes = Bytes::from_static(
b"\n\
-\r\n\
--a\r\n\
Content-Type: text/plain\r\n\
Content-Length: 4\r\n\
\r\n\
data\r\n\
--a--\r\n",
);
let headers = create_header("multipart/mixed; boundary=\"a\"");
let payload = stream::once(async { Ok(bytes) });
let mut multipart = Multipart::new(&headers, payload);
let mut field = multipart.next().await.unwrap().unwrap();
assert_eq!(get_whole_field(&mut field).await, "data");
drop(field);
assert!(multipart.next().await.is_none());
}
#[actix_rt::test]
async fn first_boundary_can_be_final() {
let bytes = Bytes::from_static(b"--a--\r\n");
let headers = create_header("multipart/mixed; boundary=\"a\"");
let payload = stream::once(async { Ok(bytes) });
let mut multipart = Multipart::new(&headers, payload);
assert!(multipart.next().await.is_none());
}
#[actix_rt::test]
async fn malformed_preamble_over_buffer_limit_errors() {
let body = stream::iter(
[b"aaaaaaaa", b"bbbbbbbb", b"cccccccc"].map(|chunk| Ok(Bytes::from_static(chunk))),
);
let mut multipart = create_multipart_with_buffer_limit(body, 16);
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::Payload(PayloadError::Overflow)));
}
#[actix_rt::test]
async fn malformed_headers_over_buffer_limit_errors() {
let body = stream::iter(
[
Bytes::from_static(b"--a\r\n"),
Bytes::from_static(b"X-Long: 12345678"),
Bytes::from_static(b"9012345678901234"),
Bytes::from_static(b"5678901234567890"),
]
.map(Ok),
);
let mut multipart = create_multipart_with_buffer_limit(body, 24);
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::Payload(PayloadError::Overflow)));
}
#[actix_rt::test]
async fn raw_extractor_uses_configured_buffer_limit() {
let (req, mut payload) = TestRequest::default()
.insert_header((header::CONTENT_TYPE, "multipart/mixed; boundary=\"a\""))
.app_data(MultipartConfig::default().buffer_limit(16))
.set_payload(Bytes::from_static(b"aaaaaaaabbbbbbbbcccccccc"))
.to_http_parts();
let mut multipart = Multipart::from_request(&req, &mut payload).await.unwrap();
let res = multipart.next().await.unwrap();
assert_matches!(res, Err(Error::Payload(PayloadError::Overflow)));
}
#[actix_rt::test]
async fn valid_large_field_streams_through_small_parser_buffer() {
let mut bytes = BytesMut::new();
bytes.put(&b"--a\r\nContent-Length: 100\r\n\r\n"[..]);
bytes.put(&[b'x'; 100][..]);
bytes.put(&b"\r\n--a--\r\n"[..]);
let body = stream::once(async { Ok(bytes.freeze()) });
let mut multipart = create_multipart_with_buffer_limit(body, 32);
let mut field = multipart.next().await.unwrap().unwrap();
assert_eq!(
get_whole_field(&mut field).await,
Bytes::from(vec![b'x'; 100])
);
drop(field);
assert!(multipart.next().await.is_none());
}
#[actix_rt::test]
async fn test_multipart_no_end_crlf() {
let (sender, payload) = create_stream();
let (mut bytes, headers) = create_double_request_with_header();
let bytes_stripped = bytes.split_to(bytes.len()); // strip crlf
let bytes_stripped = bytes.split_to(bytes.len() - 2); // strip crlf
sender.send(Ok(bytes_stripped)).unwrap();
drop(sender); // eof

View File

@ -14,6 +14,9 @@ use futures_core::stream::{LocalBoxStream, Stream};
use crate::{error::Error, safety::Safety};
pub(crate) const DEFAULT_BUFFER_LIMIT: usize = 65_536; // 64 KiB
const MAX_READY_CHUNKS_PER_POLL: usize = 16;
pub(crate) struct PayloadRef {
payload: Rc<RefCell<PayloadBuffer>>,
}
@ -45,31 +48,64 @@ impl Clone for PayloadRef {
/// Payload buffer.
pub(crate) struct PayloadBuffer {
pub(crate) stream: LocalBoxStream<'static, Result<Bytes, PayloadError>>,
pending: Option<Bytes>,
pub(crate) buf: BytesMut,
buffer_limit: usize,
/// EOF flag. If true, no more payload reads will be attempted.
pub(crate) eof: bool,
}
impl PayloadBuffer {
/// Constructs new payload buffer.
pub(crate) fn new<S>(stream: S) -> Self
pub(crate) fn new_with_limit<S>(stream: S, buffer_limit: usize) -> Self
where
S: Stream<Item = Result<Bytes, PayloadError>> + 'static,
{
PayloadBuffer {
stream: Box::pin(stream),
pending: None,
buf: BytesMut::with_capacity(1_024), // pre-allocate 1KiB
buffer_limit,
eof: false,
}
}
/// Polls a bounded amount of payload into the parser buffer.
///
/// This does not drain the stream to EOF in one call. Callers must be prepared to poll again
/// after consuming buffered data.
pub(crate) fn poll_stream(&mut self, cx: &mut Context<'_>) -> Result<(), PayloadError> {
loop {
if self.buffer_limit == 0 {
return Err(PayloadError::Overflow);
}
let mut appended = false;
for _ in 0..MAX_READY_CHUNKS_PER_POLL {
if self.pending.is_some() {
appended |= self.append_pending()?;
if self.pending.is_some() || self.buf.len() >= self.buffer_limit {
if appended {
cx.waker().wake_by_ref();
}
return Ok(());
}
continue;
}
match Pin::new(&mut self.stream).poll_next(cx) {
Poll::Ready(Some(Ok(data))) => {
self.buf.extend_from_slice(&data);
// try to read more data
continue;
self.pending = Some(data);
appended |= self.append_pending()?;
if self.pending.is_some() || self.buf.len() >= self.buffer_limit {
if appended {
cx.waker().wake_by_ref();
}
return Ok(());
}
}
Poll::Ready(Some(Err(err))) => return Err(err),
Poll::Ready(None) => {
@ -79,6 +115,40 @@ impl PayloadBuffer {
Poll::Pending => return Ok(()),
}
}
if appended {
cx.waker().wake_by_ref();
}
Ok(())
}
fn append_pending(&mut self) -> Result<bool, PayloadError> {
let Some(mut data) = self.pending.take() else {
return Ok(false);
};
if data.is_empty() {
return Ok(false);
}
if self.buf.len() >= self.buffer_limit {
self.pending = Some(data);
return Err(PayloadError::Overflow);
}
let available = self.buffer_limit - self.buf.len();
let len = cmp::min(data.len(), available);
if len == data.len() {
self.buf.extend_from_slice(&data);
} else {
let chunk = data.split_to(len);
self.buf.extend_from_slice(&chunk);
self.pending = Some(data);
}
Ok(len != 0)
}
/// Reads exact number of bytes.
@ -162,7 +232,7 @@ mod tests {
#[actix_rt::test]
async fn basic() {
let (_, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
assert_eq!(payload.buf.len(), 0);
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
@ -172,7 +242,7 @@ mod tests {
#[actix_rt::test]
async fn eof() {
let (mut sender, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
assert_eq!(None, payload.read_max(4).unwrap());
sender.feed_data(Bytes::from("data"));
@ -181,6 +251,8 @@ mod tests {
assert_eq!(Some(Bytes::from("data")), payload.read_max(4).unwrap());
assert_eq!(payload.buf.len(), 0);
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert!(payload.read_max(1).is_err());
assert!(payload.eof);
}
@ -188,7 +260,7 @@ mod tests {
#[actix_rt::test]
async fn err() {
let (mut sender, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
assert_eq!(None, payload.read_max(1).unwrap());
sender.set_error(PayloadError::Incomplete(None));
lazy(|cx| payload.poll_stream(cx)).await.err().unwrap();
@ -197,11 +269,12 @@ mod tests {
#[actix_rt::test]
async fn read_max() {
let (mut sender, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
sender.feed_data(Bytes::from("line1"));
sender.feed_data(Bytes::from("line2"));
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(payload.buf.len(), 10);
assert_eq!(Some(Bytes::from("line1")), payload.read_max(5).unwrap());
@ -214,13 +287,14 @@ mod tests {
#[actix_rt::test]
async fn read_exactly() {
let (mut sender, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
assert_eq!(None, payload.read_exact(2));
sender.feed_data(Bytes::from("line1"));
sender.feed_data(Bytes::from("line2"));
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(Some(Bytes::from_static(b"li")), payload.read_exact(2));
assert_eq!(payload.buf.len(), 8);
@ -232,13 +306,14 @@ mod tests {
#[actix_rt::test]
async fn read_until() {
let (mut sender, payload) = h1::Payload::create(false);
let mut payload = PayloadBuffer::new(payload);
let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT);
assert_eq!(None, payload.read_until(b"ne").unwrap());
sender.feed_data(Bytes::from("line1"));
sender.feed_data(Bytes::from("line2"));
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(
Some(Bytes::from("line")),
@ -252,4 +327,38 @@ mod tests {
);
assert_eq!(payload.buf.len(), 0);
}
#[actix_rt::test]
async fn poll_stream_does_not_exceed_buffer_limit() {
let stream = futures_util::stream::iter([
Ok(Bytes::from_static(b"12345678")),
Ok(Bytes::from_static(b"abcdefgh")),
Ok(Bytes::from_static(b"overflow")),
]);
let mut payload = PayloadBuffer::new_with_limit(stream, 16);
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(payload.buf.len(), 16);
let err = lazy(|cx| payload.poll_stream(cx)).await.unwrap_err();
assert!(matches!(err, PayloadError::Overflow));
assert_eq!(payload.buf.len(), 16);
}
#[actix_rt::test]
async fn oversized_chunk_can_be_consumed_incrementally() {
let stream = futures_util::stream::once(async { Ok(Bytes::from_static(b"12345678")) });
let mut payload = PayloadBuffer::new_with_limit(stream, 4);
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(payload.buf, Bytes::from_static(b"1234"));
assert_eq!(payload.read_max(4).unwrap().unwrap(), "1234");
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert_eq!(payload.buf, Bytes::from_static(b"5678"));
assert_eq!(payload.read_max(4).unwrap().unwrap(), "5678");
lazy(|cx| payload.poll_stream(cx)).await.unwrap();
assert!(payload.eof);
}
}

View File

@ -1,5 +1,7 @@
//! Multipart testing utilities.
use std::borrow::Cow;
use actix_web::{
http::header::{self, HeaderMap},
web::{BufMut as _, Bytes, BytesMut},
@ -12,6 +14,38 @@ const CRLF_CRLF: &[u8] = b"\r\n\r\n";
const HYPHENS: &[u8] = b"--";
const BOUNDARY_PREFIX: &str = "------------------------";
/// Multipart form field for test payload generation.
pub struct TestFormField<'a> {
name: Cow<'a, str>,
filename: Option<Cow<'a, str>>,
content_type: Option<Mime>,
data: Bytes,
}
impl<'a> TestFormField<'a> {
/// Creates a multipart form field from bytes.
pub fn new(name: impl Into<Cow<'a, str>>, data: impl Into<Bytes>) -> Self {
Self {
name: name.into(),
filename: None,
content_type: None,
data: data.into(),
}
}
/// Sets the field's file name metadata.
pub fn filename(mut self, filename: impl Into<Cow<'a, str>>) -> Self {
self.filename = Some(filename.into());
self
}
/// Sets the field's content type metadata.
pub fn content_type(mut self, content_type: Mime) -> Self {
self.content_type = Some(content_type);
self
}
}
/// Constructs a `multipart/form-data` payload from bytes and metadata.
///
/// Returned header map can be extended or merged with existing headers.
@ -61,15 +95,17 @@ pub fn create_form_data_payload_and_headers(
content_type: Option<Mime>,
file: Bytes,
) -> (Bytes, HeaderMap) {
let boundary = Alphanumeric.sample_string(&mut rand::rng(), 32);
let mut field = TestFormField::new(name, file);
create_form_data_payload_and_headers_with_boundary(
&boundary,
name,
filename,
content_type,
file,
)
if let Some(filename) = filename {
field = field.filename(filename);
}
if let Some(content_type) = content_type {
field = field.content_type(content_type);
}
create_form_data_payload_and_headers_from_fields([field])
}
/// Constructs a `multipart/form-data` payload from bytes and metadata with a fixed boundary.
@ -82,32 +118,101 @@ pub fn create_form_data_payload_and_headers_with_boundary(
content_type: Option<Mime>,
file: Bytes,
) -> (Bytes, HeaderMap) {
let mut buf = BytesMut::with_capacity(file.len() + 128);
let mut field = TestFormField::new(name, file);
if let Some(filename) = filename {
field = field.filename(filename);
}
if let Some(content_type) = content_type {
field = field.content_type(content_type);
}
create_form_data_payload_and_headers_from_fields_with_boundary(boundary, [field])
}
/// Constructs a `multipart/form-data` payload from multiple fields.
///
/// Returned header map can be extended or merged with existing headers.
///
/// Multipart boundary used is a random alphanumeric string.
///
/// # Examples
///
/// ```
/// use actix_multipart::test::{
/// create_form_data_payload_and_headers_from_fields, TestFormField,
/// };
/// use actix_web::{test::TestRequest, web::Bytes};
/// use memchr::memmem::find_iter;
///
/// let (body, headers) = create_form_data_payload_and_headers_from_fields([
/// TestFormField::new("title", Bytes::from_static(b"Multipart support")),
/// TestFormField::new("tags", Bytes::from_static(b"tests")),
/// TestFormField::new("tags", Bytes::from_static(b"actix")),
/// ]);
///
/// assert_eq!(find_iter(&body, b"name=\"tags\"").count(), 2);
///
/// let req = headers
/// .into_iter()
/// .fold(TestRequest::post(), |req, hdr| req.insert_header(hdr))
/// .set_payload(body)
/// .to_http_request();
///
/// assert!(req.headers().contains_key("content-type"));
/// ```
pub fn create_form_data_payload_and_headers_from_fields<'a>(
fields: impl IntoIterator<Item = TestFormField<'a>>,
) -> (Bytes, HeaderMap) {
let boundary = Alphanumeric.sample_string(&mut rand::rng(), 32);
create_form_data_payload_and_headers_from_fields_with_boundary(&boundary, fields)
}
/// Constructs a `multipart/form-data` payload from multiple fields with a fixed boundary.
// FIXME: terrible naming, but this is needed for compat with the current naming.
// Maybe we can rename the func here in a next major version.
pub fn create_form_data_payload_and_headers_from_fields_with_boundary<'a>(
boundary: &str,
fields: impl IntoIterator<Item = TestFormField<'a>>,
) -> (Bytes, HeaderMap) {
let fields = fields.into_iter().collect::<Vec<_>>();
let mut buf = BytesMut::with_capacity(fields.iter().map(|field| field.data.len() + 128).sum());
let boundary_str = [BOUNDARY_PREFIX, boundary].concat();
let boundary = boundary_str.as_bytes();
buf.put(HYPHENS);
buf.put(boundary);
buf.put(CRLF);
for field in fields {
let TestFormField {
name,
filename,
content_type,
data,
} = field;
buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes());
if let Some(filename) = filename {
buf.put(format!("; filename=\"{filename}\"").as_bytes());
}
buf.put(CRLF);
buf.put(HYPHENS);
buf.put(boundary);
buf.put(CRLF);
if let Some(ct) = content_type {
buf.put(format!("Content-Type: {ct}").as_bytes());
buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes());
if let Some(filename) = filename {
buf.put(format!("; filename=\"{filename}\"").as_bytes());
}
buf.put(CRLF);
if let Some(ct) = content_type {
buf.put(format!("Content-Type: {ct}").as_bytes());
buf.put(CRLF);
}
buf.put(format!("Content-Length: {}", data.len()).as_bytes());
buf.put(CRLF_CRLF);
buf.put(data);
buf.put(CRLF);
}
buf.put(format!("Content-Length: {}", file.len()).as_bytes());
buf.put(CRLF_CRLF);
buf.put(file);
buf.put(CRLF);
buf.put(HYPHENS);
buf.put(boundary);
buf.put(HYPHENS);
@ -128,9 +233,15 @@ pub fn create_form_data_payload_and_headers_with_boundary(
mod tests {
use std::convert::Infallible;
use actix_web::{
http::StatusCode,
test::{call_service, init_service, TestRequest},
web, App, HttpResponse, Responder,
};
use futures_util::stream;
use super::*;
use crate::form::{text::Text, MultipartForm};
fn find_boundary(headers: &HeaderMap) -> String {
headers
@ -191,6 +302,30 @@ mod tests {
sit ame.\r\n\
--------------------------qWeRtYuIoP--\r\n",
);
let (pl, _headers) = create_form_data_payload_and_headers_from_fields_with_boundary(
"qWeRtYuIoP",
[
TestFormField::new("foo", Bytes::from_static(b"Lorem ipsum dolor\nsit ame.")),
TestFormField::new("bar", Bytes::from_static(b"dolor sit")),
],
);
assert_eq!(
std::str::from_utf8(&pl).unwrap(),
"--------------------------qWeRtYuIoP\r\n\
Content-Disposition: form-data; name=\"foo\"\r\n\
Content-Length: 26\r\n\
\r\n\
Lorem ipsum dolor\n\
sit ame.\r\n\
--------------------------qWeRtYuIoP\r\n\
Content-Disposition: form-data; name=\"bar\"\r\n\
Content-Length: 9\r\n\
\r\n\
dolor sit\r\n\
--------------------------qWeRtYuIoP--\r\n",
);
}
/// Test using an external library to prevent the two-wrongs-make-a-right class of errors.
@ -214,4 +349,50 @@ mod tests {
assert_eq!(field.content_type(), None);
assert!(field.bytes().await.unwrap().starts_with(b"Lorem"));
}
#[derive(MultipartForm)]
struct TestMultipartRequestForm {
title: Text<String>,
tags: Vec<Text<String>>,
}
async fn multipart_test_request_route(
form: MultipartForm<TestMultipartRequestForm>,
) -> impl Responder {
let form = form.into_inner();
assert_eq!(form.title.into_inner(), "Multipart support");
assert_eq!(
form.tags
.into_iter()
.map(Text::into_inner)
.collect::<Vec<_>>(),
vec!["tests", "actix"],
);
HttpResponse::Ok().finish()
}
#[actix_web::test]
async fn test_request_compat() {
let app =
init_service(App::new().route("/", web::post().to(multipart_test_request_route))).await;
let (body, headers) = create_form_data_payload_and_headers_from_fields([
TestFormField::new("title", Bytes::from_static(b"Multipart support")),
TestFormField::new("tags", Bytes::from_static(b"tests")),
TestFormField::new("tags", Bytes::from_static(b"actix")),
]);
let req = headers
.into_iter()
.fold(TestRequest::post().uri("/"), |req, header| {
req.insert_header(header)
})
.set_payload(body)
.to_request();
let res = call_service(&app, req).await;
assert_eq!(res.status(), StatusCode::OK);
}
}

View File

@ -2,6 +2,19 @@
## Unreleased
- Add support for extracting multi-component path params into a sequence (Vec, tuple, ...). [#3432]
[#3432]: https://github.com/actix/actix-web/pull/3432
## 0.5.4
- Minimum supported Rust version (MSRV) is now 1.88.
- Support `deserialize_any` in `PathDeserializer` (enables derived `#[serde(untagged)]` enums in path segments). [#2881]
- Fix stale path segment indices after path rewrites, preventing out-of-bounds access during extraction. [#3562]
[#2881]: https://github.com/actix/actix-web/pull/2881
[#3562]: https://github.com/actix/actix-web/issues/3562
## 0.5.3
- Add `unicode` crate feature (on-by-default) to switch between `regex` and `regex-lite` as a trade-off between full unicode support and binary size.

View File

@ -1,6 +1,6 @@
[package]
name = "actix-router"
version = "0.5.3"
version = "0.5.4"
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Ali MJ Al-Nasrawy <alimjalnasrawy@gmail.com>",

View File

@ -4,7 +4,7 @@
[![crates.io](https://img.shields.io/crates/v/actix-router?label=latest)](https://crates.io/crates/actix-router)
[![Documentation](https://docs.rs/actix-router/badge.svg?version=0.5.3)](https://docs.rs/actix-router/0.5.3)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-router.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-router/0.5.3/status.svg)](https://deps.rs/crate/actix-router/0.5.3)

View File

@ -13,6 +13,7 @@ macro_rules! register {
register!(finish => "(.*)", "(.*)", "(.*)", "(.*)")
}};
(finish => $p1:literal, $p2:literal, $p3:literal, $p4:literal) => {{
#[expect(clippy::useless_concat)]
let arr = [
concat!("/authorizations"),
concat!("/authorizations/", $p1),

View File

@ -27,6 +27,9 @@ macro_rules! unsupported_type {
macro_rules! parse_single_value {
($trait_fn:ident) => {
parse_single_value!($trait_fn, $trait_fn);
};
($trait_fn:ident, $visit_fn:ident) => {
fn $trait_fn<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
@ -43,7 +46,7 @@ macro_rules! parse_single_value {
Value {
value: &self.path[0],
}
.$trait_fn(visitor)
.$visit_fn(visitor)
}
}
};
@ -205,11 +208,11 @@ impl<'de, T: ResourcePath + 'de> Deserializer<'de> for PathDeserializer<'de, T>
})
}
unsupported_type!(deserialize_any, "'any'");
unsupported_type!(deserialize_option, "Option<T>");
unsupported_type!(deserialize_identifier, "identifier");
unsupported_type!(deserialize_ignored_any, "ignored_any");
parse_single_value!(deserialize_any);
parse_single_value!(deserialize_bool);
parse_single_value!(deserialize_i8);
parse_single_value!(deserialize_i16);
@ -396,11 +399,25 @@ impl<'de> Deserializer<'de> for Value<'de> {
visitor.visit_newtype_struct(self)
}
fn deserialize_tuple<V>(self, _: usize, _: V) -> Result<V::Value, Self::Error>
fn deserialize_tuple<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
Err(de::value::Error::custom("unsupported type: tuple"))
let value_seq = ValueSeq::new(self.value);
if len == value_seq.len() {
visitor.visit_seq(value_seq)
} else {
Err(de::value::Error::custom(
"path and tuple lengths don't match",
))
}
}
fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_seq(ValueSeq::new(self.value))
}
fn deserialize_struct<V>(
@ -418,17 +435,48 @@ impl<'de> Deserializer<'de> for Value<'de> {
fn deserialize_tuple_struct<V>(
self,
_: &'static str,
_: usize,
_: V,
len: usize,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
Err(de::value::Error::custom("unsupported type: tuple struct"))
self.deserialize_tuple(len, visitor)
}
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let decoded = FULL_QUOTER
.with(|q| q.requote_str_lossy(self.value))
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(self.value));
let s = decoded.as_ref();
// We have to do it manually here on behalf of serde.
if let Ok(v) = s.parse::<u64>() {
if let Ok(v) = u32::try_from(v) {
return visitor.visit_u32(v);
}
return visitor.visit_u64(v);
}
if let Ok(v) = s.parse::<i64>() {
if let Ok(v) = i32::try_from(v) {
return visitor.visit_i32(v);
}
return visitor.visit_i64(v);
}
match decoded {
Cow::Borrowed(value) => visitor.visit_borrowed_str(value),
Cow::Owned(value) => visitor.visit_string(value),
}
}
unsupported_type!(deserialize_any, "any");
unsupported_type!(deserialize_seq, "seq");
unsupported_type!(deserialize_map, "map");
unsupported_type!(deserialize_identifier, "identifier");
}
@ -498,6 +546,43 @@ impl<'de> de::VariantAccess<'de> for UnitVariant {
}
}
struct ValueSeq<'de> {
elems: std::str::Split<'de, char>,
}
impl<'de> ValueSeq<'de> {
fn new(value: &'de str) -> Self {
Self {
elems: value.split('/'),
}
}
fn len(&self) -> usize {
self.elems.clone().filter(|s| !s.is_empty()).count()
}
}
impl<'de> de::SeqAccess<'de> for ValueSeq<'de> {
type Error = de::value::Error;
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
where
T: de::DeserializeSeed<'de>,
{
for elem in &mut self.elems {
if !elem.is_empty() {
return seed.deserialize(Value { value: elem }).map(Some);
}
}
Ok(None)
}
fn size_hint(&self) -> Option<usize> {
Some(self.len())
}
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
@ -532,6 +617,24 @@ mod tests {
val: TestEnum,
}
#[derive(Debug, Deserialize)]
struct TestSeq1 {
tail: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct TestSeq2 {
tail: (String, String, String),
}
#[derive(Debug, Deserialize)]
struct TestSeq3 {
tail: TestTupleStruct,
}
#[derive(Debug, Deserialize, PartialEq)]
struct TestTupleStruct(String, String, String);
#[test]
fn test_request_extract() {
let mut router = Router::<()>::build();
@ -627,6 +730,62 @@ mod tests {
assert!(format!("{:?}", i).contains("unknown variant"));
}
#[test]
fn test_extract_seq() {
let mut router = Router::<()>::build();
router.path("/path/to/{tail}*", ());
let router = router.finish();
let mut path = Path::new("/path/to/tail/with/slash%2fes");
assert!(router.recognize(&mut path).is_some());
let i: (String,) = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap();
assert_eq!(i.0, String::from("tail/with/slash/es"));
let i: TestSeq1 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap();
assert_eq!(
i.tail,
vec![
String::from("tail"),
String::from("with"),
String::from("slash/es")
]
);
let i: TestSeq2 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap();
assert_eq!(
i.tail,
(
String::from("tail"),
String::from("with"),
String::from("slash/es")
)
);
let i: TestSeq3 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap();
assert_eq!(
i.tail,
TestTupleStruct(
String::from("tail"),
String::from("with"),
String::from("slash/es")
)
);
}
#[test]
fn test_value_seq_size_hint_counts_remaining_elements() {
use serde::de::SeqAccess as _;
let mut seq = ValueSeq::new("tail/with/slash");
assert_eq!(seq.size_hint(), Some(3));
let elem = seq.next_element::<String>().unwrap();
assert_eq!(elem.as_deref(), Some("tail"));
assert_eq!(seq.size_hint(), Some(2));
}
#[test]
fn test_extract_errors() {
let mut router = Router::<()>::build();
@ -704,6 +863,119 @@ mod tests {
assert_eq!(vals.value, "/");
}
#[test]
fn deserialize_path_decode_any() {
#[derive(Debug, PartialEq)]
pub enum AnyEnumCustom {
String(String),
Int(u32),
Other,
}
impl<'de> Deserialize<'de> for AnyEnumCustom {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Vis;
impl<'de> Visitor<'de> for Vis {
type Value = AnyEnumCustom;
fn expecting<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result {
write!(f, "my thing")
}
fn visit_u32<E>(self, v: u32) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(AnyEnumCustom::Int(v))
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match u32::try_from(v) {
Ok(v) => Ok(AnyEnumCustom::Int(v)),
Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))),
}
}
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match u32::try_from(v) {
Ok(v) => Ok(AnyEnumCustom::Int(v)),
Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))),
}
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
v.parse().map(AnyEnumCustom::Int).or_else(|_| {
Ok(match v {
"other" => AnyEnumCustom::Other,
_ => AnyEnumCustom::String(format!("some str: {v}")),
})
})
}
}
deserializer.deserialize_any(Vis)
}
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum AnyEnumDerive {
String(String),
Int(u32),
Other,
}
// single
let rdef = ResourceDef::new("/{key}");
let mut path = Path::new("/%25");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: AnyEnumCustom = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment, AnyEnumCustom::String("some str: %".to_string()));
let mut path = Path::new("/%25");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: AnyEnumDerive = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment, AnyEnumDerive::String("%".to_string()));
// seq
let rdef = ResourceDef::new("/{key}/{value}");
let mut path = Path::new("/other/123");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: (AnyEnumCustom, AnyEnumDerive) = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment.0, AnyEnumCustom::Other);
assert_eq!(segment.1, AnyEnumDerive::Int(123));
// map
#[derive(Deserialize)]
struct Vals {
key: AnyEnumCustom,
value: AnyEnumDerive,
}
let rdef = ResourceDef::new("/{key}/{value}");
let mut path = Path::new("/123/%2F");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let vals: Vals = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(vals.key, AnyEnumCustom::Int(123));
assert_eq!(vals.value, AnyEnumDerive::String("/".to_string()));
}
#[test]
fn deserialize_borrowed() {
#[derive(Debug, Deserialize)]

View File

@ -2,7 +2,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod de;
mod path;

View File

@ -93,6 +93,45 @@ impl<T: ResourcePath> Path<T> {
self.segments.clear();
}
/// Set new path while preserving and remapping existing captured segment indices.
///
/// The `reindex` closure maps byte indices from the previous path to byte indices in the new
/// path.
#[doc(hidden)]
pub fn update_with_reindex<F>(&mut self, path: T, mut reindex: F)
where
F: FnMut(u16) -> u16,
{
self.skip = reindex(self.skip);
for (_, item) in &mut self.segments {
if let PathItem::Segment(start, end) = item {
*start = reindex(*start);
*end = reindex(*end);
if *start > *end {
*start = *end;
}
}
}
self.path = path;
let path = self.path.path();
self.skip = clamp_to_char_boundary(path, self.skip);
for (_, item) in &mut self.segments {
if let PathItem::Segment(start, end) = item {
*start = clamp_to_char_boundary(path, *start);
*end = clamp_to_char_boundary(path, *end);
if *start > *end {
*start = *end;
}
}
}
}
/// Reset state.
#[inline]
pub fn reset(&mut self) {
@ -179,6 +218,16 @@ impl<T: ResourcePath> Path<T> {
}
}
fn clamp_to_char_boundary(path: &str, idx: u16) -> u16 {
let mut idx = usize::from(idx).min(path.len());
while idx > 0 && !path.is_char_boundary(idx) {
idx -= 1;
}
idx as u16
}
#[derive(Debug)]
pub struct PathIter<'a, T> {
idx: usize,

View File

@ -1,11 +1,16 @@
use crate::Path;
// TODO: this trait is necessary, document it
// see impl Resource for ServiceRequest
/// Abstraction over types that can provide a mutable [`Path`] for routing.
///
/// This trait is used by the router to extract the request path in a uniform way across different
/// request types (e.g., Actix Web's `ServiceRequest`). Implementors return a mutable [`Path`]
/// wrapper so routing can read and potentially normalize/parse the path without requiring the
/// original request type.
pub trait Resource {
/// Type of resource's path returned in `resource_path`.
type Path: ResourcePath;
/// Returns a mutable reference to the path wrapper used by the router.
fn resource_path(&mut self) -> &mut Path<Self::Path>;
}

View File

@ -2,6 +2,8 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.88.
## 0.1.5
- Add `TestServerConfig::listen_address()` method.

View File

@ -4,7 +4,7 @@
[![crates.io](https://img.shields.io/crates/v/actix-test?label=latest)](https://crates.io/crates/actix-test)
[![Documentation](https://docs.rs/actix-test/badge.svg?version=0.1.5)](https://docs.rs/actix-test/0.1.5)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-test.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-test/0.1.5/status.svg)](https://deps.rs/crate/actix-test/0.1.5)

View File

@ -29,7 +29,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "openssl")]
extern crate tls_openssl as openssl;

View File

@ -2,6 +2,8 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.88.
## 4.3.1 <!-- v4.3.1+deprecated -->
- Reduce memory usage by `take`-ing (rather than `split`-ing) the encoded buffer when yielding bytes in the response stream.

View File

@ -24,7 +24,7 @@ allowed_external_types = [
actix = { version = ">=0.12, <0.14", default-features = false }
actix-codec = "0.5"
actix-http = "3"
actix-web = { version = "4", default-features = false }
actix-web = { version = "4", default-features = false, features = ["ws"] }
bytes = "1"
bytestring = "1"

View File

@ -8,7 +8,7 @@
[![crates.io](https://img.shields.io/crates/v/actix-web-actors?label=latest)](https://crates.io/crates/actix-web-actors)
[![Documentation](https://docs.rs/actix-web-actors/badge.svg?version=4.3.1)](https://docs.rs/actix-web-actors/4.3.1)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![License](https://img.shields.io/crates/l/actix-web-actors.svg)
<br />
![maintenance-status](https://img.shields.io/badge/maintenance-deprecated-red.svg)

View File

@ -59,7 +59,7 @@
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
mod context;
pub mod ws;

View File

@ -2,6 +2,8 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.88.
## 4.3.0
- Add `#[scope]` macro.

View File

@ -6,7 +6,7 @@
[![crates.io](https://img.shields.io/crates/v/actix-web-codegen?label=latest)](https://crates.io/crates/actix-web-codegen)
[![Documentation](https://docs.rs/actix-web-codegen/badge.svg?version=4.3.0)](https://docs.rs/actix-web-codegen/4.3.0)
![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![License](https://img.shields.io/crates/l/actix-web-codegen.svg)
<br />
[![dependency status](https://deps.rs/crate/actix-web-codegen/4.3.0/status.svg)](https://deps.rs/crate/actix-web-codegen/4.3.0)

View File

@ -75,7 +75,7 @@
#![recursion_limit = "512"]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(docsrs, feature(doc_cfg))]
use proc_macro::TokenStream;
use quote::quote;

View File

@ -59,6 +59,7 @@ macro_rules! standard_method_type {
(
$($variant:ident, $upper:ident, $lower:ident,)+
) => {
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MethodType {
$(
@ -466,7 +467,7 @@ impl ToTokens for Route {
let stream = quote! {
#(#doc_attributes)*
#[allow(non_camel_case_types, missing_docs)]
#[allow(non_camel_case_types)]
#vis struct #name;
impl ::actix_web::dev::HttpServiceFactory for #name {

View File

@ -13,14 +13,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future<Output = String>
| required by a bound introduced by this call
|
= help: the following other types implement trait `HttpServiceFactory`:
Resource<T>
actix_web::Scope<T>
Vec<T>
Redirect
(A,)
(A, B)
(A, B, C)
(A, B, C, D)
(A, B, C, D, E)
(A, B, C, D, E, F)
(A, B, C, D, E, F, G)
(A, B, C, D, E, F, G, H)
(A, B, C, D, E, F, G, H, I)
and $N others
note: required by a bound in `App::<T>::service`
--> $WORKSPACE/actix-web/src/app.rs

View File

@ -13,14 +13,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future<Output = String>
| required by a bound introduced by this call
|
= help: the following other types implement trait `HttpServiceFactory`:
Resource<T>
actix_web::Scope<T>
Vec<T>
Redirect
(A,)
(A, B)
(A, B, C)
(A, B, C, D)
(A, B, C, D, E)
(A, B, C, D, E, F)
(A, B, C, D, E, F, G)
(A, B, C, D, E, F, G, H)
(A, B, C, D, E, F, G, H, I)
and $N others
note: required by a bound in `App::<T>::service`
--> $WORKSPACE/actix-web/src/app.rs

View File

@ -15,14 +15,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future<Output = String>
| required by a bound introduced by this call
|
= help: the following other types implement trait `HttpServiceFactory`:
Resource<T>
actix_web::Scope<T>
Vec<T>
Redirect
(A,)
(A, B)
(A, B, C)
(A, B, C, D)
(A, B, C, D, E)
(A, B, C, D, E, F)
(A, B, C, D, E, F, G)
(A, B, C, D, E, F, G, H)
(A, B, C, D, E, F, G, H, I)
and $N others
note: required by a bound in `App::<T>::service`
--> $WORKSPACE/actix-web/src/app.rs

View File

@ -29,14 +29,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future<Output = String>
| required by a bound introduced by this call
|
= help: the following other types implement trait `HttpServiceFactory`:
Resource<T>
actix_web::Scope<T>
Vec<T>
Redirect
(A,)
(A, B)
(A, B, C)
(A, B, C, D)
(A, B, C, D, E)
(A, B, C, D, E, F)
(A, B, C, D, E, F, G)
(A, B, C, D, E, F, G, H)
(A, B, C, D, E, F, G, H, I)
and $N others
note: required by a bound in `App::<T>::service`
--> $WORKSPACE/actix-web/src/app.rs

View File

@ -15,14 +15,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future<Output = String>
| required by a bound introduced by this call
|
= help: the following other types implement trait `HttpServiceFactory`:
Resource<T>
actix_web::Scope<T>
Vec<T>
Redirect
(A,)
(A, B)
(A, B, C)
(A, B, C, D)
(A, B, C, D, E)
(A, B, C, D, E, F)
(A, B, C, D, E, F, G)
(A, B, C, D, E, F, G, H)
(A, B, C, D, E, F, G, H, I)
and $N others
note: required by a bound in `App::<T>::service`
--> $WORKSPACE/actix-web/src/app.rs

View File

@ -2,6 +2,49 @@
## Unreleased
- Add `HttpRequest::{cookies_raw,cookie_raw}` and `ServiceRequest::{cookies_raw,cookie_raw}` for reading request cookies without percent-decoding names and values. [#3542]
- Enable dual-stack IPv6 sockets on Windows when possible so that Actix-created listeners bound to `[::]` also accept IPv4 connections.
- Panic when calling `Route::to()` or `Route::service()` after `Route::wrap()` to prevent silently dropping route middleware. [#3944]
- Fix `HttpRequest::{match_pattern,match_name}` reporting path-only matches when route guards disambiguate overlapping resources. [#3346]
- Fix `Readlines` handling of lines split across payload chunks so combined line limits are enforced and complete lines are yielded.
- Update `foldhash` dependency to `0.2`.
- Update `rand` dependency to `0.10`.
- Add `HttpServer::h1_write_buffer_size()`.
[#3944]: https://github.com/actix/actix-web/pull/3944
[#3346]: https://github.com/actix/actix-web/issues/3346
[#3542]: https://github.com/actix/actix-web/issues/3542
## 4.13.0
- Minimum supported Rust version (MSRV) is now 1.88.
- Improve HTTP/2 upload throughput by increasing default flow control window sizes. [#3638]
- Add `HttpServer::{h2_initial_window_size, h2_initial_connection_window_size}` methods for tuning. [#3638]
- Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895]
- Ignore unparsable cookies in `Cookie` request header.
- Add `experimental-introspection` feature to report configured routes [#3594]
- Add config/method for `TCP_NODELAY`. [#3918]
- Fix panic when `NormalizePath` rewrites a scoped dynamic path before extraction (e.g., `scope("{tail:.*}")` + `Path<String>`). [#3562]
- Do not compress 206 Partial Content responses. [#3191]
[#3895]: https://github.com/actix/actix-web/pull/3895
[#3594]: https://github.com/actix/actix-web/pull/3594
[#3918]: https://github.com/actix/actix-web/pull/3918
[#3638]: https://github.com/actix/actix-web/issues/3638
[#3562]: https://github.com/actix/actix-web/issues/3562
[#3191]: https://github.com/actix/actix-web/issues/3191
## 4.12.1
- Correct `actix-http` dependency requirement.
## 4.12.0
- `actix_web::response::builder::HttpResponseBuilder::streaming()` now sets `Content-Type` to `application/octet-stream` if `Content-Type` does not exist.
- `actix_web::response::builder::HttpResponseBuilder::streaming()` now calls `actix_web::response::builder::HttpResponseBuilder::no_chunking()` and returns `SizedStream` if `Content-Length` is set by user.
- Add `ws` crate feature (on-by-default) which forwards to `actix-http` and guards some of its `ResponseError` impls.
- Add public export for `EitherExtractError` in `error` module.
## 4.11.0
- Add `Logger::log_level()` method.

View File

@ -1,6 +1,6 @@
[package]
name = "actix-web"
version = "4.11.0"
version = "4.13.0"
description = "Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust"
authors = ["Nikolay Kim <fafhrd91@gmail.com>", "Rob Ede <robjtede@icloud.com>"]
keywords = ["actix", "http", "web", "framework", "async"]
@ -17,7 +17,6 @@ edition.workspace = true
rust-version.workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["--cfg", "docsrs"]
features = [
"macros",
"openssl",
@ -53,7 +52,6 @@ allowed_external_types = [
"serde_json::*",
"serde_urlencoded::*",
"serde::*",
"serde::*",
"tokio::*",
"url::*",
]
@ -68,6 +66,7 @@ default = [
"http2",
"unicode",
"compat",
"ws",
]
# Brotli algorithm content-encoding support
@ -86,9 +85,12 @@ cookies = ["dep:cookie"]
# Secure & signed cookies
secure-cookies = ["cookies", "cookie/secure"]
# HTTP/2 support (including h2c).
# HTTP/2 support (including h2c)
http2 = ["actix-http/http2"]
# WebSocket support
ws = ["actix-http/ws"]
# TLS via OpenSSL
openssl = ["__tls", "http2", "actix-http/openssl", "actix-tls/accept", "actix-tls/openssl"]
@ -123,6 +125,9 @@ compat = ["compat-routing-macros-force-pub"]
# Opt-out forwards-compatibility for handler visibility inheritance fix.
compat-routing-macros-force-pub = ["actix-web-codegen?/compat-routing-macros-force-pub"]
# Enabling the retrieval of metadata for initialized resources, including path and HTTP method.
experimental-introspection = ["serde/derive"]
[dependencies]
actix-codec = "0.5"
actix-macros = { version = "0.2.3", optional = true }
@ -132,8 +137,8 @@ actix-service = "2"
actix-tls = { version = "3.4", default-features = false, optional = true }
actix-utils = "3"
actix-http = { version = "3.11", features = ["ws"] }
actix-router = { version = "0.5.3", default-features = false, features = ["http"] }
actix-http = "3.12.1"
actix-router = { version = "0.5.4", default-features = false, features = ["http"] }
actix-web-codegen = { version = "4.3", optional = true, default-features = false }
bytes = "1"
@ -142,7 +147,7 @@ cfg-if = "1"
cookie = { version = "0.16", features = ["percent-encode"], optional = true }
derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] }
encoding_rs = "0.8"
foldhash = "0.1"
foldhash = "0.2"
futures-core = { version = "0.3.17", default-features = false }
futures-util = { version = "0.3.17", default-features = false }
impl-more = "0.1.4"
@ -158,7 +163,7 @@ serde = "1.0"
serde_json = "1.0"
serde_urlencoded = "0.7"
smallvec = "1.6.1"
socket2 = "0.5"
socket2 = "0.6"
time = { version = "0.3", default-features = false, features = ["formatting"] }
tracing = "0.1.30"
url = "2.5.4"
@ -169,15 +174,15 @@ actix-test = { version = "0.1", features = ["openssl", "rustls-0_23"] }
awc = { version = "3", features = ["openssl"] }
brotli = "8"
const-str = "0.5" # TODO(MSRV 1.77): update to 0.6
const-str = "1.1"
core_affinity = "0.8"
criterion = { version = "0.5", features = ["html_reports"] }
env_logger = "0.11"
flate2 = "1.0.13"
futures-util = { version = "0.3.17", default-features = false, features = ["std"] }
rand = "0.9"
rand = "0.10.1"
rcgen = "0.13"
rustls-pemfile = "2"
rustls-pki-types = "1.13.1"
serde = { version = "1", features = ["derive"] }
static_assertions = "1"
tls-openssl = { package = "openssl", version = "0.10.55" }

View File

@ -3,7 +3,6 @@
- The return type for `ServiceRequest::app_data::<T>()` was changed from returning a `Data<T>` to simply a `T`. To access a `Data<T>` use `ServiceRequest::app_data::<Data<T>>()`.
- Cookie handling has been offloaded to the `cookie` crate:
- `USERINFO_ENCODE_SET` is no longer exposed. Percent-encoding is still supported; check docs.
- Some types now require lifetime parameters.

View File

@ -115,7 +115,7 @@ An alternative [path param type with public field but no `Deref` impl is availab
## Rustls Crate Upgrade
Actix Web now depends on version 0.20 of `rustls`. As a result, the server config builder has changed. [See the updated example project.](https://github.com/actix/examples/tree/master/https-tls/rustls/)
Actix Web now depends on version 0.20 of `rustls`. As a result, the server config builder has changed. [See the updated example project.](https://github.com/actix/examples/tree/main/https-tls/rustls/)
## Removed `awc` Client Re-export

View File

@ -8,10 +8,10 @@
<!-- prettier-ignore-start -->
[![crates.io](https://img.shields.io/crates/v/actix-web?label=latest)](https://crates.io/crates/actix-web)
[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.11.0)](https://docs.rs/actix-web/4.11.0)
![MSRV](https://img.shields.io/badge/rustc-1.72+-ab6000.svg)
[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.13.0)](https://docs.rs/actix-web/4.13.0)
![MSRV](https://img.shields.io/badge/rustc-1.88+-ab6000.svg)
![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-web.svg)
[![Dependency Status](https://deps.rs/crate/actix-web/4.11.0/status.svg)](https://deps.rs/crate/actix-web/4.11.0)
[![Dependency Status](https://deps.rs/crate/actix-web/4.13.0/status.svg)](https://deps.rs/crate/actix-web/4.13.0)
<br />
[![CI](https://github.com/actix/actix-web/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-web/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/actix/actix-web/graph/badge.svg?token=dSwOnp9QCv)](https://codecov.io/gh/actix/actix-web)
@ -37,14 +37,24 @@
- SSL support using OpenSSL or Rustls
- Middlewares ([Logger, Session, CORS, etc](https://actix.rs/docs/middleware/))
- Integrates with the [`awc` HTTP client](https://docs.rs/awc/)
- Runs on stable Rust 1.72+
- Runs on stable Rust 1.88+
### Experimental features
To enable faster release iterations, we mark some features as experimental.
These features are prefixed with `experimental` and a breaking change may happen at any release.
Please use them in a production environment at your own risk.
- `experimental-introspection`: exposes route and method reporting helpers for local diagnostics
and tooling. See [`examples/introspection.rs`](examples/introspection.rs) and
[`examples/introspection_multi_servers.rs`](examples/introspection_multi_servers.rs).
## Documentation
- [Website & User Guide](https://actix.rs)
- [Examples Repository](https://github.com/actix/examples)
- [API Documentation](https://docs.rs/actix-web)
- [API Documentation (master branch)](https://actix.rs/actix-web/actix_web)
- [API Documentation (mainranch)](https://actix.rs/actix-web/actix_web)
## Example
@ -78,23 +88,23 @@ async fn main() -> std::io::Result<()> {
### More Examples
- [Hello World](https://github.com/actix/examples/tree/master/basics/hello-world)
- [Basic Setup](https://github.com/actix/examples/tree/master/basics/basics)
- [Application State](https://github.com/actix/examples/tree/master/basics/state)
- [JSON Handling](https://github.com/actix/examples/tree/master/json/json)
- [Multipart Streams](https://github.com/actix/examples/tree/master/forms/multipart)
- [MongoDB Integration](https://github.com/actix/examples/tree/master/databases/mongodb)
- [Diesel Integration](https://github.com/actix/examples/tree/master/databases/diesel)
- [SQLite Integration](https://github.com/actix/examples/tree/master/databases/sqlite)
- [Postgres Integration](https://github.com/actix/examples/tree/master/databases/postgres)
- [Tera Templates](https://github.com/actix/examples/tree/master/templating/tera)
- [Askama Templates](https://github.com/actix/examples/tree/master/templating/askama)
- [HTTPS using Rustls](https://github.com/actix/examples/tree/master/https-tls/rustls)
- [HTTPS using OpenSSL](https://github.com/actix/examples/tree/master/https-tls/openssl)
- [Simple WebSocket](https://github.com/actix/examples/tree/master/websockets)
- [WebSocket Chat](https://github.com/actix/examples/tree/master/websockets/chat)
- [Hello World](https://github.com/actix/examples/tree/main/basics/hello-world)
- [Basic Setup](https://github.com/actix/examples/tree/main/basics/basics)
- [Application State](https://github.com/actix/examples/tree/main/basics/state)
- [JSON Handling](https://github.com/actix/examples/tree/main/json/json)
- [Multipart Streams](https://github.com/actix/examples/tree/main/forms/multipart)
- [MongoDB Integration](https://github.com/actix/examples/tree/main/databases/mongodb)
- [Diesel Integration](https://github.com/actix/examples/tree/main/databases/diesel)
- [SQLite Integration](https://github.com/actix/examples/tree/main/databases/sqlite)
- [Postgres Integration](https://github.com/actix/examples/tree/main/databases/postgres)
- [Tera Templates](https://github.com/actix/examples/tree/main/templating/tera)
- [Askama Templates](https://github.com/actix/examples/tree/main/templating/askama)
- [HTTPS using Rustls](https://github.com/actix/examples/tree/main/https-tls/rustls)
- [HTTPS using OpenSSL](https://github.com/actix/examples/tree/main/https-tls/openssl)
- [Simple WebSocket](https://github.com/actix/examples/tree/main/websockets)
- [WebSocket Chat](https://github.com/actix/examples/tree/main/websockets/chat)
You may consider checking out [this directory](https://github.com/actix/examples/tree/master) for more examples.
You may consider checking out [this directory](https://github.com/actix/examples/tree/main) for more examples.
## Benchmarks

Some files were not shown because too many files have changed in this diff Show More