mirror of https://github.com/fafhrd91/actix-web
Merge branch 'main' into compress-ws-deflate
# Conflicts: # Cargo.lock # actix-http/CHANGES.md # awc/src/ws.rs
This commit is contained in:
commit
6497364332
|
|
@ -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 },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ version: "0.2"
|
|||
words:
|
||||
- actix
|
||||
- addrs
|
||||
- ALPN
|
||||
- bytestring
|
||||
- httparse
|
||||
- msrv
|
||||
- MSRV
|
||||
- realip
|
||||
- rustls
|
||||
- rustup
|
||||
- serde
|
||||
- uring
|
||||
- webpki
|
||||
- zstd
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [robjtede]
|
||||
github: [robjtede, JohnTitor]
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-files)
|
||||
[](https://docs.rs/actix-files/0.6.6)
|
||||

|
||||
[](https://docs.rs/actix-files/0.6.9)
|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-files/0.6.6)
|
||||
[](https://deps.rs/crate/actix-files/0.6.9)
|
||||
[](https://crates.io/crates/actix-files)
|
||||
[](https://discord.gg/NWpN5mmg3x)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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("/", ".")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[](https://crates.io/crates/actix-http-test)
|
||||
[](https://docs.rs/actix-http-test/3.2.0)
|
||||

|
||||

|
||||

|
||||
<br>
|
||||
[](https://deps.rs/crate/actix-http-test/3.2.0)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-http)
|
||||
[](https://docs.rs/actix-http/3.11.0)
|
||||

|
||||
[](https://docs.rs/actix-http/3.12.0)
|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-http/3.11.0)
|
||||
[](https://deps.rs/crate/actix-http/3.12.0)
|
||||
[](https://crates.io/crates/actix-http)
|
||||
[](https://discord.gg/NWpN5mmg3x)
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()))),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-multipart-derive)
|
||||
[](https://docs.rs/actix-multipart-derive/0.7.0)
|
||||

|
||||
[](https://docs.rs/actix-multipart-derive/0.8.0)
|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-multipart-derive/0.7.0)
|
||||
[](https://deps.rs/crate/actix-multipart-derive/0.8.0)
|
||||
[](https://crates.io/crates/actix-multipart-derive)
|
||||
[](https://discord.gg/NWpN5mmg3x)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-multipart)
|
||||
[](https://docs.rs/actix-multipart/0.7.2)
|
||||

|
||||
[](https://docs.rs/actix-multipart/0.8.0)
|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-multipart/0.7.2)
|
||||
[](https://deps.rs/crate/actix-multipart/0.8.0)
|
||||
[](https://crates.io/crates/actix-multipart)
|
||||
[](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 →](https://github.com/actix/examples/tree/master/forms/multipart)
|
||||
[More available in the examples repo →](https://github.com/actix/examples/tree/main/forms/multipart)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[](https://crates.io/crates/actix-router)
|
||||
[](https://docs.rs/actix-router/0.5.3)
|
||||

|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-router/0.5.3)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Minimum supported Rust version (MSRV) is now 1.88.
|
||||
|
||||
## 0.1.5
|
||||
|
||||
- Add `TestServerConfig::listen_address()` method.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[](https://crates.io/crates/actix-test)
|
||||
[](https://docs.rs/actix-test/0.1.5)
|
||||

|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-test/0.1.5)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
[](https://crates.io/crates/actix-web-actors)
|
||||
[](https://docs.rs/actix-web-actors/4.3.1)
|
||||

|
||||

|
||||

|
||||
<br />
|
||||

|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Minimum supported Rust version (MSRV) is now 1.88.
|
||||
|
||||
## 4.3.0
|
||||
|
||||
- Add `#[scope]` macro.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
[](https://crates.io/crates/actix-web-codegen)
|
||||
[](https://docs.rs/actix-web-codegen/4.3.0)
|
||||

|
||||

|
||||

|
||||
<br />
|
||||
[](https://deps.rs/crate/actix-web-codegen/4.3.0)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
<!-- prettier-ignore-start -->
|
||||
|
||||
[](https://crates.io/crates/actix-web)
|
||||
[](https://docs.rs/actix-web/4.11.0)
|
||||

|
||||
[](https://docs.rs/actix-web/4.13.0)
|
||||

|
||||

|
||||
[](https://deps.rs/crate/actix-web/4.11.0)
|
||||
[](https://deps.rs/crate/actix-web/4.13.0)
|
||||
<br />
|
||||
[](https://github.com/actix/actix-web/actions/workflows/ci.yml)
|
||||
[](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
Loading…
Reference in New Issue