Compare commits

...

33 Commits

Author SHA1 Message Date
Yuki Okushi 9a0190a1cc
Merge branch 'main' into introspection 2026-02-10 20:22:56 +09:00
nitn3lav 4f0912d1c7
PathDeserializer: use `deserialize_str` for `deserialize_any` (#2881)
* PathDeserializer: use `deserialize_str` for `deserialize_any`

* fix `deserialize_any` for `seq` and `map`

* add tests for `deserialize_any`

* parse numeric values as well

---------

Co-authored-by: Rob Ede <robjtede@icloud.com>
Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-02-10 09:53:23 +00:00
Yuki Okushi 5548fadc7d
fix(files): handle `bytes=0-` nicely (#3914) 2026-02-09 21:40:29 +00:00
Filip Gregor 32cb3b8361
feat: ignore unparsable cookies in Cookie header (#3814)
fix: ignore unparsable cookies in Cookie header

Co-authored-by: Rob Ede <robjtede@icloud.com>
2026-02-09 12:53:40 +00:00
dependabot[bot] 747d7c0def
build(deps): bump memchr from 2.7.6 to 2.8.0 (#3913)
Bumps [memchr](https://github.com/BurntSushi/memchr) from 2.7.6 to 2.8.0.
- [Commits](https://github.com/BurntSushi/memchr/compare/2.7.6...2.8.0)

---
updated-dependencies:
- dependency-name: memchr
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-02-09 11:58:49 +00:00
dependabot[bot] b2523fb1cc
build(deps): bump taiki-e/install-action from 2.67.18 to 2.67.25 (#3912)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.18 to 2.67.25.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](650c5ca142...f176c07a0a)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 19:32:59 +09:00
Yuki Okushi 41e4863748
fix(awc): do not request as chunked if body is empty (#3910) 2026-02-08 16:03:04 +09:00
Anton Lazarev 9856a3b056
Support serving pre-compressed files for static sites (#2615)
* support serving pre-compressed files for static sites

* Update CHANGES.md

* fix behavior change for audio file

* follow-up some inconsistency

* test(files): make encoding test independent of fixture line endings

---------

Co-authored-by: Rob Ede <robjtede@icloud.com>
Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-02-08 02:15:38 +00:00
Yuki Okushi 80d7d9c01a
chore(awc): address clippy warnings (#3909) 2026-02-08 10:30:19 +09:00
Yuki Okushi bc27fd2724 chore(files): prepare v0.6.10 release 2026-02-06 21:12:50 +09:00
Yuki Okushi 06a354fe52
Merge commit from fork 2026-02-06 21:05:15 +09:00
Yuki Okushi d9b96e635d
Merge commit from fork 2026-02-06 21:04:03 +09:00
dependabot[bot] 2c0be64b68
build(deps): bump time from 0.3.46 to 0.3.47 (#3906)
Bumps [time](https://github.com/time-rs/time) from 0.3.46 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.46...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 21:09:25 +00:00
Yuki Okushi 6efc4bdfb5
chore(*): update deps (#3904) 2026-02-04 18:59:11 +09:00
dependabot[bot] ef75c402d6
build(deps): bump bytes from 1.11.0 to 1.11.1 (#3903)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 21:09:02 +00:00
ihc童鞋@提不起劲 204a3e1384
fix: set error to Payload before dispatcher disconnect (#3068)
* fix: set error to Payload before dispatcher disconnect

* align behavior

---------

Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-02-03 09:03:45 +00:00
Yuki Okushi aecc7ad09c
test(http-test): set ALPN explicitly (#3902) 2026-02-03 18:02:13 +09:00
Andrew Scott 5ee6b59b1e
feat: expose PathBufWrap utility for public access (#3694)
* feat: expose PathBufWrap utility for public access

* fix: rename to `parse_unprocessed_req`

---------

Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-02-02 12:15:47 +00:00
dependabot[bot] 9021b82b91
build(deps): bump slab from 0.4.11 to 0.4.12 (#3900)
Bumps [slab](https://github.com/tokio-rs/slab) from 0.4.11 to 0.4.12.
- [Release notes](https://github.com/tokio-rs/slab/releases)
- [Changelog](https://github.com/tokio-rs/slab/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/slab/compare/v0.4.11...v0.4.12)

---
updated-dependencies:
- dependency-name: slab
  dependency-version: 0.4.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 11:30:15 +00:00
dependabot[bot] 85cc60708c
build(deps): bump taiki-e/install-action from 2.67.10 to 2.67.18 (#3899)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.67.10 to 2.67.18.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](81a2f66614...650c5ca142)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 11:30:00 +00:00
dependabot[bot] dfff651503
build(deps): bump trybuild from 1.0.114 to 1.0.115 (#3901)
Bumps [trybuild](https://github.com/dtolnay/trybuild) from 1.0.114 to 1.0.115.
- [Release notes](https://github.com/dtolnay/trybuild/releases)
- [Commits](https://github.com/dtolnay/trybuild/compare/1.0.114...1.0.115)

---
updated-dependencies:
- dependency-name: trybuild
  dependency-version: 1.0.115
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 11:29:48 +00:00
Yuki Okushi 0eef0601a1
docs(files): refine docs of `read_mode_threshold` (#3897) 2026-02-01 22:10:47 +09:00
Yuki Okushi 69edde9662
feat(web): implement `HttpRequest::url_for_iter`/`url_for_map` (#3895) 2026-02-01 16:54:47 +09:00
Yuki Okushi cf2b097de6
feat(ci): skip semver-labeling if human added it (#3896) 2026-02-01 16:17:59 +09:00
fasilmveloor aa8df45fce
feat: implement FieldGroupReader for Option<Vec<T>> in multipart form… (#3577)
* feat: implement FieldGroupReader for Option<Vec<T>> in multipart form handling

* add tests

---------

Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-01-29 13:00:23 +00:00
Yuki Okushi 2737e88973
chore(ci): pin semver-checks action (#3894) 2026-01-29 21:19:25 +09:00
dependabot[bot] e62d849656
build(deps): bump time from 0.3.45 to 0.3.46 (#3893)
* build(deps): bump time from 0.3.45 to 0.3.46

Bumps [time](https://github.com/time-rs/time) from 0.3.45 to 0.3.46.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.45...v0.3.46)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(*): bump MSRV to 1.88

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
2026-01-28 08:10:27 +00:00
dependabot[bot] 45203e6cc5
build(deps): bump taiki-e/install-action from 2.66.6 to 2.67.10 (#3890)
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.66.6 to 2.67.10.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](30eab0fabb...81a2f66614)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.67.10
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 21:51:34 +00:00
dependabot[bot] ae77e6bd39
build(deps): bump actions/checkout from 6.0.1 to 6.0.2 (#3888)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](8e8c483db8...de0fac2e45)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 09:35:44 +00:00
dependabot[bot] f6c1fe3d0f
build(deps): bump socket2 from 0.6.1 to 0.6.2 (#3892)
Bumps [socket2](https://github.com/rust-lang/socket2) from 0.6.1 to 0.6.2.
- [Release notes](https://github.com/rust-lang/socket2/releases)
- [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/socket2/compare/v0.6.1...v0.6.2)

---
updated-dependencies:
- dependency-name: socket2
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 09:35:32 +00:00
dependabot[bot] 3d6e66b5b5
build(deps): bump proc-macro2 from 1.0.105 to 1.0.106 (#3889)
Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.105 to 1.0.106.
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.105...1.0.106)

---
updated-dependencies:
- dependency-name: proc-macro2
  dependency-version: 1.0.106
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 09:35:14 +00:00
dependabot[bot] 3551250e16
build(deps): bump quote from 1.0.43 to 1.0.44 (#3891)
Bumps [quote](https://github.com/dtolnay/quote) from 1.0.43 to 1.0.44.
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.43...1.0.44)

---
updated-dependencies:
- dependency-name: quote
  dependency-version: 1.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 09:34:51 +00:00
Yuki Okushi 7e7c38f67d fix: fix labeler config 2026-01-25 16:38:33 +09:00
51 changed files with 1411 additions and 410 deletions

View File

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
run: |

View File

@ -28,7 +28,7 @@ jobs:
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install nasm
if: matrix.target.os == 'windows-latest'
@ -49,7 +49,7 @@ jobs:
toolchain: ${{ matrix.version.version }}
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Free Disk Space
run: ./scripts/free-disk-space.sh
@ -83,7 +83,7 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
- name: Install just, cargo-hack
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just,cargo-hack

View File

@ -39,7 +39,7 @@ jobs:
runs-on: ${{ matrix.target.os }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install nasm
if: matrix.target.os == 'windows-latest'
@ -64,7 +64,7 @@ jobs:
toolchain: ${{ matrix.version.version }}
- name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean
@ -93,7 +93,7 @@ jobs:
name: io-uring tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -109,7 +109,7 @@ jobs:
name: doc tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -117,7 +117,7 @@ jobs:
toolchain: nightly
- name: Install just
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just

View File

@ -15,7 +15,7 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -24,7 +24,7 @@ jobs:
components: llvm-tools
- name: Install just, cargo-llvm-cov, cargo-nextest
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just,cargo-llvm-cov,cargo-nextest

View File

@ -12,4 +12,5 @@ jobs:
labeler:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1

View File

@ -15,7 +15,7 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -52,7 +52,7 @@ jobs:
lint-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (nightly)
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -69,7 +69,7 @@ jobs:
if: false # rustdoc mismatch currently
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Rust (${{ vars.RUST_VERSION_EXTERNAL_TYPES }})
uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2
@ -77,7 +77,7 @@ jobs:
toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }}
- name: Install just
uses: taiki-e/install-action@30eab0fabba9ea3f522099957e668b21876aa39e # v2.66.6
uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25
with:
tool: just

View File

@ -11,8 +11,10 @@ jobs:
permissions:
pull-requests: write
contents: read
env:
ACTIONS_STEP_DEBUG: true
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.workflow_run.head_sha }}
@ -21,6 +23,7 @@ jobs:
with:
toolchain: stable
- uses: JohnTitor/cargo-semver-checks@main
- uses: JohnTitor/cargo-semver-checks@3b76737b550e48ad0bd5912e2757e80eee6294b0 # v0.2.1
with:
label-prefix: B-semver-
label-strategy: skip-if-human

473
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ homepage = "https://actix.rs"
repository = "https://github.com/actix/actix-web"
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.83"
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.

View File

@ -2,7 +2,28 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
- Fix handling of `bytes=0-`
[#2615]: https://github.com/actix/actix-web/pull/2615
## 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

View File

@ -1,6 +1,6 @@
[package]
name = "actix-files"
version = "0.6.9"
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"]

View File

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

View File

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

View File

@ -50,6 +50,7 @@ pub struct Files {
use_guards: Option<Rc<dyn Guard>>,
guards: Vec<Rc<dyn Guard>>,
hidden_files: bool,
try_compressed: bool,
read_mode_threshold: u64,
}
@ -76,6 +77,7 @@ 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,
}
}
@ -96,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.
@ -105,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
}
};
@ -124,6 +130,7 @@ impl Files {
use_guards: None,
guards: Vec::new(),
hidden_files: false,
try_compressed: false,
read_mode_threshold: 0,
}
}
@ -220,11 +227,11 @@ impl Files {
/// Sets the size threshold that determines file read mode (sync/async).
///
/// When a file is smaller than the threshold (bytes), the reader will switch from synchronous
/// (blocking) file-reads to async reads to avoid blocking the main-thread when processing large
/// files.
/// 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 signifiant performance
/// 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.
@ -347,6 +354,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 {
@ -398,6 +414,7 @@ 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,
};

View File

@ -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("/", ".")));
@ -781,6 +822,16 @@ mod tests {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_rt::test]
async fn test_static_files_bad_directory_does_not_serve_cwd_files() {
let service = Files::new("/", "./missing").new_service(()).await.unwrap();
let req = TestRequest::with_uri("/Cargo.toml").to_srv_request();
let resp = test::call_service(&service, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_rt::test]
async fn test_default_handler_file_missing() {
let st = Files::new("/", ".")

View File

@ -91,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.
///
@ -117,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"))]
@ -357,11 +361,11 @@ impl NamedFile {
/// Sets the size threshold that determines file read mode (sync/async).
///
/// When a file is smaller than the threshold (bytes), the reader will switch from synchronous
/// (blocking) file-reads to async reads to avoid blocking the main-thread when processing large
/// files.
/// 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 signifiant performance
/// 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.
@ -546,13 +550,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;
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;
// When a Content-Encoding header is present in a 206 partial content response
// for video content, it prevents browser video players from starting playback
@ -599,7 +608,7 @@ impl NamedFile {
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);
}
@ -707,3 +716,14 @@ impl HttpServiceFactory for NamedFile {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_files_use_inline_content_disposition() {
let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap();
assert_eq!(cd.disposition, DispositionType::Inline);
}
}

View File

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

View File

@ -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,7 @@ 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,
}
@ -64,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;
@ -72,12 +83,36 @@ impl FilesService {
named_file.flags = self.file_flags;
let (req, _) = req.into_parts();
let res = named_file
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);
@ -138,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;
}
@ -163,6 +207,16 @@ impl Service<ServiceRequest> for FilesService {
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)),
@ -184,3 +238,84 @@ impl Service<ServiceRequest> for FilesService {
})
}
}
/// Flate doesn't have an accepted file extension, so it is not included here.
const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[
header::ContentEncoding::Brotli,
header::ContentEncoding::Gzip,
header::ContentEncoding::Zstd,
header::ContentEncoding::Identity,
];
/// Searches disk for an acceptable alternate encoding of the content at the given path, as
/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with
/// the most appropriate supported encoding, if any exist.
async fn find_compressed(
req: &ServiceRequest,
original_path: &Path,
) -> Option<(NamedFile, header::ContentEncoding)> {
use actix_web::HttpMessage;
use header::{AcceptEncoding, ContentEncoding, Encoding};
// Retrieve the content type and content disposition based on the original filename. If we
// can't get these successfully, don't even try to find a compressed file.
let (content_type, content_disposition) =
match crate::named::get_content_type_and_disposition(original_path) {
Ok(values) => values,
Err(_) => return None,
};
let accept_encoding = req.get_header::<AcceptEncoding>()?;
let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS
.iter()
.copied()
.map(Encoding::Known)
.collect::<Vec<_>>();
// Only move the original content-type/disposition into the chosen compressed file once.
let mut content_type = Some(content_type);
let mut content_disposition = Some(content_disposition);
loop {
// Select next acceptable encoding (honouring q=0 rejections) from remaining supported set.
let chosen = accept_encoding.negotiate(supported.iter())?;
let encoding = match chosen {
Encoding::Known(enc) => enc,
// No supported encoding should ever be unknown here.
Encoding::Unknown(_) => return None,
};
// Identity indicates there is no acceptable pre-compressed representation.
if encoding == ContentEncoding::Identity {
return None;
}
let extension = match encoding {
ContentEncoding::Brotli => ".br",
ContentEncoding::Gzip => ".gz",
ContentEncoding::Zstd => ".zst",
ContentEncoding::Identity => unreachable!(),
// Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here.
_ => unreachable!(),
};
let mut compressed_path = original_path.to_owned();
let mut filename = compressed_path.file_name()?.to_owned();
filename.push(extension);
compressed_path.set_file_name(filename);
match NamedFile::open_async(&compressed_path).await {
Ok(mut named_file) => {
named_file.content_type = content_type.take().unwrap();
named_file.content_disposition = content_disposition.take().unwrap();
return Some((named_file, encoding));
}
// Ignore errors while searching disk for a suitable encoding.
Err(_) => {
supported.retain(|enc| enc != &chosen);
}
}
}
}

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@ -2,7 +2,10 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Minimum supported Rust version (MSRV) is now 1.88.
- Fix truncated body ending without error when connection closed abnormally. [#3067]
[#3067]: https://github.com/actix/actix-web/pull/3067
## 3.11.2

View File

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

View File

@ -1156,6 +1156,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();
}
};

View File

@ -51,7 +51,7 @@ 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_chain = vec![cert.der().clone()];
@ -62,12 +62,23 @@ fn tls_config() -> RustlsServerConfig {
.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])
}
pub fn get_negotiated_alpn_protocol(
addr: SocketAddr,
client_alpn_protocol: &[u8],
@ -98,7 +109,7 @@ async fn h1() -> io::Result<()> {
let srv = test_server(move || {
HttpService::build()
.h1(|_| ok::<_, Error>(Response::ok()))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h1())
})
.await;
@ -112,7 +123,7 @@ async fn h2() -> io::Result<()> {
let srv = test_server(move || {
HttpService::build()
.h2(|_| ok::<_, Error>(Response::ok()))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -130,7 +141,7 @@ async fn h1_1() -> io::Result<()> {
assert_eq!(req.version(), Version::HTTP_11);
ok::<_, Error>(Response::ok())
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h1())
})
.await;
@ -149,7 +160,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)),
)
})
@ -169,7 +180,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;
@ -195,7 +206,7 @@ async fn h2_content_length() {
];
ok::<_, Infallible>(Response::new(statuses[indx]))
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -267,7 +278,7 @@ async fn h2_headers() {
}
ok::<_, Infallible>(config.body(data.clone()))
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -306,7 +317,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;
@ -323,7 +334,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;
@ -349,7 +360,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;
@ -374,7 +385,7 @@ async fn h2_head_binary2() {
let 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;
@ -400,7 +411,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;
@ -424,7 +435,7 @@ async fn h2_body_chunked_explicit() {
.body(BodyStream::new(body)),
)
})
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -453,7 +464,7 @@ async fn h2_response_http_error_handling() {
)
}))
}))
.rustls_0_23(tls_config())
.rustls_0_23(tls_config_h2())
})
.await;
@ -483,7 +494,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;
@ -500,7 +511,7 @@ 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;
@ -519,7 +530,7 @@ 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 config = tls_config_h1();
config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec());
HttpService::build()
.h1(|_| ok::<_, Error>(Response::ok()))
@ -541,7 +552,7 @@ async fn alpn_h1() -> io::Result<()> {
#[actix_rt::test]
async fn alpn_h2() -> io::Result<()> {
let srv = test_server(move || {
let mut config = tls_config();
let mut config = tls_config_h2();
config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec());
HttpService::build()
.h2(|_| ok::<_, Error>(Response::ok()))

View File

@ -443,6 +443,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);

View File

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

View File

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

View File

@ -2,7 +2,10 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Add `MultipartForm` support for `Option<Vec<T>>` fields. [#3577]
- Minimum supported Rust version (MSRV) is now 1.88.
[#3577]: https://github.com/actix/actix-web/pull/3577
## 0.7.2

View File

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

View File

@ -187,6 +187,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.
@ -506,6 +545,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 {

View File

@ -2,7 +2,10 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Minimum supported Rust version (MSRV) is now 1.88.
- Support `deserialize_any` in `PathDeserializer` (enables derived `#[serde(untagged)]` enums in path segments). [#2881]
[#2881]: https://github.com/actix/actix-web/pull/2881
## 0.5.3

View File

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

View File

@ -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);
@ -427,7 +430,39 @@ impl<'de> Deserializer<'de> for Value<'de> {
Err(de::value::Error::custom("unsupported type: tuple struct"))
}
unsupported_type!(deserialize_any, "any");
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_seq, "seq");
unsupported_type!(deserialize_map, "map");
unsupported_type!(deserialize_identifier, "identifier");
@ -704,6 +739,119 @@ mod tests {
assert_eq!(vals.value, "/");
}
#[test]
fn deserialize_path_decode_any() {
#[derive(Debug, PartialEq)]
pub enum AnyEnumCustom {
String(String),
Int(u32),
Other,
}
impl<'de> Deserialize<'de> for AnyEnumCustom {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Vis;
impl<'de> Visitor<'de> for Vis {
type Value = AnyEnumCustom;
fn expecting<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result {
write!(f, "my thing")
}
fn visit_u32<E>(self, v: u32) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(AnyEnumCustom::Int(v))
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match u32::try_from(v) {
Ok(v) => Ok(AnyEnumCustom::Int(v)),
Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))),
}
}
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match u32::try_from(v) {
Ok(v) => Ok(AnyEnumCustom::Int(v)),
Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))),
}
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
v.parse().map(AnyEnumCustom::Int).or_else(|_| {
Ok(match v {
"other" => AnyEnumCustom::Other,
_ => AnyEnumCustom::String(format!("some str: {v}")),
})
})
}
}
deserializer.deserialize_any(Vis)
}
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum AnyEnumDerive {
String(String),
Int(u32),
Other,
}
// single
let rdef = ResourceDef::new("/{key}");
let mut path = Path::new("/%25");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: AnyEnumCustom = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment, AnyEnumCustom::String("some str: %".to_string()));
let mut path = Path::new("/%25");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: AnyEnumDerive = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment, AnyEnumDerive::String("%".to_string()));
// seq
let rdef = ResourceDef::new("/{key}/{value}");
let mut path = Path::new("/other/123");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let segment: (AnyEnumCustom, AnyEnumDerive) = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(segment.0, AnyEnumCustom::Other);
assert_eq!(segment.1, AnyEnumDerive::Int(123));
// map
#[derive(Deserialize)]
struct Vals {
key: AnyEnumCustom,
value: AnyEnumDerive,
}
let rdef = ResourceDef::new("/{key}/{value}");
let mut path = Path::new("/123/%2F");
rdef.capture_match_info(&mut path);
let de = PathDeserializer::new(&path);
let vals: Vals = serde::Deserialize::deserialize(de).unwrap();
assert_eq!(vals.key, AnyEnumCustom::Int(123));
assert_eq!(vals.value, AnyEnumDerive::String("/".to_string()));
}
#[test]
fn deserialize_borrowed() {
#[derive(Debug, Deserialize)]

View File

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

View File

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

View File

@ -2,7 +2,7 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Minimum supported Rust version (MSRV) is now 1.88.
## 4.3.1 <!-- v4.3.1+deprecated -->

View File

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

View File

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

View File

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

View File

@ -2,8 +2,13 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.82.
- Add `experimental-introspection` feature to report configured routes (paths, methods, guards, resource metadata), include reachability hints for shadowed/conflicting routes, and provide a separate report for external resources.
- Minimum supported Rust version (MSRV) is now 1.88.
- 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]
[#3895]: https://github.com/actix/actix-web/pull/3895
[#3594]: https://github.com/actix/actix-web/pull/3594
## 4.12.1

View File

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

View File

@ -1,6 +1,9 @@
use std::{
cell::{Ref, RefCell, RefMut},
fmt, net,
collections::HashMap,
fmt,
hash::{BuildHasher, Hash},
net,
rc::Rc,
str,
};
@ -242,6 +245,76 @@ impl HttpRequest {
self.resource_map().url_for(self, name, elements)
}
/// Generates URL for a named resource using a map of dynamic segment values.
///
/// This substitutes URL parameters by name from `elements`, including parameters from parent
/// scopes.
///
/// # Examples
/// ```
/// # use std::collections::HashMap;
/// # use actix_web::{web, App, HttpRequest, HttpResponse};
/// fn index(req: HttpRequest) -> HttpResponse {
/// let mut params = HashMap::new();
/// params.insert("one", "1");
/// params.insert("two", "2");
/// let url = req.url_for_map("foo", &params); // <- generate URL for "foo" resource
/// HttpResponse::Ok().into()
/// }
///
/// let app = App::new()
/// .service(web::resource("/test/{one}/{two}")
/// .name("foo") // <- set resource name so it can be used in `url_for_map`
/// .route(web::get().to(|| HttpResponse::Ok()))
/// );
/// ```
pub fn url_for_map<K, V, S>(
&self,
name: &str,
elements: &HashMap<K, V, S>,
) -> Result<url::Url, UrlGenerationError>
where
K: std::borrow::Borrow<str> + Eq + Hash,
V: AsRef<str>,
S: BuildHasher,
{
self.resource_map().url_for_map(self, name, elements)
}
/// Generates URL for a named resource using an iterator of key-value pairs.
///
/// This is a convenience wrapper around [`HttpRequest::url_for_map`].
///
/// Note: passing a borrowed map (e.g. `&HashMap<String, String>`) directly does not satisfy the
/// trait bounds because the iterator yields `(&String, &String)`. Prefer `url_for_map` for
/// borrowed maps, or map entries to `&str`:
///
/// ```
/// # use std::collections::HashMap;
/// # use actix_web::{web, App, HttpRequest, HttpResponse};
/// fn index(req: HttpRequest) -> HttpResponse {
/// let mut params = HashMap::new();
/// params.insert("one".to_string(), "1".to_string());
/// params.insert("two".to_string(), "2".to_string());
///
/// let iter = params.iter().map(|(k, v)| (k.as_str(), v.as_str()));
/// let url = req.url_for_iter("foo", iter);
/// HttpResponse::Ok().into()
/// }
/// ```
pub fn url_for_iter<K, V, I>(
&self,
name: &str,
elements: I,
) -> Result<url::Url, UrlGenerationError>
where
I: IntoIterator<Item = (K, V)>,
K: std::borrow::Borrow<str> + Eq + Hash,
V: AsRef<str>,
{
self.resource_map().url_for_iter(self, name, elements)
}
/// Generate URL for named resource
///
/// This method is similar to `HttpRequest::url_for()` but it can be used
@ -341,6 +414,9 @@ impl HttpRequest {
}
/// Load request cookies.
///
/// Any cookie that cannot be parsed is omitted from the result.
/// This includes cookies with an empty name (e.g. `document.cookie = "=value"`).
#[cfg(feature = "cookies")]
pub fn cookies(&self) -> Result<Ref<'_, Vec<Cookie<'static>>>, CookieParseError> {
use actix_http::header::COOKIE;
@ -349,9 +425,9 @@ impl HttpRequest {
let mut cookies = Vec::new();
for hdr in self.headers().get_all(COOKIE) {
let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?;
for cookie_str in s.split(';').map(|s| s.trim()) {
if !cookie_str.is_empty() {
cookies.push(Cookie::parse_encoded(cookie_str)?.into_owned());
for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
if let Ok(cookie) = Cookie::parse_encoded(cookie_str) {
cookies.push(cookie.into_owned());
}
}
}
@ -550,6 +626,8 @@ impl HttpRequestPool {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use bytes::Bytes;
use super::*;
@ -602,6 +680,22 @@ mod tests {
assert!(cookie.is_none());
}
#[test]
#[cfg(feature = "cookies")]
fn test_empty_key() {
let req = TestRequest::default()
.append_header((header::COOKIE, "cookie1=value1; value2; cookie3=value3"))
.to_http_request();
{
let cookies = req.cookies().unwrap();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].name(), "cookie1");
assert_eq!(cookies[0].value(), "value1");
assert_eq!(cookies[1].name(), "cookie3");
assert_eq!(cookies[1].value(), "value3");
}
}
#[test]
fn test_request_query() {
let req = TestRequest::with_uri("/?id=test").to_http_request();
@ -638,6 +732,59 @@ mod tests {
);
}
#[test]
fn test_url_for_map() {
let mut res = ResourceDef::new("/user/{name}.{ext}");
res.set_name("index");
let mut rmap = ResourceMap::new(ResourceDef::prefix(""));
rmap.add(&mut res, None);
let req = TestRequest::default()
.insert_header((header::HOST, "www.actix.rs"))
.rmap(rmap)
.to_http_request();
let mut params = HashMap::new();
params.insert("name", "test");
params.insert("ext", "html");
let url = req.url_for_map("index", &params);
assert_eq!(
url.ok().unwrap().as_str(),
"http://www.actix.rs/user/test.html"
);
params.remove("ext");
assert_eq!(
req.url_for_map("index", &params),
Err(UrlGenerationError::NotEnoughElements)
);
}
#[test]
fn test_url_for_iter() {
let mut res = ResourceDef::new("/user/{name}.{ext}");
res.set_name("index");
let mut rmap = ResourceMap::new(ResourceDef::prefix(""));
rmap.add(&mut res, None);
let req = TestRequest::default()
.insert_header((header::HOST, "www.actix.rs"))
.rmap(rmap)
.to_http_request();
let url = req.url_for_iter("index", [("ext", "html"), ("name", "test")]);
assert_eq!(
url.ok().unwrap().as_str(),
"http://www.actix.rs/user/test.html"
);
let url = req.url_for_iter("index", [("name", "test")]);
assert_eq!(url, Err(UrlGenerationError::NotEnoughElements));
}
#[test]
fn test_url_for_static() {
let mut rdef = ResourceDef::new("/index.html");

View File

@ -1,7 +1,9 @@
use std::{
borrow::Cow,
borrow::{Borrow, Cow},
cell::RefCell,
collections::HashMap,
fmt::Write as _,
hash::{BuildHasher, Hash},
rc::{Rc, Weak},
};
@ -140,6 +142,56 @@ impl ResourceMap {
})
.ok_or(UrlGenerationError::NotEnoughElements)?;
self.url_from_path(req, path)
}
/// Generate URL for named resource using map of dynamic segment values.
///
/// Check [`HttpRequest::url_for_map`] for detailed information.
pub fn url_for_map<K, V, S>(
&self,
req: &HttpRequest,
name: &str,
elements: &HashMap<K, V, S>,
) -> Result<Url, UrlGenerationError>
where
K: Borrow<str> + Eq + Hash,
V: AsRef<str>,
S: BuildHasher,
{
let path = self
.named
.get(name)
.ok_or(UrlGenerationError::ResourceNotFound)?
.root_rmap_fn(String::with_capacity(AVG_PATH_LEN), |mut acc, node| {
node.pattern
.resource_path_from_map(&mut acc, elements)
.then_some(acc)
})
.ok_or(UrlGenerationError::NotEnoughElements)?;
self.url_from_path(req, path)
}
/// Generate URL for named resource using an iterator of key-value pairs.
///
/// Check [`HttpRequest::url_for_iter`] for detailed information.
pub fn url_for_iter<K, V, I>(
&self,
req: &HttpRequest,
name: &str,
elements: I,
) -> Result<Url, UrlGenerationError>
where
I: IntoIterator<Item = (K, V)>,
K: Borrow<str> + Eq + Hash,
V: AsRef<str>,
{
let elements = elements.into_iter().collect::<FoldHashMap<K, V>>();
self.url_for_map(req, name, &elements)
}
fn url_from_path(&self, req: &HttpRequest, path: String) -> Result<Url, UrlGenerationError> {
let (base, path): (Cow<'_, _>, _) = if path.starts_with('/') {
// build full URL from connection info parts and resource path
let conn = req.connection_info();

View File

@ -2,7 +2,8 @@
## Unreleased
- Minimum supported Rust version (MSRV) is now 1.83.
- Minimum supported Rust version (MSRV) is now 1.88.
- Fix empty streaming request bodies being sent with chunked transfer encoding.
## 3.8.1

View File

@ -34,6 +34,35 @@ where
B: MessageBody,
B::Error: Into<BoxError>,
{
actix_rt::pin!(body);
let orig_length = body.size();
let mut length = orig_length;
let mut first_chunk = None;
// This avoids sending `Transfer-Encoding: chunked` for requests with an empty body stream.
// https://github.com/actix/actix-web/issues/2320
if matches!(orig_length, BodySize::Stream) {
enum Peek<E> {
Pending,
Item(Result<Bytes, E>),
Eof,
}
match poll_fn(|cx| match body.as_mut().poll_next(cx) {
Poll::Pending => Poll::Ready(Peek::Pending),
Poll::Ready(Some(res)) => Poll::Ready(Peek::Item(res)),
Poll::Ready(None) => Poll::Ready(Peek::Eof),
})
.await
{
Peek::Pending => {}
Peek::Eof => length = BodySize::Sized(0),
Peek::Item(Ok(chunk)) => first_chunk = Some(chunk),
Peek::Item(Err(err)) => return Err(SendRequestError::Body(err.into())),
}
}
// set request host header
if !head.as_ref().headers.contains_key(HOST)
&& !head.extra_headers().iter().any(|h| h.contains_key(HOST))
@ -67,7 +96,7 @@ where
// Check EXPECT header and enable expect handle flag accordingly.
// See https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1
let is_expect = if head.as_ref().headers.contains_key(EXPECT) {
match body.size() {
match orig_length {
BodySize::None | BodySize::Sized(0) => {
let keep_alive = framed.codec_ref().keep_alive();
framed.io_mut().on_release(keep_alive);
@ -86,7 +115,7 @@ where
// special handle for EXPECT request.
let (do_send, mut res_head) = if is_expect {
pin_framed.send((head, body.size()).into()).await?;
pin_framed.send((head, length).into()).await?;
let head = poll_fn(|cx| pin_framed.as_mut().poll_next(cx))
.await
@ -96,18 +125,18 @@ where
// and current head would be used as final response head.
(head.status == StatusCode::CONTINUE, Some(head))
} else {
pin_framed.feed((head, body.size()).into()).await?;
pin_framed.feed((head, length).into()).await?;
(true, None)
};
if do_send {
// send request body
match body.size() {
match length {
BodySize::None | BodySize::Sized(0) => {
poll_fn(|cx| pin_framed.as_mut().flush(cx)).await?;
}
_ => send_body(body, pin_framed.as_mut()).await?,
_ => send_body(body.as_mut(), pin_framed.as_mut(), first_chunk).await?,
};
// read response and init read body
@ -157,15 +186,18 @@ where
/// send request body to the peer
pub(crate) async fn send_body<Io, B>(
body: B,
mut body: Pin<&mut B>,
mut framed: Pin<&mut Framed<Io, h1::ClientCodec>>,
first_chunk: Option<Bytes>,
) -> Result<(), SendRequestError>
where
Io: ConnectionIo,
B: MessageBody,
B::Error: Into<BoxError>,
{
actix_rt::pin!(body);
if let Some(chunk) = first_chunk {
framed.as_mut().write(h1::Message::Chunk(Some(chunk)))?;
}
let mut eof = false;
while !eof {

View File

@ -263,13 +263,9 @@ impl ClientRequest {
/// ```
#[cfg(feature = "cookies")]
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
if self.cookies.is_none() {
let mut jar = CookieJar::new();
jar.add(cookie.into_owned());
self.cookies = Some(jar)
} else {
self.cookies.as_mut().unwrap().add(cookie.into_owned());
}
self.cookies
.get_or_insert_with(CookieJar::new)
.add(cookie.into_owned());
self
}

View File

@ -125,13 +125,9 @@ impl WebsocketsRequest {
/// Set a cookie
#[cfg(feature = "cookies")]
pub fn cookie(mut self, cookie: Cookie<'_>) -> Self {
if self.cookies.is_none() {
let mut jar = CookieJar::new();
jar.add(cookie.into_owned());
self.cookies = Some(jar)
} else {
self.cookies.as_mut().unwrap().add(cookie.into_owned());
}
self.cookies
.get_or_insert_with(CookieJar::new)
.add(cookie.into_owned());
self
}

View File

@ -0,0 +1,91 @@
use std::{convert::Infallible, time::Duration};
use actix_rt::net::TcpListener;
use awc::Client;
use bytes::Bytes;
use futures_util::stream;
use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _},
time::timeout,
};
#[actix_rt::test]
async fn empty_body_stream_does_not_use_chunked_encoding() {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
// Minimal HTTP/1.1 server that rejects chunked requests.
let srv = actix_rt::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = Vec::with_capacity(1024);
let mut tmp = [0u8; 1024];
let header_end = loop {
let n = timeout(Duration::from_secs(2), sock.read(&mut tmp))
.await
.unwrap()
.unwrap();
if n == 0 {
break None;
}
buf.extend_from_slice(&tmp[..n]);
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break Some(pos + 4);
}
if buf.len() > 16 * 1024 {
break None;
}
}
.expect("did not receive complete request headers");
let headers_lower = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase();
let has_chunked = headers_lower.contains("\r\ntransfer-encoding: chunked\r\n");
if has_chunked {
// Drain terminating chunk so client doesn't error on write before response is read.
let terminator = b"0\r\n\r\n";
while !buf[header_end..]
.windows(terminator.len())
.any(|w| w == terminator)
{
let n = match timeout(Duration::from_secs(2), sock.read(&mut tmp)).await {
Ok(Ok(n)) => n,
_ => break,
};
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > 32 * 1024 {
break;
}
}
}
let status = if has_chunked {
"400 Bad Request"
} else {
"200 OK"
};
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
sock.write_all(resp.as_bytes()).await.unwrap();
});
let url = format!("http://{addr}/");
let res = Client::default()
.get(url)
.send_stream(stream::empty::<Result<Bytes, Infallible>>())
.await
.unwrap();
assert!(res.status().is_success());
srv.await.unwrap();
}