diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index deb300749..000000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,17 +0,0 @@ -[alias] -lint = "clippy --workspace --tests --examples --bins -- -Dclippy::todo" -lint-all = "clippy --workspace --all-features --tests --examples --bins -- -Dclippy::todo" - -# lib checking -ci-check-min = "hack --workspace check --no-default-features" -ci-check-default = "hack --workspace check" -ci-check-default-tests = "check --workspace --tests" -ci-check-all-feature-powerset="hack --workspace --feature-powerset --skip=__compress,experimental-io-uring check" -ci-check-all-feature-powerset-linux="hack --workspace --feature-powerset --skip=__compress check" - -# testing -ci-doctest-default = "test --workspace --doc --no-fail-fast -- --nocapture" -ci-doctest = "test --workspace --all-features --doc --no-fail-fast -- --nocapture" - -# compile docs as docs.rs would -# RUSTDOCFLAGS="--cfg=docsrs" cargo +nightly doc --no-deps --workspace diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 000000000..ef8ae3555 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,8 @@ +disallowed-names = [ + "..", + "e", # no single letter error bindings +] +disallowed-methods = [ + { 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 }, +] diff --git a/codecov.yml b/.codecov.yml similarity index 100% rename from codecov.yml rename to .codecov.yml diff --git a/.cspell.yml b/.cspell.yml new file mode 100644 index 000000000..55a01c301 --- /dev/null +++ b/.cspell.yml @@ -0,0 +1,15 @@ +version: "0.2" +words: + - actix + - addrs + - ALPN + - bytestring + - httparse + - MSRV + - realip + - rustls + - rustup + - serde + - uring + - webpki + - zstd diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 6164c657c..1f3c63cbb 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -github: [robjtede] +github: [robjtede, JohnTitor] diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1a54090f0..3aeae6b1b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,11 @@ version: 2 updates: - - package-ecosystem: "cargo" - directory: "/" + - package-ecosystem: github-actions + directory: / schedule: - interval: "monthly" - open-pull-requests-limit: 10 - - package-ecosystem: "github-actions" - directory: "/" + interval: weekly + - package-ecosystem: cargo + directory: / schedule: - interval: "monthly" - open-pull-requests-limit: 10 + interval: weekly + versioning-strategy: lockfile-only diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..3a23b91b9 --- /dev/null +++ b/.github/labeler.yml @@ -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/** diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index f8bf2abda..cabf1c267 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -2,11 +2,10 @@ name: Benchmark on: push: - branches: - - master + branches: [main] permissions: - contents: read # to fetch code (actions/checkout) + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -17,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust run: | diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index d293fea95..acf0e6135 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -2,10 +2,10 @@ name: CI (post-merge) on: push: - branches: [master] + branches: [main] permissions: - contents: read # to fetch code (actions/checkout) + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,92 +16,76 @@ jobs: strategy: fail-fast: false matrix: + # prettier-ignore target: - { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu } - { name: macOS, os: macos-latest, triple: x86_64-apple-darwin } - - { name: Windows, os: windows-2022, triple: x86_64-pc-windows-msvc } + - { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc } version: - - nightly + - { name: nightly, version: nightly } - name: ${{ matrix.target.name }} / ${{ matrix.version }} + name: ${{ matrix.target.name }} / ${{ matrix.version.name }} runs-on: ${{ matrix.target.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install nasm + if: matrix.target.os == 'windows-latest' + uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2 - name: Install OpenSSL if: matrix.target.os == 'windows-latest' - run: choco install openssl -y --forcex64 --no-progress - - name: Set OpenSSL dir in env - if: matrix.target.os == 'windows-latest' + shell: bash run: | - echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' | Out-File -FilePath $env:GITHUB_ENV -Append - echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' | Out-File -FilePath $env:GITHUB_ENV -Append + set -e + choco install openssl --version=1.1.1.2100 -y --no-progress + echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' >> $GITHUB_ENV + echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV - - name: Install Rust (${{ matrix.version }}) - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Install Rust (${{ matrix.version.name }}) + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 with: - toolchain: ${{ matrix.version }} + toolchain: ${{ matrix.version.version }} - - name: Install cargo-hack - uses: taiki-e/install-action@cargo-hack + - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean - name: check minimal - run: cargo ci-check-min + run: just check-min - name: check default - run: cargo ci-check-default + run: just check-default - name: tests timeout-minutes: 60 - run: | - cargo test --lib --tests -p=actix-router --all-features - cargo test --lib --tests -p=actix-http --all-features - cargo test --lib --tests -p=actix-web --features=rustls,openssl -- --skip=test_reading_deflate_encoding_large_random_rustls - cargo test --lib --tests -p=actix-web-codegen --all-features - cargo test --lib --tests -p=awc --all-features - cargo test --lib --tests -p=actix-http-test --all-features - cargo test --lib --tests -p=actix-test --all-features - cargo test --lib --tests -p=actix-files - cargo test --lib --tests -p=actix-multipart --all-features - cargo test --lib --tests -p=actix-web-actors --all-features + run: just test - - name: Clear the cargo caches - run: | - cargo install cargo-cache --version 0.8.3 --no-default-features --features ci-autoclean - cargo-cache + - name: CI cache clean + run: cargo-ci-cache-clean ci_feature_powerset_check: name: Verify Feature Combinations runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - 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@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 - - name: Install cargo-hack - uses: taiki-e/install-action@cargo-hack + - name: Install just, cargo-hack + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just,cargo-hack - - name: check feature combinations - run: cargo ci-check-all-feature-powerset - - - name: check feature combinations - run: cargo ci-check-all-feature-powerset-linux - - nextest: - name: nextest - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - - - name: Install nextest - uses: taiki-e/install-action@nextest - - - name: Test with cargo-nextest - run: cargo nextest run + - name: Check feature combinations + run: just check-feature-combinations diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85548505..db82b6e36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,90 +3,102 @@ name: CI on: pull_request: types: [opened, synchronize, reopened] + merge_group: + types: [checks_requested] push: - branches: [master] + branches: [main] permissions: - contents: read # to fetch code (actions/checkout) + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + read_msrv: + name: Read MSRV + uses: actions-rust-lang/msrv/.github/workflows/msrv.yml@8b553824444060021f2843d7b4d803f3624d15e5 # v0.1.0 + build_and_test: + needs: read_msrv + strategy: fail-fast: false matrix: + # prettier-ignore target: - { name: Linux, os: ubuntu-latest, triple: x86_64-unknown-linux-gnu } - { name: macOS, os: macos-latest, triple: x86_64-apple-darwin } - { name: Windows, os: windows-latest, triple: x86_64-pc-windows-msvc } version: - - 1.65.0 # MSRV - - stable + - { name: msrv, version: "${{ needs.read_msrv.outputs.msrv }}" } + - { name: stable, version: stable } - name: ${{ matrix.target.name }} / ${{ matrix.version }} + name: ${{ matrix.target.name }} / ${{ matrix.version.name }} runs-on: ${{ matrix.target.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install nasm + if: matrix.target.os == 'windows-latest' + uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2 - name: Install OpenSSL if: matrix.target.os == 'windows-latest' - run: choco install openssl -y --forcex64 --no-progress - - name: Set OpenSSL dir in env - if: matrix.target.os == 'windows-latest' + shell: bash run: | - echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' | Out-File -FilePath $env:GITHUB_ENV -Append - echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' | Out-File -FilePath $env:GITHUB_ENV -Append + set -e + choco install openssl --version=1.1.1.2100 -y --no-progress + echo 'OPENSSL_DIR=C:\Program Files\OpenSSL' >> $GITHUB_ENV + echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV - - name: Install Rust (${{ matrix.version }}) - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Setup mold linker + if: matrix.target.os == 'ubuntu-latest' + uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + + - name: Install Rust (${{ matrix.version.name }}) + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 with: - toolchain: ${{ matrix.version }} + toolchain: ${{ matrix.version.version }} - - name: Install cargo-hack - uses: taiki-e/install-action@cargo-hack + - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean - # - name: workaround MSRV issues - # if: matrix.version != 'stable' - # run: | + - name: workaround MSRV issues + if: matrix.version.name == 'msrv' + run: just downgrade-for-msrv - name: check minimal - run: cargo ci-check-min + run: just check-min - name: check default - run: cargo ci-check-default + run: just check-default - name: tests timeout-minutes: 60 - run: | - cargo test --lib --tests -p=actix-router --all-features - cargo test --lib --tests -p=actix-http --all-features - cargo test --lib --tests -p=actix-web --features=rustls,openssl -- --skip=test_reading_deflate_encoding_large_random_rustls - cargo test --lib --tests -p=actix-web-codegen --all-features - cargo test --lib --tests -p=awc --all-features - cargo test --lib --tests -p=actix-http-test --all-features - cargo test --lib --tests -p=actix-test --all-features - cargo test --lib --tests -p=actix-files - cargo test --lib --tests -p=actix-multipart --all-features - cargo test --lib --tests -p=actix-web-actors --all-features + run: just test - - name: Clear the cargo caches - run: | - cargo install cargo-cache --version 0.8.3 --no-default-features --features ci-autoclean - cargo-cache + - 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@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15 io-uring: name: io-uring tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: { toolchain: nightly } + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + toolchain: nightly - name: tests (io-uring) timeout-minutes: 60 @@ -97,12 +109,17 @@ jobs: name: doc tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: { toolchain: nightly } + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + toolchain: nightly + + - name: Install just + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just - name: doc tests - run: cargo ci-doctest - timeout-minutes: 60 + run: just test-docs diff --git a/.github/workflows/clippy-fmt.yml b/.github/workflows/clippy-fmt.yml deleted file mode 100644 index 109165ce0..000000000 --- a/.github/workflows/clippy-fmt.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Lint - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read # to fetch code (actions/checkout) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - fmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: nightly - components: rustfmt - - - run: cargo fmt --all -- --check - - clippy: - permissions: - checks: write # to add clippy checks to PR diffs - - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: { components: clippy } - - - uses: giraffate/clippy-action@v1 - with: - reporter: 'github-pr-check' - github_token: ${{ secrets.GITHUB_TOKEN }} - clippy_flags: --workspace --all-features --tests --examples --bins -- -Dclippy::todo - - lint-docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: { components: rust-docs } - - - name: Check for broken intra-doc links - env: { RUSTDOCFLAGS: "-D warnings" } - run: cargo doc --no-deps --all-features --workspace - - public-api-diff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - ref: ${{ github.base_ref }} - - - uses: actions/checkout@v3 - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - # temp: unpin once https://github.com/rust-lang/rust/issues/113152 is fixed - with: { toolchain: nightly-2023-06-28 } - - - uses: taiki-e/cache-cargo-install-action@v1 - with: { tool: cargo-public-api } - - - name: generate API diff - run: | - for f in $(find -mindepth 2 -maxdepth 2 -name Cargo.toml); do - cargo public-api --manifest-path "$f" diff ${{ github.event.pull_request.base.sha }}..${{ github.sha }} - done diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 42f16450d..6fba6ad08 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,34 +1,40 @@ -# disabled because `cargo tarpaulin` currently segfaults - name: Coverage on: push: - branches: [master] + branches: [main] permissions: - contents: read # to fetch code (actions/checkout) + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - # job currently (1st Feb 2022) segfaults coverage: - name: coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: { toolchain: nightly } - - - name: Generate coverage file - run: | - cargo install cargo-tarpaulin --vers "^0.13" - cargo tarpaulin --workspace --features=rustls,openssl --out Xml --verbose - - name: Upload to Codecov - uses: codecov/codecov-action@v3.1.4 + - name: Install Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 with: - file: cobertura.xml + toolchain: nightly + components: llvm-tools + + - name: Install just, cargo-llvm-cov, cargo-nextest + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just,cargo-llvm-cov,cargo-nextest + + - name: Generate code coverage + run: just test-coverage-codecov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + files: codecov.json + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..91d234abe --- /dev/null +++ b/.github/workflows/labeler.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..a630b9ba8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,90 @@ +name: Lint + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + toolchain: nightly + components: rustfmt + + - name: Check with Rustfmt + run: cargo fmt --all -- --check + + clippy: + permissions: + contents: read + checks: write # to add clippy checks to PR diffs + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + components: clippy + + - name: Check with Clippy + uses: giraffate/clippy-action@13b9d32482f25d29ead141b79e7e04e7900281e0 # v1.0.1 + with: + reporter: github-pr-check + github_token: ${{ secrets.GITHUB_TOKEN }} + clippy_flags: >- + --workspace --all-features --tests --examples --bins -- + -A unknown_lints -D clippy::todo -D clippy::dbg_macro + + lint-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + toolchain: nightly + components: rust-docs + + - name: Check for broken intra-doc links + env: + RUSTDOCFLAGS: -D warnings + run: cargo +nightly doc --no-deps --workspace --all-features + + check-external-types: + if: false # rustdoc mismatch currently + runs-on: ubuntu-latest + steps: + - 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 + with: + toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }} + + - name: Install just + uses: taiki-e/install-action@650c5ca14212efbbf3e580844b04bdccf68dac31 # v2.67.18 + with: + tool: just + + - name: Install cargo-check-external-types + uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1 + with: + tool: cargo-check-external-types + + - name: check external types + run: just check-external-types-all +${{ vars.RUST_VERSION_EXTERNAL_TYPES }} diff --git a/.github/workflows/semver-labeler.yml b/.github/workflows/semver-labeler.yml new file mode 100644 index 000000000..131bd81b9 --- /dev/null +++ b/.github/workflows/semver-labeler.yml @@ -0,0 +1,29 @@ +name: Semver Labeler + +on: + workflow_run: + workflows: [CI] + types: [completed] + +jobs: + semver-label: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + env: + ACTIONS_STEP_DEBUG: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 + with: + toolchain: stable + + - uses: JohnTitor/cargo-semver-checks@3b76737b550e48ad0bd5912e2757e80eee6294b0 # v0.2.1 + with: + label-prefix: B-semver- + label-strategy: skip-if-human diff --git a/.github/workflows/upload-doc.yml b/.github/workflows/upload-doc.yml deleted file mode 100644 index 05b81411d..000000000 --- a/.github/workflows/upload-doc.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Upload Documentation - -on: - push: - branches: [master] - -permissions: - contents: read # to fetch code (actions/checkout) - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - permissions: - contents: write # to push changes in repo (jamesives/github-pages-deploy-action) - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: dtolnay/rust-toolchain@nightly - - - name: Build Docs - run: cargo +nightly doc --no-deps --workspace --all-features - env: - RUSTDOCFLAGS: --cfg=docsrs - - - name: Tweak HTML - run: echo '' > target/doc/index.html - - - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4.4.3 - with: - folder: target/doc - single-commit: true diff --git a/.gitignore b/.gitignore index 543403267..516ee9919 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -Cargo.lock target/ guide/build/ /gh-pages @@ -19,3 +18,7 @@ guide/build/ # Configuration directory generated by VSCode .vscode + +# code coverage +/lcov.info +/codecov.json diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index 7b5590248..000000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1 +0,0 @@ -proseWrap: never diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 000000000..d70303479 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,5 @@ +overrides: + - files: "*.md" + options: + printWidth: 9999 + proseWrap: never diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 000000000..c52daa476 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,38 @@ +exclude = ["target/*"] +include = ["**/*.toml"] + +[formatting] +column_width = 100 +align_comments = false + +[[rule]] +include = ["**/Cargo.toml"] +keys = ["features"] +formatting.column_width = 105 +formatting.reorder_keys = false + +[[rule]] +include = ["**/Cargo.toml"] +keys = [ + "dependencies", + "*-dependencies", + "workspace.dependencies", + "workspace.*-dependencies", + "target.*.dependencies", + "target.*.*-dependencies", +] +formatting.column_width = 120 +formatting.reorder_keys = true + +[[rule]] +include = ["**/Cargo.toml"] +keys = [ + "dependencies.*", + "*-dependencies.*", + "workspace.dependencies.*", + "workspace.*-dependencies.*", + "target.*.dependencies", + "target.*.*-dependencies", +] +formatting.column_width = 120 +formatting.reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..886aba632 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3907 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" +dependencies = [ + "actix-rt", + "bitflags 2.10.0", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.9" +dependencies = [ + "actix-http", + "actix-rt", + "actix-server", + "actix-service", + "actix-test", + "actix-utils", + "actix-web", + "bitflags 2.10.0", + "bytes", + "derive_more", + "env_logger", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tempfile", + "tokio-uring", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +dependencies = [ + "actix-codec", + "actix-http-test", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web", + "async-stream", + "base64 0.22.1", + "bitflags 2.10.0", + "brotli", + "bytes", + "bytestring", + "criterion", + "derive_more", + "divan", + "encoding_rs", + "env_logger", + "flate2", + "foldhash", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "memchr", + "mime", + "once_cell", + "openssl", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "rcgen", + "regex", + "rustls 0.23.35", + "rustls-pki-types", + "rustversion", + "serde", + "serde_json", + "sha1", + "smallvec", + "static_assertions", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-http-test" +version = "3.2.0" +dependencies = [ + "actix-codec", + "actix-http", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "awc", + "bytes", + "futures-core", + "http 0.2.12", + "log", + "openssl", + "serde", + "serde_json", + "serde_urlencoded", + "slab", + "socket2 0.6.2", + "tokio", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-multipart" +version = "0.7.2" +dependencies = [ + "actix-http", + "actix-multipart-derive", + "actix-multipart-rfc7578", + "actix-rt", + "actix-test", + "actix-utils", + "actix-web", + "assert_matches", + "awc", + "derive_more", + "env_logger", + "futures-core", + "futures-test", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "multer", + "rand 0.9.2", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", + "tokio-stream", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +dependencies = [ + "actix-multipart", + "actix-web", + "bytesize", + "darling", + "proc-macro2", + "quote", + "rustversion-msrv", + "syn", + "trybuild", +] + +[[package]] +name = "actix-multipart-rfc7578" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5e8f1e4c6baf42b8b78f3b2a85df02b54f10f5fdf54bf1f9b40211c130e427" +dependencies = [ + "actix-http", + "bytes", + "common-multipart-rfc7578", + "futures-core", + "thiserror 1.0.69", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +dependencies = [ + "bytestring", + "cfg-if", + "criterion", + "http 0.2.12", + "percent-encoding", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "actix-macros", + "futures-core", + "tokio", + "tokio-uring", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tokio-uring", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-test" +version = "0.1.5" +dependencies = [ + "actix-codec", + "actix-http", + "actix-http-test", + "actix-rt", + "actix-service", + "actix-utils", + "actix-web", + "awc", + "futures-core", + "futures-util", + "log", + "openssl", + "rustls 0.20.9", + "rustls 0.21.12", + "rustls 0.22.4", + "rustls 0.23.35", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "impl-more", + "openssl", + "pin-project-lite", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-openssl", + "tokio-rustls 0.23.4", + "tokio-rustls 0.24.1", + "tokio-rustls 0.25.0", + "tokio-rustls 0.26.4", + "tokio-util", + "tracing", + "webpki-roots 0.22.6", + "webpki-roots 0.25.4", + "webpki-roots 0.26.11", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +dependencies = [ + "actix-codec", + "actix-files", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-test", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "awc", + "brotli", + "bytes", + "bytestring", + "cfg-if", + "const-str", + "cookie", + "core_affinity", + "criterion", + "derive_more", + "encoding_rs", + "env_logger", + "flate2", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "openssl", + "pin-project-lite", + "rand 0.9.2", + "rcgen", + "regex", + "regex-lite", + "rustls 0.23.35", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.2", + "static_assertions", + "time", + "tokio", + "tokio-util", + "tracing", + "url", + "zstd", +] + +[[package]] +name = "actix-web-actors" +version = "4.3.1+deprecated" +dependencies = [ + "actix", + "actix-codec", + "actix-http", + "actix-rt", + "actix-test", + "actix-web", + "awc", + "bytes", + "bytestring", + "env_logger", + "futures-core", + "futures-util", + "mime", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +dependencies = [ + "actix-macros", + "actix-router", + "actix-rt", + "actix-test", + "actix-utils", + "actix-web", + "futures-core", + "proc-macro2", + "quote", + "rustversion-msrv", + "syn", + "trybuild", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "awc" +version = "3.8.1" +dependencies = [ + "actix-codec", + "actix-http", + "actix-http-test", + "actix-rt", + "actix-server", + "actix-service", + "actix-test", + "actix-tls", + "actix-utils", + "actix-web", + "base64 0.22.1", + "brotli", + "bytes", + "cfg-if", + "const-str", + "cookie", + "derive_more", + "env_logger", + "flate2", + "futures-core", + "futures-util", + "h2", + "hickory-resolver", + "http 0.2.12", + "itoa", + "log", + "mime", + "openssl", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "rcgen", + "rustls 0.20.9", + "rustls 0.21.12", + "rustls 0.22.4", + "rustls 0.23.35", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions", + "tokio", + "zstd", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstyle", + "clap_lex", + "terminal_size", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "common-multipart-rfc7578" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f08d53b5e0c302c5830cfa7511ba0edc3f241c691a95c0d184dfb761e11a6cc2" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "mime", + "mime_guess", + "rand 0.8.5", + "thiserror 1.0.69", +] + +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + +[[package]] +name = "const-str" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-test" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5961fb6311645f46e2cdc2964a8bfae6743fd72315eaec181a71ae3eb2467113" +dependencies = [ + "futures-core", + "futures-executor", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "futures-util", + "pin-project", +] + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring 0.17.14", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595a0399f411a508feb2ec1e970a4a30c249351e30208960d58298de8660b0e5" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring 0.17.14", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustversion-msrv" +version = "0.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ceb60223ee771fb5dfe462e29e5ee92bca9a7b9c555584f4d361045dae0e12" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-openssl" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" +dependencies = [ + "openssl", + "openssl-sys", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.35", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-uring" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748482e3e13584a34664a710168ad5068e8cb1d968aa4ffa887e83ca6dd27967" +dependencies = [ + "bytes", + "futures-util", + "io-uring", + "libc", + "slab", + "socket2 0.4.10", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.4", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 65e3c6ae8..360239d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,26 @@ [workspace] resolver = "2" members = [ - "actix-files", - "actix-http-test", - "actix-http", - "actix-multipart", - "actix-multipart-derive", - "actix-router", - "actix-test", - "actix-web-actors", - "actix-web-codegen", - "actix-web", - "awc", + "actix-files", + "actix-http-test", + "actix-http", + "actix-multipart", + "actix-multipart-derive", + "actix-router", + "actix-test", + "actix-web-actors", + "actix-web-codegen", + "actix-web", + "awc", ] +[workspace.package] +homepage = "https://actix.rs" +repository = "https://github.com/actix/actix-web" +license = "MIT OR Apache-2.0" +edition = "2021" +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. debug = 0 @@ -44,3 +51,11 @@ awc = { path = "awc" } # actix-utils = { path = "../actix-net/actix-utils" } # actix-tls = { path = "../actix-net/actix-tls" } # actix-server = { path = "../actix-net/actix-server" } + +[workspace.lints.rust] +rust_2018_idioms = { level = "deny" } +future_incompatible = { level = "deny" } +nonstandard_style = { level = "deny" } + +[workspace.lints.clippy] +# clone_on_ref_ptr = { level = "deny" } diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index 4e45ce517..307f5852d 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -1,10 +1,41 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. +- `PathBufWrap` & `UriSegmentError` made public. [#3694] -## 0.6.3 - 2023-01-21 +[#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 + +- Update `tokio-uring` dependency to `0.4`. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 0.6.5 + +- Fix handling of special characters in filenames. + +## 0.6.4 + +- Fix handling of newlines in filenames. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 0.6.3 - XHTML files now use `Content-Disposition: inline` instead of `attachment`. [#2903] - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. @@ -12,14 +43,14 @@ [#2903]: https://github.com/actix/actix-web/pull/2903 -## 0.6.2 - 2022-07-23 +## 0.6.2 - Allow partial range responses for video content to start streaming sooner. [#2817] - Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. [#2817]: https://github.com/actix/actix-web/pull/2817 -## 0.6.1 - 2022-06-11 +## 0.6.1 - Add `NamedFile::{modified, metadata, content_type, content_disposition, encoding}()` getters. [#2021] - Update `tokio-uring` dependency to `0.3`. @@ -29,25 +60,25 @@ [#2021]: https://github.com/actix/actix-web/pull/2021 [#2645]: https://github.com/actix/actix-web/pull/2645 -## 0.6.0 - 2022-02-25 +## 0.6.0 - No significant changes since `0.6.0-beta.16`. -## 0.6.0-beta.16 - 2022-01-31 +## 0.6.0-beta.16 - No significant changes since `0.6.0-beta.15`. -## 0.6.0-beta.15 - 2022-01-21 +## 0.6.0-beta.15 - No significant changes since `0.6.0-beta.14`. -## 0.6.0-beta.14 - 2022-01-14 +## 0.6.0-beta.14 - The `prefer_utf8` option introduced in `0.4.0` is now true by default. [#2583] [#2583]: https://github.com/actix/actix-web/pull/2583 -## 0.6.0-beta.13 - 2022-01-04 +## 0.6.0-beta.13 - The `Files` service now rejects requests with URL paths that include `%2F` (decoded: `/`). [#2398] - The `Files` service now correctly decodes `%25` in the URL path to `%` for the file path. [#2398] @@ -55,19 +86,19 @@ [#2398]: https://github.com/actix/actix-web/pull/2398 -## 0.6.0-beta.12 - 2021-12-29 +## 0.6.0-beta.12 - No significant changes since `0.6.0-beta.11`. -## 0.6.0-beta.11 - 2021-12-27 +## 0.6.0-beta.11 - No significant changes since `0.6.0-beta.10`. -## 0.6.0-beta.10 - 2021-12-11 +## 0.6.0-beta.10 - No significant changes since `0.6.0-beta.9`. -## 0.6.0-beta.9 - 2021-11-22 +## 0.6.0-beta.9 - Add crate feature `experimental-io-uring`, enabling async file I/O to be utilized. This feature is only available on Linux OSes with recent kernel versions. This feature is semver-exempt. [#2408] - Add `NamedFile::open_async`. [#2408] @@ -79,15 +110,15 @@ [#2408]: https://github.com/actix/actix-web/pull/2408 [#2453]: https://github.com/actix/actix-web/pull/2453 -## 0.6.0-beta.8 - 2021-10-20 +## 0.6.0-beta.8 - Minimum supported Rust version (MSRV) is now 1.52. -## 0.6.0-beta.7 - 2021-09-09 +## 0.6.0-beta.7 - Minimum supported Rust version (MSRV) is now 1.51. -## 0.6.0-beta.6 - 2021-06-26 +## 0.6.0-beta.6 - Added `Files::path_filter()`. [#2274] - `Files::show_files_listing()` can now be used with `Files::index_file()` to show files listing as a fallback when the index file is not found. [#2228] @@ -95,7 +126,7 @@ [#2274]: https://github.com/actix/actix-web/pull/2274 [#2228]: https://github.com/actix/actix-web/pull/2228 -## 0.6.0-beta.5 - 2021-06-17 +## 0.6.0-beta.5 - `NamedFile` now implements `ServiceFactory` and `HttpServiceFactory` making it much more useful in routing. For example, it can be used directly as a default service. [#2135] - For symbolic links, `Content-Disposition` header no longer shows the filename of the original file. [#2156] @@ -107,17 +138,17 @@ [#2225]: https://github.com/actix/actix-web/pull/2225 [#2257]: https://github.com/actix/actix-web/pull/2257 -## 0.6.0-beta.4 - 2021-04-02 +## 0.6.0-beta.4 - Add support for `.guard` in `Files` to selectively filter `Files` services. [#2046] [#2046]: https://github.com/actix/actix-web/pull/2046 -## 0.6.0-beta.3 - 2021-03-09 +## 0.6.0-beta.3 - No notable changes. -## 0.6.0-beta.2 - 2021-02-10 +## 0.6.0-beta.2 - Fix If-Modified-Since and If-Unmodified-Since to not compare using sub-second timestamps. [#1887] - Replace `v_htmlescape` with `askama_escape`. [#1953] @@ -125,39 +156,39 @@ [#1887]: https://github.com/actix/actix-web/pull/1887 [#1953]: https://github.com/actix/actix-web/pull/1953 -## 0.6.0-beta.1 - 2021-01-07 +## 0.6.0-beta.1 - `HttpRange::parse` now has its own error type. - Update `bytes` to `1.0`. [#1813] [#1813]: https://github.com/actix/actix-web/pull/1813 -## 0.5.0 - 2020-12-26 +## 0.5.0 - Optionally support hidden files/directories. [#1811] [#1811]: https://github.com/actix/actix-web/pull/1811 -## 0.4.1 - 2020-11-24 +## 0.4.1 - Clarify order of parameters in `Files::new` and improve docs. -## 0.4.0 - 2020-10-06 +## 0.4.0 - Add `Files::prefer_utf8` option that adds UTF-8 charset on certain response types. [#1714] [#1714]: https://github.com/actix/actix-web/pull/1714 -## 0.3.0 - 2020-09-11 +## 0.3.0 - No significant changes from 0.3.0-beta.1. -## 0.3.0-beta.1 - 2020-07-15 +## 0.3.0-beta.1 - Update `v_htmlescape` to 0.10 - Update `actix-web` and `actix-http` dependencies to beta.1 -## 0.3.0-alpha.1 - 2020-05-23 +## 0.3.0-alpha.1 - Update `actix-web` and `actix-http` dependencies to alpha - Fix some typos in the docs @@ -166,73 +197,73 @@ [#1384]: https://github.com/actix/actix-web/pull/1384 -## 0.2.1 - 2019-12-22 +## 0.2.1 - Use the same format for file URLs regardless of platforms -## 0.2.0 - 2019-12-20 +## 0.2.0 - Fix BodyEncoding trait import #1220 -## 0.2.0-alpha.1 - 2019-12-07 +## 0.2.0-alpha.1 - Migrate to `std::future` -## 0.1.7 - 2019-11-06 +## 0.1.7 - Add an additional `filename*` param in the `Content-Disposition` header of `actix_files::NamedFile` to be more compatible. (#1151) -## 0.1.6 - 2019-10-14 +## 0.1.6 - Add option to redirect to a slash-ended path `Files` #1132 -## 0.1.5 - 2019-10-08 +## 0.1.5 - Bump up `mime_guess` crate version to 2.0.1 - Bump up `percent-encoding` crate version to 2.1 - Allow user defined request guards for `Files` #1113 -## 0.1.4 - 2019-07-20 +## 0.1.4 - Allow to disable `Content-Disposition` header #686 -## 0.1.3 - 2019-06-28 +## 0.1.3 - Do not set `Content-Length` header, let actix-http set it #930 -## 0.1.2 - 2019-06-13 +## 0.1.2 - Content-Length is 0 for NamedFile HEAD request #914 - Fix ring dependency from actix-web default features for #741 -## 0.1.1 - 2019-06-01 +## 0.1.1 - Static files are incorrectly served as both chunked and with length #812 -## 0.1.0 - 2019-05-25 +## 0.1.0 - NamedFile last-modified check always fails due to nano-seconds in file modified date #820 -## 0.1.0-beta.4 - 2019-05-12 +## 0.1.0-beta.4 - Update actix-web to beta.4 -## 0.1.0-beta.1 - 2019-04-20 +## 0.1.0-beta.1 - Update actix-web to beta.1 -## 0.1.0-alpha.6 - 2019-04-14 +## 0.1.0-alpha.6 - Update actix-web to alpha6 -## 0.1.0-alpha.4 - 2019-04-08 +## 0.1.0-alpha.4 - Update actix-web to alpha4 -## 0.1.0-alpha.2 - 2019-04-02 +## 0.1.0-alpha.2 - Add default handler support -## 0.1.0-alpha.1 - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/actix-files/Cargo.toml b/actix-files/Cargo.toml index 80c609d1d..148442d4f 100644 --- a/actix-files/Cargo.toml +++ b/actix-files/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "actix-files" -version = "0.6.3" -authors = [ - "Nikolay Kim ", - "Rob Ede ", -] +version = "0.6.9" +authors = ["Nikolay Kim ", "Rob Ede "] description = "Static file serving for Actix Web" keywords = ["actix", "http", "async", "futures"] homepage = "https://actix.rs" @@ -13,9 +10,8 @@ categories = ["asynchronous", "web-programming::http-server"] license = "MIT OR Apache-2.0" edition = "2021" -[lib] -name = "actix_files" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = ["actix_http::*", "actix_service::*", "actix_web::*", "http::*", "mime::*"] [features] experimental-io-uring = ["actix-web/experimental-io-uring", "tokio-uring"] @@ -28,7 +24,7 @@ actix-web = { version = "4", default-features = false } bitflags = "2" bytes = "1" -derive_more = "0.99.5" +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" @@ -40,11 +36,15 @@ v_htmlescape = "0.15.5" # experimental-io-uring [target.'cfg(target_os = "linux")'.dependencies] -tokio-uring = { version = "0.4", optional = true, features = ["bytes"] } -actix-server = { version = "2.2", optional = true } # ensure matching tokio-uring versions +tokio-uring = { version = "0.5", optional = true, features = ["bytes"] } +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" tempfile = "3.2" + +[lints] +workspace = true diff --git a/actix-files/README.md b/actix-files/README.md index f01fc8566..7bc0b90d8 100644 --- a/actix-files/README.md +++ b/actix-files/README.md @@ -1,18 +1,32 @@ -# actix-files +# `actix-files` -> Static file serving for Actix Web + [![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.3)](https://docs.rs/actix-files/0.6.3) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![Documentation](https://docs.rs/actix-files/badge.svg?version=0.6.9)](https://docs.rs/actix-files/0.6.9) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-files.svg)
-[![dependency status](https://deps.rs/crate/actix-files/0.6.3/status.svg)](https://deps.rs/crate/actix-files/0.6.3) +[![dependency status](https://deps.rs/crate/actix-files/0.6.9/status.svg)](https://deps.rs/crate/actix-files/0.6.9) [![Download](https://img.shields.io/crates/d/actix-files.svg)](https://crates.io/crates/actix-files) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources + -- [API Documentation](https://docs.rs/actix-files) -- [Example Project](https://github.com/actix/examples/tree/master/basics/static-files) -- Minimum Supported Rust Version (MSRV): 1.65 + + +Static file serving for Actix Web. + +Provides a non-blocking service for serving static files from disk. + +## Examples + +```rust +use actix_web::App; +use actix_files::Files; + +let app = App::new() + .service(Files::new("/static", ".").prefer_utf8(true)); +``` + + diff --git a/actix-files/examples/guarded-listing.rs b/actix-files/examples/guarded-listing.rs new file mode 100644 index 000000000..e8cde0c85 --- /dev/null +++ b/actix-files/examples/guarded-listing.rs @@ -0,0 +1,33 @@ +use actix_files::Files; +use actix_web::{get, guard, middleware, App, HttpServer, Responder}; + +const EXAMPLES_DIR: &str = concat![env!("CARGO_MANIFEST_DIR"), "/examples"]; + +#[get("/")] +async fn index() -> impl Responder { + "Hello world!" +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(|| { + App::new() + .service(index) + .service( + Files::new("/assets", EXAMPLES_DIR) + .show_files_listing() + .guard(guard::Header("show-listing", "?1")), + ) + .service(Files::new("/assets", EXAMPLES_DIR)) + .wrap(middleware::Compress::default()) + .wrap(middleware::Logger::default()) + }) + .bind(("127.0.0.1", 8080))? + .workers(2) + .run() + .await +} diff --git a/actix-files/src/chunked.rs b/actix-files/src/chunked.rs index c6c019038..03452e9ae 100644 --- a/actix-files/src/chunked.rs +++ b/actix-files/src/chunked.rs @@ -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, 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> { 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 Stream for ChunkedReadFile where - F: Fn(File, u64, usize) -> Fut, + F: Fn(File, u64, usize, ReadMode) -> Fut, Fut: Future>, { type Item = Result; @@ -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 }); diff --git a/actix-files/src/error.rs b/actix-files/src/error.rs index d614651fc..1ba4ce67e 100644 --- a/actix-files/src/error.rs +++ b/actix-files/src/error.rs @@ -6,11 +6,11 @@ use derive_more::Display; pub enum FilesError { /// Path is not a directory. #[allow(dead_code)] - #[display(fmt = "path is not a directory. Unable to serve static files")] + #[display("path is not a directory. Unable to serve static files")] IsNotDirectory, /// Cannot render directory. - #[display(fmt = "unable to render directory without index file")] + #[display("unable to render directory without index file")] IsDirectory, } @@ -21,23 +21,24 @@ 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 { /// Segment started with the wrapped invalid character. - #[display(fmt = "segment started with invalid character: ('{_0}')")] + #[display("segment started with invalid character: ('{_0}')")] BadStart(char), /// Segment contained the wrapped invalid character. - #[display(fmt = "segment contained invalid character ('{_0}')")] + #[display("segment contained invalid character ('{_0}')")] BadChar(char), /// Segment ended with the wrapped invalid character. - #[display(fmt = "segment ended with invalid character: ('{_0}')")] + #[display("segment ended with invalid character: ('{_0}')")] BadEnd(char), /// Path is not a valid UTF-8 string after percent-decoding. - #[display(fmt = "path is not a valid UTF-8 string after percent-decoding")] + #[display("path is not a valid UTF-8 string after percent-decoding")] NotValidUtf8, } diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index e34b5f26a..afc852a18 100644 --- a/actix-files/src/files.rs +++ b/actix-files/src/files.rs @@ -41,6 +41,7 @@ pub struct Files { index: Option, show_index: bool, redirect_to_slash: bool, + with_permanent_redirect: bool, default: Rc>>>, renderer: Rc, mime_override: Option>, @@ -49,6 +50,7 @@ pub struct Files { use_guards: Option>, guards: Vec>, hidden_files: bool, + read_mode_threshold: u64, } impl fmt::Debug for Files { @@ -64,6 +66,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 +76,7 @@ impl Clone for Files { use_guards: self.use_guards.clone(), guards: self.guards.clone(), hidden_files: self.hidden_files, + read_mode_threshold: self.read_mode_threshold, } } } @@ -111,6 +115,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 +124,7 @@ impl Files { use_guards: None, guards: Vec::new(), hidden_files: false, + read_mode_threshold: 0, } } @@ -141,6 +147,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(mut self, f: F) -> Self where @@ -204,6 +218,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. @@ -235,7 +266,7 @@ impl Files { /// request starts being handled by the file service, it will not be able to back-out and try /// the next service, you will simply get a 404 (or 405) error response. /// - /// To allow `POST` requests to retrieve files, see [`Files::use_guards`]. + /// To allow `POST` requests to retrieve files, see [`Files::method_guard()`]. /// /// # Examples /// ``` @@ -367,6 +398,8 @@ impl ServiceFactory for Files { file_flags: self.file_flags, guards: self.use_guards.clone(), hidden_files: self.hidden_files, + size_threshold: self.read_mode_threshold, + with_permanent_redirect: self.with_permanent_redirect, }; if let Some(ref default) = *self.default.borrow() { diff --git a/actix-files/src/lib.rs b/actix-files/src/lib.rs index 1d8609889..b0bb76ace 100644 --- a/actix-files/src/lib.rs +++ b/actix-files/src/lib.rs @@ -11,12 +11,10 @@ //! .service(Files::new("/static", ".").prefer_utf8(true)); //! ``` -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible, missing_docs, missing_debug_implementations)] -#![allow(clippy::uninlined_format_args)] +#![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; @@ -39,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; @@ -66,6 +63,7 @@ type PathFilter = dyn Fn(&Path, &RequestHead) -> bool; #[cfg(test)] mod tests { use std::{ + fmt::Write as _, fs::{self}, ops::Add, time::{Duration, SystemTime}, @@ -75,7 +73,7 @@ mod tests { dev::ServiceFactory, guard, http::{ - header::{self, ContentDisposition, DispositionParam, DispositionType}, + header::{self, ContentDisposition, DispositionParam}, Method, StatusCode, }, middleware::Compress, @@ -307,11 +305,11 @@ mod tests { let resp = file.respond_to(&req); assert_eq!( resp.headers().get(header::CONTENT_TYPE).unwrap(), - "application/javascript; charset=utf-8" + "text/javascript", ); assert_eq!( resp.headers().get(header::CONTENT_DISPOSITION).unwrap(), - "inline; filename=\"test.js\"" + "inline; filename=\"test.js\"", ); } @@ -568,6 +566,30 @@ mod tests { assert_eq!(bytes, data); } + #[cfg(not(target_os = "windows"))] + #[actix_rt::test] + async fn test_static_files_with_special_characters() { + // Create the file we want to test against ad-hoc. We can't check it in as otherwise + // Windows can't even checkout this repository. + let temp_dir = tempfile::tempdir().unwrap(); + let file_with_newlines = temp_dir.path().join("test\n\x0B\x0C\rnewline.text"); + fs::write(&file_with_newlines, "Look at my newlines").unwrap(); + + let srv = test::init_service( + App::new().service(Files::new("/", temp_dir.path()).index_file("Cargo.toml")), + ) + .await; + let request = TestRequest::get() + .uri("/test%0A%0B%0C%0Dnewline.text") + .to_request(); + let response = test::call_service(&srv, request).await; + assert_eq!(response.status(), StatusCode::OK); + + let bytes = test::read_body(response).await; + let data = web::Bytes::from(fs::read(file_with_newlines).unwrap()); + assert_eq!(bytes, data); + } + #[actix_rt::test] async fn test_files_not_allowed() { let srv = test::init_service(App::new().service(Files::new("/", "."))).await; @@ -713,7 +735,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( @@ -726,7 +762,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(); @@ -840,19 +876,21 @@ mod tests { #[actix_rt::test] async fn test_percent_encoding_2() { - let tmpdir = tempfile::tempdir().unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); let filename = match cfg!(unix) { - true => "ض:?#[]{}<>()@!$&'`|*+,;= %20.test", + true => "ض:?#[]{}<>()@!$&'`|*+,;= %20\n.test", false => "ض#[]{}()@!$&'`+,;= %20.test", }; let filename_encoded = filename .as_bytes() .iter() - .map(|c| format!("%{:02X}", c)) - .collect::(); - std::fs::File::create(tmpdir.path().join(filename)).unwrap(); + .fold(String::new(), |mut buf, c| { + write!(&mut buf, "%{:02X}", c).unwrap(); + buf + }); + std::fs::File::create(temp_dir.path().join(filename)).unwrap(); - let srv = test::init_service(App::new().service(Files::new("", tmpdir.path()))).await; + let srv = test::init_service(App::new().service(Files::new("/", temp_dir.path()))).await; let req = TestRequest::get() .uri(&format!("/{}", filename_encoded)) diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index d7795ba73..7ff66e74d 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -24,7 +24,6 @@ use bitflags::bitflags; use derive_more::{Deref, DerefMut}; use futures_core::future::LocalBoxFuture; use mime::Mime; -use mime_guess::from_path; use crate::{encoding::equiv_utf8_text, range::HttpRange}; @@ -81,6 +80,7 @@ pub struct NamedFile { pub(crate) content_type: Mime, pub(crate) content_disposition: ContentDisposition, pub(crate) encoding: Option, + pub(crate) read_mode_threshold: u64, } #[cfg(not(feature = "experimental-io-uring"))] @@ -128,7 +128,7 @@ impl NamedFile { } }; - let ct = from_path(&path).first_or_octet_stream(); + 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, @@ -140,7 +140,13 @@ impl NamedFile { _ => DispositionType::Attachment, }; - let mut parameters = vec![DispositionParam::Filename(String::from(filename.as_ref()))]; + // 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 { @@ -195,6 +201,7 @@ impl NamedFile { encoding, status_code: StatusCode::OK, flags: Flags::default(), + read_mode_threshold: 0, }) } @@ -348,6 +355,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. @@ -435,7 +459,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); } @@ -572,7 +597,7 @@ 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() { res.status(StatusCode::PARTIAL_CONTENT); diff --git a/actix-files/src/path_buf.rs b/actix-files/src/path_buf.rs index c1983279b..0fe8493bf 100644 --- a/actix-files/src/path_buf.rs +++ b/actix-files/src/path_buf.rs @@ -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::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::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>; 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()) } } diff --git a/actix-files/src/range.rs b/actix-files/src/range.rs index 528911ae0..87be8363d 100644 --- a/actix-files/src/range.rs +++ b/actix-files/src/range.rs @@ -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:?}"); } } diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index 3d3b36c40..f63ba46c6 100644 --- a/actix-files/src/service.rs +++ b/actix-files/src/service.rs @@ -39,6 +39,8 @@ pub struct FilesServiceInner { pub(crate) file_flags: named::Flags, pub(crate) guards: Option>, pub(crate) hidden_files: bool, + pub(crate) size_threshold: u64, + pub(crate) with_permanent_redirect: bool, } impl fmt::Debug for FilesServiceInner { @@ -70,7 +72,9 @@ impl FilesService { named_file.flags = self.file_flags; let (req, _) = req.into_parts(); - let res = named_file.into_response(&req); + let res = named_file + .read_mode_threshold(self.size_threshold) + .into_response(&req); ServiceResponse::new(req, res) } @@ -79,7 +83,7 @@ impl FilesService { let (req, _) = req.into_parts(); - (self.renderer)(&dir, &req).unwrap_or_else(|e| ServiceResponse::from_err(e, req)) + (self.renderer)(&dir, &req).unwrap_or_else(|err| ServiceResponse::from_err(err, req)) } } @@ -145,11 +149,15 @@ impl Service 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 { @@ -169,17 +177,7 @@ impl Service 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, } } diff --git a/actix-http-test/CHANGES.md b/actix-http-test/CHANGES.md index 3b8c83b3c..be19d251a 100644 --- a/actix-http-test/CHANGES.md +++ b/actix-http-test/CHANGES.md @@ -1,14 +1,18 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 3.1.0 - 2023-01-21 +## 3.2.0 + +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 3.1.0 - Minimum supported Rust version (MSRV) is now 1.59. -## 3.0.0 - 2022-07-24 +## 3.0.0 - `TestServer::stop` is now async and will wait for the server and system to shutdown. [#2442] - Added `TestServer::client_headers` method. [#2097] @@ -24,41 +28,41 @@
3.0.0 Pre-Releases -## 3.0.0-beta.13 - 2022-02-16 +## 3.0.0-beta.13 - No significant changes since `3.0.0-beta.12`. -## 3.0.0-beta.12 - 2022-01-31 +## 3.0.0-beta.12 - No significant changes since `3.0.0-beta.11`. -## 3.0.0-beta.11 - 2022-01-04 +## 3.0.0-beta.11 - Minimum supported Rust version (MSRV) is now 1.54. -## 3.0.0-beta.10 - 2021-12-27 +## 3.0.0-beta.10 - Update `actix-server` to `2.0.0-rc.2`. [#2550] [#2550]: https://github.com/actix/actix-web/pull/2550 -## 3.0.0-beta.9 - 2021-12-11 +## 3.0.0-beta.9 - No significant changes since `3.0.0-beta.8`. -## 3.0.0-beta.8 - 2021-11-30 +## 3.0.0-beta.8 - Update `actix-tls` to `3.0.0-rc.1`. [#2474] [#2474]: https://github.com/actix/actix-web/pull/2474 -## 3.0.0-beta.7 - 2021-11-22 +## 3.0.0-beta.7 - Fix compatibility with experimental `io-uring` feature of `actix-rt`. [#2408] [#2408]: https://github.com/actix/actix-web/pull/2408 -## 3.0.0-beta.6 - 2021-11-15 +## 3.0.0-beta.6 - `TestServer::stop` is now async and will wait for the server and system to shutdown. [#2442] - Update `actix-server` to `2.0.0-beta.9`. [#2442] @@ -66,25 +70,25 @@ [#2442]: https://github.com/actix/actix-web/pull/2442 -## 3.0.0-beta.5 - 2021-09-09 +## 3.0.0-beta.5 - Minimum supported Rust version (MSRV) is now 1.51. -## 3.0.0-beta.4 - 2021-04-02 +## 3.0.0-beta.4 - Added `TestServer::client_headers` method. [#2097] [#2097]: https://github.com/actix/actix-web/pull/2097 -## 3.0.0-beta.3 - 2021-03-09 +## 3.0.0-beta.3 - No notable changes. -## 3.0.0-beta.2 - 2021-02-10 +## 3.0.0-beta.2 - No notable changes. -## 3.0.0-beta.1 - 2021-01-07 +## 3.0.0-beta.1 - Update `bytes` to `1.0`. [#1813] @@ -92,7 +96,7 @@
-## 2.1.0 - 2020-11-25 +## 2.1.0 - Add ability to set address for `TestServer`. [#1645] - Upgrade `base64` to `0.13`. @@ -101,11 +105,11 @@ [#1773]: https://github.com/actix/actix-web/pull/1773 [#1645]: https://github.com/actix/actix-web/pull/1645 -## 2.0.0 - 2020-09-11 +## 2.0.0 - Update actix-codec and actix-utils dependencies. -## 2.0.0-alpha.1 - 2020-05-23 +## 2.0.0-alpha.1 - Update the `time` dependency to 0.2.7 - Update `actix-connect` dependency to 2.0.0-alpha.2 @@ -115,57 +119,57 @@ - Update `base64` dependency to 0.12 - Update `env_logger` dependency to 0.7 -## 1.0.0 - 2019-12-13 +## 1.0.0 - Replaced `TestServer::start()` with `test_server()` -## 1.0.0-alpha.3 - 2019-12-07 +## 1.0.0-alpha.3 - Migrate to `std::future` -## 0.2.5 - 2019-09-17 +## 0.2.5 - Update serde_urlencoded to "0.6.1" - Increase TestServerRuntime timeouts from 500ms to 3000ms - Do not override current `System` -## 0.2.4 - 2019-07-18 +## 0.2.4 - Update actix-server to 0.6 -## 0.2.3 - 2019-07-16 +## 0.2.3 - Add `delete`, `options`, `patch` methods to `TestServerRunner` -## 0.2.2 - 2019-06-16 +## 0.2.2 - Add .put() and .sput() methods -## 0.2.1 - 2019-06-05 +## 0.2.1 - Add license files -## 0.2.0 - 2019-05-12 +## 0.2.0 - Update awc and actix-http deps -## 0.1.1 - 2019-04-24 +## 0.1.1 - Always make new connection for http client -## 0.1.0 - 2019-04-16 +## 0.1.0 - No changes -## 0.1.0-alpha.3 - 2019-04-02 +## 0.1.0-alpha.3 - Request functions accept path #743 -## 0.1.0-alpha.2 - 2019-03-29 +## 0.1.0-alpha.2 - Added TestServerRuntime::load_body() method - Update actix-http and awc libraries -## 0.1.0-alpha.1 - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/actix-http-test/Cargo.toml b/actix-http-test/Cargo.toml index 7f00ba30a..6ddee64cf 100644 --- a/actix-http-test/Cargo.toml +++ b/actix-http-test/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "actix-http-test" -version = "3.1.0" +version = "3.2.0" authors = ["Nikolay Kim "] description = "Various helpers for Actix applications to use during testing" keywords = ["http", "web", "framework", "async", "futures"] homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" +repository = "https://github.com/actix/actix-web" categories = [ - "network-programming", - "asynchronous", - "web-programming::http-server", - "web-programming::websocket", + "network-programming", + "asynchronous", + "web-programming::http-server", + "web-programming::websocket", ] license = "MIT OR Apache-2.0" edition = "2021" @@ -18,9 +18,17 @@ edition = "2021" [package.metadata.docs.rs] features = [] -[lib] -name = "actix_http_test" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_codec::*", + "actix_http::*", + "actix_server::*", + "awc::*", + "bytes::*", + "futures_core::*", + "http::*", + "tokio::*", +] [features] default = [] @@ -29,25 +37,28 @@ default = [] openssl = ["tls-openssl", "awc/openssl"] [dependencies] -actix-service = "2" actix-codec = "0.5" -actix-tls = "3" -actix-utils = "3" actix-rt = "2.2" actix-server = "2" +actix-service = "2" +actix-tls = "3" +actix-utils = "3" awc = { version = "3", default-features = false } bytes = "1" futures-core = { version = "0.3.17", default-features = false } http = "0.2.7" log = "0.4" -socket2 = "0.5" serde = "1" serde_json = "1" -slab = "0.4" serde_urlencoded = "0.7" +slab = "0.4" +socket2 = "0.6" tls-openssl = { version = "0.10.55", package = "openssl", optional = true } -tokio = { version = "1.24.2", features = ["sync"] } +tokio = { version = "1.38.2", features = ["sync"] } [dev-dependencies] actix-http = "3" + +[lints] +workspace = true diff --git a/actix-http-test/README.md b/actix-http-test/README.md index 935be407f..c242c8ab2 100644 --- a/actix-http-test/README.md +++ b/actix-http-test/README.md @@ -1,17 +1,20 @@ -# actix-http-test +# `actix-http-test` -> Various helpers for Actix applications to use during testing. + [![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.1.0)](https://docs.rs/actix-http-test/3.1.0) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http-test)
-[![Dependency Status](https://deps.rs/crate/actix-http-test/3.1.0/status.svg)](https://deps.rs/crate/actix-http-test/3.1.0) +[![Dependency Status](https://deps.rs/crate/actix-http-test/3.2.0/status.svg)](https://deps.rs/crate/actix-http-test/3.2.0) [![Download](https://img.shields.io/crates/d/actix-http-test.svg)](https://crates.io/crates/actix-http-test) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources + -- [API Documentation](https://docs.rs/actix-http-test) -- Minimum Supported Rust Version (MSRV): 1.65 + + +Various helpers for Actix applications to use during testing. + + diff --git a/actix-http-test/src/lib.rs b/actix-http-test/src/lib.rs index 2f1725d1c..e3ea69e5c 100644 --- a/actix-http-test/src/lib.rs +++ b/actix-http-test/src/lib.rs @@ -1,11 +1,8 @@ //! Various helpers for Actix applications to use during testing. -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] -#![allow(clippy::uninlined_format_args)] #![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; @@ -109,7 +106,7 @@ pub async fn test_server_with_addr>( builder.set_verify(SslVerifyMode::NONE); let _ = builder .set_alpn_protos(b"\x02h2\x08http/1.1") - .map_err(|e| log::error!("Can not set alpn protocol: {:?}", e)); + .map_err(|err| log::error!("Can not set ALPN protocol: {err}")); Connector::new() .conn_lifetime(Duration::from_secs(0)) diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index 519942980..c057fef82 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -1,10 +1,99 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased + +- Minimum supported Rust version (MSRV) is now 1.88. + +## 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 + +- Update `brotli` dependency to `8`. + +## 3.10.0 ### Added -- Add `body::to_body_limit()` function. +- Add `header::CLEAR_SITE_DATA` constant. +- Add `Extensions::get_or_insert[_with]()` methods. +- Implement `From` for `Payload`. +- Implement `From>` for `Payload`. + +### Changed + +- Update `brotli` dependency to `7`. +- Minimum supported Rust version (MSRV) is now 1.75. + +## 3.9.0 + +### Added + +- Implement `FromIterator<(HeaderName, HeaderValue)>` for `HeaderMap`. + +## 3.8.0 + +### Added + +- Add `error::InvalidStatusCode` re-export. + +## 3.7.0 + +### Added + +- Add `rustls-0_23` crate feature +- Add `{h1::H1Service, h2::H2Service, HttpService}::rustls_0_23()` and `HttpService::rustls_0_23_with_config()` service constructors. + +### Changed + +- Update `brotli` dependency to `6`. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 3.6.0 + +### Added + +- Add `rustls-0_22` crate feature. +- Add `{h1::H1Service, h2::H2Service, HttpService}::rustls_0_22()` and `HttpService::rustls_0_22_with_config()` service constructors. +- Implement `From<&HeaderMap>` for `http::HeaderMap`. + +## 3.5.1 + +### Fixed + +- Prevent hang when returning zero-sized response bodies through compression layer. + +## 3.5.0 + +### Added + +- Implement `From` for `http::HeaderMap`. + +### Changed + +- Updated `zstd` dependency to `0.13`. + +### Fixed + +- Prevent compression of zero-sized response bodies. + +## 3.4.0 + +### Added + +- Add `rustls-0_20` crate feature. +- Add `{h1::H1Service, h2::H2Service, HttpService}::rustls_021()` and `HttpService::rustls_021_with_config()` service constructors. +- Add `body::to_bytes_limited()` function. - Add `body::BodyLimitExceeded` error type. ### Fixed @@ -12,15 +101,15 @@ ### Changed -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. -## 3.3.1 - 2023-03-02 +## 3.3.1 ### Fixed - Use correct `http` version requirement to ensure support for const `HeaderName` definitions. -## 3.3.0 - 2023-01-21 +## 3.3.0 ### Added @@ -56,7 +145,7 @@ [#2956]: https://github.com/actix/actix-web/pull/2956 [#2968]: https://github.com/actix/actix-web/pull/2968 -## 3.2.2 - 2022-09-11 +## 3.2.2 ### Changed @@ -68,7 +157,7 @@ [#2369]: https://github.com/actix/actix-web/pull/2369 -## 3.2.1 - 2022-07-02 +## 3.2.1 ### Fixed @@ -76,7 +165,7 @@ [#2794]: https://github.com/actix/actix-web/pull/2794 -## 3.2.0 - 2022-06-30 +## 3.2.0 ### Changed @@ -90,7 +179,7 @@ [#2790]: https://github.com/actix/actix-web/pull/2790 [#2798]: https://github.com/actix/actix-web/pull/2798 -## 3.1.0 - 2022-06-11 +## 3.1.0 ### Changed @@ -104,13 +193,13 @@ [#2357]: https://github.com/actix/actix-web/issues/2357 [#2779]: https://github.com/actix/actix-web/pull/2779 -## 3.0.4 - 2022-03-09 +## 3.0.4 ### Fixed - Document on docs.rs with `ws` feature enabled. -## 3.0.3 - 2022-03-08 +## 3.0.3 ### Fixed @@ -118,7 +207,7 @@ [#2684]: https://github.com/actix/actix-web/pull/2684 -## 3.0.2 - 2022-03-05 +## 3.0.2 ### Fixed @@ -126,13 +215,13 @@ [#2683]: https://github.com/actix/actix-web/pull/2683 -## 3.0.1 - 2022-03-04 +## 3.0.1 - Fix panic in H1 dispatcher when pipelining is used with keep-alive. [#2678] [#2678]: https://github.com/actix/actix-web/issues/2678 -## 3.0.0 - 2022-02-25 +## 3.0.0 ### Dependencies @@ -420,7 +509,7 @@
3.0.0 Pre-Releases -## 3.0.0-rc.4 - 2022-02-22 +## 3.0.0-rc.4 ### Fixed @@ -428,11 +517,11 @@ [1ce58ecb]: https://github.com/actix/actix-web/commit/1ce58ecb305c60e51db06e6c913b7a1344e229ca -## 3.0.0-rc.3 - 2022-02-16 +## 3.0.0-rc.3 - No significant changes since `3.0.0-rc.2`. -## 3.0.0-rc.2 - 2022-02-08 +## 3.0.0-rc.2 ### Added @@ -449,7 +538,7 @@ [#2624]: https://github.com/actix/actix-web/pull/2624 [#2625]: https://github.com/actix/actix-web/pull/2625 -## 3.0.0-rc.1 - 2022-01-31 +## 3.0.0-rc.1 ### Added @@ -485,7 +574,7 @@ [#2611]: https://github.com/actix/actix-web/pull/2611 [#2618]: https://github.com/actix/actix-web/pull/2618 -## 3.0.0-beta.19 - 2022-01-21 +## 3.0.0-beta.19 ### Added @@ -505,7 +594,7 @@ [#2585]: https://github.com/actix/actix-web/pull/2585 [#2587]: https://github.com/actix/actix-web/pull/2587 -## 3.0.0-beta.18 - 2022-01-04 +## 3.0.0-beta.18 ### Added @@ -536,7 +625,7 @@ [#2501]: https://github.com/actix/actix-web/pull/2501 [#2565]: https://github.com/actix/actix-web/pull/2565 -## 3.0.0-beta.17 - 2021-12-27 +## 3.0.0-beta.17 ### Changed @@ -554,7 +643,7 @@ [#2527]: https://github.com/actix/actix-web/pull/2527 [#2545]: https://github.com/actix/actix-web/pull/2545 -## 3.0.0-beta.16 - 2021-12-17 +## 3.0.0-beta.16 ### Added @@ -573,7 +662,7 @@ [#2510]: https://github.com/actix/actix-web/pull/2510 [#2522]: https://github.com/actix/actix-web/pull/2522 -## 3.0.0-beta.15 - 2021-12-11 +## 3.0.0-beta.15 ### Added @@ -627,7 +716,7 @@ [#2497]: https://github.com/actix/actix-web/pull/2497 [#2520]: https://github.com/actix/actix-web/pull/2520 -## 3.0.0-beta.14 - 2021-11-30 +## 3.0.0-beta.14 ### Changed @@ -640,7 +729,7 @@ [#2470]: https://github.com/actix/actix-web/pull/2470 [#2474]: https://github.com/actix/actix-web/pull/2474 -## 3.0.0-beta.13 - 2021-11-22 +## 3.0.0-beta.13 ### Added @@ -669,7 +758,7 @@ [#2448]: https://github.com/actix/actix-web/pull/2448 [#2456]: https://github.com/actix/actix-web/pull/2456 -## 3.0.0-beta.12 - 2021-11-15 +## 3.0.0-beta.12 ### Changed @@ -683,7 +772,7 @@ [#2425]: https://github.com/actix/actix-web/pull/2425 [#2442]: https://github.com/actix/actix-web/pull/2442 -## 3.0.0-beta.11 - 2021-10-20 +## 3.0.0-beta.11 ### Changed @@ -692,7 +781,7 @@ [#2414]: https://github.com/actix/actix-web/pull/2414 -## 3.0.0-beta.10 - 2021-09-09 +## 3.0.0-beta.10 ### Changed @@ -710,13 +799,13 @@ [#2344]: https://github.com/actix/actix-web/pull/2344 [#2377]: https://github.com/actix/actix-web/pull/2377 -## 3.0.0-beta.9 - 2021-08-09 +## 3.0.0-beta.9 ### Fixed - Potential HTTP request smuggling vulnerabilities. [RUSTSEC-2021-0081](https://github.com/rustsec/advisory-db/pull/977) -## 3.0.0-beta.8 - 2021-06-26 +## 3.0.0-beta.8 ### Changed @@ -729,7 +818,7 @@ [#2291]: https://github.com/actix/actix-web/pull/2291 [#2250]: https://github.com/actix/actix-web/pull/2250 -## 3.0.0-beta.7 - 2021-06-17 +## 3.0.0-beta.7 ### Added @@ -776,7 +865,7 @@ [#2253]: https://github.com/actix/actix-web/pull/2253 [#2244]: https://github.com/actix/actix-web/pull/2244 -## 3.0.0-beta.6 - 2021-04-17 +## 3.0.0-beta.6 ### Added @@ -811,7 +900,7 @@ [#2158]: https://github.com/actix/actix-web/pull/2158 [#2161]: https://github.com/actix/actix-web/pull/2161 -## 3.0.0-beta.5 - 2021-04-02 +## 3.0.0-beta.5 ### Added @@ -833,7 +922,7 @@ [#2094]: https://github.com/actix/actix-web/pull/2094 [#2127]: https://github.com/actix/actix-web/pull/2127 -## 3.0.0-beta.4 - 2021-03-08 +## 3.0.0-beta.4 ### Changed @@ -851,11 +940,11 @@ [#2035]: https://github.com/actix/actix-web/pull/2035 [#2052]: https://github.com/actix/actix-web/pull/2052 -## 3.0.0-beta.3 - 2021-02-10 +## 3.0.0-beta.3 - No notable changes. -## 3.0.0-beta.2 - 2021-02-10 +## 3.0.0-beta.2 ### Added @@ -907,7 +996,7 @@ [#1964]: https://github.com/actix/actix-web/pull/1964 [#1969]: https://github.com/actix/actix-web/pull/1969 -## 3.0.0-beta.1 - 2021-01-07 +## 3.0.0-beta.1 ### Added @@ -935,7 +1024,7 @@
-## 2.2.2 - 2022-01-21 +## 2.2.2 ### Changed @@ -943,13 +1032,13 @@ [ad7e3c06]: https://github.com/actix/actix-web/commit/ad7e3c06 -## 2.2.1 - 2021-08-09 +## 2.2.1 ### Fixed - Potential HTTP request smuggling vulnerabilities. [RUSTSEC-2021-0081](https://github.com/rustsec/advisory-db/pull/977) -## 2.2.0 - 2020-11-25 +## 2.2.0 ### Added @@ -971,7 +1060,7 @@ [#1793]: https://github.com/actix/actix-web/pull/1793 [#1797]: https://github.com/actix/actix-web/pull/1797 -## 2.1.0 - 2020-10-30 +## 2.1.0 ### Added @@ -988,18 +1077,18 @@ [#1733]: https://github.com/actix/actix-web/pull/1733 [#1744]: https://github.com/actix/actix-web/pull/1744 -## 2.0.0 - 2020-09-11 +## 2.0.0 - No significant changes from `2.0.0-beta.4`. -## 2.0.0-beta.4 - 2020-09-09 +## 2.0.0-beta.4 ### Changed - Update actix-codec and actix-utils dependencies. - Update actix-connect and actix-tls dependencies. -## 2.0.0-beta.3 - 2020-08-14 +## 2.0.0-beta.3 ### Fixed @@ -1007,7 +1096,7 @@ [#1626]: https://github.com/actix/actix-web/pull/1626 -## 2.0.0-beta.2 - 2020-07-21 +## 2.0.0-beta.2 ### Fixed @@ -1020,7 +1109,7 @@ [#1614]: https://github.com/actix/actix-web/pull/1614 [#1615]: https://github.com/actix/actix-web/pull/1615 -## 2.0.0-beta.1 - 2020-07-11 +## 2.0.0-beta.1 ### Changed @@ -1033,7 +1122,7 @@ [#1586]: https://github.com/actix/actix-web/pull/1586 [#1580]: https://github.com/actix/actix-web/pull/1580 -## 2.0.0-alpha.4 - 2020-05-21 +## 2.0.0-alpha.4 ### Changed @@ -1049,7 +1138,7 @@ [#1439]: https://github.com/actix/actix-web/pull/1439 [#1503]: https://github.com/actix/actix-web/pull/1503 -## 2.0.0-alpha.3 - 2020-05-08 +## 2.0.0-alpha.3 ### Fixed @@ -1064,7 +1153,7 @@ [#1422]: https://github.com/actix/actix-web/pull/1422 [#1487]: https://github.com/actix/actix-web/pull/1487 -## 2.0.0-alpha.2 - 2020-03-07 +## 2.0.0-alpha.2 ### Changed @@ -1076,7 +1165,7 @@ [#1394]: https://github.com/actix/actix-web/pull/1394 [#1395]: https://github.com/actix/actix-web/pull/1395 -## 2.0.0-alpha.1 - 2020-02-27 +## 2.0.0-alpha.1 ### Changed @@ -1089,14 +1178,14 @@ - Allow `SameSite=None` cookies to be sent in a response. -## 1.0.1 - 2019-12-20 +## 1.0.1 ### Fixed - Poll upgrade service's readiness from HTTP service handlers - Replace brotli with brotli2 #1224 -## 1.0.0 - 2019-12-13 +## 1.0.0 ### Added @@ -1106,7 +1195,7 @@ - Replace `flate2-xxx` features with `compress` -## 1.0.0-alpha.5 - 2019-12-09 +## 1.0.0-alpha.5 ### Fixed @@ -1117,7 +1206,7 @@ - Websockets: Ping and Pong should have binary data #1049 -## 1.0.0-alpha.4 - 2019-12-08 +## 1.0.0-alpha.4 ### Added @@ -1127,14 +1216,14 @@ - Use rust based brotli compression library -## 1.0.0-alpha.3 - 2019-12-07 +## 1.0.0-alpha.3 ### Changed - Migrate to tokio 0.2 - Migrate to `std::future` -## 0.2.11 - 2019-11-06 +## 0.2.11 ### Added @@ -1148,7 +1237,7 @@ [#1878]: https://github.com/actix/actix-web/pull/1878 -## 0.2.10 - 2019-09-11 +## 0.2.10 ### Added @@ -1159,7 +1248,7 @@ - h2 will use error response #1080 - on_connect result isn't added to request extensions for http2 requests #1009 -## 0.2.9 - 2019-08-13 +## 0.2.9 ### Changed @@ -1171,7 +1260,7 @@ - Fixed a panic in the HTTP2 handshake in client HTTP requests (#1031) -## 0.2.8 - 2019-08-01 +## 0.2.8 ### Added @@ -1183,20 +1272,20 @@ - awc client panic #1016 - Invalid response with compression middleware enabled, but compression-related features disabled #997 -## 0.2.7 - 2019-07-18 +## 0.2.7 ### Added - Add support for downcasting response errors #986 -## 0.2.6 - 2019-07-17 +## 0.2.6 ### Changed - Replace `ClonableService` with local copy - Upgrade `rand` dependency version to 0.7 -## 0.2.5 - 2019-06-28 +## 0.2.5 ### Added @@ -1207,13 +1296,13 @@ - Use `encoding_rs` crate instead of unmaintained `encoding` crate - Add `Copy` and `Clone` impls for `ws::Codec` -## 0.2.4 - 2019-06-16 +## 0.2.4 ### Fixed - Do not compress NoContent (204) responses #918 -## 0.2.3 - 2019-06-02 +## 0.2.3 ### Added @@ -1224,19 +1313,19 @@ - SizedStream uses u64 -## 0.2.2 - 2019-05-29 +## 0.2.2 ### Fixed - Parse incoming stream before closing stream on disconnect #868 -## 0.2.1 - 2019-05-25 +## 0.2.1 ### Fixed - Handle socket read disconnect -## 0.2.0 - 2019-05-12 +## 0.2.0 ### Changed @@ -1247,13 +1336,13 @@ - `OneRequest` service -## 0.1.5 - 2019-05-04 +## 0.1.5 ### Fixed - Clean up response extensions in response pool #817 -## 0.1.4 - 2019-04-24 +## 0.1.4 ### Added @@ -1263,20 +1352,20 @@ - Read until eof for http/1.0 responses #771 -## 0.1.3 - 2019-04-23 +## 0.1.3 ### Fixed - Fix http client pool management - Fix http client wait queue management #794 -## 0.1.2 - 2019-04-23 +## 0.1.2 ### Fixed - Fix BorrowMutError panic in client connector #793 -## 0.1.1 - 2019-04-19 +## 0.1.1 ### Changed @@ -1284,7 +1373,7 @@ - Cookie::max_age_time() accepts value in time::Duration - Allow to specify server address for client connector -## 0.1.0 - 2019-04-16 +## 0.1.0 ### Added @@ -1295,7 +1384,7 @@ - `actix_http::encoding` always available - use trust-dns-resolver 0.11.0 -## 0.1.0-alpha.5 - 2019-04-12 +## 0.1.0-alpha.5 ### Added @@ -1307,7 +1396,7 @@ - MessageBody::length() renamed to MessageBody::size() for consistency - ws handshake verification functions take RequestHead instead of Request -## 0.1.0-alpha.4 - 2019-04-08 +## 0.1.0-alpha.4 ### Added @@ -1324,7 +1413,7 @@ - Removed PayloadBuffer -## 0.1.0-alpha.3 - 2019-04-02 +## 0.1.0-alpha.3 ### Added @@ -1336,7 +1425,7 @@ - Preallocate read buffer for h1 codec - Detect socket disconnection during protocol selection -## 0.1.0-alpha.2 - 2019-03-29 +## 0.1.0-alpha.2 ### Added @@ -1346,6 +1435,6 @@ - Do not use thread pool for decompression if chunk size is smaller than 2048. -## 0.1.0-alpha.1 - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/actix-http/Cargo.toml b/actix-http/Cargo.toml index 6909b785f..59a763736 100644 --- a/actix-http/Cargo.toml +++ b/actix-http/Cargo.toml @@ -1,72 +1,108 @@ [package] name = "actix-http" -version = "3.3.1" -authors = [ - "Nikolay Kim ", - "Rob Ede ", -] -description = "HTTP primitives for the Actix ecosystem" +version = "3.11.2" +authors = ["Nikolay Kim ", "Rob Ede "] +description = "HTTP types and services for the Actix ecosystem" keywords = ["actix", "http", "framework", "async", "futures"] homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" +repository = "https://github.com/actix/actix-web" categories = [ - "network-programming", - "asynchronous", - "web-programming::http-server", - "web-programming::websocket", + "network-programming", + "asynchronous", + "web-programming::http-server", + "web-programming::websocket", ] -license = "MIT OR Apache-2.0" -edition = "2021" +license.workspace = true +edition.workspace = true +rust-version.workspace = true [package.metadata.docs.rs] -# features that docs.rs will build with -features = ["http2", "ws", "openssl", "rustls", "compress-brotli", "compress-gzip", "compress-zstd"] +features = [ + "http2", + "ws", + "openssl", + "rustls-0_20", + "rustls-0_21", + "rustls-0_22", + "rustls-0_23", + "compress-brotli", + "compress-gzip", + "compress-zstd", +] -[lib] -name = "actix_http" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_codec::*", + "actix_service::*", + "actix_tls::*", + "actix_utils::*", + "bytes::*", + "bytestring::*", + "encoding_rs::*", + "futures_core::*", + "h2::*", + "http::*", + "httparse::*", + "language_tags::*", + "mime::*", + "openssl::*", + "rustls::*", + "tokio_util::*", + "tokio::*", +] [features] default = [] # HTTP/2 protocol support -http2 = ["h2"] +http2 = ["dep:h2"] # WebSocket protocol implementation -ws = [ - "local-channel", - "base64", - "rand", - "sha1", -] +ws = ["dep:local-channel", "dep:base64", "dep:rand", "dep:sha1"] # TLS via OpenSSL -openssl = ["actix-tls/accept", "actix-tls/openssl"] +openssl = ["__tls", "actix-tls/accept", "actix-tls/openssl"] -# TLS via Rustls -rustls = ["actix-tls/accept", "actix-tls/rustls"] +# TLS via Rustls v0.20 +rustls = ["__tls", "rustls-0_20"] + +# TLS via Rustls v0.20 +rustls-0_20 = ["__tls", "actix-tls/accept", "actix-tls/rustls-0_20"] + +# TLS via Rustls v0.21 +rustls-0_21 = ["__tls", "actix-tls/accept", "actix-tls/rustls-0_21"] + +# TLS via Rustls v0.22 +rustls-0_22 = ["__tls", "actix-tls/accept", "actix-tls/rustls-0_22"] + +# TLS via Rustls v0.23 +rustls-0_23 = ["__tls", "actix-tls/accept", "actix-tls/rustls-0_23"] # Compression codecs -compress-brotli = ["__compress", "brotli"] -compress-gzip = ["__compress", "flate2"] -compress-zstd = ["__compress", "zstd"] +compress-brotli = ["__compress", "dep:brotli"] +compress-gzip = ["__compress", "dep:flate2"] +compress-zstd = ["__compress", "dep:zstd"] -# Internal (PRIVATE!) features used to aid testing and cheking feature status. +# Internal (PRIVATE!) features used to aid testing and checking feature status. # Don't rely on these whatsoever. They are semver-exempt and may disappear at anytime. __compress = [] -[dependencies] -actix-service = "2" -actix-codec = "0.5" -actix-utils = "3" -actix-rt = { version = "2.2", default-features = false } +# Internal (PRIVATE!) features used to aid checking feature status. +# Don't rely on these whatsoever. They may disappear at anytime. +__tls = [] + +[dependencies] +actix-codec = "0.5" +actix-rt = { version = "2.2", default-features = false } +actix-service = "2" +actix-utils = "3" -ahash = "0.8" bitflags = "2" bytes = "1" bytestring = "1" -derive_more = "0.99.5" +derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] } encoding_rs = "0.8" +foldhash = "0.1" futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } http = "0.2.7" httparse = "1.5.1" @@ -77,55 +113,67 @@ mime = "0.3.4" percent-encoding = "2.1" pin-project-lite = "0.2" smallvec = "1.6.1" -tokio = { version = "1.24.2", features = [] } +tokio = { version = "1.38.2", features = [] } tokio-util = { version = "0.7", features = ["io", "codec"] } tracing = { version = "0.1.30", default-features = false, features = ["log"] } # http2 -h2 = { version = "0.3.17", optional = true } +h2 = { version = "0.3.27", optional = true } # websockets +base64 = { version = "0.22", optional = true } local-channel = { version = "0.1", optional = true } -base64 = { version = "0.21", optional = true } -rand = { version = "0.8", optional = true } +rand = { version = "0.9", optional = true } sha1 = { version = "0.10", optional = true } # openssl/rustls -actix-tls = { version = "3", default-features = false, optional = true } +actix-tls = { version = "3.4", default-features = false, optional = true } # compress-* -brotli = { version = "3.3.3", optional = true } +brotli = { version = "8", optional = true } flate2 = { version = "1.0.13", optional = true } -zstd = { version = "0.12", optional = true } +zstd = { version = "0.13", optional = true } [dev-dependencies] actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" -actix-tls = { version = "3", features = ["openssl"] } +actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23-webpki-roots"] } actix-web = "4" async-stream = "0.3" criterion = { version = "0.5", features = ["html_reports"] } -env_logger = "0.10" +divan = "0.1.8" +env_logger = "0.11" futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] } memchr = "2.4" -once_cell = "1.9" -rcgen = "0.11" +once_cell = "1.21" +rcgen = "0.13" regex = "1.3" +rustls-pki-types = "1.13.1" rustversion = "1" -rustls-pemfile = "1" -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1", features = ["derive"] } serde_json = "1.0" static_assertions = "1" tls-openssl = { package = "openssl", version = "0.10.55" } -tls-rustls = { package = "rustls", version = "0.20" } -tokio = { version = "1.24.2", features = ["net", "rt", "macros"] } +tls-rustls_023 = { package = "rustls", version = "0.23" } +tokio = { version = "1.38.2", features = ["net", "rt", "macros", "sync"] } + +[lints] +workspace = true [[example]] name = "ws" -required-features = ["ws", "rustls"] +required-features = ["ws", "rustls-0_23"] + +[[example]] +name = "tls_rustls" +required-features = ["http2", "rustls-0_23"] [[bench]] name = "response-body-compression" harness = false required-features = ["compress-brotli", "compress-gzip", "compress-zstd"] + +[[bench]] +name = "date-formatting" +harness = false diff --git a/actix-http/README.md b/actix-http/README.md index 0c5fcb6b6..08dae437c 100644 --- a/actix-http/README.md +++ b/actix-http/README.md @@ -1,22 +1,21 @@ -# actix-http +# `actix-http` -> HTTP primitives for the Actix ecosystem. +> HTTP types and services for the Actix ecosystem. + + [![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.3.1)](https://docs.rs/actix-http/3.3.1) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http.svg)
-[![dependency status](https://deps.rs/crate/actix-http/3.3.1/status.svg)](https://deps.rs/crate/actix-http/3.3.1) +[![dependency status](https://deps.rs/crate/actix-http/3.11.2/status.svg)](https://deps.rs/crate/actix-http/3.11.2) [![Download](https://img.shields.io/crates/d/actix-http.svg)](https://crates.io/crates/actix-http) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources + -- [API Documentation](https://docs.rs/actix-http) -- Minimum Supported Rust Version (MSRV): 1.65 - -## Example +## Examples ```rust use std::{env, io}; diff --git a/actix-http/benches/date-formatting.rs b/actix-http/benches/date-formatting.rs new file mode 100644 index 000000000..26d0f3daa --- /dev/null +++ b/actix-http/benches/date-formatting.rs @@ -0,0 +1,20 @@ +use std::time::SystemTime; + +use actix_http::header::HttpDate; +use divan::{black_box, AllocProfiler, Bencher}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +#[divan::bench] +fn date_formatting(b: Bencher<'_, '_>) { + let now = SystemTime::now(); + + b.bench(|| { + black_box(HttpDate::from(black_box(now)).to_string()); + }) +} + +fn main() { + divan::main(); +} diff --git a/actix-http/benches/response-body-compression.rs b/actix-http/benches/response-body-compression.rs index d128bf75b..53279e312 100644 --- a/actix-http/benches/response-body-compression.rs +++ b/actix-http/benches/response-body-compression.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use std::convert::Infallible; use actix_http::{encoding::Encoder, ContentEncoding, Request, Response, StatusCode}; diff --git a/actix-http/examples/actix-web.rs b/actix-http/examples/actix-web.rs index 449e5899b..e07abfd97 100644 --- a/actix-http/examples/actix-web.rs +++ b/actix-http/examples/actix-web.rs @@ -1,10 +1,10 @@ use actix_http::HttpService; use actix_server::Server; use actix_service::map_config; -use actix_web::{dev::AppConfig, get, App}; +use actix_web::{dev::AppConfig, get, App, Responder}; #[get("/")] -async fn index() -> &'static str { +async fn index() -> impl Responder { "Hello, world. From Actix Web!" } diff --git a/actix-http/examples/echo.rs b/actix-http/examples/echo.rs index ae6f00cce..11fd2750e 100644 --- a/actix-http/examples/echo.rs +++ b/actix-http/examples/echo.rs @@ -23,7 +23,7 @@ async fn main() -> io::Result<()> { body.extend_from_slice(&item?); } - info!("request body: {:?}", body); + info!("request body: {body:?}"); let res = Response::build(StatusCode::OK) .insert_header(("x-head", HeaderValue::from_static("dummy value!"))) @@ -31,8 +31,7 @@ async fn main() -> io::Result<()> { Ok::<_, Error>(res) }) - // No TLS - .tcp() + .tcp() // No TLS })? .run() .await diff --git a/actix-http/examples/h2c-detect.rs b/actix-http/examples/h2c-detect.rs index aa3dd5d31..b0bde3fe6 100644 --- a/actix-http/examples/h2c-detect.rs +++ b/actix-http/examples/h2c-detect.rs @@ -8,7 +8,7 @@ use std::{convert::Infallible, io}; -use actix_http::{HttpService, Request, Response, StatusCode}; +use actix_http::{body::BodyStream, HttpService, Request, Response, StatusCode}; use actix_server::Server; #[tokio::main(flavor = "current_thread")] @@ -19,7 +19,12 @@ async fn main() -> io::Result<()> { .bind("h2c-detect", ("127.0.0.1", 8080), || { HttpService::build() .finish(|_req: Request| async move { - Ok::<_, Infallible>(Response::build(StatusCode::OK).body("Hello!")) + Ok::<_, Infallible>(Response::build(StatusCode::OK).body(BodyStream::new( + futures_util::stream::iter([ + Ok::<_, String>("123".into()), + Err("wertyuikmnbvcxdfty6t".to_owned()), + ]), + ))) }) .tcp_auto_h2c() })? diff --git a/actix-http/examples/hello-world.rs b/actix-http/examples/hello-world.rs index cf10beddf..afa3883a4 100644 --- a/actix-http/examples/hello-world.rs +++ b/actix-http/examples/hello-world.rs @@ -17,7 +17,7 @@ async fn main() -> io::Result<()> { ext.insert(42u32); }) .finish(|req: Request| async move { - info!("{:?}", req); + info!("{req:?}"); let mut res = Response::build(StatusCode::OK); res.insert_header(("x-head", HeaderValue::from_static("dummy value!"))); diff --git a/actix-http/examples/streaming-error.rs b/actix-http/examples/streaming-error.rs index 8c8a249cb..8d494b64e 100644 --- a/actix-http/examples/streaming-error.rs +++ b/actix-http/examples/streaming-error.rs @@ -22,16 +22,16 @@ async fn main() -> io::Result<()> { .bind("streaming-error", ("127.0.0.1", 8080), || { HttpService::build() .finish(|req| async move { - info!("{:?}", req); + info!("{req:?}"); let res = Response::ok(); Ok::<_, Infallible>(res.set_body(BodyStream::new(stream! { yield Ok(Bytes::from("123")); yield Ok(Bytes::from("456")); - actix_rt::time::sleep(Duration::from_millis(1000)).await; + actix_rt::time::sleep(Duration::from_secs(1)).await; - yield Err(io::Error::new(io::ErrorKind::Other, "")); + yield Err(io::Error::other("abc")); }))) }) .tcp() diff --git a/actix-http/examples/tls_rustls.rs b/actix-http/examples/tls_rustls.rs new file mode 100644 index 000000000..a1db91b12 --- /dev/null +++ b/actix-http/examples/tls_rustls.rs @@ -0,0 +1,65 @@ +//! Demonstrates TLS configuration (via Rustls) for HTTP/1.1 and HTTP/2 connections. +//! +//! Test using cURL: +//! +//! ```console +//! $ curl --insecure https://127.0.0.1:8443 +//! Hello World! +//! Protocol: HTTP/2.0 +//! +//! $ curl --insecure --http1.1 https://127.0.0.1:8443 +//! Hello World! +//! Protocol: HTTP/1.1 +//! ``` + +extern crate tls_rustls_023 as rustls; + +use std::io; + +use actix_http::{Error, HttpService, Request, Response}; +use actix_utils::future::ok; + +#[actix_rt::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + tracing::info!("starting HTTP server at https://127.0.0.1:8443"); + + actix_server::Server::build() + .bind("echo", ("127.0.0.1", 8443), || { + HttpService::build() + .finish(|req: Request| { + let body = format!( + "Hello World!\n\ + Protocol: {:?}", + req.head().version + ); + ok::<_, Error>(Response::ok().set_body(body)) + }) + .rustls_0_23(rustls_config()) + })? + .run() + .await +} + +fn rustls_config() -> rustls::ServerConfig { + let rcgen::CertifiedKey { cert, key_pair } = + rcgen::generate_simple_self_signed(["localhost".to_owned()]).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, key_der) + .unwrap(); + + const H1_ALPN: &[u8] = b"http/1.1"; + const H2_ALPN: &[u8] = b"h2"; + + config.alpn_protocols.push(H2_ALPN.to_vec()); + config.alpn_protocols.push(H1_ALPN.to_vec()); + + config +} diff --git a/actix-http/examples/ws.rs b/actix-http/examples/ws.rs index 6af6d5095..9750a1a2a 100644 --- a/actix-http/examples/ws.rs +++ b/actix-http/examples/ws.rs @@ -1,7 +1,7 @@ //! Sets up a WebSocket server over TCP and TLS. //! Sends a heartbeat message every 4 seconds but does not respond to any incoming frames. -extern crate tls_rustls as rustls; +extern crate tls_rustls_023 as rustls; use std::{ io, @@ -17,7 +17,6 @@ use bytes::{Bytes, BytesMut}; use bytestring::ByteString; use futures_core::{ready, Stream}; use tokio_util::codec::Encoder; -use tracing::{info, trace}; #[actix_rt::main] async fn main() -> io::Result<()> { @@ -28,19 +27,21 @@ async fn main() -> io::Result<()> { HttpService::build().h1(handler).tcp() })? .bind("tls", ("127.0.0.1", 8443), || { - HttpService::build().finish(handler).rustls(tls_config()) + HttpService::build() + .finish(handler) + .rustls_0_23(tls_config()) })? .run() .await } async fn handler(req: Request) -> Result>, Error> { - info!("handshaking"); + tracing::info!("handshaking"); let mut res = ws::handshake(req.head())?; // handshake will always fail under HTTP/2 - info!("responding"); + tracing::info!("responding"); res.message_body(BodyStream::new(Heartbeat::new(ws::Codec::new()))) } @@ -62,7 +63,7 @@ impl Stream for Heartbeat { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - trace!("poll"); + tracing::trace!("poll"); ready!(self.as_mut().interval.poll_tick(cx)); @@ -81,29 +82,16 @@ impl Stream for Heartbeat { } fn tls_config() -> rustls::ServerConfig { - use std::io::BufReader; - - use rustls::{Certificate, PrivateKey}; - use rustls_pemfile::{certs, pkcs8_private_keys}; - - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_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) - .unwrap() - .into_iter() - .map(Certificate) - .collect(); - let mut keys = pkcs8_private_keys(key_file).unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = + rcgen::generate_simple_self_signed(["localhost".to_owned()]).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_safe_defaults() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKey(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap(); config.alpn_protocols.push(b"http/1.1".to_vec()); diff --git a/actix-http/src/body/body_stream.rs b/actix-http/src/body/body_stream.rs index 4574b2519..657ffe9c8 100644 --- a/actix-http/src/body/body_stream.rs +++ b/actix-http/src/body/body_stream.rs @@ -131,7 +131,7 @@ mod tests { assert_eq!(to_bytes(body).await.ok(), Some(Bytes::from("12"))); } #[derive(Debug, Display, Error)] - #[display(fmt = "stream error")] + #[display("stream error")] struct StreamErr; #[actix_rt::test] diff --git a/actix-http/src/body/message_body.rs b/actix-http/src/body/message_body.rs index c3f55ce7d..739fe5027 100644 --- a/actix-http/src/body/message_body.rs +++ b/actix-http/src/body/message_body.rs @@ -531,7 +531,6 @@ where mod tests { use actix_rt::pin; use actix_utils::future::poll_fn; - use bytes::{Bytes, BytesMut}; use futures_util::stream; use super::*; diff --git a/actix-http/src/body/utils.rs b/actix-http/src/body/utils.rs index d1449179f..a234222aa 100644 --- a/actix-http/src/body/utils.rs +++ b/actix-http/src/body/utils.rs @@ -38,7 +38,7 @@ pub async fn to_bytes(body: B) -> Result { /// Error type returned from [`to_bytes_limited`] when body produced exceeds limit. #[derive(Debug, Display, Error)] -#[display(fmt = "limit exceeded while collecting body bytes")] +#[display("limit exceeded while collecting body bytes")] #[non_exhaustive] pub struct BodyLimitExceeded; @@ -190,7 +190,7 @@ mod tests { #[actix_rt::test] async fn to_body_limit_error() { - let err_stream = stream::once(async { Err(io::Error::new(io::ErrorKind::Other, "")) }); + let err_stream = stream::once(async { Err(io::Error::other("")) }); let body = SizedStream::new(8, err_stream); // not too big, but propagates error from body stream assert!(to_bytes_limited(body, 10).await.unwrap().is_err()); diff --git a/actix-http/src/builder.rs b/actix-http/src/builder.rs index 916083a98..09b379e87 100644 --- a/actix-http/src/builder.rs +++ b/actix-http/src/builder.rs @@ -7,7 +7,7 @@ use crate::{ body::{BoxBody, MessageBody}, h1::{self, ExpectHandler, H1Service, UpgradeHandler}, service::HttpService, - ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfig, + ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfigBuilder, }; /// An HTTP service builder. @@ -19,6 +19,7 @@ pub struct HttpServiceBuilder { client_disconnect_timeout: Duration, secure: bool, local_addr: Option, + h1_allow_half_closed: bool, expect: X, upgrade: Option, on_connect_ext: Option>>, @@ -40,6 +41,7 @@ where client_disconnect_timeout: Duration::ZERO, secure: false, local_addr: None, + h1_allow_half_closed: true, // dispatcher parts expect: ExpectHandler, @@ -124,6 +126,18 @@ where 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 + } + /// Provide service for `EXPECT: 100-Continue` support. /// /// Service get called with request that contains `EXPECT` header. @@ -142,6 +156,7 @@ where client_disconnect_timeout: self.client_disconnect_timeout, secure: self.secure, local_addr: self.local_addr, + h1_allow_half_closed: self.h1_allow_half_closed, expect: expect.into_factory(), upgrade: self.upgrade, on_connect_ext: self.on_connect_ext, @@ -166,6 +181,7 @@ where client_disconnect_timeout: self.client_disconnect_timeout, secure: self.secure, local_addr: self.local_addr, + h1_allow_half_closed: self.h1_allow_half_closed, expect: self.expect, upgrade: Some(upgrade.into_factory()), on_connect_ext: self.on_connect_ext, @@ -195,13 +211,14 @@ where S::InitError: fmt::Debug, S::Response: Into>, { - 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) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .build(); H1Service::with_config(cfg, service.into_factory()) .expect(self.expect) @@ -220,13 +237,14 @@ 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) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .build(); crate::h2::H2Service::with_config(cfg, service.into_factory()) .on_connect_ext(self.on_connect_ext) @@ -242,13 +260,14 @@ 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) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .build(); HttpService::with_config(cfg, service.into_factory()) .expect(self.expect) diff --git a/actix-http/src/config.rs b/actix-http/src/config.rs index b3b215da4..96e2aef07 100644 --- a/actix-http/src/config.rs +++ b/actix-http/src/config.rs @@ -1,5 +1,5 @@ use std::{ - net, + net::SocketAddr, rc::Rc, time::{Duration, Instant}, }; @@ -8,8 +8,76 @@ use bytes::BytesMut; use crate::{date::DateService, KeepAlive}; +/// 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) -> 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 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 + } + + /// 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); #[derive(Debug)] @@ -18,19 +86,22 @@ struct Inner { client_request_timeout: Duration, client_disconnect_timeout: Duration, secure: bool, - local_addr: Option, + local_addr: Option, date_service: DateService, + h1_allow_half_closed: bool, } -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, + date_service: DateService::new(), + h1_allow_half_closed: true, + } } } @@ -41,7 +112,7 @@ impl ServiceConfig { client_request_timeout: Duration, client_disconnect_timeout: Duration, secure: bool, - local_addr: Option, + local_addr: Option, ) -> ServiceConfig { ServiceConfig(Rc::new(Inner { keep_alive: keep_alive.normalize(), @@ -50,6 +121,7 @@ impl ServiceConfig { secure, local_addr, date_service: DateService::new(), + h1_allow_half_closed: true, })) } @@ -63,7 +135,7 @@ impl ServiceConfig { /// /// Returns `None` for connections via UDS (Unix Domain Socket). #[inline] - pub fn local_addr(&self) -> Option { + pub fn local_addr(&self) -> Option { self.0.local_addr } @@ -100,6 +172,15 @@ 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 + } + pub(crate) fn now(&self) -> Instant { self.0.date_service.now() } diff --git a/actix-http/src/date.rs b/actix-http/src/date.rs index 1358bbd8c..735dd9100 100644 --- a/actix-http/src/date.rs +++ b/actix-http/src/date.rs @@ -28,7 +28,7 @@ impl Date { fn update(&mut self) { self.pos = 0; - write!(self, "{}", httpdate::fmt_http_date(SystemTime::now())).unwrap(); + write!(self, "{}", httpdate::HttpDate::from(SystemTime::now())).unwrap(); } } diff --git a/actix-http/src/encoding/decoder.rs b/actix-http/src/encoding/decoder.rs index e9aba25e7..1247c0a55 100644 --- a/actix-http/src/encoding/decoder.rs +++ b/actix-http/src/encoding/decoder.rs @@ -100,10 +100,7 @@ where loop { if let Some(ref mut fut) = this.fut { let (chunk, decoder) = ready!(Pin::new(fut).poll(cx)).map_err(|_| { - PayloadError::Io(io::Error::new( - io::ErrorKind::Other, - "Blocking task was cancelled unexpectedly", - )) + PayloadError::Io(io::Error::other("Blocking task was cancelled unexpectedly")) })??; *this.decoder = Some(decoder); @@ -191,7 +188,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-gzip")] @@ -205,7 +202,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-gzip")] @@ -218,7 +215,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-zstd")] @@ -231,7 +228,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, } } @@ -250,7 +247,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-gzip")] @@ -265,7 +262,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-gzip")] @@ -280,7 +277,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, #[cfg(feature = "compress-zstd")] @@ -295,7 +292,7 @@ impl ContentDecoder { Ok(None) } } - Err(e) => Err(e), + Err(err) => Err(err), }, } } diff --git a/actix-http/src/encoding/encoder.rs b/actix-http/src/encoding/encoder.rs index 527bfebaa..0da95c462 100644 --- a/actix-http/src/encoding/encoder.rs +++ b/actix-http/src/encoding/encoder.rs @@ -50,10 +50,21 @@ impl Encoder { } } + fn empty() -> Self { + Encoder { + body: EncoderBody::Full { body: Bytes::new() }, + encoder: None, + fut: None, + eof: true, + } + } + pub fn response(encoding: ContentEncoding, head: &mut ResponseHead, body: B) -> Self { - // no need to compress an empty body - if matches!(body.size(), BodySize::None) { - return Self::none(); + // no need to compress empty bodies + match body.size() { + BodySize::None => return Self::none(), + BodySize::Sized(0) => return Self::empty(), + _ => {} } let should_encode = !(head.headers().contains_key(&CONTENT_ENCODING) @@ -172,8 +183,7 @@ where if let Some(ref mut fut) = this.fut { let mut encoder = ready!(Pin::new(fut).poll(cx)) .map_err(|_| { - EncoderError::Io(io::Error::new( - io::ErrorKind::Other, + EncoderError::Io(io::Error::other( "Blocking task was cancelled unexpectedly", )) })? @@ -404,11 +414,11 @@ fn new_brotli_compressor() -> Box> { #[non_exhaustive] pub enum EncoderError { /// Wrapped body stream error. - #[display(fmt = "body")] + #[display("body")] Body(Box), /// Generic I/O error. - #[display(fmt = "io")] + #[display("io")] Io(io::Error), } diff --git a/actix-http/src/error.rs b/actix-http/src/error.rs index fbd2eb7ae..e4d640518 100644 --- a/actix-http/src/error.rs +++ b/actix-http/src/error.rs @@ -3,7 +3,7 @@ use std::{error::Error as StdError, fmt, io, str::Utf8Error, string::FromUtf8Error}; use derive_more::{Display, Error, From}; -pub use http::Error as HttpError; +pub use http::{status::InvalidStatusCode, Error as HttpError}; use http::{uri::InvalidUri, StatusCode}; use crate::{body::BoxBody, Response}; @@ -80,28 +80,28 @@ impl From for Response { #[derive(Debug, Clone, Copy, PartialEq, Eq, Display)] pub(crate) enum Kind { - #[display(fmt = "error processing HTTP")] + #[display("error processing HTTP")] Http, - #[display(fmt = "error parsing HTTP message")] + #[display("error parsing HTTP message")] Parse, - #[display(fmt = "request payload read error")] + #[display("request payload read error")] Payload, - #[display(fmt = "response body write error")] + #[display("response body write error")] Body, - #[display(fmt = "send response error")] + #[display("send response error")] SendResponse, - #[display(fmt = "error in WebSocket process")] + #[display("error in WebSocket process")] Ws, - #[display(fmt = "connection error")] + #[display("connection error")] Io, - #[display(fmt = "encoder error")] + #[display("encoder error")] Encoder, } @@ -160,44 +160,44 @@ impl From for Error { #[non_exhaustive] pub enum ParseError { /// An invalid `Method`, such as `GE.T`. - #[display(fmt = "invalid method specified")] + #[display("invalid method specified")] Method, /// An invalid `Uri`, such as `exam ple.domain`. - #[display(fmt = "URI error: {}", _0)] + #[display("URI error: {}", _0)] Uri(InvalidUri), /// An invalid `HttpVersion`, such as `HTP/1.1` - #[display(fmt = "invalid HTTP version specified")] + #[display("invalid HTTP version specified")] Version, /// An invalid `Header`. - #[display(fmt = "invalid Header provided")] + #[display("invalid Header provided")] Header, /// A message head is too large to be reasonable. - #[display(fmt = "message head is too large")] + #[display("message head is too large")] TooLarge, /// A message reached EOF, but is not complete. - #[display(fmt = "message is incomplete")] + #[display("message is incomplete")] Incomplete, /// An invalid `Status`, such as `1337 ELITE`. - #[display(fmt = "invalid status provided")] + #[display("invalid status provided")] Status, /// A timeout occurred waiting for an IO event. #[allow(dead_code)] - #[display(fmt = "timeout")] + #[display("timeout")] Timeout, /// An I/O error that occurred while trying to read or write to a network stream. - #[display(fmt = "I/O error: {}", _0)] + #[display("I/O error: {}", _0)] Io(io::Error), /// Parsing a field as string failed. - #[display(fmt = "UTF-8 error: {}", _0)] + #[display("UTF-8 error: {}", _0)] Utf8(Utf8Error), } @@ -256,28 +256,28 @@ impl From for Response { #[non_exhaustive] pub enum PayloadError { /// A payload reached EOF, but is not complete. - #[display(fmt = "payload reached EOF before completing: {:?}", _0)] + #[display("payload reached EOF before completing: {:?}", _0)] Incomplete(Option), /// Content encoding stream corruption. - #[display(fmt = "can not decode content-encoding")] + #[display("can not decode content-encoding")] EncodingCorrupted, /// Payload reached size limit. - #[display(fmt = "payload reached size limit")] + #[display("payload reached size limit")] Overflow, /// Payload length is unknown. - #[display(fmt = "payload length is unknown")] + #[display("payload length is unknown")] UnknownLength, /// HTTP/2 payload error. #[cfg(feature = "http2")] - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Http2Payload(::h2::Error), /// Generic I/O error. - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Io(io::Error), } @@ -326,44 +326,44 @@ impl From for Error { #[non_exhaustive] pub enum DispatchError { /// Service error. - #[display(fmt = "service error")] + #[display("service error")] Service(Response), /// Body streaming error. - #[display(fmt = "body error: {}", _0)] + #[display("body error: {}", _0)] Body(Box), /// Upgrade service error. - #[display(fmt = "upgrade error")] + #[display("upgrade error")] Upgrade, /// An `io::Error` that occurred while trying to read or write to a network stream. - #[display(fmt = "I/O error: {}", _0)] + #[display("I/O error: {}", _0)] Io(io::Error), /// Request parse error. - #[display(fmt = "request parse error: {}", _0)] + #[display("request parse error: {}", _0)] Parse(ParseError), /// HTTP/2 error. - #[display(fmt = "{}", _0)] + #[display("{}", _0)] #[cfg(feature = "http2")] H2(h2::Error), /// The first request did not complete within the specified timeout. - #[display(fmt = "request did not complete within the specified timeout")] + #[display("request did not complete within the specified timeout")] SlowRequestTimeout, /// Disconnect timeout. Makes sense for TLS streams. - #[display(fmt = "connection shutdown timeout")] + #[display("connection shutdown timeout")] DisconnectTimeout, /// Handler dropped payload before reading EOF. - #[display(fmt = "handler dropped payload before reading EOF")] + #[display("handler dropped payload before reading EOF")] HandlerDroppedPayload, /// Internal error. - #[display(fmt = "internal error")] + #[display("internal error")] InternalError, } @@ -389,19 +389,17 @@ impl StdError for DispatchError { #[non_exhaustive] pub enum ContentTypeError { /// Can not parse content type. - #[display(fmt = "could not parse content type")] + #[display("could not parse content type")] ParseError, /// Unknown content encoding. - #[display(fmt = "unknown content encoding")] + #[display("unknown content encoding")] UnknownEncoding, } #[cfg(test)] mod tests { - use std::io; - - use http::{Error as HttpError, StatusCode}; + use http::Error as HttpError; use super::*; @@ -417,7 +415,7 @@ mod tests { #[test] fn test_as_response() { - let orig = io::Error::new(io::ErrorKind::Other, "other"); + let orig = io::Error::other("other"); let err: Error = ParseError::Io(orig).into(); assert_eq!( format!("{}", err), @@ -427,14 +425,14 @@ mod tests { #[test] fn test_error_display() { - let orig = io::Error::new(io::ErrorKind::Other, "other"); + let orig = io::Error::other("other"); let err = Error::new_io().with_cause(orig); assert_eq!("connection error: other", err.to_string()); } #[test] fn test_error_http_response() { - let orig = io::Error::new(io::ErrorKind::Other, "other"); + let orig = io::Error::other("other"); let err = Error::new_io().with_cause(orig); let resp: Response = err.into(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); @@ -442,7 +440,7 @@ mod tests { #[test] fn test_payload_error() { - let err: PayloadError = io::Error::new(io::ErrorKind::Other, "ParseError").into(); + let err: PayloadError = io::Error::other("ParseError").into(); assert!(err.to_string().contains("ParseError")); let err = PayloadError::Incomplete(None); @@ -477,7 +475,7 @@ mod tests { #[test] fn test_from() { - from_and_cause!(io::Error::new(io::ErrorKind::Other, "other") => ParseError::Io(..)); + from_and_cause!(io::Error::other("other") => ParseError::Io(..)); from!(httparse::Error::HeaderName => ParseError::Header); from!(httparse::Error::HeaderName => ParseError::Header); from!(httparse::Error::HeaderValue => ParseError::Header); diff --git a/actix-http/src/extensions.rs b/actix-http/src/extensions.rs index f2047a9ce..9c85caf37 100644 --- a/actix-http/src/extensions.rs +++ b/actix-http/src/extensions.rs @@ -31,7 +31,7 @@ impl Hasher for NoOpHasher { /// All entries into this map must be owned types (or static references). #[derive(Default)] pub struct Extensions { - /// Use AHasher with a std HashMap with for faster lookups on the small `TypeId` keys. + // use no-op hasher with a std HashMap with for faster lookups on the small `TypeId` keys map: HashMap, BuildHasherDefault>, } @@ -104,6 +104,46 @@ impl Extensions { .and_then(|boxed| boxed.downcast_mut()) } + /// Inserts the given `value` into the extensions if it is not present, then returns a reference + /// to the value in the extensions. + /// + /// ``` + /// # use actix_http::Extensions; + /// let mut map = Extensions::new(); + /// assert_eq!(map.get::>(), None); + /// + /// map.get_or_insert(Vec::::new()).push(1); + /// assert_eq!(map.get::>(), Some(&vec![1])); + /// + /// map.get_or_insert(Vec::::new()).push(2); + /// assert_eq!(map.get::>(), Some(&vec![1,2])); + /// ``` + pub fn get_or_insert(&mut self, value: T) -> &mut T { + self.get_or_insert_with(|| value) + } + + /// Inserts a value computed from `f` into the extensions if the given `value` is not present, + /// then returns a reference to the value in the extensions. + /// + /// ``` + /// # use actix_http::Extensions; + /// let mut map = Extensions::new(); + /// assert_eq!(map.get::>(), None); + /// + /// map.get_or_insert_with(Vec::::new).push(1); + /// assert_eq!(map.get::>(), Some(&vec![1])); + /// + /// map.get_or_insert_with(Vec::::new).push(2); + /// assert_eq!(map.get::>(), Some(&vec![1,2])); + /// ``` + pub fn get_or_insert_with T>(&mut self, default: F) -> &mut T { + self.map + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(default())) + .downcast_mut() + .expect("extensions map should now contain a T value") + } + /// Remove an item from the map of a given type. /// /// If an item of this type was already stored, it will be returned. diff --git a/actix-http/src/h1/codec.rs b/actix-http/src/h1/codec.rs index 8dae2e43e..2b452f8f8 100644 --- a/actix-http/src/h1/codec.rs +++ b/actix-http/src/h1/codec.rs @@ -198,9 +198,6 @@ impl Encoder, BodySize)>> for Codec { #[cfg(test)] mod tests { - use bytes::BytesMut; - use http::Method; - use super::*; use crate::HttpMessage as _; diff --git a/actix-http/src/h1/decoder.rs b/actix-http/src/h1/decoder.rs index 9e9b253d6..af64e8802 100644 --- a/actix-http/src/h1/decoder.rs +++ b/actix-http/src/h1/decoder.rs @@ -532,7 +532,7 @@ impl Decoder for PayloadDecoder { *state = match state.step(src, size, &mut buf) { Poll::Pending => return Ok(None), Poll::Ready(Ok(state)) => state, - Poll::Ready(Err(e)) => return Err(e), + Poll::Ready(Err(err)) => return Err(err), }; if *state == ChunkedState::End { @@ -563,15 +563,8 @@ impl Decoder for PayloadDecoder { #[cfg(test)] mod tests { - use bytes::{Bytes, BytesMut}; - use http::{Method, Version}; - use super::*; - use crate::{ - error::ParseError, - header::{HeaderName, SET_COOKIE}, - HttpMessage as _, - }; + use crate::{header::SET_COOKIE, HttpMessage as _}; impl PayloadType { pub(crate) fn unwrap(self) -> PayloadDecoder { diff --git a/actix-http/src/h1/dispatcher.rs b/actix-http/src/h1/dispatcher.rs index 6d332010a..ac19ef935 100644 --- a/actix-http/src/h1/dispatcher.rs +++ b/actix-http/src/h1/dispatcher.rs @@ -386,7 +386,14 @@ where let mut this = self.project(); this.state.set(match size { BodySize::None | BodySize::Sized(0) => { - this.flags.insert(Flags::FINISHED); + let payload_unfinished = this.payload.is_some(); + + if payload_unfinished { + this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); + } else { + this.flags.insert(Flags::FINISHED); + } + State::None } _ => State::SendPayload { body }, @@ -404,7 +411,14 @@ where let mut this = self.project(); this.state.set(match size { BodySize::None | BodySize::Sized(0) => { - this.flags.insert(Flags::FINISHED); + let payload_unfinished = this.payload.is_some(); + + if payload_unfinished { + this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); + } else { + this.flags.insert(Flags::FINISHED); + } + State::None } _ => State::SendErrorPayload { body }, @@ -503,17 +517,31 @@ 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 payload_unfinished = this.payload.is_some(); + 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 && payload_unfinished { + this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); + } else { + this.flags.insert(Flags::FINISHED); + } continue 'res; } Poll::Ready(Some(Err(err))) => { + let err = err.into(); + tracing::error!("Response payload stream error: {err:?}"); this.flags.insert(Flags::FINISHED); - return Err(DispatchError::Body(err.into())); + return Err(DispatchError::Body(err)); } Poll::Pending => return Ok(PollResponse::DoNothing), @@ -540,15 +568,28 @@ 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 payload_unfinished = this.payload.is_some(); + 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 && payload_unfinished { + this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); + } else { + this.flags.insert(Flags::FINISHED); + } continue 'res; } Poll::Ready(Some(Err(err))) => { + tracing::error!("Response payload stream error: {err:?}"); this.flags.insert(Flags::FINISHED); return Err(DispatchError::Body( Error::new_body().with_cause(err).into(), @@ -703,7 +744,7 @@ where req.head_mut().peer_addr = *this.peer_addr; - req.conn_data = this.conn_data.as_ref().map(Rc::clone); + req.conn_data.clone_from(this.conn_data); match this.codec.message_type() { // request has no payload @@ -1179,8 +1220,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); } @@ -1214,6 +1263,9 @@ where inner_p.shutdown_timer, ); + if inner_p.flags.contains(Flags::SHUTDOWN) { + cx.waker().wake_by_ref(); + } Poll::Pending }; diff --git a/actix-http/src/h1/dispatcher_tests.rs b/actix-http/src/h1/dispatcher_tests.rs index 50259e6ce..49582ad8a 100644 --- a/actix-http/src/h1/dispatcher_tests.rs +++ b/actix-http/src/h1/dispatcher_tests.rs @@ -1,4 +1,10 @@ -use std::{future::Future, str, task::Poll, time::Duration}; +use std::{ + future::Future, + pin::Pin, + str, + task::{Context, Poll}, + time::Duration, +}; use actix_codec::Framed; use actix_rt::{pin, time::sleep}; @@ -9,7 +15,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 +23,26 @@ use crate::{ Error, HttpMessage, KeepAlive, Method, OnConnectData, Request, Response, StatusCode, }; +struct YieldService; + +impl Service for YieldService { + type Response = Response; + type Error = Response; + type Future = Pin>>>; + + 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()) + }) + } +} + fn find_slice(haystack: &[u8], needle: &[u8], from: usize) -> Option { memchr::memmem::find(&haystack[from..], needle) } @@ -509,6 +535,73 @@ async fn pipelining_ok_then_ok() { .await; } +#[actix_rt::test] +async fn early_response_with_payload_closes_connection() { + lazy(|cx| { + let buf = TestBuffer::new( + "\ + GET /unfinished HTTP/1.1\r\n\ + Content-Length: 2\r\n\ + \r\n\ + ", + ); + + 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 => panic!("Should have shut down"), + Poll::Ready(res) => assert!(res.is_ok()), + } + + // polls: initial => shutdown + assert_eq!(h1.poll_count, 2); + + { + let mut res = buf.write_buf_slice_mut(); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = b"\ + HTTP/1.1 200 OK\r\n\ + content-length: 11\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) + ); + } + }) + .await; +} + #[actix_rt::test] async fn pipelining_ok_then_bad() { lazy(|cx| { @@ -924,6 +1017,91 @@ async fn handler_drop_payload() { .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::); + + 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::); + 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 { .. })) +} + fn http_msg(msg: impl AsRef) -> BytesMut { let mut msg = msg .as_ref() diff --git a/actix-http/src/h1/encoder.rs b/actix-http/src/h1/encoder.rs index abe396ce2..81af7868b 100644 --- a/actix-http/src/h1/encoder.rs +++ b/actix-http/src/h1/encoder.rs @@ -310,10 +310,10 @@ impl MessageType for RequestHeadType { Version::HTTP_11 => "HTTP/1.1", Version::HTTP_2 => "HTTP/2.0", Version::HTTP_3 => "HTTP/3.0", - _ => return Err(io::Error::new(io::ErrorKind::Other, "unsupported version")), + _ => return Err(io::Error::other("Unsupported version")), } ) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) + .map_err(io::Error::other) } } @@ -433,7 +433,7 @@ impl TransferEncoding { buf.extend_from_slice(b"0\r\n\r\n"); } else { writeln!(helpers::MutWriter(buf), "{:X}\r", msg.len()) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)?; buf.reserve(msg.len() + 2); buf.extend_from_slice(msg); diff --git a/actix-http/src/h1/payload.rs b/actix-http/src/h1/payload.rs index 1ed785a1b..92875a9db 100644 --- a/actix-http/src/h1/payload.rs +++ b/actix-http/src/h1/payload.rs @@ -117,6 +117,7 @@ impl PayloadSender { } } + #[allow(clippy::needless_pass_by_ref_mut)] #[inline] pub fn need_read(&self, cx: &mut Context<'_>) -> PayloadStatus { // we check need_read only if Payload (other side) is alive, @@ -174,23 +175,19 @@ impl Inner { /// Register future waiting data from payload. /// Waker would be used in `Inner::wake` - fn register(&mut self, cx: &mut Context<'_>) { - if self - .task - .as_ref() - .map_or(true, |w| !cx.waker().will_wake(w)) - { + fn register(&mut self, cx: &Context<'_>) { + if self.task.as_ref().is_none_or(|w| !cx.waker().will_wake(w)) { self.task = Some(cx.waker().clone()); } } // Register future feeding data to payload. /// Waker would be used in `Inner::wake_io` - fn register_io(&mut self, cx: &mut Context<'_>) { + fn register_io(&mut self, cx: &Context<'_>) { 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()); } @@ -199,11 +196,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] @@ -221,7 +220,7 @@ impl Inner { fn poll_next( mut self: Pin<&mut Self>, - cx: &mut Context<'_>, + cx: &Context<'_>, ) -> Poll>> { if let Some(data) = self.items.pop_front() { self.len -= data.len(); @@ -252,8 +251,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::*; @@ -262,6 +266,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>, + ) -> (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); diff --git a/actix-http/src/h1/service.rs b/actix-http/src/h1/service.rs index fbda7138e..2cf76edb2 100644 --- a/actix-http/src/h1/service.rs +++ b/actix-http/src/h1/service.rs @@ -152,13 +152,13 @@ mod openssl { } } -#[cfg(feature = "rustls")] -mod rustls { +#[cfg(feature = "rustls-0_20")] +mod rustls_0_20 { use std::io; use actix_service::ServiceFactoryExt as _; use actix_tls::accept::{ - rustls::{reexports::ServerConfig, Acceptor, TlsStream}, + rustls_0_20::{reexports::ServerConfig, Acceptor, TlsStream}, TlsError, }; @@ -188,7 +188,7 @@ mod rustls { U::Error: fmt::Display + Into>, U::InitError: fmt::Debug, { - /// Create Rustls based service. + /// Create Rustls v0.20 based service. pub fn rustls( self, config: ServerConfig, @@ -213,6 +213,189 @@ mod rustls { } } +#[cfg(feature = "rustls-0_21")] +mod rustls_0_21 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_21::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H1Service, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into>, + S::InitError: fmt::Debug, + S::Response: Into>, + + B: MessageBody, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.21 based service. + pub fn rustls_021( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_22")] +mod rustls_0_22 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_22::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H1Service, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into>, + S::InitError: fmt::Debug, + S::Response: Into>, + + B: MessageBody, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.22 based service. + pub fn rustls_0_22( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_23")] +mod rustls_0_23 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_23::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H1Service, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into>, + S::InitError: fmt::Debug, + S::Response: Into>, + + B: MessageBody, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.23 based service. + pub fn rustls_0_23( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + impl H1Service where S: ServiceFactory, @@ -297,15 +480,15 @@ where let cfg = self.cfg.clone(); Box::pin(async move { - let expect = expect - .await - .map_err(|e| error!("Init http expect service error: {:?}", e))?; + let expect = expect.await.map_err(|err| { + tracing::error!("Initialization of HTTP expect service error: {err:?}"); + })?; let upgrade = match upgrade { Some(upgrade) => { - let upgrade = upgrade - .await - .map_err(|e| error!("Init http upgrade service error: {:?}", e))?; + let upgrade = upgrade.await.map_err(|err| { + tracing::error!("Initialization of HTTP upgrade service error: {err:?}"); + })?; Some(upgrade) } None => None, @@ -313,7 +496,7 @@ where let service = service .await - .map_err(|e| error!("Init http service error: {:?}", e))?; + .map_err(|err| error!("Initialization of HTTP service error: {err:?}"))?; Ok(H1ServiceHandler::new( cfg, @@ -358,6 +541,6 @@ where fn call(&self, (io, addr): (T, Option)) -> Self::Future { let conn_data = OnConnectData::from_io(&io, self.on_connect_ext.as_deref()); - Dispatcher::new(io, self.flow.clone(), self.cfg.clone(), addr, conn_data) + Dispatcher::new(io, Rc::clone(&self.flow), self.cfg.clone(), addr, conn_data) } } diff --git a/actix-http/src/h2/dispatcher.rs b/actix-http/src/h2/dispatcher.rs index 3e618820e..400476c88 100644 --- a/actix-http/src/h2/dispatcher.rs +++ b/actix-http/src/h2/dispatcher.rs @@ -4,7 +4,7 @@ use std::{ future::Future, marker::PhantomData, net, - pin::Pin, + pin::{pin, Pin}, rc::Rc, task::{Context, Poll}, }; @@ -20,7 +20,6 @@ use h2::{ Ping, PingPong, }; use pin_project_lite::pin_project; -use tracing::{error, trace, warn}; use crate::{ body::{BodySize, BoxBody, MessageBody}, @@ -127,7 +126,7 @@ where head.headers = parts.headers.into(); head.peer_addr = this.peer_addr; - req.conn_data = this.conn_data.as_ref().map(Rc::clone); + req.conn_data.clone_from(&this.conn_data); let fut = this.flow.service.call(req); let config = this.config.clone(); @@ -147,11 +146,13 @@ where if let Err(err) = res { match err { DispatchError::SendResponse(err) => { - trace!("Error sending HTTP/2 response: {:?}", err) + tracing::trace!("Error sending response: {err:?}"); + } + DispatchError::SendData(err) => { + tracing::warn!("Send data error: {err:?}"); } - DispatchError::SendData(err) => warn!("{:?}", err), DispatchError::ResponseBody(err) => { - error!("Response payload stream error: {:?}", err) + tracing::error!("Response payload stream error: {err:?}"); } } } @@ -228,9 +229,9 @@ where return Ok(()); } - // poll response body and send chunks to client - actix_rt::pin!(body); + let mut body = pin!(body); + // poll response body and send chunks to client while let Some(res) = poll_fn(|cx| body.as_mut().poll_next(cx)).await { let mut chunk = res.map_err(|err| DispatchError::ResponseBody(err.into()))?; diff --git a/actix-http/src/h2/service.rs b/actix-http/src/h2/service.rs index 3f742135a..debc73e59 100644 --- a/actix-http/src/h2/service.rs +++ b/actix-http/src/h2/service.rs @@ -140,8 +140,8 @@ mod openssl { } } -#[cfg(feature = "rustls")] -mod rustls { +#[cfg(feature = "rustls-0_20")] +mod rustls_0_20 { use std::io; use actix_service::ServiceFactoryExt as _; @@ -162,7 +162,7 @@ mod rustls { B: MessageBody + 'static, { - /// Create Rustls based service. + /// Create Rustls v0.20 based service. pub fn rustls( self, mut config: ServerConfig, @@ -191,6 +191,159 @@ mod rustls { } } +#[cfg(feature = "rustls-0_21")] +mod rustls_0_21 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_21::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H2Service, S, B> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + { + /// Create Rustls v0.21 based service. + pub fn rustls_021( + self, + mut config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = S::InitError, + > { + let mut protos = vec![b"h2".to_vec()]; + protos.extend_from_slice(&config.alpn_protocols); + config.alpn_protocols = protos; + + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_22")] +mod rustls_0_22 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_22::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H2Service, S, B> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + { + /// Create Rustls v0.22 based service. + pub fn rustls_0_22( + self, + mut config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = S::InitError, + > { + let mut protos = vec![b"h2".to_vec()]; + protos.extend_from_slice(&config.alpn_protocols); + config.alpn_protocols = protos; + + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_23")] +mod rustls_0_23 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_23::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl H2Service, S, B> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + { + /// Create Rustls v0.23 based service. + pub fn rustls_0_23( + self, + mut config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = S::InitError, + > { + let mut protos = vec![b"h2".to_vec()]; + protos.extend_from_slice(&config.alpn_protocols); + config.alpn_protocols = protos; + + Acceptor::new(config) + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .map(|io: TlsStream| { + let peer_addr = io.get_ref().0.peer_addr().ok(); + (io, peer_addr) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + impl ServiceFactory<(T, Option)> for H2Service where T: AsyncRead + AsyncWrite + Unpin + 'static, @@ -281,7 +434,7 @@ where H2ServiceHandlerResponse { state: State::Handshake( - Some(self.flow.clone()), + Some(Rc::clone(&self.flow)), Some(self.cfg.clone()), addr, on_connect_data, diff --git a/actix-http/src/header/common.rs b/actix-http/src/header/common.rs index 6942dc26a..ebdd6708f 100644 --- a/actix-http/src/header/common.rs +++ b/actix-http/src/header/common.rs @@ -18,6 +18,14 @@ pub const CACHE_STATUS: HeaderName = HeaderName::from_static("cache-status"); // TODO(breaking): replace with http's version pub const CDN_CACHE_CONTROL: HeaderName = HeaderName::from_static("cdn-cache-control"); +/// Response header field that sends a signal to the user agent that it ought to remove all data of +/// a certain set of types. +/// +/// See the [W3C Clear-Site-Data spec] for full semantics. +/// +/// [W3C Clear-Site-Data spec]: https://www.w3.org/TR/clear-site-data/#header +pub const CLEAR_SITE_DATA: HeaderName = HeaderName::from_static("clear-site-data"); + /// Response header that prevents a document from loading any cross-origin resources that don't /// explicitly grant the document permission (using [CORP] or [CORS]). /// diff --git a/actix-http/src/header/map.rs b/actix-http/src/header/map.rs index e8118be93..a9a201e1a 100644 --- a/actix-http/src/header/map.rs +++ b/actix-http/src/header/map.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::hash_map, iter, ops}; -use ahash::AHashMap; +use foldhash::{HashMap as FoldHashMap, HashMapExt as _}; use http::header::{HeaderName, HeaderValue}; use smallvec::{smallvec, SmallVec}; @@ -13,8 +13,9 @@ use super::AsHeaderName; /// `HeaderMap` is a "multi-map" of [`HeaderName`] to one or more [`HeaderValue`]s. /// /// # Examples +/// /// ``` -/// use actix_http::header::{self, HeaderMap, HeaderValue}; +/// # use actix_http::header::{self, HeaderMap, HeaderValue}; /// /// let mut map = HeaderMap::new(); /// @@ -29,9 +30,24 @@ use super::AsHeaderName; /// /// assert!(!map.contains_key(header::ORIGIN)); /// ``` +/// +/// Construct a header map using the [`FromIterator`] implementation. Note that it uses the append +/// strategy, so duplicate header names are preserved. +/// +/// ``` +/// use actix_http::header::{self, HeaderMap, HeaderValue}; +/// +/// let headers = HeaderMap::from_iter([ +/// (header::CONTENT_TYPE, HeaderValue::from_static("text/plain")), +/// (header::COOKIE, HeaderValue::from_static("foo=1")), +/// (header::COOKIE, HeaderValue::from_static("bar=1")), +/// ]); +/// +/// assert_eq!(headers.len(), 3); +/// ``` #[derive(Debug, Clone, Default)] pub struct HeaderMap { - pub(crate) inner: AHashMap, + pub(crate) inner: FoldHashMap, } /// A bespoke non-empty list for HeaderMap values. @@ -100,7 +116,7 @@ impl HeaderMap { /// ``` pub fn with_capacity(capacity: usize) -> Self { HeaderMap { - inner: AHashMap::with_capacity(capacity), + inner: FoldHashMap::with_capacity(capacity), } } @@ -368,8 +384,8 @@ impl HeaderMap { /// let removed = map.insert(header::ACCEPT, HeaderValue::from_static("text/html")); /// assert!(!removed.is_empty()); /// ``` - pub fn insert(&mut self, key: HeaderName, val: HeaderValue) -> Removed { - let value = self.inner.insert(key, Value::one(val)); + pub fn insert(&mut self, name: HeaderName, val: HeaderValue) -> Removed { + let value = self.inner.insert(name, Value::one(val)); Removed::new(value) } @@ -636,10 +652,34 @@ impl<'a> IntoIterator for &'a HeaderMap { } } -/// Convert `http::HeaderMap` to our `HeaderMap`. +impl FromIterator<(HeaderName, HeaderValue)> for HeaderMap { + fn from_iter>(iter: T) -> Self { + iter.into_iter() + .fold(Self::new(), |mut map, (name, value)| { + map.append(name, value); + map + }) + } +} + +/// Convert a `http::HeaderMap` to our `HeaderMap`. impl From for HeaderMap { - fn from(mut map: http::HeaderMap) -> HeaderMap { - HeaderMap::from_drain(map.drain()) + fn from(mut map: http::HeaderMap) -> Self { + Self::from_drain(map.drain()) + } +} + +/// Convert our `HeaderMap` to a `http::HeaderMap`. +impl From for http::HeaderMap { + fn from(map: HeaderMap) -> Self { + Self::from_iter(map) + } +} + +/// Convert our `&HeaderMap` to a `http::HeaderMap`. +impl From<&HeaderMap> for http::HeaderMap { + fn from(map: &HeaderMap) -> Self { + map.to_owned().into() } } @@ -790,7 +830,7 @@ impl<'a> Drain<'a> { } } -impl<'a> Iterator for Drain<'a> { +impl Iterator for Drain<'_> { type Item = (Option, HeaderValue); fn next(&mut self) -> Option { diff --git a/actix-http/src/header/mod.rs b/actix-http/src/header/mod.rs index 79f91afef..b22c43f76 100644 --- a/actix-http/src/header/mod.rs +++ b/actix-http/src/header/mod.rs @@ -42,9 +42,9 @@ pub use self::{ as_name::AsHeaderName, // re-export list is explicit so that any updates to `http` do not conflict with this set common::{ - CACHE_STATUS, CDN_CACHE_CONTROL, CROSS_ORIGIN_EMBEDDER_POLICY, CROSS_ORIGIN_OPENER_POLICY, - CROSS_ORIGIN_RESOURCE_POLICY, PERMISSIONS_POLICY, X_FORWARDED_FOR, X_FORWARDED_HOST, - X_FORWARDED_PROTO, + CACHE_STATUS, CDN_CACHE_CONTROL, CLEAR_SITE_DATA, CROSS_ORIGIN_EMBEDDER_POLICY, + CROSS_ORIGIN_OPENER_POLICY, CROSS_ORIGIN_RESOURCE_POLICY, PERMISSIONS_POLICY, + X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO, }, into_pair::TryIntoHeaderPair, into_value::TryIntoHeaderValue, diff --git a/actix-http/src/header/shared/content_encoding.rs b/actix-http/src/header/shared/content_encoding.rs index c3b4bc4c2..6c4cc9229 100644 --- a/actix-http/src/header/shared/content_encoding.rs +++ b/actix-http/src/header/shared/content_encoding.rs @@ -11,7 +11,7 @@ use crate::{ /// Error returned when a content encoding is unknown. #[derive(Debug, Display, Error)] -#[display(fmt = "unsupported content encoding")] +#[display("unsupported content encoding")] pub struct ContentEncodingParseError; /// Represents a supported content encoding. diff --git a/actix-http/src/header/shared/http_date.rs b/actix-http/src/header/shared/http_date.rs index 21ed49f0c..bdfbc7051 100644 --- a/actix-http/src/header/shared/http_date.rs +++ b/actix-http/src/header/shared/http_date.rs @@ -24,8 +24,7 @@ impl FromStr for HttpDate { impl fmt::Display for HttpDate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let date_str = httpdate::fmt_http_date(self.0); - f.write_str(&date_str) + httpdate::HttpDate::from(self.0).fmt(f) } } @@ -37,7 +36,7 @@ impl TryIntoHeaderValue for HttpDate { let mut wrt = MutWriter(&mut buf); // unwrap: date output is known to be well formed and of known length - write!(wrt, "{}", httpdate::fmt_http_date(self.0)).unwrap(); + write!(wrt, "{}", self).unwrap(); HeaderValue::from_maybe_shared(buf.split().freeze()) } diff --git a/actix-http/src/header/shared/quality.rs b/actix-http/src/header/shared/quality.rs index c2276cf1b..c9b6c2ae6 100644 --- a/actix-http/src/header/shared/quality.rs +++ b/actix-http/src/header/shared/quality.rs @@ -125,7 +125,7 @@ pub fn itoa_fmt(mut wr: W, value: V) -> fmt::Re } #[derive(Debug, Clone, Display, Error)] -#[display(fmt = "quality out of bounds")] +#[display("quality out of bounds")] #[non_exhaustive] pub struct QualityOutOfBounds; diff --git a/actix-http/src/header/utils.rs b/actix-http/src/header/utils.rs index f4f34d347..caaab3b1e 100644 --- a/actix-http/src/header/utils.rs +++ b/actix-http/src/header/utils.rs @@ -80,18 +80,18 @@ mod tests { #[test] fn comma_delimited_parsing() { - let headers = vec![]; + let headers = []; let res: Vec = from_comma_delimited(headers.iter()).unwrap(); assert_eq!(res, vec![0; 0]); - let headers = vec![ + let headers = [ HeaderValue::from_static("1, 2"), HeaderValue::from_static("3,4"), ]; let res: Vec = from_comma_delimited(headers.iter()).unwrap(); assert_eq!(res, vec![1, 2, 3, 4]); - let headers = vec![ + let headers = [ HeaderValue::from_static(""), HeaderValue::from_static(","), HeaderValue::from_static(" "), diff --git a/actix-http/src/helpers.rs b/actix-http/src/helpers.rs index 7f28018e7..61175bdc9 100644 --- a/actix-http/src/helpers.rs +++ b/actix-http/src/helpers.rs @@ -61,7 +61,7 @@ pub fn write_content_length(n: u64, buf: &mut B, camel_case: bool) { /// perform a remaining length check before writing. pub(crate) struct MutWriter<'a, B>(pub(crate) &'a mut B); -impl<'a, B> io::Write for MutWriter<'a, B> +impl io::Write for MutWriter<'_, B> where B: BufMut, { diff --git a/actix-http/src/http_message.rs b/actix-http/src/http_message.rs index 3ba9ef752..2800f40ba 100644 --- a/actix-http/src/http_message.rs +++ b/actix-http/src/http_message.rs @@ -103,7 +103,7 @@ pub trait HttpMessage: Sized { } } -impl<'a, T> HttpMessage for &'a mut T +impl HttpMessage for &mut T where T: HttpMessage, { diff --git a/actix-http/src/lib.rs b/actix-http/src/lib.rs index 8b755f2f4..ae3713f20 100644 --- a/actix-http/src/lib.rs +++ b/actix-http/src/lib.rs @@ -1,11 +1,15 @@ -//! HTTP primitives for the Actix ecosystem. +//! HTTP types and services for the Actix ecosystem. //! //! ## Crate Features +//! //! | Feature | Functionality | //! | ------------------- | ------------------------------------------- | //! | `http2` | HTTP/2 support via [h2]. | //! | `openssl` | TLS support via [OpenSSL]. | -//! | `rustls` | TLS support via [rustls]. | +//! | `rustls-0_20` | TLS support via rustls 0.20. | +//! | `rustls-0_21` | TLS support via rustls 0.21. | +//! | `rustls-0_22` | TLS support via rustls 0.22. | +//! | `rustls-0_23` | TLS support via [rustls] 0.23. | //! | `compress-brotli` | Payload compression support: Brotli. | //! | `compress-gzip` | Payload compression support: Deflate, Gzip. | //! | `compress-zstd` | Payload compression support: Zstd. | @@ -16,19 +20,16 @@ //! [rustls]: https://crates.io/crates/rustls //! [trust-dns]: https://crates.io/crates/trust-dns -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] #![allow( clippy::type_complexity, clippy::too_many_arguments, - clippy::borrow_interior_mutable_const, - clippy::uninlined_format_args + clippy::borrow_interior_mutable_const )] #![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}; +pub use http::{uri, uri::Uri, Method, StatusCode, Version}; pub mod body; mod builder; @@ -58,11 +59,11 @@ pub mod ws; #[allow(deprecated)] pub use self::payload::PayloadStream; -#[cfg(any(feature = "openssl", feature = "rustls"))] +#[cfg(feature = "__tls")] pub use self::service::TlsAcceptorConfig; pub use self::{ builder::HttpServiceBuilder, - config::ServiceConfig, + config::{ServiceConfig, ServiceConfigBuilder}, error::Error, extensions::Extensions, header::ContentEncoding, diff --git a/actix-http/src/message.rs b/actix-http/src/message.rs index 47b128fd0..d2241b229 100644 --- a/actix-http/src/message.rs +++ b/actix-http/src/message.rs @@ -66,7 +66,7 @@ impl ops::DerefMut for Message { impl Drop for Message { fn drop(&mut self) { - T::with_pool(|p| p.release(self.head.clone())) + T::with_pool(|p| p.release(Rc::clone(&self.head))) } } diff --git a/actix-http/src/notify_on_drop.rs b/actix-http/src/notify_on_drop.rs index 98544bb5d..95904b28e 100644 --- a/actix-http/src/notify_on_drop.rs +++ b/actix-http/src/notify_on_drop.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; thread_local! { - static NOTIFY_DROPPED: RefCell> = RefCell::new(None); + static NOTIFY_DROPPED: RefCell> = const { RefCell::new(None) }; } /// Check if the spawned task is dropped. diff --git a/actix-http/src/payload.rs b/actix-http/src/payload.rs index 7d476c55f..d7a52417e 100644 --- a/actix-http/src/payload.rs +++ b/actix-http/src/payload.rs @@ -41,13 +41,31 @@ pin_project! { } impl From for Payload { + #[inline] fn from(payload: crate::h1::Payload) -> Self { Payload::H1 { payload } } } +impl From for Payload { + #[inline] + fn from(bytes: Bytes) -> Self { + let (_, mut pl) = crate::h1::Payload::create(true); + pl.unread_data(bytes); + self::Payload::from(pl) + } +} + +impl From> for Payload { + #[inline] + fn from(vec: Vec) -> Self { + Payload::from(Bytes::from(vec)) + } +} + #[cfg(feature = "http2")] impl From for Payload { + #[inline] fn from(payload: crate::h2::Payload) -> Self { Payload::H2 { payload } } @@ -55,6 +73,7 @@ impl From for Payload { #[cfg(feature = "http2")] impl From<::h2::RecvStream> for Payload { + #[inline] fn from(stream: ::h2::RecvStream) -> Self { Payload::H2 { payload: crate::h2::Payload::new(stream), @@ -63,13 +82,15 @@ impl From<::h2::RecvStream> for Payload { } impl From for Payload { + #[inline] fn from(payload: BoxedPayloadStream) -> Self { Payload::Stream { payload } } } impl Payload { - /// Takes current payload and replaces it with `None` value + /// Takes current payload and replaces it with `None` value. + #[must_use] pub fn take(&mut self) -> Payload { mem::replace(self, Payload::None) } diff --git a/actix-http/src/requests/head.rs b/actix-http/src/requests/head.rs index 4558801f3..9ceb2a20c 100644 --- a/actix-http/src/requests/head.rs +++ b/actix-http/src/requests/head.rs @@ -16,7 +16,10 @@ pub struct RequestHead { pub uri: Uri, pub version: Version, pub headers: HeaderMap, + + /// Will only be None when called in unit tests unless set manually. pub peer_addr: Option, + flags: Flags, } diff --git a/actix-http/src/requests/request.rs b/actix-http/src/requests/request.rs index 1750fb2f7..6a267a7a6 100644 --- a/actix-http/src/requests/request.rs +++ b/actix-http/src/requests/request.rs @@ -173,7 +173,7 @@ impl

Request

{ /// Peer address is the directly connected peer's socket address. If a proxy is used in front of /// the Actix Web server, then it would be address of this proxy. /// - /// Will only return None when called in unit tests. + /// Will only return None when called in unit tests unless set manually. #[inline] pub fn peer_addr(&self) -> Option { self.head().peer_addr diff --git a/actix-http/src/responses/builder.rs b/actix-http/src/responses/builder.rs index 063af92da..bb7d0f712 100644 --- a/actix-http/src/responses/builder.rs +++ b/actix-http/src/responses/builder.rs @@ -93,7 +93,7 @@ impl ResponseBuilder { Ok((key, value)) => { parts.headers.insert(key, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; } @@ -119,7 +119,7 @@ impl ResponseBuilder { if let Some(parts) = self.inner() { match header.try_into_pair() { Ok((key, value)) => parts.headers.append(key, value), - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; } @@ -193,7 +193,7 @@ impl ResponseBuilder { Ok(value) => { parts.headers.insert(header::CONTENT_TYPE, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; } self @@ -351,12 +351,9 @@ mod tests { assert_eq!(resp.headers().get(CONTENT_TYPE).unwrap(), "text/plain"); let resp = Response::build(StatusCode::OK) - .content_type(mime::APPLICATION_JAVASCRIPT_UTF_8) + .content_type(mime::TEXT_JAVASCRIPT) .body(Bytes::new()); - assert_eq!( - resp.headers().get(CONTENT_TYPE).unwrap(), - "application/javascript; charset=utf-8" - ); + assert_eq!(resp.headers().get(CONTENT_TYPE).unwrap(), "text/javascript"); } #[test] diff --git a/actix-http/src/service.rs b/actix-http/src/service.rs index e118d8361..3be099d9f 100644 --- a/actix-http/src/service.rs +++ b/actix-http/src/service.rs @@ -241,13 +241,13 @@ where } /// Configuration options used when accepting TLS connection. -#[cfg(any(feature = "openssl", feature = "rustls"))] +#[cfg(feature = "__tls")] #[derive(Debug, Default)] pub struct TlsAcceptorConfig { pub(crate) handshake_timeout: Option, } -#[cfg(any(feature = "openssl", feature = "rustls"))] +#[cfg(feature = "__tls")] impl TlsAcceptorConfig { /// Set TLS handshake timeout duration. pub fn handshake_timeout(self, dur: std::time::Duration) -> Self { @@ -352,13 +352,13 @@ mod openssl { } } -#[cfg(feature = "rustls")] -mod rustls { +#[cfg(feature = "rustls-0_20")] +mod rustls_0_20 { use std::io; use actix_service::ServiceFactoryExt as _; use actix_tls::accept::{ - rustls::{reexports::ServerConfig, Acceptor, TlsStream}, + rustls_0_20::{reexports::ServerConfig, Acceptor, TlsStream}, TlsError, }; @@ -389,7 +389,7 @@ mod rustls { U::Error: fmt::Display + Into>, U::InitError: fmt::Debug, { - /// Create Rustls based service. + /// Create Rustls v0.20 based service. pub fn rustls( self, config: ServerConfig, @@ -403,7 +403,7 @@ mod rustls { self.rustls_with_config(config, TlsAcceptorConfig::default()) } - /// Create Rustls based service with custom TLS acceptor configuration. + /// Create Rustls v0.20 based service with custom TLS acceptor configuration. pub fn rustls_with_config( self, mut config: ServerConfig, @@ -448,6 +448,294 @@ mod rustls { } } +#[cfg(feature = "rustls-0_21")] +mod rustls_0_21 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_21::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl HttpService, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::InitError: fmt::Debug, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, h1::Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.21 based service. + pub fn rustls_021( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + self.rustls_021_with_config(config, TlsAcceptorConfig::default()) + } + + /// Create Rustls v0.21 based service with custom TLS acceptor configuration. + pub fn rustls_021_with_config( + self, + mut config: ServerConfig, + tls_acceptor_config: TlsAcceptorConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + 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; + + let mut acceptor = Acceptor::new(config); + + if let Some(handshake_timeout) = tls_acceptor_config.handshake_timeout { + acceptor.set_handshake_timeout(handshake_timeout); + } + + acceptor + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .and_then(|io: TlsStream| async { + let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { + if protos.windows(2).any(|window| window == b"h2") { + Protocol::Http2 + } else { + Protocol::Http1 + } + } else { + Protocol::Http1 + }; + let peer_addr = io.get_ref().0.peer_addr().ok(); + Ok((io, proto, peer_addr)) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_22")] +mod rustls_0_22 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_22::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl HttpService, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::InitError: fmt::Debug, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, h1::Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.22 based service. + pub fn rustls_0_22( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + self.rustls_0_22_with_config(config, TlsAcceptorConfig::default()) + } + + /// Create Rustls v0.22 based service with custom TLS acceptor configuration. + pub fn rustls_0_22_with_config( + self, + mut config: ServerConfig, + tls_acceptor_config: TlsAcceptorConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + 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; + + let mut acceptor = Acceptor::new(config); + + if let Some(handshake_timeout) = tls_acceptor_config.handshake_timeout { + acceptor.set_handshake_timeout(handshake_timeout); + } + + acceptor + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .and_then(|io: TlsStream| async { + let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { + if protos.windows(2).any(|window| window == b"h2") { + Protocol::Http2 + } else { + Protocol::Http1 + } + } else { + Protocol::Http1 + }; + let peer_addr = io.get_ref().0.peer_addr().ok(); + Ok((io, proto, peer_addr)) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + +#[cfg(feature = "rustls-0_23")] +mod rustls_0_23 { + use std::io; + + use actix_service::ServiceFactoryExt as _; + use actix_tls::accept::{ + rustls_0_23::{reexports::ServerConfig, Acceptor, TlsStream}, + TlsError, + }; + + use super::*; + + impl HttpService, S, B, X, U> + where + S: ServiceFactory, + S::Future: 'static, + S::Error: Into> + 'static, + S::InitError: fmt::Debug, + S::Response: Into> + 'static, + >::Future: 'static, + + B: MessageBody + 'static, + + X: ServiceFactory, + X::Future: 'static, + X::Error: Into>, + X::InitError: fmt::Debug, + + U: ServiceFactory< + (Request, Framed, h1::Codec>), + Config = (), + Response = (), + >, + U::Future: 'static, + U::Error: fmt::Display + Into>, + U::InitError: fmt::Debug, + { + /// Create Rustls v0.23 based service. + pub fn rustls_0_23( + self, + config: ServerConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + self.rustls_0_23_with_config(config, TlsAcceptorConfig::default()) + } + + /// Create Rustls v0.23 based service with custom TLS acceptor configuration. + pub fn rustls_0_23_with_config( + self, + mut config: ServerConfig, + tls_acceptor_config: TlsAcceptorConfig, + ) -> impl ServiceFactory< + TcpStream, + Config = (), + Response = (), + Error = TlsError, + InitError = (), + > { + 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; + + let mut acceptor = Acceptor::new(config); + + if let Some(handshake_timeout) = tls_acceptor_config.handshake_timeout { + acceptor.set_handshake_timeout(handshake_timeout); + } + + acceptor + .map_init_err(|_| { + unreachable!("TLS acceptor service factory does not error on init") + }) + .map_err(TlsError::into_service_error) + .and_then(|io: TlsStream| async { + let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { + if protos.windows(2).any(|window| window == b"h2") { + Protocol::Http2 + } else { + Protocol::Http1 + } + } else { + Protocol::Http1 + }; + let peer_addr = io.get_ref().0.peer_addr().ok(); + Ok((io, proto, peer_addr)) + }) + .and_then(self.map_err(TlsError::Service)) + } + } +} + impl ServiceFactory<(T, Protocol, Option)> for HttpService where @@ -487,23 +775,23 @@ where let cfg = self.cfg.clone(); Box::pin(async move { - let expect = expect - .await - .map_err(|e| error!("Init http expect service error: {:?}", e))?; + let expect = expect.await.map_err(|err| { + tracing::error!("Initialization of HTTP expect service error: {err:?}"); + })?; let upgrade = match upgrade { Some(upgrade) => { - let upgrade = upgrade - .await - .map_err(|e| error!("Init http upgrade service error: {:?}", e))?; + let upgrade = upgrade.await.map_err(|err| { + tracing::error!("Initialization of HTTP upgrade service error: {err:?}"); + })?; Some(upgrade) } None => None, }; - let service = service - .await - .map_err(|e| error!("Init http service error: {:?}", e))?; + let service = service.await.map_err(|err| { + tracing::error!("Initialization of HTTP service error: {err:?}"); + })?; Ok(HttpServiceHandler::new( cfg, @@ -622,7 +910,7 @@ where handshake: Some(( crate::h2::handshake_with_timeout(io, &self.cfg), self.cfg.clone(), - self.flow.clone(), + Rc::clone(&self.flow), conn_data, peer_addr, )), @@ -638,7 +926,7 @@ where state: State::H1 { dispatcher: h1::Dispatcher::new( io, - self.flow.clone(), + Rc::clone(&self.flow), self.cfg.clone(), peer_addr, conn_data, diff --git a/actix-http/src/test.rs b/actix-http/src/test.rs index 3815e64c6..926efe2f5 100644 --- a/actix-http/src/test.rs +++ b/actix-http/src/test.rs @@ -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) -> &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 } @@ -159,8 +163,8 @@ impl TestBuffer { #[allow(dead_code)] pub(crate) fn clone(&self) -> Self { Self { - read_buf: self.read_buf.clone(), - write_buf: self.write_buf.clone(), + read_buf: Rc::clone(&self.read_buf), + write_buf: Rc::clone(&self.write_buf), err: self.err.clone(), } } @@ -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> { 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>(&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, } impl io::Read for TestSeqBuffer { fn read(&mut self, dst: &mut [u8]) -> Result { - 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) } diff --git a/actix-http/src/ws/codec.rs b/actix-http/src/ws/codec.rs index 681649a7e..ad487e400 100644 --- a/actix-http/src/ws/codec.rs +++ b/actix-http/src/ws/codec.rs @@ -296,7 +296,7 @@ impl Decoder for Codec { } } Ok(None) => Ok(None), - Err(e) => Err(e), + Err(err) => Err(err), } } } diff --git a/actix-http/src/ws/dispatcher.rs b/actix-http/src/ws/dispatcher.rs index 1354d5ae1..7d0a300b7 100644 --- a/actix-http/src/ws/dispatcher.rs +++ b/actix-http/src/ws/dispatcher.rs @@ -114,14 +114,14 @@ mod inner { { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { - DispatcherError::Service(ref e) => { - write!(fmt, "DispatcherError::Service({:?})", e) + DispatcherError::Service(ref err) => { + write!(fmt, "DispatcherError::Service({err:?})") } - DispatcherError::Encoder(ref e) => { - write!(fmt, "DispatcherError::Encoder({:?})", e) + DispatcherError::Encoder(ref err) => { + write!(fmt, "DispatcherError::Encoder({err:?})") } - DispatcherError::Decoder(ref e) => { - write!(fmt, "DispatcherError::Decoder({:?})", e) + DispatcherError::Decoder(ref err) => { + write!(fmt, "DispatcherError::Decoder({err:?})") } } } @@ -136,9 +136,9 @@ mod inner { { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match *self { - DispatcherError::Service(ref e) => write!(fmt, "{}", e), - DispatcherError::Encoder(ref e) => write!(fmt, "{:?}", e), - DispatcherError::Decoder(ref e) => write!(fmt, "{:?}", e), + DispatcherError::Service(ref err) => write!(fmt, "{err}"), + DispatcherError::Encoder(ref err) => write!(fmt, "{err:?}"), + DispatcherError::Decoder(ref err) => write!(fmt, "{err:?}"), } } } diff --git a/actix-http/src/ws/frame.rs b/actix-http/src/ws/frame.rs index c9fb0cde9..7147cc92a 100644 --- a/actix-http/src/ws/frame.rs +++ b/actix-http/src/ws/frame.rs @@ -94,11 +94,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); } @@ -178,14 +188,14 @@ impl Parser { }; if payload_len < 126 { - dst.reserve(p_len + 2 + if mask { 4 } else { 0 }); + dst.reserve(p_len + 2); dst.put_slice(&[one, two | payload_len as u8]); } else if payload_len <= 65_535 { - dst.reserve(p_len + 4 + if mask { 4 } else { 0 }); + dst.reserve(p_len + 4); dst.put_slice(&[one, two | 126]); dst.put_u16(payload_len as u16); } else { - dst.reserve(p_len + 10 + if mask { 4 } else { 0 }); + dst.reserve(p_len + 10); dst.put_slice(&[one, two | 127]); dst.put_u64(payload_len as u64); }; @@ -402,4 +412,14 @@ mod tests { Parser::write_close(&mut buf, None, 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))); + } } diff --git a/actix-http/src/ws/mod.rs b/actix-http/src/ws/mod.rs index 87f9b38f3..c2ae010c2 100644 --- a/actix-http/src/ws/mod.rs +++ b/actix-http/src/ws/mod.rs @@ -27,43 +27,43 @@ pub use self::{ #[derive(Debug, Display, Error, From)] pub enum ProtocolError { /// Received an unmasked frame from client. - #[display(fmt = "received an unmasked frame from client")] + #[display("received an unmasked frame from client")] UnmaskedFrame, /// Received a masked frame from server. - #[display(fmt = "received a masked frame from server")] + #[display("received a masked frame from server")] MaskedFrame, /// Encountered invalid opcode. - #[display(fmt = "invalid opcode ({})", _0)] + #[display("invalid opcode ({})", _0)] InvalidOpcode(#[error(not(source))] u8), /// Invalid control frame length - #[display(fmt = "invalid control frame length ({})", _0)] + #[display("invalid control frame length ({})", _0)] InvalidLength(#[error(not(source))] usize), /// Bad opcode. - #[display(fmt = "bad opcode")] + #[display("bad opcode")] BadOpCode, /// A payload reached size limit. - #[display(fmt = "payload reached size limit")] + #[display("payload reached size limit")] Overflow, /// Continuation has not started. - #[display(fmt = "continuation has not started")] + #[display("continuation has not started")] ContinuationNotStarted, /// Received new continuation but it is already started. - #[display(fmt = "received new continuation but it has already started")] + #[display("received new continuation but it has already started")] ContinuationStarted, /// Unknown continuation fragment. - #[display(fmt = "unknown continuation fragment: {}", _0)] + #[display("unknown continuation fragment: {}", _0)] ContinuationFragment(#[error(not(source))] OpCode), /// I/O error. - #[display(fmt = "I/O error: {}", _0)] + #[display("I/O error: {}", _0)] Io(io::Error), } @@ -71,27 +71,27 @@ pub enum ProtocolError { #[derive(Debug, Clone, Copy, PartialEq, Eq, Display, Error)] pub enum HandshakeError { /// Only get method is allowed. - #[display(fmt = "method not allowed")] + #[display("method not allowed")] GetMethodRequired, /// Upgrade header if not set to WebSocket. - #[display(fmt = "WebSocket upgrade is expected")] + #[display("WebSocket upgrade is expected")] NoWebsocketUpgrade, /// Connection header is not set to upgrade. - #[display(fmt = "connection upgrade is expected")] + #[display("connection upgrade is expected")] NoConnectionUpgrade, /// WebSocket version header is not set. - #[display(fmt = "WebSocket version header is required")] + #[display("WebSocket version header is required")] NoVersionHeader, /// Unsupported WebSocket version. - #[display(fmt = "unsupported WebSocket version")] + #[display("unsupported WebSocket version")] UnsupportedVersion, /// WebSocket key is not set or wrong. - #[display(fmt = "unknown WebSocket key")] + #[display("unknown WebSocket key")] BadWebsocketKey, } @@ -221,7 +221,7 @@ pub fn handshake_response(req: &RequestHead) -> ResponseBuilder { #[cfg(test)] mod tests { use super::*; - use crate::{header, test::TestRequest, Method}; + use crate::{header, test::TestRequest}; #[test] fn test_handshake() { diff --git a/actix-http/src/ws/proto.rs b/actix-http/src/ws/proto.rs index 0653c00b0..27815eaf2 100644 --- a/actix-http/src/ws/proto.rs +++ b/actix-http/src/ws/proto.rs @@ -1,7 +1,4 @@ -use std::{ - convert::{From, Into}, - fmt, -}; +use std::fmt; use base64::prelude::*; use tracing::error; diff --git a/actix-http/tests/test_client.rs b/actix-http/tests/test_client.rs index 5888527f1..2d940984d 100644 --- a/actix-http/tests/test_client.rs +++ b/actix-http/tests/test_client.rs @@ -94,7 +94,7 @@ async fn with_query_parameter() { } #[derive(Debug, Display, Error)] -#[display(fmt = "expect failed")] +#[display("expect failed")] struct ExpectFailed; impl From for Response { diff --git a/actix-http/tests/test_openssl.rs b/actix-http/tests/test_openssl.rs index b4d8ed1a5..83456b0cb 100644 --- a/actix-http/tests/test_openssl.rs +++ b/actix-http/tests/test_openssl.rs @@ -1,5 +1,4 @@ #![cfg(feature = "openssl")] -#![allow(clippy::uninlined_format_args)] extern crate tls_openssl as openssl; @@ -43,9 +42,11 @@ where } fn tls_config() -> SslAcceptor { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_pem(); + 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 = X509::from_pem(cert_file.as_bytes()).unwrap(); let key = PKey::private_key_from_pem(key_file.as_bytes()).unwrap(); @@ -397,7 +398,7 @@ async fn h2_response_http_error_handling() { } #[derive(Debug, Display, Error)] -#[display(fmt = "error")] +#[display("error")] struct BadRequest; impl From for Response { diff --git a/actix-http/tests/test_rustls.rs b/actix-http/tests/test_rustls.rs index 3d9a39cbd..29e559666 100644 --- a/actix-http/tests/test_rustls.rs +++ b/actix-http/tests/test_rustls.rs @@ -1,11 +1,10 @@ -#![cfg(feature = "rustls")] -#![allow(clippy::uninlined_format_args)] +#![cfg(feature = "rustls-0_23")] -extern crate tls_rustls as rustls; +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, @@ -21,14 +20,14 @@ use actix_http::{ use actix_http_test::test_server; use actix_rt::pin; use actix_service::{fn_factory_with_config, fn_service}; -use actix_tls::connect::rustls::webpki_roots_cert_store; +use actix_tls::connect::rustls_0_23::webpki_roots_cert_store; use actix_utils::future::{err, ok, poll_fn}; use bytes::{Bytes, BytesMut}; use derive_more::{Display, Error}; use futures_core::{ready, Stream}; use futures_util::stream::once; -use rustls::{Certificate, PrivateKey, ServerConfig as RustlsServerConfig, ServerName}; -use rustls_pemfile::{certs, pkcs8_private_keys}; +use rustls::{pki_types::ServerName, ServerConfig as RustlsServerConfig}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; async fn load_body(stream: S) -> Result where @@ -53,24 +52,14 @@ where } fn tls_config() -> RustlsServerConfig { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_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) - .unwrap() - .into_iter() - .map(Certificate) - .collect(); - let mut keys = pkcs8_private_keys(key_file).unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = + rcgen::generate_simple_self_signed(["localhost".to_owned()]).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_safe_defaults() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKey(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap(); config.alpn_protocols.push(HTTP1_1_ALPN_PROTOCOL.to_vec()); @@ -84,7 +73,6 @@ pub fn get_negotiated_alpn_protocol( client_alpn_protocol: &[u8], ) -> Option> { let mut config = rustls::ClientConfig::builder() - .with_safe_defaults() .with_root_certificates(webpki_roots_cert_store()) .with_no_client_auth(); @@ -110,7 +98,7 @@ async fn h1() -> io::Result<()> { let srv = test_server(move || { HttpService::build() .h1(|_| ok::<_, Error>(Response::ok())) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -124,7 +112,7 @@ async fn h2() -> io::Result<()> { let srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Error>(Response::ok())) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -142,7 +130,7 @@ async fn h1_1() -> io::Result<()> { assert_eq!(req.version(), Version::HTTP_11); ok::<_, Error>(Response::ok()) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -160,7 +148,7 @@ async fn h2_1() -> io::Result<()> { assert_eq!(req.version(), Version::HTTP_2); ok::<_, Error>(Response::ok()) }) - .rustls_with_config( + .rustls_0_23_with_config( tls_config(), TlsAcceptorConfig::default().handshake_timeout(Duration::from_secs(5)), ) @@ -181,7 +169,7 @@ async fn h2_body1() -> io::Result<()> { let body = load_body(req.take_payload()).await?; Ok::<_, Error>(Response::ok().set_body(body)) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -207,7 +195,7 @@ async fn h2_content_length() { ]; ok::<_, Infallible>(Response::new(statuses[indx])) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -279,7 +267,7 @@ async fn h2_headers() { } ok::<_, Infallible>(config.body(data.clone())) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -318,7 +306,7 @@ async fn h2_body2() { let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -335,7 +323,7 @@ async fn h2_head_empty() { let mut srv = test_server(move || { HttpService::build() .finish(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -361,7 +349,7 @@ async fn h2_head_binary() { let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -386,7 +374,7 @@ async fn h2_head_binary2() { let srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -412,7 +400,7 @@ async fn h2_body_length() { Response::ok().set_body(SizedStream::new(STR.len() as u64, body)), ) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -436,7 +424,7 @@ async fn h2_body_chunked_explicit() { .body(BodyStream::new(body)), ) }) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -465,7 +453,7 @@ async fn h2_response_http_error_handling() { ) })) })) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -481,7 +469,7 @@ async fn h2_response_http_error_handling() { } #[derive(Debug, Display, Error)] -#[display(fmt = "error")] +#[display("error")] struct BadRequest; impl From for Response { @@ -495,7 +483,7 @@ async fn h2_service_error() { let mut srv = test_server(move || { HttpService::build() .h2(|_| err::, _>(BadRequest)) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -512,7 +500,7 @@ async fn h1_service_error() { let mut srv = test_server(move || { HttpService::build() .h1(|_| err::, _>(BadRequest)) - .rustls(tls_config()) + .rustls_0_23(tls_config()) }) .await; @@ -535,7 +523,7 @@ async fn alpn_h1() -> io::Result<()> { config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() .h1(|_| ok::<_, Error>(Response::ok())) - .rustls(config) + .rustls_0_23(config) }) .await; @@ -557,7 +545,7 @@ async fn alpn_h2() -> io::Result<()> { config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() .h2(|_| ok::<_, Error>(Response::ok())) - .rustls(config) + .rustls_0_23(config) }) .await; @@ -583,7 +571,7 @@ async fn alpn_h2_1() -> io::Result<()> { config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() .finish(|_| ok::<_, Error>(Response::ok())) - .rustls(config) + .rustls_0_23(config) }) .await; diff --git a/actix-http/tests/test_server.rs b/actix-http/tests/test_server.rs index e953c4726..688fc9d0b 100644 --- a/actix-http/tests/test_server.rs +++ b/actix-http/tests/test_server.rs @@ -16,6 +16,7 @@ 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 regex::Regex; #[actix_rt::test] @@ -62,7 +63,7 @@ async fn h1_2() { } #[derive(Debug, Display, Error)] -#[display(fmt = "expect failed")] +#[display("expect failed")] struct ExpectFailed; impl From for Response { @@ -147,7 +148,7 @@ async fn chunked_payload() { .take_payload() .map(|res| match res { Ok(pl) => pl, - Err(e) => panic!("Error reading payload: {}", e), + Err(err) => panic!("Error reading payload: {err}"), }) .fold(0usize, |acc, chunk| ready(acc + chunk.len())) .map(|req_size| { @@ -164,7 +165,10 @@ async fn chunked_payload() { for chunk_size in chunk_sizes.iter() { let mut bytes = Vec::new(); - let random_bytes: Vec = (0..*chunk_size).map(|_| rand::random::()).collect(); + let random_bytes = rand::rng() + .sample_iter(rand::distr::StandardUniform) + .take(*chunk_size) + .collect::>(); bytes.extend(format!("{:X}\r\n", chunk_size).as_bytes()); bytes.extend(&random_bytes[..]); @@ -777,7 +781,7 @@ async fn h1_response_http_error_handling() { } #[derive(Debug, Display, Error)] -#[display(fmt = "error")] +#[display("error")] struct BadRequest; impl From for Response { diff --git a/actix-http/tests/test_ws.rs b/actix-http/tests/test_ws.rs index a2866613b..da16ab5f5 100644 --- a/actix-http/tests/test_ws.rs +++ b/actix-http/tests/test_ws.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use std::{ cell::Cell, convert::Infallible, @@ -39,16 +37,16 @@ impl WsService { #[derive(Debug, Display, Error, From)] enum WsServiceError { - #[display(fmt = "HTTP error")] + #[display("HTTP error")] Http(actix_http::Error), - #[display(fmt = "WS handshake error")] + #[display("WS handshake error")] Ws(actix_http::ws::HandshakeError), - #[display(fmt = "I/O error")] + #[display("I/O error")] Io(std::io::Error), - #[display(fmt = "dispatcher error")] + #[display("dispatcher error")] Dispatcher, } diff --git a/actix-multipart-derive/CHANGES.md b/actix-multipart-derive/CHANGES.md index 8a78900ec..44e0d8435 100644 --- a/actix-multipart-derive/CHANGES.md +++ b/actix-multipart-derive/CHANGES.md @@ -2,9 +2,17 @@ ## Unreleased -- Update `syn` dependency to `2`. -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 0.6.0 - 2023-02-26 +## 0.7.0 + +- Minimum supported Rust version (MSRV) is now 1.72. + +## 0.6.1 + +- Update `syn` dependency to `2`. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 0.6.0 - Add `MultipartForm` derive macro. diff --git a/actix-multipart-derive/Cargo.toml b/actix-multipart-derive/Cargo.toml index 41e687e50..9859f6c8b 100644 --- a/actix-multipart-derive/Cargo.toml +++ b/actix-multipart-derive/Cargo.toml @@ -1,30 +1,33 @@ [package] name = "actix-multipart-derive" -version = "0.6.0" +version = "0.7.0" authors = ["Jacob Halsey "] description = "Multipart form derive macro for Actix Web" keywords = ["http", "web", "framework", "async", "futures"] -homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" -license = "MIT OR Apache-2.0" -edition = "2021" +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] all-features = true [lib] proc-macro = true [dependencies] +bytesize = "2" darling = "0.20" -parse-size = "1" proc-macro2 = "1" quote = "1" syn = "2" [dev-dependencies] -actix-multipart = "0.6" +actix-multipart = "0.7" actix-web = "4" -rustversion = "1" +rustversion-msrv = "0.100" trybuild = "1" + +[lints] +workspace = true diff --git a/actix-multipart-derive/README.md b/actix-multipart-derive/README.md index b077d355c..05b37dfe8 100644 --- a/actix-multipart-derive/README.md +++ b/actix-multipart-derive/README.md @@ -1,17 +1,16 @@ -# actix-multipart-derive +# `actix-multipart-derive` > The derive macro implementation for actix-multipart-derive. + + [![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.5.0)](https://docs.rs/actix-multipart-derive/0.5.0) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart-derive.svg)
-[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.5.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.5.0) +[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.7.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.7.0) [![Download](https://img.shields.io/crates/d/actix-multipart-derive.svg)](https://crates.io/crates/actix-multipart-derive) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources - -- [API Documentation](https://docs.rs/actix-multipart-derive) -- Minimum Supported Rust Version (MSRV): 1.65 + diff --git a/actix-multipart-derive/src/lib.rs b/actix-multipart-derive/src/lib.rs index 9552ad2d9..5b5e28254 100644 --- a/actix-multipart-derive/src/lib.rs +++ b/actix-multipart-derive/src/lib.rs @@ -2,34 +2,28 @@ //! //! See [`macro@MultipartForm`] for usage examples. -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] #![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; +use bytesize::ByteSize; use darling::{FromDeriveInput, FromField, FromMeta}; -use parse_size::parse_size; use proc_macro::TokenStream; 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 { @@ -37,6 +31,7 @@ struct MultipartFormAttrs { duplicate_field: DuplicateField, } +#[allow(clippy::disallowed_names)] // false positive in macro expansion #[derive(FromField, Default)] #[darling(attributes(multipart), default)] struct FieldAttrs { @@ -103,7 +98,7 @@ struct ParsedField<'t> { /// # Field Limits /// /// You can use the `#[multipart(limit = "")]` attribute to set field level limits. The limit -/// string is parsed using [parse_size]. +/// string is parsed using [`bytesize`]. /// /// Note: the form is also subject to the global limits configured using `MultipartFormConfig`. /// @@ -138,7 +133,7 @@ struct ParsedField<'t> { /// `#[multipart(duplicate_field = "")]` attribute: /// /// - "ignore": (default) Extra fields are ignored. I.e., the first one is persisted. -/// - "deny": A `MultipartError::UnsupportedField` error response is returned. +/// - "deny": A `MultipartError::UnknownField` error response is returned. /// - "replace": Each field is processed, but only the last one is persisted. /// /// Note that `Vec` fields will ignore this option. @@ -150,7 +145,7 @@ struct ParsedField<'t> { /// struct Form { } /// ``` /// -/// [parse_size]: https://docs.rs/parse-size/1/parse_size +/// [`bytesize`]: https://docs.rs/bytesize/2 #[proc_macro_derive(MultipartForm, attributes(multipart))] pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input: syn::DeriveInput = parse_macro_input!(input); @@ -191,8 +186,8 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS let attrs = FieldAttrs::from_field(field).map_err(|err| err.write_errors())?; let serialization_name = attrs.rename.unwrap_or_else(|| rust_name.to_string()); - let limit = match attrs.limit.map(|limit| match parse_size(&limit) { - Ok(size) => Ok(usize::try_from(size).unwrap()), + let limit = match attrs.limit.map(|limit| match limit.parse::() { + Ok(ByteSize(size)) => Ok(usize::try_from(size).unwrap()), Err(err) => Err(syn::Error::new( field.ident.as_ref().unwrap().span(), format!("Could not parse size limit `{}`: {}", limit, err), @@ -229,7 +224,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS // Return value when a field name is not supported by the form let unknown_field_result = if attrs.deny_unknown_fields { quote!(::std::result::Result::Err( - ::actix_multipart::MultipartError::UnsupportedField(field.name().to_string()) + ::actix_multipart::MultipartError::UnknownField(field.name().unwrap().to_string()) )) } else { quote!(::std::result::Result::Ok(())) @@ -292,7 +287,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS limits: &'t mut ::actix_multipart::form::Limits, state: &'t mut ::actix_multipart::form::State, ) -> ::std::pin::Pin<::std::boxed::Box> + 't>> { - match field.name() { + match field.name().unwrap() { #handle_field_impl _ => return ::std::boxed::Box::pin(::std::future::ready(#unknown_field_result)), } diff --git a/actix-multipart-derive/tests/trybuild.rs b/actix-multipart-derive/tests/trybuild.rs index 829c1d771..7bd140324 100644 --- a/actix-multipart-derive/tests/trybuild.rs +++ b/actix-multipart-derive/tests/trybuild.rs @@ -1,4 +1,4 @@ -#[rustversion::stable(1.65)] // MSRV +#[rustversion_msrv::msrv] #[test] fn compile_macros() { let t = trybuild::TestCases::new(); diff --git a/actix-multipart-derive/tests/trybuild/size-limit-parse-fail.stderr b/actix-multipart-derive/tests/trybuild/size-limit-parse-fail.stderr index fc02a78c4..089569c09 100644 --- a/actix-multipart-derive/tests/trybuild/size-limit-parse-fail.stderr +++ b/actix-multipart-derive/tests/trybuild/size-limit-parse-fail.stderr @@ -1,16 +1,16 @@ -error: Could not parse size limit `2 bytes`: invalid digit found in string +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, | ^^^^^^^^^^^ -error: Could not parse size limit `2 megabytes`: invalid digit found in string +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, | ^^^^^^^^^^^ -error: Could not parse size limit `four meters`: invalid digit found in string +error: Could not parse size limit `four meters`: couldn't parse "four meters" into a ByteSize, cannot parse float from empty string --> tests/trybuild/size-limit-parse-fail.rs:18:5 | 18 | description: Text, diff --git a/actix-multipart/CHANGES.md b/actix-multipart/CHANGES.md index 410c8af17..3dfca18b5 100644 --- a/actix-multipart/CHANGES.md +++ b/actix-multipart/CHANGES.md @@ -1,49 +1,81 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Add `MultipartForm` support for `Option>` fields. [#3577] +- Minimum supported Rust version (MSRV) is now 1.88. -## 0.6.0 - 2023-02-26 +[#3577]: https://github.com/actix/actix-web/pull/3577 + +## 0.7.2 + +- Fix re-exported version of `actix-multipart-derive`. + +## 0.7.1 + +- Expose `LimitExceeded` error type. + +## 0.7.0 + +- Add `MultipartError::ContentTypeIncompatible` variant. +- Add `MultipartError::ContentDispositionNameMissing` variant. +- Add `Field::bytes()` method. +- Rename `MultipartError::{NoContentDisposition => ContentDispositionMissing}` variant. +- Rename `MultipartError::{NoContentType => ContentTypeMissing}` variant. +- Rename `MultipartError::{ParseContentType => ContentTypeParse}` variant. +- Rename `MultipartError::{Boundary => BoundaryMissing}` variant. +- Rename `MultipartError::{UnsupportedField => UnknownField}` variant. +- Remove top-level re-exports of `test` utilities. + +## 0.6.2 + +- Add testing utilities under new module `test`. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 0.6.1 + +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 0.6.0 - Added `MultipartForm` typed data extractor. [#2883] [#2883]: https://github.com/actix/actix-web/pull/2883 -## 0.5.0 - 2023-01-21 +## 0.5.0 - `Field::content_type()` now returns `Option<&mime::Mime>`. [#2885] - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. [#2885]: https://github.com/actix/actix-web/pull/2885 -## 0.4.0 - 2022-02-25 +## 0.4.0 - No significant changes since `0.4.0-beta.13`. -## 0.4.0-beta.13 - 2022-01-31 +## 0.4.0-beta.13 - No significant changes since `0.4.0-beta.12`. -## 0.4.0-beta.12 - 2022-01-04 +## 0.4.0-beta.12 - Minimum supported Rust version (MSRV) is now 1.54. -## 0.4.0-beta.11 - 2021-12-27 +## 0.4.0-beta.11 - No significant changes since `0.4.0-beta.10`. -## 0.4.0-beta.10 - 2021-12-11 +## 0.4.0-beta.10 - No significant changes since `0.4.0-beta.9`. -## 0.4.0-beta.9 - 2021-12-01 +## 0.4.0-beta.9 - Polling `Field` after dropping `Multipart` now fails immediately instead of hanging forever. [#2463] [#2463]: https://github.com/actix/actix-web/pull/2463 -## 0.4.0-beta.8 - 2021-11-22 +## 0.4.0-beta.8 - Ensure a correct Content-Disposition header is included in every part of a multipart message. [#2451] - Added `MultipartError::NoContentDisposition` variant. [#2451] @@ -54,31 +86,31 @@ [#2451]: https://github.com/actix/actix-web/pull/2451 -## 0.4.0-beta.7 - 2021-10-20 +## 0.4.0-beta.7 - Minimum supported Rust version (MSRV) is now 1.52. -## 0.4.0-beta.6 - 2021-09-09 +## 0.4.0-beta.6 - Minimum supported Rust version (MSRV) is now 1.51. -## 0.4.0-beta.5 - 2021-06-17 +## 0.4.0-beta.5 - No notable changes. -## 0.4.0-beta.4 - 2021-04-02 +## 0.4.0-beta.4 - No notable changes. -## 0.4.0-beta.3 - 2021-03-09 +## 0.4.0-beta.3 - No notable changes. -## 0.4.0-beta.2 - 2021-02-10 +## 0.4.0-beta.2 - No notable changes. -## 0.4.0-beta.1 - 2021-01-07 +## 0.4.0-beta.1 - Fix multipart consuming payload before header checks. [#1513] - Update `bytes` to `1.0`. [#1813] @@ -86,19 +118,19 @@ [#1813]: https://github.com/actix/actix-web/pull/1813 [#1513]: https://github.com/actix/actix-web/pull/1513 -## 0.3.0 - 2020-09-11 +## 0.3.0 - No significant changes from `0.3.0-beta.2`. -## 0.3.0-beta.2 - 2020-09-10 +## 0.3.0-beta.2 - Update `actix-*` dependencies to latest versions. -## 0.3.0-beta.1 - 2020-07-15 +## 0.3.0-beta.1 - Update `actix-web` to 3.0.0-beta.1 -## 0.3.0-alpha.1 - 2020-05-25 +## 0.3.0-alpha.1 - Update `actix-web` to 3.0.0-alpha.3 - Bump minimum supported Rust version to 1.40 @@ -106,45 +138,45 @@ - Remove the unused `time` dependency - Fix missing `std::error::Error` implement for `MultipartError`. -## [0.2.0] - 2019-12-20 +## 0.2.0 - Release -## [0.2.0-alpha.4] - 2019-12-xx +## 0.2.0-alpha.4 - Multipart handling now handles Pending during read of boundary #1205 -## [0.2.0-alpha.2] - 2019-12-03 +## 0.2.0-alpha.2 - Migrate to `std::future` -## [0.1.4] - 2019-09-12 +## 0.1.4 - Multipart handling now parses requests which do not end in CRLF #1038 -## [0.1.3] - 2019-08-18 +## 0.1.3 - Fix ring dependency from actix-web default features for #741. -## [0.1.2] - 2019-06-02 +## 0.1.2 - Fix boundary parsing #876 -## [0.1.1] - 2019-05-25 +## 0.1.1 - Fix disconnect handling #834 -## [0.1.0] - 2019-05-18 +## 0.1.0 - Release -## [0.1.0-beta.4] - 2019-05-12 +## 0.1.0-beta.4 - Handle cancellation of uploads #736 - Upgrade to actix-web 1.0.0-beta.4 -## [0.1.0-beta.1] - 2019-04-21 +## 0.1.0-beta.1 - Do not support nested multipart diff --git a/actix-multipart/Cargo.toml b/actix-multipart/Cargo.toml index 9220e793c..384430f06 100644 --- a/actix-multipart/Cargo.toml +++ b/actix-multipart/Cargo.toml @@ -1,33 +1,47 @@ [package] name = "actix-multipart" -version = "0.6.0" +version = "0.7.2" authors = [ - "Nikolay Kim ", - "Jacob Halsey ", + "Nikolay Kim ", + "Jacob Halsey ", + "Rob Ede ", ] -description = "Multipart form support for Actix Web" -keywords = ["http", "web", "framework", "async", "futures"] -homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" -license = "MIT OR Apache-2.0" -edition = "2021" +description = "Multipart request & form support for Actix Web" +keywords = ["http", "actix", "web", "multipart", "form"] +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] all-features = true +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_http::*", + "actix_multipart_derive::*", + "actix_utils::*", + "actix_web::*", + "bytes::*", + "futures_core::*", + "mime::*", + "serde_json::*", + "serde_plain::*", + "serde::*", + "tempfile::*", +] + [features] default = ["tempfile", "derive"] derive = ["actix-multipart-derive"] tempfile = ["dep:tempfile", "tokio/fs"] [dependencies] -actix-multipart-derive = { version = "=0.6.0", optional = true } +actix-multipart-derive = { version = "=0.7.0", optional = true } actix-utils = "3" actix-web = { version = "4", default-features = false } -bytes = "1" -derive_more = "0.99.5" +derive_more = { version = "2", features = ["display", "error", "from"] } futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] } httparse = "1.3" @@ -35,18 +49,27 @@ local-waker = "0.1" log = "0.4" memchr = "2.5" mime = "0.3" +rand = "0.9" serde = "1" serde_json = "1" serde_plain = "1" tempfile = { version = "3.4", optional = true } -tokio = { version = "1.24.2", features = ["sync", "io-util"] } +tokio = { version = "1.38.2", features = ["sync", "io-util"] } [dev-dependencies] actix-http = "3" -actix-multipart-rfc7578 = "0.10" +actix-multipart-rfc7578 = "0.11" actix-rt = "2.2" actix-test = "0.1" +actix-web = "4" +assert_matches = "1" awc = "3" +env_logger = "0.11" +futures-test = "0.3" futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] } -tokio = { version = "1.24.2", features = ["sync"] } +multer = "3" +tokio = { version = "1.38.2", features = ["sync"] } tokio-stream = "0.1" + +[lints] +workspace = true diff --git a/actix-multipart/README.md b/actix-multipart/README.md index 3e2a7a127..1faacd6a8 100644 --- a/actix-multipart/README.md +++ b/actix-multipart/README.md @@ -1,17 +1,87 @@ -# actix-multipart +# `actix-multipart` -> Multipart form support for Actix Web. + [![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.6.0)](https://docs.rs/actix-multipart/0.6.0) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart.svg)
-[![dependency status](https://deps.rs/crate/actix-multipart/0.6.0/status.svg)](https://deps.rs/crate/actix-multipart/0.6.0) +[![dependency status](https://deps.rs/crate/actix-multipart/0.7.2/status.svg)](https://deps.rs/crate/actix-multipart/0.7.2) [![Download](https://img.shields.io/crates/d/actix-multipart.svg)](https://crates.io/crates/actix-multipart) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources + -- [API Documentation](https://docs.rs/actix-multipart) -- Minimum Supported Rust Version (MSRV): 1.65 + + +Multipart request & form support for Actix Web. + +The [`Multipart`] extractor aims to support all kinds of `multipart/*` requests, including `multipart/form-data`, `multipart/related` and `multipart/mixed`. This is a lower-level extractor which supports reading [multipart fields](Field), in the order they are sent by the client. + +Due to additional requirements for `multipart/form-data` requests, the higher level [`MultipartForm`] extractor and derive macro only supports this media type. + +## Examples + +```rust +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)] +struct Metadata { + name: String, +} + +#[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, +} + +#[post("/videos")] +async fn post_video(MultipartForm(form): MultipartForm) -> impl Responder { + format!( + "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<()> { + 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 +} +``` + +cURL request: + +```sh +curl -v --request POST \ + --url http://localhost:8080/videos \ + -F 'json={"name": "Cargo.lock"};type=application/json' \ + -F file=@./Cargo.lock +``` + +[`MultipartForm`]: struct@form::MultipartForm + + + +[More available in the examples repo →](https://github.com/actix/examples/tree/main/forms/multipart) diff --git a/actix-multipart/examples/form.rs b/actix-multipart/examples/form.rs new file mode 100644 index 000000000..e3fda9a23 --- /dev/null +++ b/actix-multipart/examples/form.rs @@ -0,0 +1,45 @@ +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)] +struct Metadata { + name: String, +} + +#[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, +} + +#[post("/videos")] +async fn post_video(MultipartForm(form): MultipartForm) -> impl Responder { + format!( + "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<()> { + 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 +} diff --git a/actix-multipart/src/error.rs b/actix-multipart/src/error.rs index 77b5a559f..cb4f06d39 100644 --- a/actix-multipart/src/error.rs +++ b/actix-multipart/src/error.rs @@ -10,78 +10,96 @@ use derive_more::{Display, Error, From}; /// A set of errors that can occur during parsing multipart streams. #[derive(Debug, Display, From, Error)] #[non_exhaustive] -pub enum MultipartError { - /// Content-Disposition header is not found or is not equal to "form-data". +pub enum Error { + /// Could not find Content-Type header. + #[display("Could not find Content-Type header")] + ContentTypeMissing, + + /// Could not parse Content-Type header. + #[display("Could not parse Content-Type header")] + ContentTypeParse, + + /// Parsed Content-Type did not have "multipart" top-level media type. /// - /// According to [RFC 7578 §4.2](https://datatracker.ietf.org/doc/html/rfc7578#section-4.2) a - /// Content-Disposition header must always be present and equal to "form-data". - #[display(fmt = "No Content-Disposition `form-data` header")] - NoContentDisposition, + /// Also raised when extracting a [`MultipartForm`] from a request that does not have the + /// "multipart/form-data" media type. + /// + /// [`MultipartForm`]: struct@crate::form::MultipartForm + #[display("Parsed Content-Type did not have 'multipart' top-level media type")] + ContentTypeIncompatible, - /// Content-Type header is not found - #[display(fmt = "No Content-Type header found")] - NoContentType, + /// Multipart boundary is not found. + #[display("Multipart boundary is not found")] + BoundaryMissing, - /// Can not parse Content-Type header - #[display(fmt = "Can not parse Content-Type header")] - ParseContentType, + /// Content-Disposition header was not found or not of disposition type "form-data" when parsing + /// a "form-data" field. + /// + /// As per [RFC 7578 §4.2], a "multipart/form-data" field's Content-Disposition header must + /// always be present and have a disposition type of "form-data". + /// + /// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 + #[display("Content-Disposition header was not found when parsing a \"form-data\" field")] + ContentDispositionMissing, - /// Multipart boundary is not found - #[display(fmt = "Multipart boundary is not found")] - Boundary, + /// Content-Disposition name parameter was not found when parsing a "form-data" field. + /// + /// As per [RFC 7578 §4.2], a "multipart/form-data" field's Content-Disposition header must + /// always include a "name" parameter. + /// + /// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 + #[display("Content-Disposition header was not found when parsing a \"form-data\" field")] + ContentDispositionNameMissing, - /// Nested multipart is not supported - #[display(fmt = "Nested multipart is not supported")] + /// Nested multipart is not supported. + #[display("Nested multipart is not supported")] Nested, - /// Multipart stream is incomplete - #[display(fmt = "Multipart stream is incomplete")] + /// Multipart stream is incomplete. + #[display("Multipart stream is incomplete")] Incomplete, - /// Error during field parsing - #[display(fmt = "{}", _0)] + /// Field parsing failed. + #[display("Error during field parsing")] Parse(ParseError), - /// Payload error - #[display(fmt = "{}", _0)] + /// HTTP payload error. + #[display("Payload error")] Payload(PayloadError), - /// Not consumed - #[display(fmt = "Multipart stream is not consumed")] + /// Stream is not consumed. + #[display("Stream is not consumed")] NotConsumed, - /// An error from a field handler in a form - #[display( - fmt = "An error occurred processing field `{}`: {}", - field_name, - source - )] + /// Form field handler raised error. + #[display("An error occurred processing field: {name}")] Field { - field_name: String, + name: String, source: actix_web::Error, }, - /// Duplicate field - #[display(fmt = "Duplicate field found for: `{}`", _0)] + /// Duplicate field found (for structure that opted-in to denying duplicate fields). + #[display("Duplicate field found: {_0}")] #[from(ignore)] DuplicateField(#[error(not(source))] String), - /// Missing field - #[display(fmt = "Field with name `{}` is required", _0)] + /// Required field is missing. + #[display("Required field is missing: {_0}")] #[from(ignore)] MissingField(#[error(not(source))] String), - /// Unknown field - #[display(fmt = "Unsupported field `{}`", _0)] + /// Unknown field (for structure that opted-in to denying unknown fields). + #[display("Unknown field: {_0}")] #[from(ignore)] - UnsupportedField(#[error(not(source))] String), + UnknownField(#[error(not(source))] String), } -/// Return `BadRequest` for `MultipartError` -impl ResponseError for MultipartError { +/// Return `BadRequest` for `MultipartError`. +impl ResponseError for Error { fn status_code(&self) -> StatusCode { match &self { - MultipartError::Field { source, .. } => source.as_response_error().status_code(), + Error::Field { source, .. } => source.as_response_error().status_code(), + Error::ContentTypeIncompatible => StatusCode::UNSUPPORTED_MEDIA_TYPE, _ => StatusCode::BAD_REQUEST, } } @@ -93,7 +111,7 @@ mod tests { #[test] fn test_multipart_error() { - let resp = MultipartError::Boundary.error_response(); + let resp = Error::BoundaryMissing.error_response(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } } diff --git a/actix-multipart/src/extractor.rs b/actix-multipart/src/extractor.rs index 56ed69ae4..31999228e 100644 --- a/actix-multipart/src/extractor.rs +++ b/actix-multipart/src/extractor.rs @@ -1,21 +1,20 @@ -//! Multipart payload support - use actix_utils::future::{ready, Ready}; use actix_web::{dev::Payload, Error, FromRequest, HttpRequest}; -use crate::server::Multipart; +use crate::multipart::Multipart; -/// Get request's payload as multipart stream. +/// Extract request's payload as multipart stream. /// -/// Content-type: multipart/form-data; +/// Content-type: multipart/*; /// /// # Examples +/// /// ``` -/// use actix_web::{web, HttpResponse, Error}; +/// use actix_web::{web, HttpResponse}; /// use actix_multipart::Multipart; /// use futures_util::StreamExt as _; /// -/// async fn index(mut payload: Multipart) -> Result { +/// async fn index(mut payload: Multipart) -> actix_web::Result { /// // iterate over multipart stream /// while let Some(item) = payload.next().await { /// let mut field = item?; @@ -26,7 +25,7 @@ use crate::server::Multipart; /// } /// } /// -/// Ok(HttpResponse::Ok().into()) +/// Ok(HttpResponse::Ok().finish()) /// } /// ``` impl FromRequest for Multipart { @@ -35,9 +34,6 @@ impl FromRequest for Multipart { #[inline] fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - ready(Ok(match Multipart::boundary(req.headers()) { - Ok(boundary) => Multipart::from_boundary(boundary, payload.take()), - Err(err) => Multipart::from_error(err), - })) + ready(Ok(Multipart::from_req(req, payload))) } } diff --git a/actix-multipart/src/field.rs b/actix-multipart/src/field.rs new file mode 100644 index 000000000..0bbb8a657 --- /dev/null +++ b/actix-multipart/src/field.rs @@ -0,0 +1,501 @@ +use std::{ + cell::RefCell, + cmp, fmt, + future::poll_fn, + mem, + pin::Pin, + rc::Rc, + task::{ready, Context, Poll}, +}; + +use actix_web::{ + error::PayloadError, + http::header::{self, ContentDisposition, HeaderMap}, + web::{Bytes, BytesMut}, +}; +use derive_more::{Display, Error}; +use futures_core::Stream; +use mime::Mime; + +use crate::{ + error::Error, + payload::{PayloadBuffer, PayloadRef}, + safety::Safety, +}; + +/// Error type returned from [`Field::bytes()`] when field data is larger than limit. +#[derive(Debug, Display, Error)] +#[display("size limit exceeded while collecting field data")] +#[non_exhaustive] +pub struct LimitExceeded; + +/// A single field in a multipart stream. +pub struct Field { + /// Field's Content-Type. + content_type: Option, + + /// Field's Content-Disposition. + content_disposition: Option, + + /// Form field name. + /// + /// A non-optional storage for form field names to avoid unwraps in `form` module. Will be an + /// empty string in non-form contexts. + /// + // INVARIANT: always non-empty when request content-type is multipart/form-data. + pub(crate) form_field_name: String, + + /// Field's header map. + headers: HeaderMap, + + safety: Safety, + inner: Rc>, +} + +impl Field { + pub(crate) fn new( + content_type: Option, + content_disposition: Option, + form_field_name: Option, + headers: HeaderMap, + safety: Safety, + inner: Rc>, + ) -> Self { + Field { + content_type, + content_disposition, + form_field_name: form_field_name.unwrap_or_default(), + headers, + inner, + safety, + } + } + + /// Returns a reference to the field's header map. + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Returns a reference to the field's content (mime) type, if it is supplied by the client. + /// + /// According to [RFC 7578](https://www.rfc-editor.org/rfc/rfc7578#section-4.4), if it is not + /// present, it should default to "text/plain". Note it is the responsibility of the client to + /// provide the appropriate content type, there is no attempt to validate this by the server. + pub fn content_type(&self) -> Option<&Mime> { + self.content_type.as_ref() + } + + /// Returns this field's parsed Content-Disposition header, if set. + /// + /// # Validation + /// + /// Per [RFC 7578 §4.2], the parts of a multipart/form-data payload MUST contain a + /// Content-Disposition header field where the disposition type is `form-data` and MUST also + /// contain an additional parameter of `name` with its value being the original field name from + /// the form. This requirement is enforced during extraction for multipart/form-data requests, + /// but not other kinds of multipart requests (such as multipart/related). + /// + /// As such, it is safe to `.unwrap()` calls `.content_disposition()` if you've verified. + /// + /// The [`name()`](Self::name) method is also provided as a convenience for obtaining the + /// aforementioned name parameter. + /// + /// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 + pub fn content_disposition(&self) -> Option<&ContentDisposition> { + self.content_disposition.as_ref() + } + + /// Returns the field's name, if set. + /// + /// See [`content_disposition()`](Self::content_disposition) regarding guarantees on presence of + /// the "name" field. + pub fn name(&self) -> Option<&str> { + self.content_disposition()?.get_name() + } + + /// Collects the raw field data, up to `limit` bytes. + /// + /// # Errors + /// + /// Any errors produced by the data stream are returned as `Ok(Err(Error))` immediately. + /// + /// If the buffered data size would exceed `limit`, an `Err(LimitExceeded)` is returned. Note + /// that, in this case, the full data stream is exhausted before returning the error so that + /// subsequent fields can still be read. To better defend against malicious/infinite requests, + /// it is advisable to also put a timeout on this call. + pub async fn bytes(&mut self, limit: usize) -> Result, LimitExceeded> { + /// Sensible default (2kB) for initial, bounded allocation when collecting body bytes. + const INITIAL_ALLOC_BYTES: usize = 2 * 1024; + + let mut exceeded_limit = false; + let mut buf = BytesMut::with_capacity(INITIAL_ALLOC_BYTES); + + let mut field = Pin::new(self); + + match poll_fn(|cx| loop { + match ready!(field.as_mut().poll_next(cx)) { + // if already over limit, discard chunk to advance multipart request + Some(Ok(_chunk)) if exceeded_limit => {} + + // if limit is exceeded set flag to true and continue + Some(Ok(chunk)) if buf.len() + chunk.len() > limit => { + exceeded_limit = true; + // eagerly de-allocate field data buffer + let _ = mem::take(&mut buf); + } + + Some(Ok(chunk)) => buf.extend_from_slice(&chunk), + + None => return Poll::Ready(Ok(())), + Some(Err(err)) => return Poll::Ready(Err(err)), + } + }) + .await + { + // propagate error returned from body poll + Err(err) => Ok(Err(err)), + + // limit was exceeded while reading body + Ok(()) if exceeded_limit => Err(LimitExceeded), + + // otherwise return body buffer + Ok(()) => Ok(Ok(buf.freeze())), + } + } +} + +impl Stream for Field { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + let mut inner = this.inner.borrow_mut(); + + if let Some(mut buffer) = inner + .payload + .as_ref() + .expect("Field should not be polled after completion") + .get_mut(&this.safety) + { + // check safety and poll read payload to buffer. + buffer.poll_stream(cx)?; + } else if !this.safety.is_clean() { + // safety violation + return Poll::Ready(Some(Err(Error::NotConsumed))); + } else { + return Poll::Pending; + } + + inner.poll(&this.safety) + } +} + +impl fmt::Debug for Field { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(ct) = &self.content_type { + writeln!(f, "\nField: {}", ct)?; + } else { + writeln!(f, "\nField:")?; + } + writeln!(f, " boundary: {}", self.inner.borrow().boundary)?; + writeln!(f, " headers:")?; + for (key, val) in self.headers.iter() { + writeln!(f, " {:?}: {:?}", key, val)?; + } + Ok(()) + } +} + +pub(crate) struct InnerField { + /// Payload is initialized as Some and is `take`n when the field stream finishes. + payload: Option, + + /// Field boundary (without "--" prefix). + boundary: String, + + /// True if request payload has been exhausted. + eof: bool, + + /// Field data's stated size according to it's Content-Length header. + length: Option, +} + +impl InnerField { + pub(crate) fn new_in_rc( + payload: PayloadRef, + boundary: String, + headers: &HeaderMap, + ) -> Result>, PayloadError> { + Self::new(payload, boundary, headers).map(|this| Rc::new(RefCell::new(this))) + } + + pub(crate) fn new( + payload: PayloadRef, + boundary: String, + headers: &HeaderMap, + ) -> Result { + let len = if let Some(len) = headers.get(&header::CONTENT_LENGTH) { + match len.to_str().ok().and_then(|len| len.parse::().ok()) { + Some(len) => Some(len), + None => return Err(PayloadError::Incomplete(None)), + } + } else { + None + }; + + Ok(InnerField { + boundary, + payload: Some(payload), + eof: false, + length: len, + }) + } + + /// Reads body part content chunk of the specified size. + /// + /// The body part must has `Content-Length` header with proper value. + pub(crate) fn read_len( + payload: &mut PayloadBuffer, + size: &mut u64, + ) -> Poll>> { + if *size == 0 { + Poll::Ready(None) + } else { + match payload.read_max(*size)? { + Some(mut chunk) => { + let len = cmp::min(chunk.len() as u64, *size); + *size -= len; + let ch = chunk.split_to(len as usize); + if !chunk.is_empty() { + payload.unprocessed(chunk); + } + Poll::Ready(Some(Ok(ch))) + } + None => { + if payload.eof && (*size != 0) { + Poll::Ready(Some(Err(Error::Incomplete))) + } else { + Poll::Pending + } + } + } + } + } + + /// Reads content chunk of body part with unknown length. + /// + /// The `Content-Length` header for body part is not necessary. + pub(crate) fn read_stream( + payload: &mut PayloadBuffer, + boundary: &str, + ) -> Poll>> { + let mut pos = 0; + + let len = payload.buf.len(); + + if len == 0 { + return if payload.eof { + Poll::Ready(Some(Err(Error::Incomplete))) + } else { + Poll::Pending + }; + } + + // check boundary + if len > 4 && payload.buf[0] == b'\r' { + let b_len = if payload.buf.starts_with(b"\r\n") && &payload.buf[2..4] == b"--" { + Some(4) + } else if &payload.buf[1..3] == b"--" { + Some(3) + } else { + None + }; + + if let Some(b_len) = b_len { + let b_size = boundary.len() + b_len; + if len < b_size { + return Poll::Pending; + } else if &payload.buf[b_len..b_size] == boundary.as_bytes() { + // found boundary + return Poll::Ready(None); + } + } + } + + loop { + return if let Some(idx) = memchr::memmem::find(&payload.buf[pos..], b"\r") { + let cur = pos + idx; + + // check if we have enough data for boundary detection + if cur + 4 > len { + if cur > 0 { + Poll::Ready(Some(Ok(payload.buf.split_to(cur).freeze()))) + } else { + Poll::Pending + } + } else { + // check boundary + if (&payload.buf[cur..cur + 2] == b"\r\n" + && &payload.buf[cur + 2..cur + 4] == b"--") + || (&payload.buf[cur..=cur] == b"\r" + && &payload.buf[cur + 1..cur + 3] == b"--") + { + if cur != 0 { + // return buffer + Poll::Ready(Some(Ok(payload.buf.split_to(cur).freeze()))) + } else { + pos = cur + 1; + continue; + } + } else { + // not boundary + pos = cur + 1; + continue; + } + } + } else { + Poll::Ready(Some(Ok(payload.buf.split().freeze()))) + }; + } + } + + pub(crate) fn poll(&mut self, safety: &Safety) -> Poll>> { + if self.payload.is_none() { + return Poll::Ready(None); + } + + let Some(mut payload) = self + .payload + .as_ref() + .expect("Field should not be polled after completion") + .get_mut(safety) + else { + return Poll::Pending; + }; + + if !self.eof { + let res = if let Some(ref mut len) = self.length { + Self::read_len(&mut payload, len) + } else { + Self::read_stream(&mut payload, &self.boundary) + }; + + match ready!(res) { + Some(Ok(bytes)) => return Poll::Ready(Some(Ok(bytes))), + Some(Err(err)) => return Poll::Ready(Some(Err(err))), + None => self.eof = true, + } + } + + let result = match payload.readline() { + Ok(None) => Poll::Pending, + Ok(Some(line)) => { + if line.as_ref() != b"\r\n" { + log::warn!("multipart field did not read all the data or it is malformed"); + } + Poll::Ready(None) + } + Err(err) => Poll::Ready(Some(Err(err))), + }; + + drop(payload); + + if let Poll::Ready(None) = result { + // drop payload buffer and make future un-poll-able + let _ = self.payload.take(); + } + + result + } +} + +#[cfg(test)] +mod tests { + use futures_util::{stream, StreamExt as _}; + + use super::*; + use crate::Multipart; + + // TODO: use test utility when multi-file support is introduced + fn create_double_request_with_header() -> (Bytes, HeaderMap) { + let bytes = Bytes::from( + "testasdadsad\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + \r\n\ + one+one+one\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + \r\n\ + two+two+two\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0--\r\n", + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", + ), + ); + (bytes, headers) + } + + #[actix_rt::test] + async fn bytes_unlimited() { + let (body, headers) = create_double_request_with_header(); + + let mut multipart = Multipart::new(&headers, stream::iter([Ok(body)])); + + let field = multipart + .next() + .await + .expect("multipart should have two fields") + .expect("multipart body should be well formatted") + .bytes(usize::MAX) + .await + .expect("field data should not be size limited") + .expect("reading field data should not error"); + assert_eq!(field, "one+one+one"); + + let field = multipart + .next() + .await + .expect("multipart should have two fields") + .expect("multipart body should be well formatted") + .bytes(usize::MAX) + .await + .expect("field data should not be size limited") + .expect("reading field data should not error"); + assert_eq!(field, "two+two+two"); + } + + #[actix_rt::test] + async fn bytes_limited() { + let (body, headers) = create_double_request_with_header(); + + let mut multipart = Multipart::new(&headers, stream::iter([Ok(body)])); + + multipart + .next() + .await + .expect("multipart should have two fields") + .expect("multipart body should be well formatted") + .bytes(8) // smaller than data size + .await + .expect_err("field data should be size limited"); + + // next field still readable + let field = multipart + .next() + .await + .expect("multipart should have two fields") + .expect("multipart body should be well formatted") + .bytes(usize::MAX) + .await + .expect("field data should not be size limited") + .expect("reading field data should not error"); + assert_eq!(field, "two+two+two"); + } +} diff --git a/actix-multipart/src/form/bytes.rs b/actix-multipart/src/form/bytes.rs index 3c5e2eb10..51b0cf7d9 100644 --- a/actix-multipart/src/form/bytes.rs +++ b/actix-multipart/src/form/bytes.rs @@ -1,7 +1,6 @@ //! Reads a field into memory. -use actix_web::HttpRequest; -use bytes::BytesMut; +use actix_web::{web::BytesMut, HttpRequest}; use futures_core::future::LocalBoxFuture; use futures_util::TryStreamExt as _; use mime::Mime; @@ -15,7 +14,7 @@ use crate::{ #[derive(Debug)] pub struct Bytes { /// The data. - pub data: bytes::Bytes, + pub data: actix_web::web::Bytes, /// The value of the `Content-Type` header. pub content_type: Option, @@ -41,8 +40,9 @@ impl<'t> FieldReader<'t> for Bytes { content_type: field.content_type().map(ToOwned::to_owned), file_name: field .content_disposition() + .expect("multipart form fields should have a content-disposition header") .get_filename() - .map(str::to_owned), + .map(ToOwned::to_owned), }) }) } diff --git a/actix-multipart/src/form/json.rs b/actix-multipart/src/form/json.rs index fb90a82b9..70874e5de 100644 --- a/actix-multipart/src/form/json.rs +++ b/actix-multipart/src/form/json.rs @@ -32,7 +32,6 @@ where fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future { Box::pin(async move { let config = JsonConfig::from_req(req); - let field_name = field.name().to_owned(); if config.validate_content_type { let valid = if let Some(mime) = field.content_type() { @@ -43,17 +42,19 @@ where if !valid { return Err(MultipartError::Field { - field_name, + name: field.form_field_name, source: config.map_error(req, JsonFieldError::ContentType), }); } } + let form_field_name = field.form_field_name.clone(); + let bytes = Bytes::read_field(req, field, limits).await?; Ok(Json(serde_json::from_slice(bytes.data.as_ref()).map_err( |err| MultipartError::Field { - field_name, + name: form_field_name, source: config.map_error(req, JsonFieldError::Deserialize(err)), }, )?)) @@ -65,11 +66,11 @@ where #[non_exhaustive] pub enum JsonFieldError { /// Deserialize error. - #[display(fmt = "Json deserialize error: {}", _0)] + #[display("Json deserialize error: {}", _0)] Deserialize(serde_json::Error), /// Content type error. - #[display(fmt = "Content type error")] + #[display("Content type error")] ContentType, } @@ -131,14 +132,12 @@ impl Default for JsonConfig { #[cfg(test)] mod tests { - use std::{collections::HashMap, io::Cursor}; + use std::collections::HashMap; - use actix_multipart_rfc7578::client::multipart; - use actix_web::{http::StatusCode, web, App, HttpResponse, Responder}; + use actix_web::{http::StatusCode, web, web::Bytes, App, HttpResponse, Responder}; use crate::form::{ json::{Json, JsonConfig}, - tests::send_form, MultipartForm, }; @@ -155,6 +154,8 @@ mod tests { HttpResponse::Ok().finish() } + const TEST_JSON: &str = r#"{"key1": "value1", "key2": "value2"}"#; + #[actix_rt::test] async fn test_json_without_content_type() { let srv = actix_test::start(|| { @@ -163,10 +164,16 @@ mod tests { .app_data(JsonConfig::default().validate_content_type(false)) }); - let mut form = multipart::Form::default(); - form.add_text("json", "{\"key1\": \"value1\", \"key2\": \"value2\"}"); - let response = send_form(&srv, form, "/").await; - assert_eq!(response.status(), StatusCode::OK); + let (body, headers) = crate::test::create_form_data_payload_and_headers( + "json", + None, + None, + Bytes::from_static(TEST_JSON.as_bytes()), + ); + let mut req = srv.post("/"); + *req.headers_mut() = headers; + let res = req.send_body(body).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); } #[actix_rt::test] @@ -178,17 +185,27 @@ mod tests { }); // Deny because wrong content type - let bytes = Cursor::new("{\"key1\": \"value1\", \"key2\": \"value2\"}"); - let mut form = multipart::Form::default(); - form.add_reader_file_with_mime("json", bytes, "", mime::APPLICATION_OCTET_STREAM); - let response = send_form(&srv, form, "/").await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let (body, headers) = crate::test::create_form_data_payload_and_headers( + "json", + None, + Some(mime::APPLICATION_OCTET_STREAM), + Bytes::from_static(TEST_JSON.as_bytes()), + ); + let mut req = srv.post("/"); + *req.headers_mut() = headers; + let res = req.send_body(body).await.unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); // Allow because correct content type - let bytes = Cursor::new("{\"key1\": \"value1\", \"key2\": \"value2\"}"); - let mut form = multipart::Form::default(); - form.add_reader_file_with_mime("json", bytes, "", mime::APPLICATION_JSON); - let response = send_form(&srv, form, "/").await; - assert_eq!(response.status(), StatusCode::OK); + let (body, headers) = crate::test::create_form_data_payload_and_headers( + "json", + None, + Some(mime::APPLICATION_JSON), + Bytes::from_static(TEST_JSON.as_bytes()), + ); + let mut req = srv.post("/"); + *req.headers_mut() = headers; + let res = req.send_body(body).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); } } diff --git a/actix-multipart/src/form/mod.rs b/actix-multipart/src/form/mod.rs index 67adfd4b2..cb89b7cc1 100644 --- a/actix-multipart/src/form/mod.rs +++ b/actix-multipart/src/form/mod.rs @@ -1,4 +1,4 @@ -//! Process and extract typed data from a multipart stream. +//! Extract and process typed data from fields of a `multipart/form-data` request. use std::{ any::Any, @@ -33,6 +33,14 @@ pub trait FieldReader<'t>: Sized + Any { type Future: Future>; /// The form will call this function to handle the field. + /// + /// # Panics + /// + /// When reading the `field` payload using its `Stream` implementation, polling (manually or via + /// `next()`/`try_next()`) may panic after the payload is exhausted. If this is a problem for + /// your implementation of this method, you should [`fuse()`] the `Field` first. + /// + /// [`fuse()`]: futures_util::stream::StreamExt::fuse() fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future; } @@ -72,13 +80,13 @@ where state: &'t mut State, duplicate_field: DuplicateField, ) -> Self::Future { - if state.contains_key(field.name()) { + if state.contains_key(&field.form_field_name) { match duplicate_field { DuplicateField::Ignore => return Box::pin(ready(Ok(()))), DuplicateField::Deny => { return Box::pin(ready(Err(MultipartError::DuplicateField( - field.name().to_owned(), + field.form_field_name, )))) } @@ -87,7 +95,7 @@ where } Box::pin(async move { - let field_name = field.name().to_owned(); + let field_name = field.form_field_name.clone(); let t = T::read_field(req, field, limits).await?; state.insert(field_name, Box::new(t)); Ok(()) @@ -115,10 +123,8 @@ where Box::pin(async move { // Note: Vec GroupReader always allows duplicates - let field_name = field.name().to_owned(); - let vec = state - .entry(field_name) + .entry(field.form_field_name.clone()) .or_insert_with(|| Box::>::default()) .downcast_mut::>() .unwrap(); @@ -151,13 +157,13 @@ where state: &'t mut State, duplicate_field: DuplicateField, ) -> Self::Future { - if state.contains_key(field.name()) { + if state.contains_key(&field.form_field_name) { match duplicate_field { DuplicateField::Ignore => return Box::pin(ready(Ok(()))), DuplicateField::Deny => { return Box::pin(ready(Err(MultipartError::DuplicateField( - field.name().to_owned(), + field.form_field_name, )))) } @@ -166,7 +172,7 @@ where } Box::pin(async move { - let field_name = field.name().to_owned(); + let field_name = field.form_field_name.clone(); let t = T::read_field(req, field, limits).await?; state.insert(field_name, Box::new(t)); Ok(()) @@ -181,6 +187,45 @@ where } } +impl<'t, T> FieldGroupReader<'t> for Option> +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::>::default()) + .downcast_mut::>() + .unwrap(); + + let item = T::read_field(req, field, limits).await?; + vec.push(item); + + Ok(()) + }) + } + + fn from_state(name: &str, state: &'t mut State) -> Result { + if let Some(boxed_vec) = state.remove(name) { + let vec = *boxed_vec.downcast::>().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 +318,9 @@ impl Limits { /// [`MultipartCollect`] trait. You should use the [`macro@MultipartForm`] macro to derive this /// for your struct. /// +/// Note that this extractor rejects requests with any other Content-Type such as `multipart/mixed`, +/// `multipart/related`, or non-multipart media types. +/// /// Add a [`MultipartFormConfig`] to your app data to configure extraction. #[derive(Deref, DerefMut)] pub struct MultipartForm(pub T); @@ -286,14 +334,24 @@ impl MultipartForm { impl FromRequest for MultipartForm where - T: MultipartCollect, + T: MultipartCollect + 'static, { type Error = Error; type Future = LocalBoxFuture<'static, Result>; #[inline] fn from_request(req: &HttpRequest, payload: &mut dev::Payload) -> Self::Future { - let mut payload = Multipart::new(req.headers(), payload.take()); + let mut multipart = Multipart::from_req(req, payload); + + let content_type = match multipart.content_type_or_bail() { + Ok(content_type) => content_type, + Err(err) => return Box::pin(ready(Err(err.into()))), + }; + + if content_type.subtype() != mime::FORM_DATA { + // this extractor only supports multipart/form-data + return Box::pin(ready(Err(MultipartError::ContentTypeIncompatible.into()))); + }; let config = MultipartFormConfig::from_req(req); let mut limits = Limits::new(config.total_limit, config.memory_limit); @@ -305,21 +363,29 @@ where Box::pin( async move { let mut state = State::default(); - // We need to ensure field limits are shared for all instances of this field name + + // ensure limits are shared for all fields with this name let mut field_limits = HashMap::>::new(); - while let Some(field) = payload.try_next().await? { + while let Some(field) = multipart.try_next().await? { + debug_assert!( + !field.form_field_name.is_empty(), + "multipart form fields should have names", + ); + // Retrieve the limit for this field let entry = field_limits - .entry(field.name().to_owned()) - .or_insert_with(|| T::limit(field.name())); - limits.field_limit_remaining = entry.to_owned(); + .entry(field.form_field_name.clone()) + .or_insert_with(|| T::limit(&field.form_field_name)); + + limits.field_limit_remaining.clone_from(entry); T::handle_field(&req, field, &mut limits, &mut state).await?; // Update the stored limit *entry = limits.field_limit_remaining; } + let inner = T::from_state(state)?; Ok(MultipartForm(inner)) } @@ -395,11 +461,20 @@ mod tests { use actix_http::encoding::Decoder; use actix_multipart_rfc7578::client::multipart; use actix_test::TestServer; - use actix_web::{dev::Payload, http::StatusCode, web, App, HttpResponse, Responder}; + use actix_web::{ + dev::Payload, http::StatusCode, web, App, HttpRequest, HttpResponse, Resource, Responder, + }; use awc::{Client, ClientResponse}; + use futures_core::future::LocalBoxFuture; + use futures_util::TryStreamExt as _; use super::MultipartForm; - use crate::form::{bytes::Bytes, tempfile::TempFile, text::Text, MultipartFormConfig}; + use crate::{ + form::{ + bytes::Bytes, tempfile::TempFile, text::Text, FieldReader, Limits, MultipartFormConfig, + }, + Field, MultipartError, + }; pub async fn send_form( srv: &TestServer, @@ -470,6 +545,40 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + /// Test `Option` fields. + #[derive(MultipartForm)] + struct TestOptionVec { + list1: Option>>, + list2: Option>>, + } + + async fn test_option_vec_route(form: MultipartForm) -> impl Responder { + let form = form.into_inner(); + let strings = form + .list1 + .unwrap() + .into_iter() + .map(|s| s.into_inner()) + .collect::>(); + 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 { @@ -733,4 +842,84 @@ mod tests { let response = send_form(&srv, form, "/").await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + + #[actix_rt::test] + async fn non_multipart_form_data() { + #[derive(MultipartForm)] + struct TestNonMultipartFormData { + #[allow(unused)] + #[multipart(limit = "30B")] + foo: Text, + } + + async fn non_multipart_form_data_route( + _form: MultipartForm, + ) -> String { + unreachable!("request is sent with multipart/mixed"); + } + + let srv = actix_test::start(|| { + App::new().route("/", web::post().to(non_multipart_form_data_route)) + }); + + let mut form = multipart::Form::default(); + form.add_text("foo", "foo"); + + // mangle content-type, keeping the boundary + let ct = form.content_type().replacen("/form-data", "/mixed", 1); + + let res = Client::default() + .post(srv.url("/")) + .content_type(ct) + .send_body(multipart::Body::from(form)) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: Connect(Disconnected)")] + #[actix_web::test] + async fn field_try_next_panic() { + #[derive(Debug)] + struct NullSink; + + impl<'t> FieldReader<'t> for NullSink { + type Future = LocalBoxFuture<'t, Result>; + + fn read_field( + _: &'t HttpRequest, + mut field: Field, + _limits: &'t mut Limits, + ) -> Self::Future { + Box::pin(async move { + // exhaust field stream + while let Some(_chunk) = field.try_next().await? {} + + // poll again, crash + let _post = field.try_next().await; + + Ok(Self) + }) + } + } + + #[allow(dead_code)] + #[derive(MultipartForm)] + struct NullSinkForm { + foo: NullSink, + } + + async fn null_sink(_form: MultipartForm) -> impl Responder { + "unreachable" + } + + let srv = actix_test::start(|| App::new().service(Resource::new("/").post(null_sink))); + + let mut form = multipart::Form::default(); + form.add_text("foo", "data is not important to this test"); + + // panics with Err(Connect(Disconnected)) due to form NullSink panic + let _res = send_form(&srv, form, "/").await; + } } diff --git a/actix-multipart/src/form/tempfile.rs b/actix-multipart/src/form/tempfile.rs index 9371a026b..218b91bff 100644 --- a/actix-multipart/src/form/tempfile.rs +++ b/actix-multipart/src/form/tempfile.rs @@ -42,38 +42,36 @@ impl<'t> FieldReader<'t> for TempFile { fn read_field(req: &'t HttpRequest, mut field: Field, limits: &'t mut Limits) -> Self::Future { Box::pin(async move { let config = TempFileConfig::from_req(req); - let field_name = field.name().to_owned(); let mut size = 0; - let file = config - .create_tempfile() - .map_err(|err| config.map_error(req, &field_name, TempFileError::FileIo(err)))?; + let file = config.create_tempfile().map_err(|err| { + config.map_error(req, &field.form_field_name, TempFileError::FileIo(err)) + })?; - let mut file_async = - tokio::fs::File::from_std(file.reopen().map_err(|err| { - config.map_error(req, &field_name, TempFileError::FileIo(err)) - })?); + let mut file_async = tokio::fs::File::from_std(file.reopen().map_err(|err| { + config.map_error(req, &field.form_field_name, TempFileError::FileIo(err)) + })?); while let Some(chunk) = field.try_next().await? { limits.try_consume_limits(chunk.len(), false)?; size += chunk.len(); file_async.write_all(chunk.as_ref()).await.map_err(|err| { - config.map_error(req, &field_name, TempFileError::FileIo(err)) + config.map_error(req, &field.form_field_name, TempFileError::FileIo(err)) })?; } - file_async - .flush() - .await - .map_err(|err| config.map_error(req, &field_name, TempFileError::FileIo(err)))?; + file_async.flush().await.map_err(|err| { + config.map_error(req, &field.form_field_name, TempFileError::FileIo(err)) + })?; Ok(TempFile { file, content_type: field.content_type().map(ToOwned::to_owned), file_name: field .content_disposition() + .expect("multipart form fields should have a content-disposition header") .get_filename() - .map(str::to_owned), + .map(ToOwned::to_owned), size, }) }) @@ -84,7 +82,7 @@ impl<'t> FieldReader<'t> for TempFile { #[non_exhaustive] pub enum TempFileError { /// File I/O Error - #[display(fmt = "File I/O error: {}", _0)] + #[display("File I/O error: {}", _0)] FileIo(std::io::Error), } @@ -137,7 +135,7 @@ impl TempFileConfig { }; MultipartError::Field { - field_name: field_name.to_owned(), + name: field_name.to_owned(), source, } } diff --git a/actix-multipart/src/form/text.rs b/actix-multipart/src/form/text.rs index 83e211524..b56a20353 100644 --- a/actix-multipart/src/form/text.rs +++ b/actix-multipart/src/form/text.rs @@ -36,7 +36,6 @@ where fn read_field(req: &'t HttpRequest, field: Field, limits: &'t mut Limits) -> Self::Future { Box::pin(async move { let config = TextConfig::from_req(req); - let field_name = field.name().to_owned(); if config.validate_content_type { let valid = if let Some(mime) = field.content_type() { @@ -49,22 +48,24 @@ where if !valid { return Err(MultipartError::Field { - field_name, + name: field.form_field_name, source: config.map_error(req, TextError::ContentType), }); } } + let form_field_name = field.form_field_name.clone(); + let bytes = Bytes::read_field(req, field, limits).await?; let text = str::from_utf8(&bytes.data).map_err(|err| MultipartError::Field { - field_name: field_name.clone(), + name: form_field_name.clone(), source: config.map_error(req, TextError::Utf8Error(err)), })?; Ok(Text(serde_plain::from_str(text).map_err(|err| { MultipartError::Field { - field_name, + name: form_field_name, source: config.map_error(req, TextError::Deserialize(err)), } })?)) @@ -76,15 +77,15 @@ where #[non_exhaustive] pub enum TextError { /// UTF-8 decoding error. - #[display(fmt = "UTF-8 decoding error: {}", _0)] + #[display("UTF-8 decoding error: {}", _0)] Utf8Error(str::Utf8Error), /// Deserialize error. - #[display(fmt = "Plain text deserialize error: {}", _0)] + #[display("Plain text deserialize error: {}", _0)] Deserialize(serde_plain::Error), /// Content type error. - #[display(fmt = "Content type error")] + #[display("Content type error")] ContentType, } diff --git a/actix-multipart/src/lib.rs b/actix-multipart/src/lib.rs index 615a8e6de..ca5166d33 100644 --- a/actix-multipart/src/lib.rs +++ b/actix-multipart/src/lib.rs @@ -1,11 +1,70 @@ -//! Multipart form support for Actix Web. +//! Multipart request & form support for Actix Web. +//! +//! The [`Multipart`] extractor aims to support all kinds of `multipart/*` requests, including +//! `multipart/form-data`, `multipart/related` and `multipart/mixed`. This is a lower-level +//! extractor which supports reading [multipart fields](Field), in the order they are sent by the +//! client. +//! +//! Due to additional requirements for `multipart/form-data` requests, the higher level +//! [`MultipartForm`] extractor and derive macro only supports this media type. +//! +//! # Examples +//! +//! ```no_run +//! use actix_web::{post, App, HttpServer, Responder}; +//! +//! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig}; +//! use serde::Deserialize; +//! +//! #[derive(Debug, Deserialize)] +//! struct Metadata { +//! name: String, +//! } +//! +//! #[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, +//! } +//! +//! #[post("/videos")] +//! pub async fn post_video(MultipartForm(form): MultipartForm) -> impl Responder { +//! format!( +//! "Uploaded file {}, with size: {}", +//! form.json.name, form.file.size +//! ) +//! } +//! +//! #[actix_web::main] +//! async fn main() -> std::io::Result<()> { +//! 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 +//! } +//! ``` +//! +//! cURL request: +//! +//! ```sh +//! curl -v --request POST \ +//! --url http://localhost:8080/videos \ +//! -F 'json={"name": "Cargo.lock"};type=application/json' \ +//! -F file=@./Cargo.lock +//! ``` +//! +//! [`MultipartForm`]: struct@form::MultipartForm -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] -#![allow(clippy::borrow_interior_mutable_const, clippy::uninlined_format_args)] #![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)] @@ -13,11 +72,15 @@ extern crate self as actix_multipart; mod error; mod extractor; -mod server; - +pub(crate) mod field; pub mod form; +mod multipart; +pub(crate) mod payload; +pub(crate) mod safety; +pub mod test; pub use self::{ - error::MultipartError, - server::{Field, Multipart}, + error::Error as MultipartError, + field::{Field, LimitExceeded}, + multipart::Multipart, }; diff --git a/actix-multipart/src/multipart.rs b/actix-multipart/src/multipart.rs new file mode 100644 index 000000000..e38fbde9e --- /dev/null +++ b/actix-multipart/src/multipart.rs @@ -0,0 +1,883 @@ +//! Multipart response payload support. + +use std::{ + cell::RefCell, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; + +use actix_web::{ + dev, + error::{ParseError, PayloadError}, + http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue}, + web::Bytes, + HttpRequest, +}; +use futures_core::stream::Stream; +use mime::Mime; + +use crate::{ + error::Error, + field::InnerField, + payload::{PayloadBuffer, PayloadRef}, + safety::Safety, + Field, +}; + +const MAX_HEADERS: usize = 32; + +/// The server-side implementation of `multipart/form-data` requests. +/// +/// This will parse the incoming stream into `MultipartItem` instances via its `Stream` +/// implementation. `MultipartItem::Field` contains multipart field. `MultipartItem::Multipart` is +/// used for nested multipart streams. +pub struct Multipart { + flow: Flow, + safety: Safety, +} + +enum Flow { + InFlight(Inner), + + /// Error container is Some until an error is returned out of the flow. + Error(Option), +} + +impl Multipart { + /// Creates multipart instance from parts. + pub fn new(headers: &HeaderMap, stream: S) -> Self + where + S: Stream> + 'static, + { + match Self::find_ct_and_boundary(headers) { + Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, stream), + Err(err) => Self::from_error(err), + } + } + + /// Creates multipart instance from parts. + pub(crate) fn from_req(req: &HttpRequest, payload: &mut dev::Payload) -> Self { + match Self::find_ct_and_boundary(req.headers()) { + Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, payload.take()), + Err(err) => Self::from_error(err), + } + } + + /// Extract Content-Type and boundary info from headers. + pub(crate) fn find_ct_and_boundary(headers: &HeaderMap) -> Result<(Mime, String), Error> { + let content_type = headers + .get(&header::CONTENT_TYPE) + .ok_or(Error::ContentTypeMissing)? + .to_str() + .ok() + .and_then(|content_type| content_type.parse::().ok()) + .ok_or(Error::ContentTypeParse)?; + + if content_type.type_() != mime::MULTIPART { + return Err(Error::ContentTypeIncompatible); + } + + let boundary = content_type + .get_param(mime::BOUNDARY) + .ok_or(Error::BoundaryMissing)? + .as_str() + .to_owned(); + + Ok((content_type, boundary)) + } + + /// Constructs a new multipart reader from given Content-Type, boundary, and stream. + pub(crate) fn from_ct_and_boundary(ct: Mime, boundary: String, stream: S) -> Multipart + where + S: Stream> + 'static, + { + Multipart { + safety: Safety::new(), + flow: Flow::InFlight(Inner { + payload: PayloadRef::new(PayloadBuffer::new(stream)), + content_type: ct, + boundary, + state: State::FirstBoundary, + item: Item::None, + }), + } + } + + /// Constructs a new multipart reader from given `MultipartError`. + pub(crate) fn from_error(err: Error) -> Multipart { + Multipart { + flow: Flow::Error(Some(err)), + safety: Safety::new(), + } + } + + /// Return requests parsed Content-Type or raise the stored error. + pub(crate) fn content_type_or_bail(&mut self) -> Result { + match self.flow { + Flow::InFlight(ref inner) => Ok(inner.content_type.clone()), + Flow::Error(ref mut err) => Err(err + .take() + .expect("error should not be taken after it was returned")), + } + } +} + +impl Stream for Multipart { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + + match this.flow { + Flow::InFlight(ref mut inner) => { + if let Some(mut buffer) = inner.payload.get_mut(&this.safety) { + // check safety and poll read payload to buffer. + buffer.poll_stream(cx)?; + } else if !this.safety.is_clean() { + // safety violation + return Poll::Ready(Some(Err(Error::NotConsumed))); + } else { + return Poll::Pending; + } + + inner.poll(&this.safety, cx) + } + + Flow::Error(ref mut err) => Poll::Ready(Some(Err(err + .take() + .expect("Multipart polled after finish")))), + } + } +} + +#[derive(PartialEq, Debug)] +enum State { + /// Skip data until first boundary. + FirstBoundary, + + /// Reading boundary. + Boundary, + + /// Reading Headers. + Headers, + + /// Stream EOF. + Eof, +} + +enum Item { + None, + Field(Rc>), +} + +struct Inner { + /// Request's payload stream & buffer. + payload: PayloadRef, + + /// Request's Content-Type. + /// + /// Guaranteed to have "multipart" top-level media type, i.e., `multipart/*`. + content_type: Mime, + + /// Field boundary. + boundary: String, + + state: State, + item: Item, +} + +impl Inner { + fn read_field_headers(payload: &mut PayloadBuffer) -> Result, Error> { + match payload.read_until(b"\r\n\r\n")? { + None => { + if payload.eof { + Err(Error::Incomplete) + } else { + Ok(None) + } + } + + Some(bytes) => { + let mut hdrs = [httparse::EMPTY_HEADER; MAX_HEADERS]; + + match httparse::parse_headers(&bytes, &mut hdrs).map_err(ParseError::from)? { + httparse::Status::Complete((_, hdrs)) => { + // convert headers + let mut headers = HeaderMap::with_capacity(hdrs.len()); + + for h in hdrs { + let name = + HeaderName::try_from(h.name).map_err(|_| ParseError::Header)?; + let value = + HeaderValue::try_from(h.value).map_err(|_| ParseError::Header)?; + headers.append(name, value); + } + + Ok(Some(headers)) + } + + httparse::Status::Partial => Err(ParseError::Header.into()), + } + } + } + } + + /// Reads a field boundary from the payload buffer (and discards it). + /// + /// Reads "in-between" and "final" boundaries. E.g. for boundary = "foo": + /// + /// ```plain + /// --foo <-- in-between fields + /// --foo-- <-- end of request body, should be followed by EOF + /// ``` + /// + /// Returns: + /// + /// - `Ok(Some(true))` - final field boundary read (EOF) + /// - `Ok(Some(false))` - field boundary read + /// - `Ok(None)` - boundary not found, more data needs reading + /// - `Err(BoundaryMissing)` - multipart boundary is missing + fn read_boundary(payload: &mut PayloadBuffer, boundary: &str) -> Result, Error> { + // 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) + None => return Ok(payload.eof.then_some(true)), + Some(chunk) => chunk, + }; + + 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() + { + 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 + + if &chunk[boundary_len + 2..] == 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 + ) + { + return Ok(Some(true)); + } + + Err(Error::BoundaryMissing) + } + + fn skip_until_boundary( + payload: &mut PayloadBuffer, + boundary: &str, + ) -> Result, Error> { + let mut eof = false; + + loop { + match payload.readline()? { + Some(chunk) => { + if chunk.is_empty() { + return Err(Error::BoundaryMissing); + } + if chunk.len() < boundary.len() { + continue; + } + if &chunk[..2] == b"--" && &chunk[2..chunk.len() - 2] == boundary.as_bytes() { + break; + } else { + if chunk.len() < boundary.len() + 2 { + continue; + } + let b: &[u8] = boundary.as_ref(); + if &chunk[..boundary.len()] == b + && &chunk[boundary.len()..boundary.len() + 2] == b"--" + { + eof = true; + break; + } + } + } + None => { + return if payload.eof { + Err(Error::Incomplete) + } else { + Ok(None) + }; + } + } + } + Ok(Some(eof)) + } + + fn poll(&mut self, safety: &Safety, cx: &Context<'_>) -> Poll>> { + if self.state == State::Eof { + Poll::Ready(None) + } else { + // release field + loop { + // Nested multipart streams of fields has to be consumed + // before switching to next + if safety.current() { + let stop = match self.item { + Item::Field(ref mut field) => match field.borrow_mut().poll(safety) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(_))) => continue, + Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), + Poll::Ready(None) => true, + }, + Item::None => false, + }; + if stop { + self.item = Item::None; + } + if let Item::None = self.item { + break; + } + } + } + + let field_headers = if let Some(mut payload) = self.payload.get_mut(safety) { + match self.state { + // read until first boundary + State::FirstBoundary => { + match Inner::skip_until_boundary(&mut payload, &self.boundary)? { + None => return Poll::Pending, + Some(eof) => { + if eof { + self.state = State::Eof; + return Poll::Ready(None); + } else { + self.state = State::Headers; + } + } + } + } + + // read boundary + State::Boundary => match Inner::read_boundary(&mut payload, &self.boundary)? { + None => return Poll::Pending, + Some(eof) => { + if eof { + self.state = State::Eof; + return Poll::Ready(None); + } else { + self.state = State::Headers; + } + } + }, + + _ => {} + } + + // read field headers for next field + if self.state == State::Headers { + if let Some(headers) = Inner::read_field_headers(&mut payload)? { + self.state = State::Boundary; + headers + } else { + return Poll::Pending; + } + } else { + unreachable!() + } + } else { + log::debug!("NotReady: field is in flight"); + return Poll::Pending; + }; + + let field_content_disposition = field_headers + .get(&header::CONTENT_DISPOSITION) + .and_then(|cd| ContentDisposition::from_raw(cd).ok()) + .filter(|content_disposition| { + matches!( + content_disposition.disposition, + header::DispositionType::FormData, + ) + }); + + let form_field_name = if self.content_type.subtype() == mime::FORM_DATA { + // According to RFC 7578 §4.2, which relates to "multipart/form-data" requests + // specifically, fields must have a Content-Disposition header, its disposition + // type must be set as "form-data", and it must have a name parameter. + + let Some(cd) = &field_content_disposition else { + return Poll::Ready(Some(Err(Error::ContentDispositionMissing))); + }; + + let Some(field_name) = cd.get_name() else { + return Poll::Ready(Some(Err(Error::ContentDispositionNameMissing))); + }; + + Some(field_name.to_owned()) + } else { + None + }; + + // TODO: check out other multipart/* RFCs for specific requirements + + let field_content_type: Option = field_headers + .get(&header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .and_then(|ct| ct.parse().ok()); + + self.state = State::Boundary; + + // nested multipart stream is not supported + if let Some(mime) = &field_content_type { + if mime.type_() == mime::MULTIPART { + return Poll::Ready(Some(Err(Error::Nested))); + } + } + + let field_inner = + InnerField::new_in_rc(self.payload.clone(), self.boundary.clone(), &field_headers)?; + + self.item = Item::Field(Rc::clone(&field_inner)); + + Poll::Ready(Some(Ok(Field::new( + field_content_type, + field_content_disposition, + form_field_name, + field_headers, + safety.clone(cx), + field_inner, + )))) + } + } +} + +impl Drop for Inner { + fn drop(&mut self) { + // InnerMultipartItem::Field has to be dropped first because of Safety. + self.item = Item::None; + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use actix_http::h1; + use actix_web::{ + http::header::{DispositionParam, DispositionType}, + rt, + test::TestRequest, + web::{BufMut as _, BytesMut}, + FromRequest, + }; + use assert_matches::assert_matches; + use futures_test::stream::StreamTestExt as _; + use futures_util::{stream, StreamExt as _}; + use tokio::sync::mpsc; + use tokio_stream::wrappers::UnboundedReceiverStream; + + use super::*; + + const BOUNDARY: &str = "abbc761f78ff4d7cb7573b5a23f96ef0"; + + #[actix_rt::test] + async fn test_boundary() { + let headers = HeaderMap::new(); + match Multipart::find_ct_and_boundary(&headers) { + Err(Error::ContentTypeMissing) => {} + _ => unreachable!("should not happen"), + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("test"), + ); + + match Multipart::find_ct_and_boundary(&headers) { + Err(Error::ContentTypeParse) => {} + _ => unreachable!("should not happen"), + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("multipart/mixed"), + ); + match Multipart::find_ct_and_boundary(&headers) { + Err(Error::BoundaryMissing) => {} + _ => unreachable!("should not happen"), + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/mixed; boundary=\"5c02368e880e436dab70ed54e1c58209\"", + ), + ); + + assert_eq!( + Multipart::find_ct_and_boundary(&headers).unwrap().1, + "5c02368e880e436dab70ed54e1c58209", + ); + } + + fn create_stream() -> ( + mpsc::UnboundedSender>, + impl Stream>, + ) { + let (tx, rx) = mpsc::unbounded_channel(); + + ( + tx, + UnboundedReceiverStream::new(rx).map(|res| res.map_err(|_| panic!())), + ) + } + + fn create_simple_request_with_header() -> (Bytes, HeaderMap) { + let (body, headers) = crate::test::create_form_data_payload_and_headers_with_boundary( + BOUNDARY, + "file", + Some("fn.txt".to_owned()), + Some(mime::TEXT_PLAIN_UTF_8), + Bytes::from_static(b"data"), + ); + + let mut buf = BytesMut::with_capacity(body.len() + 14); + + // add junk before form to test pre-boundary data rejection + buf.put("testasdadsad\r\n".as_bytes()); + + buf.put(body); + + (buf.freeze(), headers) + } + + // TODO: use test utility when multi-file support is introduced + fn create_double_request_with_header() -> (Bytes, HeaderMap) { + let bytes = Bytes::from( + "testasdadsad\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ + test\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ + data\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0--\r\n", + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", + ), + ); + (bytes, headers) + } + + #[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 + + sender.send(Ok(bytes_stripped)).unwrap(); + drop(sender); // eof + + let mut multipart = Multipart::new(&headers, payload); + + match multipart.next().await.unwrap() { + Ok(_) => {} + _ => unreachable!(), + } + + match multipart.next().await.unwrap() { + Ok(_) => {} + _ => unreachable!(), + } + + match multipart.next().await { + None => {} + _ => unreachable!(), + } + } + + #[actix_rt::test] + async fn test_multipart() { + let (sender, payload) = create_stream(); + let (bytes, headers) = create_double_request_with_header(); + + sender.send(Ok(bytes)).unwrap(); + + let mut multipart = Multipart::new(&headers, payload); + match multipart.next().await { + Some(Ok(mut field)) => { + let cd = field.content_disposition().unwrap(); + assert_eq!(cd.disposition, DispositionType::FormData); + assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); + + assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); + assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); + + match field.next().await.unwrap() { + Ok(chunk) => assert_eq!(chunk, "test"), + _ => unreachable!(), + } + match field.next().await { + None => {} + _ => unreachable!(), + } + } + _ => unreachable!(), + } + + match multipart.next().await.unwrap() { + Ok(mut field) => { + assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); + assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); + + match field.next().await { + Some(Ok(chunk)) => assert_eq!(chunk, "data"), + _ => unreachable!(), + } + match field.next().await { + None => {} + _ => unreachable!(), + } + } + _ => unreachable!(), + } + + match multipart.next().await { + None => {} + _ => unreachable!(), + } + } + + // Loops, collecting all bytes until end-of-field + async fn get_whole_field(field: &mut Field) -> BytesMut { + let mut b = BytesMut::new(); + loop { + match field.next().await { + Some(Ok(chunk)) => b.extend_from_slice(&chunk), + None => return b, + _ => unreachable!(), + } + } + } + + #[actix_rt::test] + async fn test_stream() { + let (bytes, headers) = create_double_request_with_header(); + let payload = stream::iter(bytes) + .map(|byte| Ok(Bytes::copy_from_slice(&[byte]))) + .interleave_pending(); + + let mut multipart = Multipart::new(&headers, payload); + match multipart.next().await.unwrap() { + Ok(mut field) => { + let cd = field.content_disposition().unwrap(); + assert_eq!(cd.disposition, DispositionType::FormData); + assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); + + assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); + assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); + + assert_eq!(get_whole_field(&mut field).await, "test"); + } + _ => unreachable!(), + } + + match multipart.next().await { + Some(Ok(mut field)) => { + assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); + assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); + + assert_eq!(get_whole_field(&mut field).await, "data"); + } + _ => unreachable!(), + } + + match multipart.next().await { + None => {} + _ => unreachable!(), + } + } + + #[actix_rt::test] + async fn test_multipart_from_error() { + let err = Error::ContentTypeMissing; + let mut multipart = Multipart::from_error(err); + assert!(multipart.next().await.unwrap().is_err()) + } + + #[actix_rt::test] + async fn test_multipart_from_boundary() { + let (_, payload) = create_stream(); + let (_, headers) = create_simple_request_with_header(); + let (ct, boundary) = Multipart::find_ct_and_boundary(&headers).unwrap(); + let _ = Multipart::from_ct_and_boundary(ct, boundary, payload); + } + + #[actix_rt::test] + async fn test_multipart_payload_consumption() { + // with sample payload and HttpRequest with no headers + let (_, inner_payload) = h1::Payload::create(false); + let mut payload = actix_web::dev::Payload::from(inner_payload); + let req = TestRequest::default().to_http_request(); + + // multipart should generate an error + let mut mp = Multipart::from_request(&req, &mut payload).await.unwrap(); + assert!(mp.next().await.unwrap().is_err()); + + // and should not consume the payload + match payload { + actix_web::dev::Payload::H1 { .. } => {} //expected + _ => unreachable!(), + } + } + + #[actix_rt::test] + async fn no_content_disposition_form_data() { + let bytes = Bytes::from( + "testasdadsad\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: 4\r\n\ + \r\n\ + test\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n", + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/form-data; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", + ), + ); + let payload = stream::iter(bytes) + .map(|byte| Ok(Bytes::copy_from_slice(&[byte]))) + .interleave_pending(); + + let mut multipart = Multipart::new(&headers, payload); + let res = multipart.next().await.unwrap(); + assert_matches!( + res.expect_err( + "according to RFC 7578, form-data fields require a content-disposition header" + ), + Error::ContentDispositionMissing + ); + } + + #[actix_rt::test] + async fn no_content_disposition_non_form_data() { + let bytes = Bytes::from( + "testasdadsad\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: 4\r\n\ + \r\n\ + test\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n", + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", + ), + ); + let payload = stream::iter(bytes) + .map(|byte| Ok(Bytes::copy_from_slice(&[byte]))) + .interleave_pending(); + + let mut multipart = Multipart::new(&headers, payload); + let res = multipart.next().await.unwrap(); + res.unwrap(); + } + + #[actix_rt::test] + async fn no_name_in_form_data_content_disposition() { + let bytes = Bytes::from( + "testasdadsad\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ + Content-Disposition: form-data; filename=\"fn.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: 4\r\n\ + \r\n\ + test\r\n\ + --abbc761f78ff4d7cb7573b5a23f96ef0\r\n", + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static( + "multipart/form-data; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", + ), + ); + let payload = stream::iter(bytes) + .map(|byte| Ok(Bytes::copy_from_slice(&[byte]))) + .interleave_pending(); + + let mut multipart = Multipart::new(&headers, payload); + let res = multipart.next().await.unwrap(); + assert_matches!( + res.expect_err("according to RFC 7578, form-data fields require a name attribute"), + Error::ContentDispositionNameMissing + ); + } + + #[actix_rt::test] + async fn test_drop_multipart_dont_hang() { + let (sender, payload) = create_stream(); + let (bytes, headers) = create_simple_request_with_header(); + sender.send(Ok(bytes)).unwrap(); + drop(sender); // eof + + let mut multipart = Multipart::new(&headers, payload); + let mut field = multipart.next().await.unwrap().unwrap(); + + drop(multipart); + + // should fail immediately + match field.next().await { + Some(Err(Error::NotConsumed)) => {} + _ => panic!(), + }; + } + + #[actix_rt::test] + async fn test_drop_field_awaken_multipart() { + let (sender, payload) = create_stream(); + let (bytes, headers) = create_double_request_with_header(); + sender.send(Ok(bytes)).unwrap(); + drop(sender); // eof + + let mut multipart = Multipart::new(&headers, payload); + let mut field = multipart.next().await.unwrap().unwrap(); + + let task = rt::spawn(async move { + rt::time::sleep(Duration::from_millis(500)).await; + assert_eq!(field.next().await.unwrap().unwrap(), "test"); + drop(field); + }); + + // dropping field should awaken current task + let _ = multipart.next().await.unwrap().unwrap(); + task.await.unwrap(); + } +} diff --git a/actix-multipart/src/payload.rs b/actix-multipart/src/payload.rs new file mode 100644 index 000000000..858634bc0 --- /dev/null +++ b/actix-multipart/src/payload.rs @@ -0,0 +1,255 @@ +use std::{ + cell::{RefCell, RefMut}, + cmp, mem, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; + +use actix_web::{ + error::PayloadError, + web::{Bytes, BytesMut}, +}; +use futures_core::stream::{LocalBoxStream, Stream}; + +use crate::{error::Error, safety::Safety}; + +pub(crate) struct PayloadRef { + payload: Rc>, +} + +impl PayloadRef { + pub(crate) fn new(payload: PayloadBuffer) -> PayloadRef { + PayloadRef { + payload: Rc::new(RefCell::new(payload)), + } + } + + pub(crate) fn get_mut(&self, safety: &Safety) -> Option> { + if safety.current() { + Some(self.payload.borrow_mut()) + } else { + None + } + } +} + +impl Clone for PayloadRef { + fn clone(&self) -> PayloadRef { + PayloadRef { + payload: Rc::clone(&self.payload), + } + } +} + +/// Payload buffer. +pub(crate) struct PayloadBuffer { + pub(crate) stream: LocalBoxStream<'static, Result>, + pub(crate) buf: BytesMut, + /// 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(stream: S) -> Self + where + S: Stream> + 'static, + { + PayloadBuffer { + stream: Box::pin(stream), + buf: BytesMut::with_capacity(1_024), // pre-allocate 1KiB + eof: false, + } + } + + pub(crate) fn poll_stream(&mut self, cx: &mut Context<'_>) -> Result<(), PayloadError> { + loop { + 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; + } + Poll::Ready(Some(Err(err))) => return Err(err), + Poll::Ready(None) => { + self.eof = true; + return Ok(()); + } + Poll::Pending => return Ok(()), + } + } + } + + /// Reads exact number of bytes. + #[cfg(test)] + pub(crate) fn read_exact(&mut self, size: usize) -> Option { + if size <= self.buf.len() { + Some(self.buf.split_to(size).freeze()) + } else { + None + } + } + + pub(crate) fn read_max(&mut self, size: u64) -> Result, Error> { + if !self.buf.is_empty() { + let size = cmp::min(self.buf.len() as u64, size) as usize; + Ok(Some(self.buf.split_to(size).freeze())) + } else if self.eof { + Err(Error::Incomplete) + } else { + Ok(None) + } + } + + /// Reads until specified ending. + /// + /// Returns: + /// + /// - `Ok(Some(chunk))` - `needle` is found, with chunk ending after needle + /// - `Err(Incomplete)` - `needle` is not found and we're at EOF + /// - `Ok(None)` - `needle` is not found otherwise + pub(crate) fn read_until(&mut self, needle: &[u8]) -> Result, Error> { + match memchr::memmem::find(&self.buf, needle) { + // buffer exhausted and EOF without finding needle + None if self.eof => Err(Error::Incomplete), + + // needle not yet found + None => Ok(None), + + // needle found, split chunk out of buf + Some(idx) => Ok(Some(self.buf.split_to(idx + needle.len()).freeze())), + } + } + + /// Reads bytes until new line delimiter (`\n`, `0x0A`). + /// + /// Returns: + /// + /// - `Ok(Some(chunk))` - `needle` is found, with chunk ending after needle + /// - `Err(Incomplete)` - `needle` is not found and we're at EOF + /// - `Ok(None)` - `needle` is not found otherwise + #[inline] + pub(crate) fn readline(&mut self) -> Result, Error> { + self.read_until(b"\n") + } + + /// Reads bytes until new line delimiter or until EOF. + #[inline] + pub(crate) fn readline_or_eof(&mut self) -> Result, Error> { + match self.readline() { + Err(Error::Incomplete) if self.eof => Ok(Some(self.buf.split().freeze())), + line => line, + } + } + + /// Puts unprocessed data back to the buffer. + pub(crate) fn unprocessed(&mut self, data: Bytes) { + // TODO: use BytesMut::from when it's released, see https://github.com/tokio-rs/bytes/pull/710 + let buf = BytesMut::from(&data[..]); + let buf = mem::replace(&mut self.buf, buf); + self.buf.extend_from_slice(&buf); + } +} + +#[cfg(test)] +mod tests { + use actix_http::h1; + use futures_util::future::lazy; + + use super::*; + + #[actix_rt::test] + async fn basic() { + let (_, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + + assert_eq!(payload.buf.len(), 0); + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + assert_eq!(None, payload.read_max(1).unwrap()); + } + + #[actix_rt::test] + async fn eof() { + let (mut sender, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + + assert_eq!(None, payload.read_max(4).unwrap()); + sender.feed_data(Bytes::from("data")); + sender.feed_eof(); + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + + assert_eq!(Some(Bytes::from("data")), payload.read_max(4).unwrap()); + assert_eq!(payload.buf.len(), 0); + assert!(payload.read_max(1).is_err()); + assert!(payload.eof); + } + + #[actix_rt::test] + async fn err() { + let (mut sender, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + assert_eq!(None, payload.read_max(1).unwrap()); + sender.set_error(PayloadError::Incomplete(None)); + lazy(|cx| payload.poll_stream(cx)).await.err().unwrap(); + } + + #[actix_rt::test] + async fn read_max() { + let (mut sender, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + + sender.feed_data(Bytes::from("line1")); + sender.feed_data(Bytes::from("line2")); + 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()); + assert_eq!(payload.buf.len(), 5); + + assert_eq!(Some(Bytes::from("line2")), payload.read_max(5).unwrap()); + assert_eq!(payload.buf.len(), 0); + } + + #[actix_rt::test] + async fn read_exactly() { + let (mut sender, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + + 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(); + + assert_eq!(Some(Bytes::from_static(b"li")), payload.read_exact(2)); + assert_eq!(payload.buf.len(), 8); + + assert_eq!(Some(Bytes::from_static(b"ne1l")), payload.read_exact(4)); + assert_eq!(payload.buf.len(), 4); + } + + #[actix_rt::test] + async fn read_until() { + let (mut sender, payload) = h1::Payload::create(false); + let mut payload = PayloadBuffer::new(payload); + + 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(); + + assert_eq!( + Some(Bytes::from("line")), + payload.read_until(b"ne").unwrap() + ); + assert_eq!(payload.buf.len(), 6); + + assert_eq!( + Some(Bytes::from("1line2")), + payload.read_until(b"2").unwrap() + ); + assert_eq!(payload.buf.len(), 0); + } +} diff --git a/actix-multipart/src/safety.rs b/actix-multipart/src/safety.rs new file mode 100644 index 000000000..db6b3b18b --- /dev/null +++ b/actix-multipart/src/safety.rs @@ -0,0 +1,60 @@ +use std::{cell::Cell, marker::PhantomData, rc::Rc, task}; + +use local_waker::LocalWaker; + +/// Counter. It tracks of number of clones of payloads and give access to payload only to top most. +/// +/// - When dropped, parent task is awakened. This is to support the case where `Field` is dropped in +/// a separate task than `Multipart`. +/// - Assumes that parent owners don't move to different tasks; only the top-most is allowed to. +/// - If dropped and is not top most owner, is_clean flag is set to false. +#[derive(Debug)] +pub(crate) struct Safety { + task: LocalWaker, + level: usize, + payload: Rc>, + clean: Rc>, +} + +impl Safety { + pub(crate) fn new() -> Safety { + let payload = Rc::new(PhantomData); + Safety { + task: LocalWaker::new(), + level: Rc::strong_count(&payload), + clean: Rc::new(Cell::new(true)), + payload, + } + } + + pub(crate) fn current(&self) -> bool { + Rc::strong_count(&self.payload) == self.level && self.clean.get() + } + + pub(crate) fn is_clean(&self) -> bool { + self.clean.get() + } + + pub(crate) fn clone(&self, cx: &task::Context<'_>) -> Safety { + let payload = Rc::clone(&self.payload); + let s = Safety { + task: LocalWaker::new(), + level: Rc::strong_count(&payload), + clean: self.clean.clone(), + payload, + }; + s.task.register(cx.waker()); + s + } +} + +impl Drop for Safety { + fn drop(&mut self) { + if Rc::strong_count(&self.payload) != self.level { + // Multipart dropped leaving a Field + self.clean.set(false); + } + + self.task.wake(); + } +} diff --git a/actix-multipart/src/server.rs b/actix-multipart/src/server.rs deleted file mode 100644 index b20429040..000000000 --- a/actix-multipart/src/server.rs +++ /dev/null @@ -1,1339 +0,0 @@ -//! Multipart response payload support. - -use std::{ - cell::{Cell, RefCell, RefMut}, - cmp, fmt, - marker::PhantomData, - pin::Pin, - rc::Rc, - task::{Context, Poll}, -}; - -use actix_web::{ - error::{ParseError, PayloadError}, - http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue}, -}; -use bytes::{Bytes, BytesMut}; -use futures_core::stream::{LocalBoxStream, Stream}; -use local_waker::LocalWaker; - -use crate::error::MultipartError; - -const MAX_HEADERS: usize = 32; - -/// The server-side implementation of `multipart/form-data` requests. -/// -/// This will parse the incoming stream into `MultipartItem` instances via its -/// Stream implementation. -/// `MultipartItem::Field` contains multipart field. `MultipartItem::Multipart` -/// is used for nested multipart streams. -pub struct Multipart { - safety: Safety, - error: Option, - inner: Option, -} - -enum InnerMultipartItem { - None, - Field(Rc>), -} - -#[derive(PartialEq, Debug)] -enum InnerState { - /// Stream eof - Eof, - - /// Skip data until first boundary - FirstBoundary, - - /// Reading boundary - Boundary, - - /// Reading Headers, - Headers, -} - -struct InnerMultipart { - payload: PayloadRef, - boundary: String, - state: InnerState, - item: InnerMultipartItem, -} - -impl Multipart { - /// Create multipart instance for boundary. - pub fn new(headers: &HeaderMap, stream: S) -> Multipart - where - S: Stream> + 'static, - { - match Self::boundary(headers) { - Ok(boundary) => Multipart::from_boundary(boundary, stream), - Err(err) => Multipart::from_error(err), - } - } - - /// Extract boundary info from headers. - pub(crate) fn boundary(headers: &HeaderMap) -> Result { - headers - .get(&header::CONTENT_TYPE) - .ok_or(MultipartError::NoContentType)? - .to_str() - .ok() - .and_then(|content_type| content_type.parse::().ok()) - .ok_or(MultipartError::ParseContentType)? - .get_param(mime::BOUNDARY) - .map(|boundary| boundary.as_str().to_owned()) - .ok_or(MultipartError::Boundary) - } - - /// Create multipart instance for given boundary and stream - pub(crate) fn from_boundary(boundary: String, stream: S) -> Multipart - where - S: Stream> + 'static, - { - Multipart { - error: None, - safety: Safety::new(), - inner: Some(InnerMultipart { - boundary, - payload: PayloadRef::new(PayloadBuffer::new(stream)), - state: InnerState::FirstBoundary, - item: InnerMultipartItem::None, - }), - } - } - - /// Create Multipart instance from MultipartError - pub(crate) fn from_error(err: MultipartError) -> Multipart { - Multipart { - error: Some(err), - safety: Safety::new(), - inner: None, - } - } -} - -impl Stream for Multipart { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - - match this.inner.as_mut() { - Some(inner) => { - if let Some(mut buffer) = inner.payload.get_mut(&this.safety) { - // check safety and poll read payload to buffer. - buffer.poll_stream(cx)?; - } else if !this.safety.is_clean() { - // safety violation - return Poll::Ready(Some(Err(MultipartError::NotConsumed))); - } else { - return Poll::Pending; - } - - inner.poll(&this.safety, cx) - } - None => Poll::Ready(Some(Err(this - .error - .take() - .expect("Multipart polled after finish")))), - } - } -} - -impl InnerMultipart { - fn read_headers(payload: &mut PayloadBuffer) -> Result, MultipartError> { - match payload.read_until(b"\r\n\r\n")? { - None => { - if payload.eof { - Err(MultipartError::Incomplete) - } else { - Ok(None) - } - } - Some(bytes) => { - let mut hdrs = [httparse::EMPTY_HEADER; MAX_HEADERS]; - match httparse::parse_headers(&bytes, &mut hdrs) { - Ok(httparse::Status::Complete((_, hdrs))) => { - // convert headers - let mut headers = HeaderMap::with_capacity(hdrs.len()); - - for h in hdrs { - let name = - HeaderName::try_from(h.name).map_err(|_| ParseError::Header)?; - let value = - HeaderValue::try_from(h.value).map_err(|_| ParseError::Header)?; - headers.append(name, value); - } - - Ok(Some(headers)) - } - Ok(httparse::Status::Partial) => Err(ParseError::Header.into()), - Err(err) => Err(ParseError::from(err).into()), - } - } - } - } - - fn read_boundary( - payload: &mut PayloadBuffer, - boundary: &str, - ) -> Result, MultipartError> { - // TODO: need to read epilogue - match payload.readline_or_eof()? { - None => { - if payload.eof { - Ok(Some(true)) - } else { - Ok(None) - } - } - Some(chunk) => { - if chunk.len() < boundary.len() + 4 - || &chunk[..2] != b"--" - || &chunk[2..boundary.len() + 2] != boundary.as_bytes() - { - Err(MultipartError::Boundary) - } else if &chunk[boundary.len() + 2..] == b"\r\n" { - Ok(Some(false)) - } else if &chunk[boundary.len() + 2..boundary.len() + 4] == b"--" - && (chunk.len() == boundary.len() + 4 - || &chunk[boundary.len() + 4..] == b"\r\n") - { - Ok(Some(true)) - } else { - Err(MultipartError::Boundary) - } - } - } - } - - fn skip_until_boundary( - payload: &mut PayloadBuffer, - boundary: &str, - ) -> Result, MultipartError> { - let mut eof = false; - loop { - match payload.readline()? { - Some(chunk) => { - if chunk.is_empty() { - return Err(MultipartError::Boundary); - } - if chunk.len() < boundary.len() { - continue; - } - if &chunk[..2] == b"--" && &chunk[2..chunk.len() - 2] == boundary.as_bytes() { - break; - } else { - if chunk.len() < boundary.len() + 2 { - continue; - } - let b: &[u8] = boundary.as_ref(); - if &chunk[..boundary.len()] == b - && &chunk[boundary.len()..boundary.len() + 2] == b"--" - { - eof = true; - break; - } - } - } - None => { - return if payload.eof { - Err(MultipartError::Incomplete) - } else { - Ok(None) - }; - } - } - } - Ok(Some(eof)) - } - - fn poll( - &mut self, - safety: &Safety, - cx: &mut Context<'_>, - ) -> Poll>> { - if self.state == InnerState::Eof { - Poll::Ready(None) - } else { - // release field - loop { - // Nested multipart streams of fields has to be consumed - // before switching to next - if safety.current() { - let stop = match self.item { - InnerMultipartItem::Field(ref mut field) => { - match field.borrow_mut().poll(safety) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Some(Ok(_))) => continue, - Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), - Poll::Ready(None) => true, - } - } - InnerMultipartItem::None => false, - }; - if stop { - self.item = InnerMultipartItem::None; - } - if let InnerMultipartItem::None = self.item { - break; - } - } - } - - let headers = if let Some(mut payload) = self.payload.get_mut(safety) { - match self.state { - // read until first boundary - InnerState::FirstBoundary => { - match InnerMultipart::skip_until_boundary(&mut payload, &self.boundary)? { - Some(eof) => { - if eof { - self.state = InnerState::Eof; - return Poll::Ready(None); - } else { - self.state = InnerState::Headers; - } - } - None => return Poll::Pending, - } - } - // read boundary - InnerState::Boundary => { - match InnerMultipart::read_boundary(&mut payload, &self.boundary)? { - None => return Poll::Pending, - Some(eof) => { - if eof { - self.state = InnerState::Eof; - return Poll::Ready(None); - } else { - self.state = InnerState::Headers; - } - } - } - } - _ => {} - } - - // read field headers for next field - if self.state == InnerState::Headers { - if let Some(headers) = InnerMultipart::read_headers(&mut payload)? { - self.state = InnerState::Boundary; - headers - } else { - return Poll::Pending; - } - } else { - unreachable!() - } - } else { - log::debug!("NotReady: field is in flight"); - return Poll::Pending; - }; - - // According to RFC 7578 §4.2, a Content-Disposition header must always be present and - // set to "form-data". - - let content_disposition = headers - .get(&header::CONTENT_DISPOSITION) - .and_then(|cd| ContentDisposition::from_raw(cd).ok()) - .filter(|content_disposition| { - let is_form_data = - content_disposition.disposition == header::DispositionType::FormData; - - let has_field_name = content_disposition - .parameters - .iter() - .any(|param| matches!(param, header::DispositionParam::Name(_))); - - is_form_data && has_field_name - }); - - let cd = if let Some(content_disposition) = content_disposition { - content_disposition - } else { - return Poll::Ready(Some(Err(MultipartError::NoContentDisposition))); - }; - - let ct: Option = headers - .get(&header::CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .and_then(|ct| ct.parse().ok()); - - self.state = InnerState::Boundary; - - // nested multipart stream is not supported - if let Some(mime) = &ct { - if mime.type_() == mime::MULTIPART { - return Poll::Ready(Some(Err(MultipartError::Nested))); - } - } - - let field = - InnerField::new_in_rc(self.payload.clone(), self.boundary.clone(), &headers)?; - - self.item = InnerMultipartItem::Field(Rc::clone(&field)); - - Poll::Ready(Some(Ok(Field::new( - safety.clone(cx), - headers, - ct, - cd, - field, - )))) - } - } -} - -impl Drop for InnerMultipart { - fn drop(&mut self) { - // InnerMultipartItem::Field has to be dropped first because of Safety. - self.item = InnerMultipartItem::None; - } -} - -/// A single field in a multipart stream -pub struct Field { - ct: Option, - cd: ContentDisposition, - headers: HeaderMap, - inner: Rc>, - safety: Safety, -} - -impl Field { - fn new( - safety: Safety, - headers: HeaderMap, - ct: Option, - cd: ContentDisposition, - inner: Rc>, - ) -> Self { - Field { - ct, - cd, - headers, - inner, - safety, - } - } - - /// Returns a reference to the field's header map. - pub fn headers(&self) -> &HeaderMap { - &self.headers - } - - /// Returns a reference to the field's content (mime) type, if it is supplied by the client. - /// - /// According to [RFC 7578](https://www.rfc-editor.org/rfc/rfc7578#section-4.4), if it is not - /// present, it should default to "text/plain". Note it is the responsibility of the client to - /// provide the appropriate content type, there is no attempt to validate this by the server. - pub fn content_type(&self) -> Option<&mime::Mime> { - self.ct.as_ref() - } - - /// Returns the field's Content-Disposition. - /// - /// Per [RFC 7578 §4.2]: "Each part MUST contain a Content-Disposition header field where the - /// disposition type is `form-data`. The Content-Disposition header field MUST also contain an - /// additional parameter of `name`; the value of the `name` parameter is the original field name - /// from the form." - /// - /// This crate validates that it exists before returning a `Field`. As such, it is safe to - /// unwrap `.content_disposition().get_name()`. The [name](Self::name) method is provided as - /// a convenience. - /// - /// [RFC 7578 §4.2]: https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 - pub fn content_disposition(&self) -> &ContentDisposition { - &self.cd - } - - /// Returns the field's name. - /// - /// See [content_disposition](Self::content_disposition) regarding guarantees about existence of - /// the name field. - pub fn name(&self) -> &str { - self.content_disposition() - .get_name() - .expect("field name should be guaranteed to exist in multipart form-data") - } -} - -impl Stream for Field { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - let mut inner = this.inner.borrow_mut(); - if let Some(mut buffer) = inner.payload.as_ref().unwrap().get_mut(&this.safety) { - // check safety and poll read payload to buffer. - buffer.poll_stream(cx)?; - } else if !this.safety.is_clean() { - // safety violation - return Poll::Ready(Some(Err(MultipartError::NotConsumed))); - } else { - return Poll::Pending; - } - - inner.poll(&this.safety) - } -} - -impl fmt::Debug for Field { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(ct) = &self.ct { - writeln!(f, "\nField: {}", ct)?; - } else { - writeln!(f, "\nField:")?; - } - writeln!(f, " boundary: {}", self.inner.borrow().boundary)?; - writeln!(f, " headers:")?; - for (key, val) in self.headers.iter() { - writeln!(f, " {:?}: {:?}", key, val)?; - } - Ok(()) - } -} - -struct InnerField { - payload: Option, - boundary: String, - eof: bool, - length: Option, -} - -impl InnerField { - fn new_in_rc( - payload: PayloadRef, - boundary: String, - headers: &HeaderMap, - ) -> Result>, PayloadError> { - Self::new(payload, boundary, headers).map(|this| Rc::new(RefCell::new(this))) - } - - fn new( - payload: PayloadRef, - boundary: String, - headers: &HeaderMap, - ) -> Result { - let len = if let Some(len) = headers.get(&header::CONTENT_LENGTH) { - match len.to_str().ok().and_then(|len| len.parse::().ok()) { - Some(len) => Some(len), - None => return Err(PayloadError::Incomplete(None)), - } - } else { - None - }; - - Ok(InnerField { - boundary, - payload: Some(payload), - eof: false, - length: len, - }) - } - - /// Reads body part content chunk of the specified size. - /// The body part must has `Content-Length` header with proper value. - fn read_len( - payload: &mut PayloadBuffer, - size: &mut u64, - ) -> Poll>> { - if *size == 0 { - Poll::Ready(None) - } else { - match payload.read_max(*size)? { - Some(mut chunk) => { - let len = cmp::min(chunk.len() as u64, *size); - *size -= len; - let ch = chunk.split_to(len as usize); - if !chunk.is_empty() { - payload.unprocessed(chunk); - } - Poll::Ready(Some(Ok(ch))) - } - None => { - if payload.eof && (*size != 0) { - Poll::Ready(Some(Err(MultipartError::Incomplete))) - } else { - Poll::Pending - } - } - } - } - } - - /// Reads content chunk of body part with unknown length. - /// The `Content-Length` header for body part is not necessary. - fn read_stream( - payload: &mut PayloadBuffer, - boundary: &str, - ) -> Poll>> { - let mut pos = 0; - - let len = payload.buf.len(); - if len == 0 { - return if payload.eof { - Poll::Ready(Some(Err(MultipartError::Incomplete))) - } else { - Poll::Pending - }; - } - - // check boundary - if len > 4 && payload.buf[0] == b'\r' { - let b_len = if &payload.buf[..2] == b"\r\n" && &payload.buf[2..4] == b"--" { - Some(4) - } else if &payload.buf[1..3] == b"--" { - Some(3) - } else { - None - }; - - if let Some(b_len) = b_len { - let b_size = boundary.len() + b_len; - if len < b_size { - return Poll::Pending; - } else if &payload.buf[b_len..b_size] == boundary.as_bytes() { - // found boundary - return Poll::Ready(None); - } - } - } - - loop { - return if let Some(idx) = memchr::memmem::find(&payload.buf[pos..], b"\r") { - let cur = pos + idx; - - // check if we have enough data for boundary detection - if cur + 4 > len { - if cur > 0 { - Poll::Ready(Some(Ok(payload.buf.split_to(cur).freeze()))) - } else { - Poll::Pending - } - } else { - // check boundary - if (&payload.buf[cur..cur + 2] == b"\r\n" - && &payload.buf[cur + 2..cur + 4] == b"--") - || (&payload.buf[cur..=cur] == b"\r" - && &payload.buf[cur + 1..cur + 3] == b"--") - { - if cur != 0 { - // return buffer - Poll::Ready(Some(Ok(payload.buf.split_to(cur).freeze()))) - } else { - pos = cur + 1; - continue; - } - } else { - // not boundary - pos = cur + 1; - continue; - } - } - } else { - Poll::Ready(Some(Ok(payload.buf.split().freeze()))) - }; - } - } - - fn poll(&mut self, s: &Safety) -> Poll>> { - if self.payload.is_none() { - return Poll::Ready(None); - } - - let result = if let Some(mut payload) = self.payload.as_ref().unwrap().get_mut(s) { - if !self.eof { - let res = if let Some(ref mut len) = self.length { - InnerField::read_len(&mut payload, len) - } else { - InnerField::read_stream(&mut payload, &self.boundary) - }; - - match res { - Poll::Pending => return Poll::Pending, - Poll::Ready(Some(Ok(bytes))) => return Poll::Ready(Some(Ok(bytes))), - Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), - Poll::Ready(None) => self.eof = true, - } - } - - match payload.readline() { - Ok(None) => Poll::Pending, - Ok(Some(line)) => { - if line.as_ref() != b"\r\n" { - log::warn!("multipart field did not read all the data or it is malformed"); - } - Poll::Ready(None) - } - Err(err) => Poll::Ready(Some(Err(err))), - } - } else { - Poll::Pending - }; - - if let Poll::Ready(None) = result { - self.payload.take(); - } - result - } -} - -struct PayloadRef { - payload: Rc>, -} - -impl PayloadRef { - fn new(payload: PayloadBuffer) -> PayloadRef { - PayloadRef { - payload: Rc::new(payload.into()), - } - } - - fn get_mut(&self, s: &Safety) -> Option> { - if s.current() { - Some(self.payload.borrow_mut()) - } else { - None - } - } -} - -impl Clone for PayloadRef { - fn clone(&self) -> PayloadRef { - PayloadRef { - payload: Rc::clone(&self.payload), - } - } -} - -/// Counter. It tracks of number of clones of payloads and give access to payload only to top most. -/// * When dropped, parent task is awakened. This is to support the case where Field is -/// dropped in a separate task than Multipart. -/// * Assumes that parent owners don't move to different tasks; only the top-most is allowed to. -/// * If dropped and is not top most owner, is_clean flag is set to false. -#[derive(Debug)] -struct Safety { - task: LocalWaker, - level: usize, - payload: Rc>, - clean: Rc>, -} - -impl Safety { - fn new() -> Safety { - let payload = Rc::new(PhantomData); - Safety { - task: LocalWaker::new(), - level: Rc::strong_count(&payload), - clean: Rc::new(Cell::new(true)), - payload, - } - } - - fn current(&self) -> bool { - Rc::strong_count(&self.payload) == self.level && self.clean.get() - } - - fn is_clean(&self) -> bool { - self.clean.get() - } - - fn clone(&self, cx: &mut Context<'_>) -> Safety { - let payload = Rc::clone(&self.payload); - let s = Safety { - task: LocalWaker::new(), - level: Rc::strong_count(&payload), - clean: self.clean.clone(), - payload, - }; - s.task.register(cx.waker()); - s - } -} - -impl Drop for Safety { - fn drop(&mut self) { - if Rc::strong_count(&self.payload) != self.level { - // Multipart dropped leaving a Field - self.clean.set(false); - } - - self.task.wake(); - } -} - -/// Payload buffer. -struct PayloadBuffer { - eof: bool, - buf: BytesMut, - stream: LocalBoxStream<'static, Result>, -} - -impl PayloadBuffer { - /// Constructs new `PayloadBuffer` instance. - fn new(stream: S) -> Self - where - S: Stream> + 'static, - { - PayloadBuffer { - eof: false, - buf: BytesMut::new(), - stream: Box::pin(stream), - } - } - - fn poll_stream(&mut self, cx: &mut Context<'_>) -> Result<(), PayloadError> { - loop { - match Pin::new(&mut self.stream).poll_next(cx) { - Poll::Ready(Some(Ok(data))) => self.buf.extend_from_slice(&data), - Poll::Ready(Some(Err(err))) => return Err(err), - Poll::Ready(None) => { - self.eof = true; - return Ok(()); - } - Poll::Pending => return Ok(()), - } - } - } - - /// Read exact number of bytes - #[cfg(test)] - fn read_exact(&mut self, size: usize) -> Option { - if size <= self.buf.len() { - Some(self.buf.split_to(size).freeze()) - } else { - None - } - } - - fn read_max(&mut self, size: u64) -> Result, MultipartError> { - if !self.buf.is_empty() { - let size = std::cmp::min(self.buf.len() as u64, size) as usize; - Ok(Some(self.buf.split_to(size).freeze())) - } else if self.eof { - Err(MultipartError::Incomplete) - } else { - Ok(None) - } - } - - /// Read until specified ending - fn read_until(&mut self, line: &[u8]) -> Result, MultipartError> { - let res = memchr::memmem::find(&self.buf, line) - .map(|idx| self.buf.split_to(idx + line.len()).freeze()); - - if res.is_none() && self.eof { - Err(MultipartError::Incomplete) - } else { - Ok(res) - } - } - - /// Read bytes until new line delimiter - fn readline(&mut self) -> Result, MultipartError> { - self.read_until(b"\n") - } - - /// Read bytes until new line delimiter or eof - fn readline_or_eof(&mut self) -> Result, MultipartError> { - match self.readline() { - Err(MultipartError::Incomplete) if self.eof => Ok(Some(self.buf.split().freeze())), - line => line, - } - } - - /// Put unprocessed data back to the buffer - fn unprocessed(&mut self, data: Bytes) { - let buf = BytesMut::from(data.as_ref()); - let buf = std::mem::replace(&mut self.buf, buf); - self.buf.extend_from_slice(&buf); - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use actix_http::h1; - use actix_web::{ - http::header::{DispositionParam, DispositionType}, - rt, - test::TestRequest, - FromRequest, - }; - use bytes::Bytes; - use futures_util::{future::lazy, StreamExt as _}; - use tokio::sync::mpsc; - use tokio_stream::wrappers::UnboundedReceiverStream; - - use super::*; - - #[actix_rt::test] - async fn test_boundary() { - let headers = HeaderMap::new(); - match Multipart::boundary(&headers) { - Err(MultipartError::NoContentType) => {} - _ => unreachable!("should not happen"), - } - - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("test"), - ); - - match Multipart::boundary(&headers) { - Err(MultipartError::ParseContentType) => {} - _ => unreachable!("should not happen"), - } - - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("multipart/mixed"), - ); - match Multipart::boundary(&headers) { - Err(MultipartError::Boundary) => {} - _ => unreachable!("should not happen"), - } - - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static( - "multipart/mixed; boundary=\"5c02368e880e436dab70ed54e1c58209\"", - ), - ); - - assert_eq!( - Multipart::boundary(&headers).unwrap(), - "5c02368e880e436dab70ed54e1c58209" - ); - } - - fn create_stream() -> ( - mpsc::UnboundedSender>, - impl Stream>, - ) { - let (tx, rx) = mpsc::unbounded_channel(); - - ( - tx, - UnboundedReceiverStream::new(rx).map(|res| res.map_err(|_| panic!())), - ) - } - - // Stream that returns from a Bytes, one char at a time and Pending every other poll() - struct SlowStream { - bytes: Bytes, - pos: usize, - ready: bool, - } - - impl SlowStream { - fn new(bytes: Bytes) -> SlowStream { - SlowStream { - bytes, - pos: 0, - ready: false, - } - } - } - - impl Stream for SlowStream { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - if !this.ready { - this.ready = true; - cx.waker().wake_by_ref(); - return Poll::Pending; - } - - if this.pos == this.bytes.len() { - return Poll::Ready(None); - } - - let res = Poll::Ready(Some(Ok(this.bytes.slice(this.pos..(this.pos + 1))))); - this.pos += 1; - this.ready = false; - res - } - } - - fn create_simple_request_with_header() -> (Bytes, HeaderMap) { - let bytes = Bytes::from( - "testasdadsad\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ - Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ - Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ - test\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ - Content-Disposition: form-data; name=\"file\"; filename=\"fn.txt\"\r\n\ - Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ - data\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0--\r\n", - ); - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static( - "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", - ), - ); - (bytes, headers) - } - - #[actix_rt::test] - async fn test_multipart_no_end_crlf() { - let (sender, payload) = create_stream(); - let (mut bytes, headers) = create_simple_request_with_header(); - let bytes_stripped = bytes.split_to(bytes.len()); // strip crlf - - sender.send(Ok(bytes_stripped)).unwrap(); - drop(sender); // eof - - let mut multipart = Multipart::new(&headers, payload); - - match multipart.next().await.unwrap() { - Ok(_) => {} - _ => unreachable!(), - } - - match multipart.next().await.unwrap() { - Ok(_) => {} - _ => unreachable!(), - } - - match multipart.next().await { - None => {} - _ => unreachable!(), - } - } - - #[actix_rt::test] - async fn test_multipart() { - let (sender, payload) = create_stream(); - let (bytes, headers) = create_simple_request_with_header(); - - sender.send(Ok(bytes)).unwrap(); - - let mut multipart = Multipart::new(&headers, payload); - match multipart.next().await { - Some(Ok(mut field)) => { - let cd = field.content_disposition(); - assert_eq!(cd.disposition, DispositionType::FormData); - assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); - - assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); - assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); - - match field.next().await.unwrap() { - Ok(chunk) => assert_eq!(chunk, "test"), - _ => unreachable!(), - } - match field.next().await { - None => {} - _ => unreachable!(), - } - } - _ => unreachable!(), - } - - match multipart.next().await.unwrap() { - Ok(mut field) => { - assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); - assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); - - match field.next().await { - Some(Ok(chunk)) => assert_eq!(chunk, "data"), - _ => unreachable!(), - } - match field.next().await { - None => {} - _ => unreachable!(), - } - } - _ => unreachable!(), - } - - match multipart.next().await { - None => {} - _ => unreachable!(), - } - } - - // Loops, collecting all bytes until end-of-field - async fn get_whole_field(field: &mut Field) -> BytesMut { - let mut b = BytesMut::new(); - loop { - match field.next().await { - Some(Ok(chunk)) => b.extend_from_slice(&chunk), - None => return b, - _ => unreachable!(), - } - } - } - - #[actix_rt::test] - async fn test_stream() { - let (bytes, headers) = create_simple_request_with_header(); - let payload = SlowStream::new(bytes); - - let mut multipart = Multipart::new(&headers, payload); - match multipart.next().await.unwrap() { - Ok(mut field) => { - let cd = field.content_disposition(); - assert_eq!(cd.disposition, DispositionType::FormData); - assert_eq!(cd.parameters[0], DispositionParam::Name("file".into())); - - assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); - assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); - - assert_eq!(get_whole_field(&mut field).await, "test"); - } - _ => unreachable!(), - } - - match multipart.next().await { - Some(Ok(mut field)) => { - assert_eq!(field.content_type().unwrap().type_(), mime::TEXT); - assert_eq!(field.content_type().unwrap().subtype(), mime::PLAIN); - - assert_eq!(get_whole_field(&mut field).await, "data"); - } - _ => unreachable!(), - } - - match multipart.next().await { - None => {} - _ => unreachable!(), - } - } - - #[actix_rt::test] - async fn test_basic() { - let (_, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - - assert_eq!(payload.buf.len(), 0); - lazy(|cx| payload.poll_stream(cx)).await.unwrap(); - assert_eq!(None, payload.read_max(1).unwrap()); - } - - #[actix_rt::test] - async fn test_eof() { - let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - - assert_eq!(None, payload.read_max(4).unwrap()); - sender.feed_data(Bytes::from("data")); - sender.feed_eof(); - lazy(|cx| payload.poll_stream(cx)).await.unwrap(); - - assert_eq!(Some(Bytes::from("data")), payload.read_max(4).unwrap()); - assert_eq!(payload.buf.len(), 0); - assert!(payload.read_max(1).is_err()); - assert!(payload.eof); - } - - #[actix_rt::test] - async fn test_err() { - let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - assert_eq!(None, payload.read_max(1).unwrap()); - sender.set_error(PayloadError::Incomplete(None)); - lazy(|cx| payload.poll_stream(cx)).await.err().unwrap(); - } - - #[actix_rt::test] - async fn test_readmax() { - let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - - sender.feed_data(Bytes::from("line1")); - sender.feed_data(Bytes::from("line2")); - 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()); - assert_eq!(payload.buf.len(), 5); - - assert_eq!(Some(Bytes::from("line2")), payload.read_max(5).unwrap()); - assert_eq!(payload.buf.len(), 0); - } - - #[actix_rt::test] - async fn test_readexactly() { - let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - - 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(); - - assert_eq!(Some(Bytes::from_static(b"li")), payload.read_exact(2)); - assert_eq!(payload.buf.len(), 8); - - assert_eq!(Some(Bytes::from_static(b"ne1l")), payload.read_exact(4)); - assert_eq!(payload.buf.len(), 4); - } - - #[actix_rt::test] - async fn test_readuntil() { - let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); - - 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(); - - assert_eq!( - Some(Bytes::from("line")), - payload.read_until(b"ne").unwrap() - ); - assert_eq!(payload.buf.len(), 6); - - assert_eq!( - Some(Bytes::from("1line2")), - payload.read_until(b"2").unwrap() - ); - assert_eq!(payload.buf.len(), 0); - } - - #[actix_rt::test] - async fn test_multipart_from_error() { - let err = MultipartError::NoContentType; - let mut multipart = Multipart::from_error(err); - assert!(multipart.next().await.unwrap().is_err()) - } - - #[actix_rt::test] - async fn test_multipart_from_boundary() { - let (_, payload) = create_stream(); - let (_, headers) = create_simple_request_with_header(); - let boundary = Multipart::boundary(&headers); - assert!(boundary.is_ok()); - let _ = Multipart::from_boundary(boundary.unwrap(), payload); - } - - #[actix_rt::test] - async fn test_multipart_payload_consumption() { - // with sample payload and HttpRequest with no headers - let (_, inner_payload) = h1::Payload::create(false); - let mut payload = actix_web::dev::Payload::from(inner_payload); - let req = TestRequest::default().to_http_request(); - - // multipart should generate an error - let mut mp = Multipart::from_request(&req, &mut payload).await.unwrap(); - assert!(mp.next().await.unwrap().is_err()); - - // and should not consume the payload - match payload { - actix_web::dev::Payload::H1 { .. } => {} //expected - _ => unreachable!(), - } - } - - #[actix_rt::test] - async fn no_content_disposition() { - let bytes = Bytes::from( - "testasdadsad\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ - Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ - test\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n", - ); - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static( - "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", - ), - ); - let payload = SlowStream::new(bytes); - - let mut multipart = Multipart::new(&headers, payload); - let res = multipart.next().await.unwrap(); - assert!(res.is_err()); - assert!(matches!( - res.unwrap_err(), - MultipartError::NoContentDisposition, - )); - } - - #[actix_rt::test] - async fn no_name_in_content_disposition() { - let bytes = Bytes::from( - "testasdadsad\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\ - Content-Disposition: form-data; filename=\"fn.txt\"\r\n\ - Content-Type: text/plain; charset=utf-8\r\nContent-Length: 4\r\n\r\n\ - test\r\n\ - --abbc761f78ff4d7cb7573b5a23f96ef0\r\n", - ); - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static( - "multipart/mixed; boundary=\"abbc761f78ff4d7cb7573b5a23f96ef0\"", - ), - ); - let payload = SlowStream::new(bytes); - - let mut multipart = Multipart::new(&headers, payload); - let res = multipart.next().await.unwrap(); - assert!(res.is_err()); - assert!(matches!( - res.unwrap_err(), - MultipartError::NoContentDisposition, - )); - } - - #[actix_rt::test] - async fn test_drop_multipart_dont_hang() { - let (sender, payload) = create_stream(); - let (bytes, headers) = create_simple_request_with_header(); - sender.send(Ok(bytes)).unwrap(); - drop(sender); // eof - - let mut multipart = Multipart::new(&headers, payload); - let mut field = multipart.next().await.unwrap().unwrap(); - - drop(multipart); - - // should fail immediately - match field.next().await { - Some(Err(MultipartError::NotConsumed)) => {} - _ => panic!(), - }; - } - - #[actix_rt::test] - async fn test_drop_field_awaken_multipart() { - let (sender, payload) = create_stream(); - let (bytes, headers) = create_simple_request_with_header(); - sender.send(Ok(bytes)).unwrap(); - drop(sender); // eof - - let mut multipart = Multipart::new(&headers, payload); - let mut field = multipart.next().await.unwrap().unwrap(); - - let task = rt::spawn(async move { - rt::time::sleep(Duration::from_secs(1)).await; - assert_eq!(field.next().await.unwrap().unwrap(), "test"); - drop(field); - }); - - // dropping field should awaken current task - let _ = multipart.next().await.unwrap().unwrap(); - task.await.unwrap(); - } -} diff --git a/actix-multipart/src/test.rs b/actix-multipart/src/test.rs new file mode 100644 index 000000000..b0e907266 --- /dev/null +++ b/actix-multipart/src/test.rs @@ -0,0 +1,217 @@ +//! Multipart testing utilities. + +use actix_web::{ + http::header::{self, HeaderMap}, + web::{BufMut as _, Bytes, BytesMut}, +}; +use mime::Mime; +use rand::distr::{Alphanumeric, SampleString as _}; + +const CRLF: &[u8] = b"\r\n"; +const CRLF_CRLF: &[u8] = b"\r\n\r\n"; +const HYPHENS: &[u8] = b"--"; +const BOUNDARY_PREFIX: &str = "------------------------"; + +/// Constructs a `multipart/form-data` payload from bytes and metadata. +/// +/// 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; +/// use actix_web::{test::TestRequest, web::Bytes}; +/// use memchr::memmem::find; +/// +/// let (body, headers) = create_form_data_payload_and_headers( +/// "foo", +/// Some("lorem.txt".to_owned()), +/// Some(mime::TEXT_PLAIN_UTF_8), +/// Bytes::from_static(b"Lorem ipsum."), +/// ); +/// +/// assert!(find(&body, b"foo").is_some()); +/// assert!(find(&body, b"lorem.txt").is_some()); +/// assert!(find(&body, b"text/plain; charset=utf-8").is_some()); +/// assert!(find(&body, b"Lorem ipsum.").is_some()); +/// +/// let req = TestRequest::default(); +/// +/// // merge header map into existing test request and set multipart body +/// let req = headers +/// .into_iter() +/// .fold(req, |req, hdr| req.insert_header(hdr)) +/// .set_payload(body) +/// .to_http_request(); +/// +/// assert!( +/// req.headers() +/// .get("content-type") +/// .unwrap() +/// .to_str() +/// .unwrap() +/// .starts_with("multipart/form-data; boundary=\"") +/// ); +/// ``` +pub fn create_form_data_payload_and_headers( + name: &str, + filename: Option, + content_type: Option, + file: Bytes, +) -> (Bytes, HeaderMap) { + let boundary = Alphanumeric.sample_string(&mut rand::rng(), 32); + + create_form_data_payload_and_headers_with_boundary( + &boundary, + name, + filename, + content_type, + file, + ) +} + +/// Constructs a `multipart/form-data` payload from bytes and metadata with a fixed boundary. +/// +/// See [`create_form_data_payload_and_headers`] for more details. +pub fn create_form_data_payload_and_headers_with_boundary( + boundary: &str, + name: &str, + filename: Option, + content_type: Option, + file: Bytes, +) -> (Bytes, HeaderMap) { + let mut buf = BytesMut::with_capacity(file.len() + 128); + + let boundary_str = [BOUNDARY_PREFIX, boundary].concat(); + let boundary = boundary_str.as_bytes(); + + buf.put(HYPHENS); + buf.put(boundary); + buf.put(CRLF); + + 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: {}", file.len()).as_bytes()); + buf.put(CRLF_CRLF); + + buf.put(file); + buf.put(CRLF); + + buf.put(HYPHENS); + buf.put(boundary); + buf.put(HYPHENS); + buf.put(CRLF); + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary=\"{boundary_str}\"") + .parse() + .unwrap(), + ); + + (buf.freeze(), headers) +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + + use futures_util::stream; + + use super::*; + + fn find_boundary(headers: &HeaderMap) -> String { + headers + .get("content-type") + .unwrap() + .to_str() + .unwrap() + .parse::() + .unwrap() + .get_param(mime::BOUNDARY) + .unwrap() + .as_str() + .to_owned() + } + + #[test] + fn wire_format() { + let (pl, headers) = create_form_data_payload_and_headers_with_boundary( + "qWeRtYuIoP", + "foo", + None, + None, + Bytes::from_static(b"Lorem ipsum dolor\nsit ame."), + ); + + assert_eq!( + find_boundary(&headers), + "------------------------qWeRtYuIoP", + ); + + 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", + ); + + let (pl, _headers) = create_form_data_payload_and_headers_with_boundary( + "qWeRtYuIoP", + "foo", + Some("Lorem.txt".to_owned()), + Some(mime::TEXT_PLAIN_UTF_8), + Bytes::from_static(b"Lorem ipsum dolor\nsit ame."), + ); + + assert_eq!( + std::str::from_utf8(&pl).unwrap(), + "--------------------------qWeRtYuIoP\r\n\ + Content-Disposition: form-data; name=\"foo\"; filename=\"Lorem.txt\"\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: 26\r\n\ + \r\n\ + Lorem ipsum dolor\n\ + sit ame.\r\n\ + --------------------------qWeRtYuIoP--\r\n", + ); + } + + /// Test using an external library to prevent the two-wrongs-make-a-right class of errors. + #[actix_web::test] + async fn ecosystem_compat() { + let (pl, headers) = create_form_data_payload_and_headers( + "foo", + None, + None, + Bytes::from_static(b"Lorem ipsum dolor\nsit ame."), + ); + + let boundary = find_boundary(&headers); + + let pl = stream::once(async { Ok::<_, Infallible>(pl) }); + + let mut form = multer::Multipart::new(pl, boundary); + let field = form.next_field().await.unwrap().unwrap(); + assert_eq!(field.name().unwrap(), "foo"); + assert_eq!(field.file_name(), None); + assert_eq!(field.content_type(), None); + assert!(field.bytes().await.unwrap().starts_with(b"Lorem")); + } +} diff --git a/actix-router/CHANGES.md b/actix-router/CHANGES.md index 1f5552193..781bdefe5 100644 --- a/actix-router/CHANGES.md +++ b/actix-router/CHANGES.md @@ -1,17 +1,26 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 0.5.1 - 2022-09-19 +## 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. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 0.5.2 + +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 0.5.1 - Correct typo in error string for `i32` deserialization. [#2876] - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. [#2876]: https://github.com/actix/actix-web/pull/2876 -## 0.5.0 - 2022-02-22 +## 0.5.0 ### Added @@ -86,7 +95,7 @@

0.5.0 Pre-Releases -## 0.5.0-rc.3 - 2022-01-31 +## 0.5.0-rc.3 - Remove unused `ResourceInfo`. [#2612] - Add `RouterBuilder::push`. [#2612] @@ -98,32 +107,32 @@ [#2612]: https://github.com/actix/actix-web/pull/2612 [#2613]: https://github.com/actix/actix-web/pull/2613 -## 0.5.0-rc.2 - 2022-01-21 +## 0.5.0-rc.2 - Add `Path::as_str`. [#2590] - Deprecate `Path::path`. [#2590] [#2590]: https://github.com/actix/actix-web/pull/2590 -## 0.5.0-rc.1 - 2022-01-14 +## 0.5.0-rc.1 - `Resource` trait now have an associated type, `Path`, instead of the generic parameter. [#2568] - `Resource` is now implemented for `&mut Path<_>` and `RefMut>`. [#2568] [#2568]: https://github.com/actix/actix-web/pull/2568 -## 0.5.0-beta.4 - 2022-01-04 +## 0.5.0-beta.4 - `PathDeserializer` now decodes all percent encoded characters in dynamic segments. [#2566] - Minimum supported Rust version (MSRV) is now 1.54. [#2566]: https://github.com/actix/actix-net/pull/2566 -## 0.5.0-beta.3 - 2021-12-17 +## 0.5.0-beta.3 - Minimum supported Rust version (MSRV) is now 1.52. -## 0.5.0-beta.2 - 2021-09-09 +## 0.5.0-beta.2 - Introduce `ResourceDef::join`. [#380][net#380] - Disallow prefix routes with tail segments. [#379][net#379] @@ -143,7 +152,7 @@ [#2355]: https://github.com/actix/actix-web/pull/2355 [#2356]: https://github.com/actix/actix-web/pull/2356 -## 0.5.0-beta.1 - 2021-07-20 +## 0.5.0-beta.1 - Fix a bug in multi-patterns where static patterns are interpreted as regex. [#366][net#366] - Introduce `ResourceDef::pattern_iter` to get an iterator over all patterns in a multi-pattern resource. [#373][net#373] @@ -173,7 +182,7 @@
-## 0.4.0 - 2021-06-06 +## 0.4.0 - When matching path parameters, `%25` is now kept in the percent-encoded form; no longer decoded to `%`. [#357][net#357] - Path tail patterns now match new lines (`\n`) in request URL. [#360][net#360] @@ -185,70 +194,70 @@ [net#359]: https://github.com/actix/actix-net/pull/359 [net#360]: https://github.com/actix/actix-net/pull/360 -## 0.3.0 - 2019-12-31 +## 0.3.0 - Version was yanked previously. See https://crates.io/crates/actix-router/0.3.0 -## 0.2.7 - 2021-02-06 +## 0.2.7 - Add `Router::recognize_checked` [#247][net#247] [net#247]: https://github.com/actix/actix-net/pull/247 -## 0.2.6 - 2021-01-09 +## 0.2.6 - Use `bytestring` version range compatible with Bytes v1.0. [#246][net#246] [net#246]: https://github.com/actix/actix-net/pull/246 -## 0.2.5 - 2020-09-20 +## 0.2.5 - Fix `from_hex()` method -## 0.2.4 - 2019-12-31 +## 0.2.4 - Add `ResourceDef::resource_path_named()` path generation method -## 0.2.3 - 2019-12-25 +## 0.2.3 - Add impl `IntoPattern` for `&String` -## 0.2.2 - 2019-12-25 +## 0.2.2 - Use `IntoPattern` for `RouterBuilder::path()` -## 0.2.1 - 2019-12-25 +## 0.2.1 - Add `IntoPattern` trait - Add multi-pattern resources -## 0.2.0 - 2019-12-07 +## 0.2.0 - Update http to 0.2 - Update regex to 1.3 - Use bytestring instead of string -## 0.1.5 - 2019-05-15 +## 0.1.5 - Remove debug prints -## 0.1.4 - 2019-05-15 +## 0.1.4 - Fix checked resource match -## 0.1.3 - 2019-04-22 +## 0.1.3 - Added support for `remainder match` (i.e "/path/{tail}\*") -## 0.1.2 - 2019-04-07 +## 0.1.2 - Export `Quoter` type - Allow to reset `Path` instance -## 0.1.1 - 2019-04-03 +## 0.1.1 - Get dynamic segment by name instead of iterator. -## 0.1.0 - 2019-03-09 +## 0.1.0 - Initial release diff --git a/actix-router/Cargo.toml b/actix-router/Cargo.toml index adf43a086..ba801188a 100644 --- a/actix-router/Cargo.toml +++ b/actix-router/Cargo.toml @@ -1,40 +1,47 @@ [package] name = "actix-router" -version = "0.5.1" +version = "0.5.3" authors = [ - "Nikolay Kim ", - "Ali MJ Al-Nasrawy ", - "Rob Ede ", + "Nikolay Kim ", + "Ali MJ Al-Nasrawy ", + "Rob Ede ", ] description = "Resource path matching and router" keywords = ["actix", "router", "routing"] -repository = "https://github.com/actix/actix-web.git" +repository = "https://github.com/actix/actix-web" license = "MIT OR Apache-2.0" edition = "2021" -[lib] -name = "actix_router" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = ["http::*", "serde::*"] [features] -default = ["http"] +default = ["http", "unicode"] +http = ["dep:http"] +unicode = ["dep:regex"] [dependencies] bytestring = ">=0.1.5, <2" +cfg-if = "1" http = { version = "0.2.7", optional = true } -regex = "1.5" +regex = { version = "1.5", optional = true } +regex-lite = "0.1" serde = "1" tracing = { version = "0.1.30", default-features = false, features = ["log"] } [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } http = "0.2.7" -serde = { version = "1", features = ["derive"] } percent-encoding = "2.1" +serde = { version = "1", features = ["derive"] } + +[lints] +workspace = true [[bench]] name = "router" harness = false +required-features = ["unicode"] [[bench]] name = "quoter" diff --git a/actix-router/README.md b/actix-router/README.md new file mode 100644 index 000000000..20444904c --- /dev/null +++ b/actix-router/README.md @@ -0,0 +1,20 @@ +# `actix-router` + + + +[![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.88+-ab6000.svg) +![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-router.svg) +
+[![dependency status](https://deps.rs/crate/actix-router/0.5.3/status.svg)](https://deps.rs/crate/actix-router/0.5.3) +[![Download](https://img.shields.io/crates/d/actix-router.svg)](https://crates.io/crates/actix-router) +[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) + + + + + +Resource path matching and router. + + diff --git a/actix-router/benches/quoter.rs b/actix-router/benches/quoter.rs index 2065fd563..2428a767d 100644 --- a/actix-router/benches/quoter.rs +++ b/actix-router/benches/quoter.rs @@ -1,6 +1,4 @@ -#![allow(clippy::uninlined_format_args)] - -use std::borrow::Cow; +use std::{borrow::Cow, fmt::Write as _}; use criterion::{black_box, criterion_group, criterion_main, Criterion}; @@ -8,9 +6,10 @@ fn compare_quoters(c: &mut Criterion) { let mut group = c.benchmark_group("Compare Quoters"); let quoter = actix_router::Quoter::new(b"", b""); - let path_quoted = (0..=0x7f) - .map(|c| format!("%{:02X}", c)) - .collect::(); + let path_quoted = (0..=0x7f).fold(String::new(), |mut buf, c| { + write!(&mut buf, "%{:02X}", c).unwrap(); + buf + }); let path_unquoted = ('\u{00}'..='\u{7f}').collect::(); group.bench_function("quoter_unquoted", |b| { diff --git a/actix-router/benches/router.rs b/actix-router/benches/router.rs index 6f6b67b48..2c21fef6c 100644 --- a/actix-router/benches/router.rs +++ b/actix-router/benches/router.rs @@ -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), diff --git a/actix-router/src/de.rs b/actix-router/src/de.rs index e8c7c658e..2f50619f8 100644 --- a/actix-router/src/de.rs +++ b/actix-router/src/de.rs @@ -500,10 +500,10 @@ impl<'de> de::VariantAccess<'de> for UnitVariant { #[cfg(test)] mod tests { - use serde::{de, Deserialize}; + use serde::Deserialize; use super::*; - use crate::{path::Path, router::Router, ResourceDef}; + use crate::{router::Router, ResourceDef}; #[derive(Deserialize)] struct MyStruct { @@ -511,11 +511,6 @@ mod tests { value: String, } - #[derive(Deserialize)] - struct Id { - _id: String, - } - #[derive(Debug, Deserialize)] struct Test1(String, u32); diff --git a/actix-router/src/lib.rs b/actix-router/src/lib.rs index 53c0ad82a..cc59a9f58 100644 --- a/actix-router/src/lib.rs +++ b/actix-router/src/lib.rs @@ -1,16 +1,14 @@ //! Resource path matching and router. -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] -#![allow(clippy::uninlined_format_args)] #![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; mod pattern; mod quoter; +mod regex_set; mod resource; mod resource_path; mod router; diff --git a/actix-router/src/path.rs b/actix-router/src/path.rs index dc4150ddc..ab4a943fe 100644 --- a/actix-router/src/path.rs +++ b/actix-router/src/path.rs @@ -3,7 +3,7 @@ use std::{ ops::{DerefMut, Index}, }; -use serde::de; +use serde::{de, Deserialize}; use crate::{de::PathDeserializer, Resource, ResourcePath}; @@ -24,8 +24,13 @@ impl Default for PathItem { /// If resource path contains variable patterns, `Path` stores them. #[derive(Debug, Clone, Default)] pub struct Path { + /// Full path representation. path: T, + + /// Number of characters in `path` that have been processed into `segments`. pub(crate) skip: u16, + + /// List of processed dynamic segments; name->value pairs. pub(crate) segments: Vec<(Cow<'static, str>, PathItem)>, } @@ -83,8 +88,8 @@ impl Path { /// Set new path. #[inline] pub fn set(&mut self, path: T) { - self.skip = 0; self.path = path; + self.skip = 0; self.segments.clear(); } @@ -103,7 +108,7 @@ impl Path { pub(crate) fn add(&mut self, name: impl Into>, value: PathItem) { match value { - PathItem::Static(s) => self.segments.push((name.into(), PathItem::Static(s))), + PathItem::Static(seg) => self.segments.push((name.into(), PathItem::Static(seg))), PathItem::Segment(begin, end) => self.segments.push(( name.into(), PathItem::Segment(self.skip + begin, self.skip + end), @@ -138,9 +143,9 @@ impl Path { for (seg_name, val) in self.segments.iter() { if name == seg_name { return match val { - PathItem::Static(ref s) => Some(s), - PathItem::Segment(s, e) => { - Some(&self.path.path()[(*s as usize)..(*e as usize)]) + PathItem::Static(ref seg) => Some(seg), + PathItem::Segment(start, end) => { + Some(&self.path.path()[(*start as usize)..(*end as usize)]) } }; } @@ -149,15 +154,11 @@ impl Path { None } - /// Get matched parameter by name. + /// Returns matched parameter by name. /// /// If keyed parameter is not available empty string is used as default value. pub fn query(&self, key: &str) -> &str { - if let Some(s) = self.get(key) { - s - } else { - "" - } + self.get(key).unwrap_or_default() } /// Return iterator to items in parameter container. @@ -168,9 +169,13 @@ impl Path { } } - /// Try to deserialize matching parameters to a specified type `U` - pub fn load<'de, U: serde::Deserialize<'de>>(&'de self) -> Result { - de::Deserialize::deserialize(PathDeserializer::new(self)) + /// Deserializes matching parameters to a specified type `U`. + /// + /// # Errors + /// + /// Returns error when dynamic path segments cannot be deserialized into a `U` type. + pub fn load<'de, U: Deserialize<'de>>(&'de self) -> Result { + Deserialize::deserialize(PathDeserializer::new(self)) } } @@ -188,8 +193,10 @@ impl<'a, T: ResourcePath> Iterator for PathIter<'a, T> { if self.idx < self.params.segment_count() { let idx = self.idx; let res = match self.params.segments[idx].1 { - PathItem::Static(ref s) => s, - PathItem::Segment(s, e) => &self.params.path.path()[(s as usize)..(e as usize)], + PathItem::Static(ref seg) => seg, + PathItem::Segment(start, end) => { + &self.params.path.path()[(start as usize)..(end as usize)] + } }; self.idx += 1; return Some((&self.params.segments[idx].0, res)); @@ -212,8 +219,8 @@ impl Index for Path { fn index(&self, idx: usize) -> &str { match self.segments[idx].1 { - PathItem::Static(ref s) => s, - PathItem::Segment(s, e) => &self.path.path()[(s as usize)..(e as usize)], + PathItem::Static(ref seg) => seg, + PathItem::Segment(start, end) => &self.path.path()[(start as usize)..(end as usize)], } } } diff --git a/actix-router/src/quoter.rs b/actix-router/src/quoter.rs index 6c929d3ac..78694ed72 100644 --- a/actix-router/src/quoter.rs +++ b/actix-router/src/quoter.rs @@ -105,7 +105,7 @@ fn hex_pair_to_char(d1: u8, d2: u8) -> Option { let d_low = char::from(d2).to_digit(16)?; // left shift high nibble by 4 bits - Some((d_high as u8) << 4 | (d_low as u8)) + Some(((d_high as u8) << 4) | (d_low as u8)) } #[derive(Debug, Default, Clone)] diff --git a/actix-router/src/regex_set.rs b/actix-router/src/regex_set.rs new file mode 100644 index 000000000..48f38df2c --- /dev/null +++ b/actix-router/src/regex_set.rs @@ -0,0 +1,66 @@ +//! Abstraction over `regex` and `regex-lite` depending on whether we have `unicode` crate feature +//! enabled. + +use cfg_if::cfg_if; +#[cfg(feature = "unicode")] +pub(crate) use regex::{escape, Regex}; +#[cfg(not(feature = "unicode"))] +pub(crate) use regex_lite::{escape, Regex}; + +#[cfg(feature = "unicode")] +#[derive(Debug, Clone)] +pub(crate) struct RegexSet(regex::RegexSet); + +#[cfg(not(feature = "unicode"))] +#[derive(Debug, Clone)] +pub(crate) struct RegexSet(Vec); + +impl RegexSet { + /// Create a new regex set. + /// + /// # Panics + /// + /// Panics if any path patterns are malformed. + pub(crate) fn new(re_set: Vec) -> Self { + cfg_if! { + if #[cfg(feature = "unicode")] { + Self(regex::RegexSet::new(re_set).unwrap()) + } else { + Self(re_set.iter().map(|re| Regex::new(re).unwrap()).collect()) + } + } + } + + /// Create a new empty regex set. + pub(crate) fn empty() -> Self { + cfg_if! { + if #[cfg(feature = "unicode")] { + Self(regex::RegexSet::empty()) + } else { + Self(Vec::new()) + } + } + } + + /// Returns true if regex set matches `path`. + pub(crate) fn is_match(&self, path: &str) -> bool { + cfg_if! { + if #[cfg(feature = "unicode")] { + self.0.is_match(path) + } else { + self.0.iter().any(|re| re.is_match(path)) + } + } + } + + /// Returns index within `path` of first match. + pub(crate) fn first_match_idx(&self, path: &str) -> Option { + cfg_if! { + if #[cfg(feature = "unicode")] { + self.0.matches(path).into_iter().next() + } else { + Some(self.0.iter().enumerate().find(|(_, re)| re.is_match(path))?.0) + } + } + } +} diff --git a/actix-router/src/resource.rs b/actix-router/src/resource.rs index a3e89a1fe..b5ee01958 100644 --- a/actix-router/src/resource.rs +++ b/actix-router/src/resource.rs @@ -5,10 +5,13 @@ use std::{ mem, }; -use regex::{escape, Regex, RegexSet}; use tracing::error; -use crate::{path::PathItem, IntoPatterns, Patterns, Resource, ResourcePath}; +use crate::{ + path::PathItem, + regex_set::{escape, Regex, RegexSet}, + IntoPatterns, Patterns, Resource, ResourcePath, +}; const MAX_DYNAMIC_SEGMENTS: usize = 16; @@ -193,8 +196,8 @@ const REGEX_FLAGS: &str = "(?s-m)"; /// # Trailing Slashes /// It should be noted that this library takes no steps to normalize intra-path or trailing slashes. /// As such, all resource definitions implicitly expect a pre-processing step to normalize paths if -/// they you wish to accommodate "recoverable" path errors. Below are several examples of -/// resource-path pairs that would not be compatible. +/// you wish to accommodate "recoverable" path errors. Below are several examples of resource-path +/// pairs that would not be compatible. /// /// ## Examples /// ``` @@ -233,7 +236,7 @@ enum PatternSegment { Var(String), } -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] enum PatternType { /// Single constant/literal segment. @@ -501,7 +504,12 @@ impl ResourceDef { let patterns = self .pattern_iter() .flat_map(move |this| other.pattern_iter().map(move |other| (this, other))) - .map(|(this, other)| [this, other].join("")) + .map(|(this, other)| { + let mut pattern = String::with_capacity(this.len() + other.len()); + pattern.push_str(this); + pattern.push_str(other); + pattern + }) .collect::>(); match patterns.len() { @@ -598,7 +606,7 @@ impl ResourceDef { PatternType::Dynamic(re, _) => Some(re.captures(path)?[1].len()), PatternType::DynamicSet(re, params) => { - let idx = re.matches(path).into_iter().next()?; + let idx = re.first_match_idx(path)?; let (ref pattern, _) = params[idx]; Some(pattern.captures(path)?[1].len()) } @@ -701,7 +709,7 @@ impl ResourceDef { PatternType::DynamicSet(re, params) => { let path = path.unprocessed(); - let (pattern, names) = match re.matches(path).into_iter().next() { + let (pattern, names) = match re.first_match_idx(path) { Some(idx) => ¶ms[idx], _ => return false, }; @@ -865,7 +873,7 @@ impl ResourceDef { } } - let pattern_re_set = RegexSet::new(re_set).unwrap(); + let pattern_re_set = RegexSet::new(re_set); let segments = segments.unwrap_or_default(); ( @@ -1013,6 +1021,7 @@ impl ResourceDef { panic!("prefix resource definitions should not have tail segments"); } + #[allow(clippy::literal_string_with_formatting_args)] if unprocessed.ends_with('*') { // unnamed tail segment @@ -1361,6 +1370,7 @@ mod tests { assert_eq!(path.unprocessed(), ""); } + #[allow(clippy::literal_string_with_formatting_args)] #[test] fn newline_patterns_and_paths() { let re = ResourceDef::new("/user/a\nb"); diff --git a/actix-router/src/resource_path.rs b/actix-router/src/resource_path.rs index 45948aa2a..610dc344d 100644 --- a/actix-router/src/resource_path.rs +++ b/actix-router/src/resource_path.rs @@ -19,7 +19,7 @@ impl ResourcePath for String { } } -impl<'a> ResourcePath for &'a str { +impl ResourcePath for &str { fn path(&self) -> &str { self } diff --git a/actix-router/src/router.rs b/actix-router/src/router.rs index d31d10ce8..b20cb7ee3 100644 --- a/actix-router/src/router.rs +++ b/actix-router/src/router.rs @@ -97,6 +97,7 @@ impl RouterBuilder { ctx: U, ) -> (&mut ResourceDef, &mut T, &mut U) { self.routes.push((rdef, val, ctx)); + #[allow(clippy::map_identity)] // map is used to distribute &mut-ness to tuple elements self.routes .last_mut() .map(|(rdef, val, ctx)| (rdef, val, ctx)) @@ -144,6 +145,7 @@ mod tests { }; #[allow(clippy::cognitive_complexity)] + #[allow(clippy::literal_string_with_formatting_args)] #[test] fn test_recognizer_1() { let mut router = Router::::build(); @@ -186,11 +188,11 @@ mod tests { assert_eq!(path.get("file").unwrap(), "file"); assert_eq!(path.get("ext").unwrap(), "gz"); - let mut path = Path::new("/vtest/ttt/index.html"); + let mut path = Path::new("/v2/ttt/index.html"); let (h, info) = router.recognize_mut(&mut path).unwrap(); assert_eq!(*h, 14); assert_eq!(info, ResourceId(4)); - assert_eq!(path.get("val").unwrap(), "test"); + assert_eq!(path.get("val").unwrap(), "2"); assert_eq!(path.get("val2").unwrap(), "ttt"); let mut path = Path::new("/v/blah-blah/index.html"); diff --git a/actix-router/src/url.rs b/actix-router/src/url.rs index 2920e271d..b3d9e1121 100644 --- a/actix-router/src/url.rs +++ b/actix-router/src/url.rs @@ -62,6 +62,8 @@ impl ResourcePath for Url { #[cfg(test)] mod tests { + use std::fmt::Write as _; + use http::Uri; use super::*; @@ -78,7 +80,11 @@ mod tests { } fn percent_encode(data: &[u8]) -> String { - data.iter().map(|c| format!("%{:02X}", c)).collect() + data.iter() + .fold(String::with_capacity(data.len() * 3), |mut buf, c| { + write!(&mut buf, "%{:02X}", c).unwrap(); + buf + }) } #[test] diff --git a/actix-test/CHANGES.md b/actix-test/CHANGES.md index aba27dbfc..fc64cb7a3 100644 --- a/actix-test/CHANGES.md +++ b/actix-test/CHANGES.md @@ -1,78 +1,99 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Add `TestServerConfig::workers()` setter method. -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 0.1.1 - 2023-02-26 +## 0.1.5 + +- Add `TestServerConfig::listen_address()` method. + +## 0.1.4 + +- Add `TestServerConfig::rustls_0_23()` method for Rustls v0.23 support behind new `rustls-0_23` crate feature. +- Add `TestServerConfig::disable_redirects()` method. +- Various types from `awc`, such as `ClientRequest` and `ClientResponse`, are now re-exported. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 0.1.3 + +- Add `TestServerConfig::rustls_0_22()` method for Rustls v0.22 support behind new `rustls-0_22` crate feature. + +## 0.1.2 + +- Add `TestServerConfig::rustls_021()` method for Rustls v0.21 support behind new `rustls-0_21` crate feature. +- Add `TestServerConfig::workers()` method. +- Add `rustls-0_20` crate feature, which the existing `rustls` feature now aliases. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 0.1.1 - Add `TestServerConfig::port()` setter method. - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. -## 0.1.0 - 2022-07-24 +## 0.1.0 - Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. -## 0.1.0-beta.13 - 2022-02-16 +## 0.1.0-beta.13 - No significant changes since `0.1.0-beta.12`. -## 0.1.0-beta.12 - 2022-01-31 +## 0.1.0-beta.12 - Rename `TestServerConfig::{client_timeout => client_request_timeout}`. [#2611] [#2611]: https://github.com/actix/actix-web/pull/2611 -## 0.1.0-beta.11 - 2022-01-04 +## 0.1.0-beta.11 - Minimum supported Rust version (MSRV) is now 1.54. -## 0.1.0-beta.10 - 2021-12-27 +## 0.1.0-beta.10 - No significant changes since `0.1.0-beta.9`. -## 0.1.0-beta.9 - 2021-12-17 +## 0.1.0-beta.9 - Re-export `actix_http::body::to_bytes`. [#2518] - Update `actix_web::test` re-exports. [#2518] [#2518]: https://github.com/actix/actix-web/pull/2518 -## 0.1.0-beta.8 - 2021-12-11 +## 0.1.0-beta.8 - No significant changes since `0.1.0-beta.7`. -## 0.1.0-beta.7 - 2021-11-22 +## 0.1.0-beta.7 - Fix compatibility with experimental `io-uring` feature of `actix-rt`. [#2408] [#2408]: https://github.com/actix/actix-web/pull/2408 -## 0.1.0-beta.6 - 2021-11-15 +## 0.1.0-beta.6 - No significant changes from `0.1.0-beta.5`. -## 0.1.0-beta.5 - 2021-10-20 +## 0.1.0-beta.5 - Updated rustls to v0.20. [#2414] - Minimum supported Rust version (MSRV) is now 1.52. [#2414]: https://github.com/actix/actix-web/pull/2414 -## 0.1.0-beta.4 - 2021-09-09 +## 0.1.0-beta.4 - Minimum supported Rust version (MSRV) is now 1.51. -## 0.1.0-beta.3 - 2021-06-20 +## 0.1.0-beta.3 - No significant changes from `0.1.0-beta.2`. -## 0.1.0-beta.2 - 2021-04-17 +## 0.1.0-beta.2 - No significant changes from `0.1.0-beta.1`. -## 0.1.0-beta.1 - 2021-04-02 +## 0.1.0-beta.1 - Move integration testing structs from `actix-web`. [#2112] diff --git a/actix-test/Cargo.toml b/actix-test/Cargo.toml index 9cf5aa76c..eb11e8469 100644 --- a/actix-test/Cargo.toml +++ b/actix-test/Cargo.toml @@ -1,41 +1,62 @@ [package] name = "actix-test" -version = "0.1.1" -authors = [ - "Nikolay Kim ", - "Rob Ede ", -] +version = "0.1.5" +authors = ["Nikolay Kim ", "Rob Ede "] description = "Integration testing tools for Actix Web applications" keywords = ["http", "web", "framework", "async", "futures"] homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" +repository = "https://github.com/actix/actix-web" categories = [ - "network-programming", - "asynchronous", - "web-programming::http-server", - "web-programming::websocket", + "network-programming", + "asynchronous", + "web-programming::http-server", + "web-programming::websocket", ] license = "MIT OR Apache-2.0" edition = "2021" +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_codec::*", + "actix_http_test::*", + "actix_http::*", + "actix_service::*", + "actix_web::*", + "awc::*", + "bytes::*", + "futures_core::*", + "http::*", + "openssl::*", + "rustls::*", + "tokio::*", +] + [features] default = [] -# rustls -rustls = ["tls-rustls", "actix-http/rustls", "awc/rustls"] +# TLS via Rustls v0.20 +rustls = ["rustls-0_20"] +# TLS via Rustls v0.20 +rustls-0_20 = ["tls-rustls-0_20", "actix-http/rustls-0_20", "awc/rustls-0_20"] +# TLS via Rustls v0.21 +rustls-0_21 = ["tls-rustls-0_21", "actix-http/rustls-0_21", "awc/rustls-0_21"] +# TLS via Rustls v0.22 +rustls-0_22 = ["tls-rustls-0_22", "actix-http/rustls-0_22", "awc/rustls-0_22-webpki-roots"] +# TLS via Rustls v0.23 +rustls-0_23 = ["tls-rustls-0_23", "actix-http/rustls-0_23", "awc/rustls-0_23-webpki-roots"] -# openssl +# TLS via OpenSSL openssl = ["tls-openssl", "actix-http/openssl", "awc/openssl"] [dependencies] actix-codec = "0.5" -actix-http = "3" +actix-http = "3.7" actix-http-test = "3" actix-rt = "2.1" actix-service = "2" actix-utils = "3" -actix-web = { version = "4", default-features = false, features = ["cookies"] } -awc = { version = "3", default-features = false, features = ["cookies"] } +actix-web = { version = "4.6", default-features = false, features = ["cookies"] } +awc = { version = "3.5", default-features = false, features = ["cookies"] } futures-core = { version = "0.3.17", default-features = false, features = ["std"] } futures-util = { version = "0.3.17", default-features = false, features = [] } @@ -44,5 +65,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" tls-openssl = { package = "openssl", version = "0.10.55", optional = true } -tls-rustls = { package = "rustls", version = "0.20", optional = true } -tokio = { version = "1.24.2", features = ["sync"] } +tls-rustls-0_20 = { package = "rustls", version = "0.20", optional = true } +tls-rustls-0_21 = { package = "rustls", version = "0.21", optional = true } +tls-rustls-0_22 = { package = "rustls", version = "0.22", optional = true } +tls-rustls-0_23 = { package = "rustls", version = "0.23", default-features = false, optional = true } +tokio = { version = "1.38.2", features = ["sync"] } + +[lints] +workspace = true diff --git a/actix-test/README.md b/actix-test/README.md new file mode 100644 index 000000000..3a0caf2ef --- /dev/null +++ b/actix-test/README.md @@ -0,0 +1,45 @@ +# `actix-test` + + + +[![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.88+-ab6000.svg) +![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-test.svg) +
+[![dependency status](https://deps.rs/crate/actix-test/0.1.5/status.svg)](https://deps.rs/crate/actix-test/0.1.5) +[![Download](https://img.shields.io/crates/d/actix-test.svg)](https://crates.io/crates/actix-test) +[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) + + + + + +Integration testing tools for Actix Web applications. + +The main integration testing tool is [`TestServer`]. It spawns a real HTTP server on an unused port and provides methods that use a real HTTP client. Therefore, it is much closer to real-world cases than using `init_service`, which skips HTTP encoding and decoding. + +## Examples + +```rust +use actix_web::{get, web, test, App, HttpResponse, Error, Responder}; + +#[get("/")] +async fn my_handler() -> Result { + Ok(HttpResponse::Ok()) +} + +#[actix_rt::test] +async fn test_example() { + let srv = actix_test::start(|| + App::new().service(my_handler) + ); + + let req = srv.get("/"); + let res = req.send().await.unwrap(); + + assert!(res.status().is_success()); +} +``` + + diff --git a/actix-test/src/lib.rs b/actix-test/src/lib.rs index 751ab3161..84adacbce 100644 --- a/actix-test/src/lib.rs +++ b/actix-test/src/lib.rs @@ -5,6 +5,7 @@ //! real-world cases than using `init_service`, which skips HTTP encoding and decoding. //! //! # Examples +//! //! ``` //! use actix_web::{get, web, test, App, HttpResponse, Error, Responder}; //! @@ -26,16 +27,12 @@ //! } //! ``` -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] #![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; -#[cfg(feature = "rustls")] -extern crate tls_rustls as rustls; use std::{fmt, net, thread, time::Duration}; @@ -54,7 +51,7 @@ use actix_web::{ rt::{self, System}, web, Error, }; -use awc::{error::PayloadError, Client, ClientRequest, ClientResponse, Connector}; +pub use awc::{error::PayloadError, Client, ClientRequest, ClientResponse, Connector}; use futures_core::Stream; use tokio::sync::mpsc; @@ -141,14 +138,22 @@ where StreamType::Tcp => false, #[cfg(feature = "openssl")] StreamType::Openssl(_) => true, - #[cfg(feature = "rustls")] - StreamType::Rustls(_) => true, + #[cfg(feature = "rustls-0_20")] + StreamType::Rustls020(_) => true, + #[cfg(feature = "rustls-0_21")] + StreamType::Rustls021(_) => true, + #[cfg(feature = "rustls-0_22")] + StreamType::Rustls022(_) => true, + #[cfg(feature = "rustls-0_23")] + StreamType::Rustls023(_) => true, }; + let client_cfg = cfg.clone(); + // run server in separate orphaned thread thread::spawn(move || { rt::System::new().block_on(async move { - let tcp = net::TcpListener::bind(("127.0.0.1", cfg.port)).unwrap(); + let tcp = net::TcpListener::bind((cfg.listen_address.clone(), cfg.port)).unwrap(); let local_addr = tcp.local_addr().unwrap(); let factory = factory.clone(); let srv_cfg = cfg.clone(); @@ -243,8 +248,8 @@ where .openssl(acceptor.clone()) }), }, - #[cfg(feature = "rustls")] - StreamType::Rustls(config) => match cfg.tp { + #[cfg(feature = "rustls-0_20")] + StreamType::Rustls020(config) => match cfg.tp { HttpVer::Http1 => builder.listen("test", tcp, move || { let app_cfg = AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); @@ -285,6 +290,132 @@ where .rustls(config.clone()) }), }, + #[cfg(feature = "rustls-0_21")] + StreamType::Rustls021(config) => match cfg.tp { + HttpVer::Http1 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h1(map_config(fac, move |_| app_cfg.clone())) + .rustls_021(config.clone()) + }), + HttpVer::Http2 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h2(map_config(fac, move |_| app_cfg.clone())) + .rustls_021(config.clone()) + }), + HttpVer::Both => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .finish(map_config(fac, move |_| app_cfg.clone())) + .rustls_021(config.clone()) + }), + }, + #[cfg(feature = "rustls-0_22")] + StreamType::Rustls022(config) => match cfg.tp { + HttpVer::Http1 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h1(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_22(config.clone()) + }), + HttpVer::Http2 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h2(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_22(config.clone()) + }), + HttpVer::Both => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .finish(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_22(config.clone()) + }), + }, + #[cfg(feature = "rustls-0_23")] + StreamType::Rustls023(config) => match cfg.tp { + HttpVer::Http1 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h1(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_23(config.clone()) + }), + HttpVer::Http2 => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .h2(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_23(config.clone()) + }), + HttpVer::Both => builder.listen("test", tcp, move || { + let app_cfg = + AppConfig::__priv_test_new(false, local_addr.to_string(), local_addr); + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + HttpService::build() + .client_request_timeout(timeout) + .finish(map_config(fac, move |_| app_cfg.clone())) + .rustls_0_23(config.clone()) + }), + }, } .expect("test server could not be created"); @@ -316,7 +447,7 @@ where builder.set_verify(SslVerifyMode::NONE); let _ = builder .set_alpn_protos(b"\x02h2\x08http/1.1") - .map_err(|e| log::error!("Can not set alpn protocol: {:?}", e)); + .map_err(|err| log::error!("Can not set alpn protocol: {err:?}")); Connector::new() .conn_lifetime(Duration::from_secs(0)) .timeout(Duration::from_millis(30000)) @@ -330,7 +461,13 @@ where } }; - Client::builder().connector(connector).finish() + let mut client_builder = Client::builder().connector(connector); + + if client_cfg.disable_redirects { + client_builder = client_builder.disable_redirects(); + } + + client_builder.finish() }; TestServer { @@ -350,13 +487,20 @@ enum HttpVer { Both, } +#[allow(clippy::large_enum_variant)] #[derive(Clone)] enum StreamType { Tcp, #[cfg(feature = "openssl")] Openssl(openssl::ssl::SslAcceptor), - #[cfg(feature = "rustls")] - Rustls(rustls::ServerConfig), + #[cfg(feature = "rustls-0_20")] + Rustls020(tls_rustls_0_20::ServerConfig), + #[cfg(feature = "rustls-0_21")] + Rustls021(tls_rustls_0_21::ServerConfig), + #[cfg(feature = "rustls-0_22")] + Rustls022(tls_rustls_0_22::ServerConfig), + #[cfg(feature = "rustls-0_23")] + Rustls023(tls_rustls_0_23::ServerConfig), } /// Create default test server config. @@ -369,8 +513,10 @@ pub struct TestServerConfig { tp: HttpVer, stream: StreamType, client_request_timeout: Duration, + listen_address: String, port: u16, workers: usize, + disable_redirects: bool, } impl Default for TestServerConfig { @@ -380,49 +526,96 @@ impl Default for TestServerConfig { } impl TestServerConfig { - /// Create default server configuration + /// Constructs default server configuration. pub(crate) fn new() -> TestServerConfig { TestServerConfig { tp: HttpVer::Both, stream: StreamType::Tcp, client_request_timeout: Duration::from_secs(5), + listen_address: "127.0.0.1".to_string(), port: 0, workers: 1, + disable_redirects: false, } } - /// Accept HTTP/1.1 only. + /// Accepts HTTP/1.1 only. pub fn h1(mut self) -> Self { self.tp = HttpVer::Http1; self } - /// Accept HTTP/2 only. + /// Accepts HTTP/2 only. pub fn h2(mut self) -> Self { self.tp = HttpVer::Http2; self } - /// Accept secure connections via OpenSSL. + /// Accepts secure connections via OpenSSL. #[cfg(feature = "openssl")] pub fn openssl(mut self, acceptor: openssl::ssl::SslAcceptor) -> Self { self.stream = StreamType::Openssl(acceptor); self } - /// Accept secure connections via Rustls. - #[cfg(feature = "rustls")] - pub fn rustls(mut self, config: rustls::ServerConfig) -> Self { - self.stream = StreamType::Rustls(config); + #[doc(hidden)] + #[deprecated(note = "Renamed to `rustls_0_20()`.")] + #[cfg(feature = "rustls-0_20")] + pub fn rustls(mut self, config: tls_rustls_0_20::ServerConfig) -> Self { + self.stream = StreamType::Rustls020(config); self } - /// Set client timeout for first request. + /// Accepts secure connections via Rustls v0.20. + #[cfg(feature = "rustls-0_20")] + pub fn rustls_0_20(mut self, config: tls_rustls_0_20::ServerConfig) -> Self { + self.stream = StreamType::Rustls020(config); + self + } + + #[doc(hidden)] + #[deprecated(note = "Renamed to `rustls_0_21()`.")] + #[cfg(feature = "rustls-0_21")] + pub fn rustls_021(mut self, config: tls_rustls_0_21::ServerConfig) -> Self { + self.stream = StreamType::Rustls021(config); + self + } + + /// Accepts secure connections via Rustls v0.21. + #[cfg(feature = "rustls-0_21")] + pub fn rustls_0_21(mut self, config: tls_rustls_0_21::ServerConfig) -> Self { + self.stream = StreamType::Rustls021(config); + self + } + + /// Accepts secure connections via Rustls v0.22. + #[cfg(feature = "rustls-0_22")] + pub fn rustls_0_22(mut self, config: tls_rustls_0_22::ServerConfig) -> Self { + self.stream = StreamType::Rustls022(config); + self + } + + /// Accepts secure connections via Rustls v0.23. + #[cfg(feature = "rustls-0_23")] + pub fn rustls_0_23(mut self, config: tls_rustls_0_23::ServerConfig) -> Self { + self.stream = StreamType::Rustls023(config); + self + } + + /// Sets client timeout for first request. pub fn client_request_timeout(mut self, dur: Duration) -> Self { self.client_request_timeout = dur; self } + /// Sets the address the server will listen on. + /// + /// By default, only listens on `127.0.0.1`. + pub fn listen_address(mut self, addr: impl Into) -> Self { + self.listen_address = addr.into(); + self + } + /// Sets test server port. /// /// By default, a random free port is determined by the OS. @@ -438,6 +631,15 @@ impl TestServerConfig { self.workers = workers; self } + + /// Instruct the client to not follow redirects. + /// + /// By default, the client will follow up to 10 consecutive redirects + /// before giving up. + pub fn disable_redirects(mut self) -> Self { + self.disable_redirects = true; + self + } } /// A basic HTTP server controller that simplifies the process of writing integration tests for @@ -464,9 +666,9 @@ impl TestServer { let scheme = if self.tls { "https" } else { "http" }; if uri.starts_with('/') { - format!("{}://localhost:{}{}", scheme, self.addr.port(), uri) + format!("{}://{}{}", scheme, self.addr, uri) } else { - format!("{}://localhost:{}/{}", scheme, self.addr.port(), uri) + format!("{}://{}/{}", scheme, self.addr, uri) } } diff --git a/actix-web-actors/CHANGES.md b/actix-web-actors/CHANGES.md index 4799c7b67..79fecaadf 100644 --- a/actix-web-actors/CHANGES.md +++ b/actix-web-actors/CHANGES.md @@ -1,40 +1,50 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 4.2.0 - 2023-01-21 +## 4.3.1 + +- Reduce memory usage by `take`-ing (rather than `split`-ing) the encoded buffer when yielding bytes in the response stream. +- Mark crate as deprecated. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 4.3.0 + +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 4.2.0 - Minimum supported Rust version (MSRV) is now 1.57 due to transitive `time` dependency. -## 4.1.0 - 2022-03-02 +## 4.1.0 - Add support for `actix` version `0.13`. [#2675] [#2675]: https://github.com/actix/actix-web/pull/2675 -## 4.0.0 - 2022-02-25 +## 4.0.0 - No significant changes since `4.0.0-beta.12`. -## 4.0.0-beta.12 - 2022-02-16 +## 4.0.0-beta.12 - No significant changes since `4.0.0-beta.11`. -## 4.0.0-beta.11 - 2022-01-31 +## 4.0.0-beta.11 - No significant changes since `4.0.0-beta.10`. -## 4.0.0-beta.10 - 2022-01-04 +## 4.0.0-beta.10 - Minimum supported Rust version (MSRV) is now 1.54. -## 4.0.0-beta.9 - 2021-12-27 +## 4.0.0-beta.9 - No significant changes since `4.0.0-beta.8`. -## 4.0.0-beta.8 - 2021-12-11 +## 4.0.0-beta.8 - Add `ws:WsResponseBuilder` for building WebSocket session response. [#1920] - Deprecate `ws::{start_with_addr, start_with_protocols}`. [#1920] @@ -42,33 +52,33 @@ [#1920]: https://github.com/actix/actix-web/pull/1920 -## 4.0.0-beta.7 - 2021-09-09 +## 4.0.0-beta.7 - Minimum supported Rust version (MSRV) is now 1.51. -## 4.0.0-beta.6 - 2021-06-26 +## 4.0.0-beta.6 - Update `actix` to `0.12`. [#2277] [#2277]: https://github.com/actix/actix-web/pull/2277 -## 4.0.0-beta.5 - 2021-06-17 +## 4.0.0-beta.5 - No notable changes. -## 4.0.0-beta.4 - 2021-04-02 +## 4.0.0-beta.4 - No notable changes. -## 4.0.0-beta.3 - 2021-03-09 +## 4.0.0-beta.3 - No notable changes. -## 4.0.0-beta.2 - 2021-02-10 +## 4.0.0-beta.2 - No notable changes. -## 4.0.0-beta.1 - 2021-01-07 +## 4.0.0-beta.1 - Update `pin-project` to `1.0`. - Update `bytes` to `1.0`. [#1813] @@ -77,63 +87,63 @@ [#1813]: https://github.com/actix/actix-web/pull/1813 [#1864]: https://github.com/actix/actix-web/pull/1864 -## 3.0.0 - 2020-09-11 +## 3.0.0 - No significant changes from `3.0.0-beta.2`. -## 3.0.0-beta.2 - 2020-09-10 +## 3.0.0-beta.2 - Update `actix-*` dependencies to latest versions. -## [3.0.0-beta.1] - 2020-xx-xx +## 3.0.0-beta.1 - Update `actix-web` & `actix-http` dependencies to beta.1 - Bump minimum supported Rust version to 1.40 -## [3.0.0-alpha.1] - 2020-05-08 +## 3.0.0-alpha.1 - Update the actix-web dependency to 3.0.0-alpha.1 - Update the actix dependency to 0.10.0-alpha.2 - Update the actix-http dependency to 2.0.0-alpha.3 -## [2.0.0] - 2019-12-20 +## 2.0.0 - Release -## [2.0.0-alpha.1] - 2019-12-15 +## 2.0.0-alpha.1 - Migrate to actix-web 2.0.0 -## [1.0.4] - 2019-12-07 +## 1.0.4 - Allow comma-separated websocket subprotocols without spaces (#1172) -## [1.0.3] - 2019-11-14 +## 1.0.3 - Update actix-web and actix-http dependencies -## [1.0.2] - 2019-07-20 +## 1.0.2 - Add `ws::start_with_addr()`, returning the address of the created actor, along with the `HttpResponse`. - Add support for specifying protocols on websocket handshake #835 -## [1.0.1] - 2019-06-28 +## 1.0.1 - Allow to use custom ws codec with `WebsocketContext` #925 -## [1.0.0] - 2019-05-29 +## 1.0.0 - Update actix-http and actix-web -## [0.1.0-alpha.3] - 2019-04-02 +## 0.1.0-alpha.3 - Update actix-http and actix-web -## [0.1.0-alpha.2] - 2019-03-29 +## 0.1.0-alpha.2 - Update actix-http and actix-web -## [0.1.0-alpha.1] - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/actix-web-actors/Cargo.toml b/actix-web-actors/Cargo.toml index c6f14554a..c8ff628de 100644 --- a/actix-web-actors/Cargo.toml +++ b/actix-web-actors/Cargo.toml @@ -1,37 +1,47 @@ [package] name = "actix-web-actors" -version = "4.2.0" +version = "4.3.1+deprecated" authors = ["Nikolay Kim "] description = "Actix actors support for Actix Web" keywords = ["actix", "http", "web", "framework", "async"] -homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web" -license = "MIT OR Apache-2.0" -edition = "2021" +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true -[lib] -name = "actix_web_actors" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix::*", + "actix_http::*", + "actix_web::*", + "bytes::*", + "bytestring::*", + "futures_core::*", +] [dependencies] 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" futures-core = { version = "0.3.17", default-features = false } pin-project-lite = "0.2" -tokio = { version = "1.24.2", features = ["sync"] } +tokio = { version = "1.38.2", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec"] } [dev-dependencies] actix-rt = "2.2" actix-test = "0.1" -awc = { version = "3", default-features = false } actix-web = { version = "4", features = ["macros"] } +awc = { version = "3", default-features = false } -env_logger = "0.10" +env_logger = "0.11" futures-util = { version = "0.3.17", default-features = false, features = ["std"] } mime = "0.3" + +[lints] +workspace = true diff --git a/actix-web-actors/README.md b/actix-web-actors/README.md index fe6122115..6a2e8f1a8 100644 --- a/actix-web-actors/README.md +++ b/actix-web-actors/README.md @@ -1,17 +1,18 @@ -# actix-web-actors +# `actix-web-actors` > Actix actors support for Actix Web. +> +> This crate is deprecated. Migrate to [`actix-ws`](https://crates.io/crates/actix-ws). + + [![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.2.0)](https://docs.rs/actix-web-actors/4.2.0) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-web-actors.svg)
-[![dependency status](https://deps.rs/crate/actix-web-actors/4.2.0/status.svg)](https://deps.rs/crate/actix-web-actors/4.2.0) +![maintenance-status](https://img.shields.io/badge/maintenance-deprecated-red.svg) [![Download](https://img.shields.io/crates/d/actix-web-actors.svg)](https://crates.io/crates/actix-web-actors) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources - -- [API Documentation](https://docs.rs/actix-web-actors) -- Minimum Supported Rust Version (MSRV): 1.65 + diff --git a/actix-web-actors/src/context.rs b/actix-web-actors/src/context.rs index be8fd387c..23e336459 100644 --- a/actix-web-actors/src/context.rs +++ b/actix-web-actors/src/context.rs @@ -248,13 +248,11 @@ where mod tests { use std::time::Duration; - use actix::Actor; use actix_web::{ http::StatusCode, test::{call_service, init_service, read_body, TestRequest}, web, App, HttpResponse, }; - use bytes::Bytes; use super::*; diff --git a/actix-web-actors/src/lib.rs b/actix-web-actors/src/lib.rs index cf2eb3645..619a2204f 100644 --- a/actix-web-actors/src/lib.rs +++ b/actix-web-actors/src/lib.rs @@ -1,5 +1,7 @@ //! Actix actors support for Actix Web. //! +//! This crate is deprecated. Migrate to [`actix-ws`](https://crates.io/crates/actix-ws). +//! //! # Examples //! //! ```no_run @@ -55,12 +57,9 @@ //! * [`HttpContext`]: This struct provides actor support for streaming HTTP responses. //! -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] -#![allow(clippy::uninlined_format_args)] #![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; diff --git a/actix-web-actors/src/ws.rs b/actix-web-actors/src/ws.rs index 6ce3e69a1..22618c9b3 100644 --- a/actix-web-actors/src/ws.rs +++ b/actix-web-actors/src/ws.rs @@ -710,7 +710,7 @@ where } if !this.buf.is_empty() { - Poll::Ready(Some(Ok(this.buf.split().freeze()))) + Poll::Ready(Some(Ok(std::mem::take(&mut this.buf).freeze()))) } else if this.fut.alive() && !this.closed { Poll::Pending } else { @@ -775,11 +775,8 @@ where break; } Poll::Pending => break, - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(ProtocolError::Io(io::Error::new( - io::ErrorKind::Other, - format!("{}", e), - ))))); + Poll::Ready(Some(Err(err))) => { + return Poll::Ready(Some(Err(ProtocolError::Io(io::Error::other(err))))); } } } @@ -795,14 +792,10 @@ where } Some(frm) => { let msg = match frm { - Frame::Text(data) => { - Message::Text(ByteString::try_from(data).map_err(|e| { - ProtocolError::Io(io::Error::new( - io::ErrorKind::Other, - format!("{}", e), - )) - })?) - } + Frame::Text(data) => Message::Text( + ByteString::try_from(data) + .map_err(|err| ProtocolError::Io(io::Error::other(err)))?, + ), Frame::Binary(data) => Message::Binary(data), Frame::Ping(s) => Message::Ping(s), Frame::Pong(s) => Message::Pong(s), @@ -817,10 +810,7 @@ where #[cfg(test)] mod tests { - use actix_web::{ - http::{header, Method}, - test::TestRequest, - }; + use actix_web::test::TestRequest; use super::*; diff --git a/actix-web-codegen/CHANGES.md b/actix-web-codegen/CHANGES.md index 2c5f17226..6138892a1 100644 --- a/actix-web-codegen/CHANGES.md +++ b/actix-web-codegen/CHANGES.md @@ -1,52 +1,67 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased + +- Minimum supported Rust version (MSRV) is now 1.88. + +## 4.3.0 + +- Add `#[scope]` macro. +- Add `compat-routing-macros-force-pub` crate feature which, on-by-default, which when disabled causes handlers to inherit their attached function's visibility. +- Prevent inclusion of default `actix-router` features. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 4.2.2 + +- Fix regression when declaring `wrap` attribute using an expression. + +## 4.2.1 - Update `syn` dependency to `2`. -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. -## 4.2.0 - 2023-02-26 +## 4.2.0 - Add support for custom methods with the `#[route]` macro. [#2969] [#2969]: https://github.com/actix/actix-web/pull/2969 -## 4.1.0 - 2022-09-11 +## 4.1.0 - Add `#[routes]` macro to support multiple paths for one handler. [#2718] - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. [#2718]: https://github.com/actix/actix-web/pull/2718 -## 4.0.1 - 2022-06-11 +## 4.0.1 - Fix support for guard paths in route handler macros. [#2771] - Minimum supported Rust version (MSRV) is now 1.56 due to transitive `hashbrown` dependency. [#2771]: https://github.com/actix/actix-web/pull/2771 -## 4.0.0 - 2022-02-24 +## 4.0.0 - Version aligned with `actix-web` and will remain in sync going forward. - No significant changes since `0.5.0`. -## 0.5.0 - 2022-02-24 +## 0.5.0 - No significant changes since `0.5.0-rc.2`. -## 0.5.0-rc.2 - 2022-02-01 +## 0.5.0-rc.2 - No significant changes since `0.5.0-rc.1`. -## 0.5.0-rc.1 - 2022-01-04 +## 0.5.0-rc.1 - Minimum supported Rust version (MSRV) is now 1.54. -## 0.5.0-beta.6 - 2021-12-11 +## 0.5.0-beta.6 - No significant changes since `0.5.0-beta.5`. -## 0.5.0-beta.5 - 2021-10-20 +## 0.5.0-beta.5 - Improve error recovery potential when macro input is invalid. [#2410] - Add `#[actix_web::test]` macro for setting up tests with a runtime. [#2409] @@ -55,18 +70,18 @@ [#2410]: https://github.com/actix/actix-web/pull/2410 [#2409]: https://github.com/actix/actix-web/pull/2409 -## 0.5.0-beta.4 - 2021-09-09 +## 0.5.0-beta.4 - In routing macros, paths are now validated at compile time. [#2350] - Minimum supported Rust version (MSRV) is now 1.51. [#2350]: https://github.com/actix/actix-web/pull/2350 -## 0.5.0-beta.3 - 2021-06-17 +## 0.5.0-beta.3 - No notable changes. -## 0.5.0-beta.2 - 2021-03-09 +## 0.5.0-beta.2 - Preserve doc comments when using route macros. [#2022] - Add `name` attribute to `route` macro. [#1934] @@ -74,11 +89,11 @@ [#2022]: https://github.com/actix/actix-web/pull/2022 [#1934]: https://github.com/actix/actix-web/pull/1934 -## 0.5.0-beta.1 - 2021-02-10 +## 0.5.0-beta.1 - Use new call signature for `System::new`. -## 0.4.0 - 2020-09-20 +## 0.4.0 - Added compile success and failure testing. [#1677] - Add `route` macro for supporting multiple HTTP methods guards. [#1674] @@ -86,23 +101,23 @@ [#1677]: https://github.com/actix/actix-web/pull/1677 [#1674]: https://github.com/actix/actix-web/pull/1674 -## 0.3.0 - 2020-09-11 +## 0.3.0 - No significant changes from `0.3.0-beta.1`. -## 0.3.0-beta.1 - 2020-07-14 +## 0.3.0-beta.1 - Add main entry-point macro that uses re-exported runtime. [#1559] [#1559]: https://github.com/actix/actix-web/pull/1559 -## 0.2.2 - 2020-05-23 +## 0.2.2 - Add resource middleware on actix-web-codegen [#1467] [#1467]: https://github.com/actix/actix-web/pull/1467 -## 0.2.1 - 2020-02-25 +## 0.2.1 - Add `#[allow(missing_docs)]` attribute to generated structs [#1368] - Allow the handler function to be named as `config` [#1290] @@ -110,35 +125,35 @@ [#1368]: https://github.com/actix/actix-web/issues/1368 [#1290]: https://github.com/actix/actix-web/issues/1290 -## 0.2.0 - 2019-12-13 +## 0.2.0 - Generate code for actix-web 2.0 -## 0.1.3 - 2019-10-14 +## 0.1.3 - Bump up `syn` & `quote` to 1.0 - Provide better error message -## 0.1.2 - 2019-06-04 +## 0.1.2 - Add macros for head, options, trace, connect and patch http methods -## 0.1.1 - 2019-06-01 +## 0.1.1 - Add syn "extra-traits" feature -## 0.1.0 - 2019-05-18 +## 0.1.0 - Release -## 0.1.0-beta.1 - 2019-04-20 +## 0.1.0-beta.1 - Gen code for actix-web 1.0.0-beta.1 -## 0.1.0-alpha.6 - 2019-04-14 +## 0.1.0-alpha.6 - Gen code for actix-web 1.0.0-alpha.6 -## 0.1.0-alpha.1 - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/actix-web-codegen/Cargo.toml b/actix-web-codegen/Cargo.toml index c202c5d6e..c2bd75c69 100644 --- a/actix-web-codegen/Cargo.toml +++ b/actix-web-codegen/Cargo.toml @@ -1,21 +1,23 @@ [package] name = "actix-web-codegen" -version = "4.2.0" +version = "4.3.0" description = "Routing and runtime macros for Actix Web" -homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" -authors = [ - "Nikolay Kim ", - "Rob Ede ", -] -license = "MIT OR Apache-2.0" -edition = "2021" +authors = ["Nikolay Kim ", "Rob Ede "] +homepage.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true [lib] proc-macro = true +[features] +default = ["compat-routing-macros-force-pub"] +compat-routing-macros-force-pub = [] + [dependencies] -actix-router = "0.5" +actix-router = { version = "0.5", default-features = false } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } @@ -28,5 +30,8 @@ actix-utils = "3" actix-web = "4" futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } +rustversion-msrv = "0.100" trybuild = "1" -rustversion = "1" + +[lints] +workspace = true diff --git a/actix-web-codegen/README.md b/actix-web-codegen/README.md index 531f44a5d..52d522b0a 100644 --- a/actix-web-codegen/README.md +++ b/actix-web-codegen/README.md @@ -1,20 +1,19 @@ -# actix-web-codegen +# `actix-web-codegen` > Routing and runtime macros for Actix Web. + + [![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.2.0)](https://docs.rs/actix-web-codegen/4.2.0) -![Version](https://img.shields.io/badge/rustc-1.65+-ab6000.svg) +[![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.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-web-codegen.svg)
-[![dependency status](https://deps.rs/crate/actix-web-codegen/4.2.0/status.svg)](https://deps.rs/crate/actix-web-codegen/4.2.0) +[![dependency status](https://deps.rs/crate/actix-web-codegen/4.3.0/status.svg)](https://deps.rs/crate/actix-web-codegen/4.3.0) [![Download](https://img.shields.io/crates/d/actix-web-codegen.svg)](https://crates.io/crates/actix-web-codegen) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources - -- [API Documentation](https://docs.rs/actix-web-codegen) -- Minimum Supported Rust Version (MSRV): 1.65 + ## Compile Testing diff --git a/actix-web-codegen/src/lib.rs b/actix-web-codegen/src/lib.rs index 6d6f4f79d..f6ca56aa0 100644 --- a/actix-web-codegen/src/lib.rs +++ b/actix-web-codegen/src/lib.rs @@ -73,16 +73,15 @@ //! [DELETE]: macro@delete #![recursion_limit = "512"] -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] #![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; mod route; +mod scope; /// Creates resource handler, allowing multiple HTTP method guards. /// @@ -197,6 +196,43 @@ method_macro!(Options, options); method_macro!(Trace, trace); method_macro!(Patch, patch); +/// Prepends a path prefix to all handlers using routing macros inside the attached module. +/// +/// # Syntax +/// +/// ``` +/// # use actix_web_codegen::scope; +/// #[scope("/prefix")] +/// mod api { +/// // ... +/// } +/// ``` +/// +/// # Arguments +/// +/// - `"/prefix"` - Raw literal string to be prefixed onto contained handlers' paths. +/// +/// # Example +/// +/// ``` +/// # use actix_web_codegen::{scope, get}; +/// # use actix_web::Responder; +/// #[scope("/api")] +/// mod api { +/// # use super::*; +/// #[get("/hello")] +/// pub async fn hello() -> impl Responder { +/// // this has path /api/hello +/// "Hello, world!" +/// } +/// } +/// # fn main() {} +/// ``` +#[proc_macro_attribute] +pub fn scope(args: TokenStream, input: TokenStream) -> TokenStream { + scope::with_scope(args, input) +} + /// Marks async main function as the Actix Web system entry-point. /// /// Note that Actix Web also works under `#[tokio::main]` since version 4.0. However, this macro is @@ -221,7 +257,7 @@ pub fn main(_: TokenStream, item: TokenStream) -> TokenStream { output } -/// Marks async test functions to use the actix system entry-point. +/// Marks async test functions to use the Actix Web system entry-point. /// /// # Examples /// ``` @@ -240,3 +276,15 @@ pub fn test(_: TokenStream, item: TokenStream) -> TokenStream { output.extend(item); output } + +/// Converts the error to a token stream and appends it to the original input. +/// +/// Returning the original input in addition to the error is good for IDEs which can gracefully +/// recover and show more precise errors within the macro body. +/// +/// See for more info. +fn input_and_compile_error(mut item: TokenStream, err: syn::Error) -> TokenStream { + let compile_err = TokenStream::from(err.to_compile_error()); + item.extend(compile_err); + item +} diff --git a/actix-web-codegen/src/route.rs b/actix-web-codegen/src/route.rs index 525a1c8ba..cd1ad4c66 100644 --- a/actix-web-codegen/src/route.rs +++ b/actix-web-codegen/src/route.rs @@ -6,10 +6,12 @@ use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{quote, ToTokens, TokenStreamExt}; use syn::{punctuated::Punctuated, Ident, LitStr, Path, Token}; +use crate::input_and_compile_error; + #[derive(Debug)] pub struct RouteArgs { - path: syn::LitStr, - options: Punctuated, + pub(crate) path: syn::LitStr, + pub(crate) options: Punctuated, } impl syn::parse::Parse for RouteArgs { @@ -57,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 { $( @@ -78,7 +81,7 @@ macro_rules! standard_method_type { } } - fn from_path(method: &Path) -> Result { + pub(crate) fn from_path(method: &Path) -> Result { match () { $(_ if method.is_ident(stringify!($lower)) => Ok(Self::$variant),)+ _ => Err(()), @@ -224,7 +227,7 @@ struct Args { path: syn::LitStr, resource_name: Option, guards: Vec, - wrappers: Vec, + wrappers: Vec, methods: HashSet, } @@ -251,7 +254,7 @@ impl Args { } else { return Err(syn::Error::new_spanned( nv.value, - "Attribute name expects literal string!", + "Attribute name expects literal string", )); } } else if nv.path.is_ident("guard") { @@ -264,7 +267,7 @@ impl Args { } else { return Err(syn::Error::new_spanned( nv.value, - "Attribute guard expects literal string!", + "Attribute guard expects literal string", )); } } else if nv.path.is_ident("wrap") { @@ -283,9 +286,9 @@ impl Args { } else if nv.path.is_ident("method") { if !is_route_macro { return Err(syn::Error::new_spanned( - &nv, - "HTTP method forbidden here. To handle multiple methods, use `route` instead", - )); + &nv, + "HTTP method forbidden here; to handle multiple methods, use `route` instead", + )); } else if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit), .. @@ -300,13 +303,13 @@ impl Args { } else { return Err(syn::Error::new_spanned( nv.value, - "Attribute method expects literal string!", + "Attribute method expects literal string", )); } } else { return Err(syn::Error::new_spanned( nv.path, - "Unknown attribute key is specified. Allowed: guard, method and wrap", + "Unknown attribute key is specified; allowed: guard, method and wrap", )); } } @@ -411,6 +414,13 @@ impl ToTokens for Route { doc_attributes, } = self; + #[allow(unused_variables)] // used when force-pub feature is disabled + let vis = &ast.vis; + + // TODO(breaking): remove this force-pub forwards-compatibility feature + #[cfg(feature = "compat-routing-macros-force-pub")] + let vis = syn::Visibility::Public(::default()); + let registrations: TokenStream2 = args .iter() .map(|args| { @@ -457,8 +467,8 @@ impl ToTokens for Route { let stream = quote! { #(#doc_attributes)* - #[allow(non_camel_case_types, missing_docs)] - pub struct #name; + #[allow(non_camel_case_types)] + #vis struct #name; impl ::actix_web::dev::HttpServiceFactory for #name { fn register(self, __config: &mut actix_web::dev::AppService) { @@ -542,15 +552,3 @@ pub(crate) fn with_methods(input: TokenStream) -> TokenStream { Err(err) => input_and_compile_error(input, err), } } - -/// Converts the error to a token stream and appends it to the original input. -/// -/// Returning the original input in addition to the error is good for IDEs which can gracefully -/// recover and show more precise errors within the macro body. -/// -/// See for more info. -fn input_and_compile_error(mut item: TokenStream, err: syn::Error) -> TokenStream { - let compile_err = TokenStream::from(err.to_compile_error()); - item.extend(compile_err); - item -} diff --git a/actix-web-codegen/src/scope.rs b/actix-web-codegen/src/scope.rs new file mode 100644 index 000000000..067d95a60 --- /dev/null +++ b/actix-web-codegen/src/scope.rs @@ -0,0 +1,103 @@ +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{quote, ToTokens as _}; + +use crate::{ + input_and_compile_error, + route::{MethodType, RouteArgs}, +}; + +pub fn with_scope(args: TokenStream, input: TokenStream) -> TokenStream { + match with_scope_inner(args, input.clone()) { + Ok(stream) => stream, + Err(err) => input_and_compile_error(input, err), + } +} + +fn with_scope_inner(args: TokenStream, input: TokenStream) -> syn::Result { + if args.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "missing arguments for scope macro, expected: #[scope(\"/prefix\")]", + )); + } + + let scope_prefix = syn::parse::(args.clone()).map_err(|err| { + syn::Error::new( + err.span(), + "argument to scope macro is not a string literal, expected: #[scope(\"/prefix\")]", + ) + })?; + + let scope_prefix_value = scope_prefix.value(); + + if scope_prefix_value.ends_with('/') { + // trailing slashes cause non-obvious problems + // it's better to point them out to developers rather than + + return Err(syn::Error::new( + scope_prefix.span(), + "scopes should not have trailing slashes; see https://docs.rs/actix-web/4/actix_web/struct.Scope.html#avoid-trailing-slashes", + )); + } + + let mut module = syn::parse::(input).map_err(|err| { + syn::Error::new(err.span(), "#[scope] macro must be attached to a module") + })?; + + // modify any routing macros (method or route[s]) attached to + // functions by prefixing them with this scope macro's argument + if let Some((_, items)) = &mut module.content { + for item in items { + if let syn::Item::Fn(fun) = item { + fun.attrs = fun + .attrs + .iter() + .map(|attr| modify_attribute_with_scope(attr, &scope_prefix_value)) + .collect(); + } + } + } + + Ok(module.to_token_stream().into()) +} + +/// Checks if the attribute is a method type and has a route path, then modifies it. +fn modify_attribute_with_scope(attr: &syn::Attribute, scope_path: &str) -> syn::Attribute { + match (attr.parse_args::(), attr.clone().meta) { + (Ok(route_args), syn::Meta::List(meta_list)) if has_allowed_methods_in_scope(attr) => { + let modified_path = format!("{}{}", scope_path, route_args.path.value()); + + let options_tokens: Vec = route_args + .options + .iter() + .map(|option| { + quote! { ,#option } + }) + .collect(); + + let combined_options_tokens: TokenStream2 = + options_tokens + .into_iter() + .fold(TokenStream2::new(), |mut acc, ts| { + acc.extend(std::iter::once(ts)); + acc + }); + + syn::Attribute { + meta: syn::Meta::List(syn::MetaList { + tokens: quote! { #modified_path #combined_options_tokens }, + ..meta_list.clone() + }), + ..attr.clone() + } + } + _ => attr.clone(), + } +} + +fn has_allowed_methods_in_scope(attr: &syn::Attribute) -> bool { + MethodType::from_path(attr.path()).is_ok() + || attr.path().is_ident("route") + || attr.path().is_ident("ROUTE") +} diff --git a/actix-web-codegen/tests/test_macro.rs b/actix-web-codegen/tests/routes.rs similarity index 96% rename from actix-web-codegen/tests/test_macro.rs rename to actix-web-codegen/tests/routes.rs index f28654cd9..1443f9a75 100644 --- a/actix-web-codegen/tests/test_macro.rs +++ b/actix-web-codegen/tests/routes.rs @@ -136,7 +136,7 @@ async fn routes_overlapping_inaccessible_test(req: HttpRequest) -> impl Responde } #[get("/custom_resource_name", name = "custom")] -async fn custom_resource_name_test<'a>(req: HttpRequest) -> impl Responder { +async fn custom_resource_name_test(req: HttpRequest) -> impl Responder { assert!(req.url_for_static("custom").is_ok()); assert!(req.url_for_static("custom_resource_name_test").is_err()); HttpResponse::Ok() @@ -145,7 +145,7 @@ async fn custom_resource_name_test<'a>(req: HttpRequest) -> impl Responder { mod guard_module { use actix_web::{guard::GuardContext, http::header}; - pub fn guard(ctx: &GuardContext) -> bool { + pub fn guard(ctx: &GuardContext<'_>) -> bool { ctx.header::() .map(|h| h.preference() == "image/*") .unwrap_or(false) @@ -212,6 +212,19 @@ async fn get_wrap(_: web::Path) -> impl Responder { HttpResponse::Ok() } +/// Using expression, not just path to type, in wrap attribute. +/// +/// Regression from . +#[route( + "/catalog", + method = "GET", + method = "HEAD", + wrap = "actix_web::middleware::Compress::default()" +)] +async fn get_catalog() -> impl Responder { + HttpResponse::Ok().body("123123123") +} + #[actix_rt::test] async fn test_params() { let srv = actix_test::start(|| { diff --git a/actix-web-codegen/tests/scopes.rs b/actix-web-codegen/tests/scopes.rs new file mode 100644 index 000000000..b8c832682 --- /dev/null +++ b/actix-web-codegen/tests/scopes.rs @@ -0,0 +1,200 @@ +use actix_web::{guard::GuardContext, http, http::header, web, App, HttpResponse, Responder}; +use actix_web_codegen::{delete, get, post, route, routes, scope}; + +pub fn image_guard(ctx: &GuardContext<'_>) -> bool { + ctx.header::() + .map(|h| h.preference() == "image/*") + .unwrap_or(false) +} + +#[scope("/test")] +mod scope_module { + // ensure that imports can be brought into the scope + use super::*; + + #[get("/test/guard", guard = "image_guard")] + pub async fn guard() -> impl Responder { + HttpResponse::Ok() + } + + #[get("/test")] + pub async fn test() -> impl Responder { + HttpResponse::Ok().finish() + } + + #[get("/twice-test/{value}")] + pub async fn twice(value: web::Path) -> impl actix_web::Responder { + let int_value: i32 = value.parse().unwrap_or(0); + let doubled = int_value * 2; + HttpResponse::Ok().body(format!("Twice value: {}", doubled)) + } + + #[post("/test")] + pub async fn post() -> impl Responder { + HttpResponse::Ok().body("post works") + } + + #[delete("/test")] + pub async fn delete() -> impl Responder { + "delete works" + } + + #[route("/test", method = "PUT", method = "PATCH", method = "CUSTOM")] + pub async fn multiple_shared_path() -> impl Responder { + HttpResponse::Ok().finish() + } + + #[routes] + #[head("/test1")] + #[connect("/test2")] + #[options("/test3")] + #[trace("/test4")] + pub async fn multiple_separate_paths() -> impl Responder { + HttpResponse::Ok().finish() + } + + // test calling this from other mod scope with scope attribute... + pub fn mod_common(message: String) -> impl actix_web::Responder { + HttpResponse::Ok().body(message) + } +} + +/// Scope doc string to check in cargo expand. +#[scope("/v1")] +mod mod_scope_v1 { + use super::*; + + /// Route doc string to check in cargo expand. + #[get("/test")] + pub async fn test() -> impl Responder { + scope_module::mod_common("version1 works".to_string()) + } +} + +#[scope("/v2")] +mod mod_scope_v2 { + use super::*; + + // check to make sure non-function tokens in the scope block are preserved... + enum TestEnum { + Works, + } + + #[get("/test")] + pub async fn test() -> impl Responder { + // make sure this type still exists... + let test_enum = TestEnum::Works; + + match test_enum { + TestEnum::Works => scope_module::mod_common("version2 works".to_string()), + } + } +} + +#[actix_rt::test] +async fn scope_get_async() { + let srv = actix_test::start(|| App::new().service(scope_module::test)); + + let request = srv.request(http::Method::GET, srv.url("/test/test")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} + +#[actix_rt::test] +async fn scope_get_param_async() { + let srv = actix_test::start(|| App::new().service(scope_module::twice)); + + let request = srv.request(http::Method::GET, srv.url("/test/twice-test/4")); + let mut response = request.send().await.unwrap(); + let body = response.body().await.unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, "Twice value: 8"); +} + +#[actix_rt::test] +async fn scope_post_async() { + let srv = actix_test::start(|| App::new().service(scope_module::post)); + + let request = srv.request(http::Method::POST, srv.url("/test/test")); + let mut response = request.send().await.unwrap(); + let body = response.body().await.unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, "post works"); +} + +#[actix_rt::test] +async fn multiple_shared_path_async() { + let srv = actix_test::start(|| App::new().service(scope_module::multiple_shared_path)); + + let request = srv.request(http::Method::PUT, srv.url("/test/test")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::PATCH, srv.url("/test/test")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} + +#[actix_rt::test] +async fn multiple_multi_path_async() { + let srv = actix_test::start(|| App::new().service(scope_module::multiple_separate_paths)); + + let request = srv.request(http::Method::HEAD, srv.url("/test/test1")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::CONNECT, srv.url("/test/test2")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::OPTIONS, srv.url("/test/test3")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); + + let request = srv.request(http::Method::TRACE, srv.url("/test/test4")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} + +#[actix_rt::test] +async fn scope_delete_async() { + let srv = actix_test::start(|| App::new().service(scope_module::delete)); + + let request = srv.request(http::Method::DELETE, srv.url("/test/test")); + let mut response = request.send().await.unwrap(); + let body = response.body().await.unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, "delete works"); +} + +#[actix_rt::test] +async fn scope_get_with_guard_async() { + let srv = actix_test::start(|| App::new().service(scope_module::guard)); + + let request = srv + .request(http::Method::GET, srv.url("/test/test/guard")) + .insert_header(("Accept", "image/*")); + let response = request.send().await.unwrap(); + assert!(response.status().is_success()); +} + +#[actix_rt::test] +async fn scope_v1_v2_async() { + let srv = actix_test::start(|| { + App::new() + .service(mod_scope_v1::test) + .service(mod_scope_v2::test) + }); + + let request = srv.request(http::Method::GET, srv.url("/v1/test")); + let mut response = request.send().await.unwrap(); + let body = response.body().await.unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, "version1 works"); + + let request = srv.request(http::Method::GET, srv.url("/v2/test")); + let mut response = request.send().await.unwrap(); + let body = response.body().await.unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert_eq!(body_str, "version2 works"); +} diff --git a/actix-web-codegen/tests/trybuild.rs b/actix-web-codegen/tests/trybuild.rs index 9f0aa02f4..0150d56f2 100644 --- a/actix-web-codegen/tests/trybuild.rs +++ b/actix-web-codegen/tests/trybuild.rs @@ -1,4 +1,4 @@ -#[rustversion::stable(1.65)] // MSRV +#[rustversion_msrv::msrv] #[test] fn compile_macros() { let t = trybuild::TestCases::new(); @@ -18,6 +18,11 @@ fn compile_macros() { t.compile_fail("tests/trybuild/routes-missing-method-fail.rs"); t.compile_fail("tests/trybuild/routes-missing-args-fail.rs"); + t.compile_fail("tests/trybuild/scope-on-handler.rs"); + t.compile_fail("tests/trybuild/scope-missing-args.rs"); + t.compile_fail("tests/trybuild/scope-invalid-args.rs"); + t.compile_fail("tests/trybuild/scope-trailing-slash.rs"); + t.pass("tests/trybuild/docstring-ok.rs"); t.pass("tests/trybuild/test-runtime.rs"); diff --git a/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr b/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr index 88198a55d..34c79efea 100644 --- a/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr +++ b/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr @@ -25,5 +25,8 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs | + | pub fn service(mut self, factory: F) -> Self + | ------- required by a bound in this associated function + | where | F: HttpServiceFactory + 'static, | ^^^^^^^^^^^^^^^^^^ required by this bound in `App::::service` diff --git a/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr index bda736348..7b176e5b8 100644 --- a/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr @@ -25,5 +25,8 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs | + | pub fn service(mut self, factory: F) -> Self + | ------- required by a bound in this associated function + | where | F: HttpServiceFactory + 'static, | ^^^^^^^^^^^^^^^^^^ required by this bound in `App::::service` diff --git a/actix-web-codegen/tests/trybuild/route-malformed-path-fail.stderr b/actix-web-codegen/tests/trybuild/route-malformed-path-fail.stderr index 93c510109..c1100c784 100644 --- a/actix-web-codegen/tests/trybuild/route-malformed-path-fail.stderr +++ b/actix-web-codegen/tests/trybuild/route-malformed-path-fail.stderr @@ -20,10 +20,7 @@ error: custom attribute panicked 13 | #[get("/{}")] | ^^^^^^^^^^^^^ | - = help: message: Wrong path pattern: "/{}" regex parse error: - ((?s-m)^/(?P<>[^/]+))$ - ^ - error: empty capture group name + = help: message: Wrong path pattern: "/{}" empty capture group names are not allowed error: custom attribute panicked --> $DIR/route-malformed-path-fail.rs:23:1 diff --git a/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr b/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr index 9f2f788fb..e8814b9b1 100644 --- a/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr @@ -27,5 +27,8 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs | + | pub fn service(mut self, factory: F) -> Self + | ------- required by a bound in this associated function + | where | F: HttpServiceFactory + 'static, | ^^^^^^^^^^^^^^^^^^ required by this bound in `App::::service` diff --git a/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr b/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr index 2e84c296a..77d53fa90 100644 --- a/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr +++ b/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr @@ -41,5 +41,8 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs | + | pub fn service(mut self, factory: F) -> Self + | ------- required by a bound in this associated function + | where | F: HttpServiceFactory + 'static, | ^^^^^^^^^^^^^^^^^^ required by this bound in `App::::service` diff --git a/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr b/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr index 228dced9c..9c1c3e23b 100644 --- a/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr @@ -27,5 +27,8 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs | + | pub fn service(mut self, factory: F) -> Self + | ------- required by a bound in this associated function + | where | F: HttpServiceFactory + 'static, | ^^^^^^^^^^^^^^^^^^ required by this bound in `App::::service` diff --git a/actix-web-codegen/tests/trybuild/scope-invalid-args.rs b/actix-web-codegen/tests/trybuild/scope-invalid-args.rs new file mode 100644 index 000000000..ec021d5eb --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-invalid-args.rs @@ -0,0 +1,14 @@ +use actix_web_codegen::scope; + +const PATH: &str = "/api"; + +#[scope(PATH)] +mod api_const {} + +#[scope(true)] +mod api_bool {} + +#[scope(123)] +mod api_num {} + +fn main() {} diff --git a/actix-web-codegen/tests/trybuild/scope-invalid-args.stderr b/actix-web-codegen/tests/trybuild/scope-invalid-args.stderr new file mode 100644 index 000000000..0ab335966 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-invalid-args.stderr @@ -0,0 +1,17 @@ +error: argument to scope macro is not a string literal, expected: #[scope("/prefix")] + --> tests/trybuild/scope-invalid-args.rs:5:9 + | +5 | #[scope(PATH)] + | ^^^^ + +error: argument to scope macro is not a string literal, expected: #[scope("/prefix")] + --> tests/trybuild/scope-invalid-args.rs:8:9 + | +8 | #[scope(true)] + | ^^^^ + +error: argument to scope macro is not a string literal, expected: #[scope("/prefix")] + --> tests/trybuild/scope-invalid-args.rs:11:9 + | +11 | #[scope(123)] + | ^^^ diff --git a/actix-web-codegen/tests/trybuild/scope-missing-args.rs b/actix-web-codegen/tests/trybuild/scope-missing-args.rs new file mode 100644 index 000000000..39bcb9d1a --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-missing-args.rs @@ -0,0 +1,6 @@ +use actix_web_codegen::scope; + +#[scope] +mod api {} + +fn main() {} diff --git a/actix-web-codegen/tests/trybuild/scope-missing-args.stderr b/actix-web-codegen/tests/trybuild/scope-missing-args.stderr new file mode 100644 index 000000000..d59842e39 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-missing-args.stderr @@ -0,0 +1,7 @@ +error: missing arguments for scope macro, expected: #[scope("/prefix")] + --> tests/trybuild/scope-missing-args.rs:3:1 + | +3 | #[scope] + | ^^^^^^^^ + | + = note: this error originates in the attribute macro `scope` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/actix-web-codegen/tests/trybuild/scope-on-handler.rs b/actix-web-codegen/tests/trybuild/scope-on-handler.rs new file mode 100644 index 000000000..e5d478981 --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-on-handler.rs @@ -0,0 +1,8 @@ +use actix_web_codegen::scope; + +#[scope("/api")] +async fn index() -> &'static str { + "Hello World!" +} + +fn main() {} diff --git a/actix-web-codegen/tests/trybuild/scope-on-handler.stderr b/actix-web-codegen/tests/trybuild/scope-on-handler.stderr new file mode 100644 index 000000000..4491f42dd --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-on-handler.stderr @@ -0,0 +1,5 @@ +error: #[scope] macro must be attached to a module + --> tests/trybuild/scope-on-handler.rs:4:1 + | +4 | async fn index() -> &'static str { + | ^^^^^ diff --git a/actix-web-codegen/tests/trybuild/scope-trailing-slash.rs b/actix-web-codegen/tests/trybuild/scope-trailing-slash.rs new file mode 100644 index 000000000..84632b59f --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-trailing-slash.rs @@ -0,0 +1,6 @@ +use actix_web_codegen::scope; + +#[scope("/api/")] +mod api {} + +fn main() {} diff --git a/actix-web-codegen/tests/trybuild/scope-trailing-slash.stderr b/actix-web-codegen/tests/trybuild/scope-trailing-slash.stderr new file mode 100644 index 000000000..66933432e --- /dev/null +++ b/actix-web-codegen/tests/trybuild/scope-trailing-slash.stderr @@ -0,0 +1,5 @@ +error: scopes should not have trailing slashes; see https://docs.rs/actix-web/4/actix_web/struct.Scope.html#avoid-trailing-slashes + --> tests/trybuild/scope-trailing-slash.rs:3:9 + | +3 | #[scope("/api/")] + | ^^^^^^^ diff --git a/actix-web-codegen/tests/trybuild/simple-fail.stderr b/actix-web-codegen/tests/trybuild/simple-fail.stderr index 3b3f9d850..ab81599ed 100644 --- a/actix-web-codegen/tests/trybuild/simple-fail.stderr +++ b/actix-web-codegen/tests/trybuild/simple-fail.stderr @@ -38,7 +38,7 @@ error: Multiple paths specified! There should be only one. | = note: this error originates in the attribute macro `delete` (in Nightly builds, run with -Z macro-backtrace for more info) -error: HTTP method forbidden here. To handle multiple methods, use `route` instead +error: HTTP method forbidden here; to handle multiple methods, use `route` instead --> $DIR/simple-fail.rs:25:19 | 25 | #[delete("/five", method="GET")] diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 39535d41f..47624967d 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -1,23 +1,150 @@ # Changelog -## Unreleased - 2023-xx-xx +## Unreleased + +- 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] + +[#3895]: https://github.com/actix/actix-web/pull/3895 + +## 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. +- Improve handling of non-UTF-8 header values in `Logger` middleware. +- Add `HttpServer::shutdown_signal()` method. +- Mark `HttpServer` as `#[must_use]`. +- Allow SVG images to be compressed by the `Compress` middleware. +- Ignore `Host` header in `Host` guard when connection protocol is HTTP/2. +- Re-export `mime` dependency. +- Update `brotli` dependency to `8`. + +## 4.10.2 + +- No significant changes since `4.10.1`. + +## 4.10.1 + +- No significant changes since `4.10.0`. + +## 4.10.0 ### Added -- Add `HttpServer::{bind, listen}_auto_h2c()` method behind new `http2` crate feature. +- Implement `Responder` for `Result<(), E: Into>`. Returning `Ok(())` responds with HTTP 204 No Content. + +### Changed + +- On Windows, an error is now returned from `HttpServer::bind()` (or TLS variants) when binding to a socket that's already in use. +- Update `brotli` dependency to `7`. +- Minimum supported Rust version (MSRV) is now 1.75. + +## 4.9.0 + +### Added + +- Add `middleware::from_fn()` helper. +- Add `web::ThinData` extractor. + +## 4.8.0 + +### Added + +- Add `web::Html` responder. +- Add `HttpRequest::full_url()` method to get the complete URL of the request. + +### Fixed + +- Always remove port from return value of `ConnectionInfo::realip_remote_addr()` when handling IPv6 addresses. from the `Forwarded` header. +- The `UrlencodedError::ContentType` variant (relevant to the `Form` extractor) now uses the 415 (Media Type Unsupported) status code in it's `ResponseError` implementation. +- Apply `HttpServer::max_connection_rate()` setting when using rustls v0.22 or v0.23. + +## 4.7.0 + +### Added + +- Add `#[scope]` macro. +- Add `middleware::Identity` type. +- Add `CustomizeResponder::add_cookie()` method. +- Add `guard::GuardContext::app_data()` method. +- Add `compat-routing-macros-force-pub` crate feature which (on-by-default) which, when disabled, causes handlers to inherit their attached function's visibility. +- Add `compat` crate feature group (on-by-default) which, when disabled, helps with transitioning to some planned v5.0 breaking changes, starting only with `compat-routing-macros-force-pub`. +- Implement `From>` for `Error`. + +## 4.6.0 + +### Added + +- 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. +- Add `rustls-0_23` crate feature. +- Add `HttpServer::{bind_rustls_0_23, listen_rustls_0_23}()` builder methods. +- Add `HttpServer::tls_handshake_timeout()` builder method for `rustls-0_22` and `rustls-0_23`. + +### Changed + +- Update `brotli` dependency to `6`. +- Minimum supported Rust version (MSRV) is now 1.72. + +### Fixed + +- Avoid type confusion with `rustls` in some circumstances. + +## 4.5.1 + +### Fixed + +- Fix missing import when using enabling Rustls v0.22 support. + +## 4.5.0 + +### Added + +- Add `rustls-0_22` crate feature. +- Add `HttpServer::{bind_rustls_0_22, listen_rustls_0_22}()` builder methods. + +## 4.4.1 + +### Changed + +- Updated `zstd` dependency to `0.13`. +- Compression middleware now prefers brotli over zstd over gzip. + +### Fixed + +- Fix validation of `Json` extractor when `JsonConfig::validate_content_type()` is set to false. + +## 4.4.0 + +### Added + +- Add `HttpServer::{bind, listen}_auto_h2c()` methods behind new `http2` crate feature. +- Add `HttpServer::{bind, listen}_rustls_021()` methods for Rustls v0.21 support behind new `rustls-0_21` crate feature. - Add `Resource::{get, post, etc...}` methods for more concisely adding routes that don't need additional guards. - Add `web::Payload::to_bytes[_limited]()` helper methods. - Add missing constructors on `HttpResponse` for several status codes. - Add `http::header::ContentLength` typed header. +- Implement `Default` for `web::Data`. +- Implement `serde::Deserialize` for `web::Data`. +- Add `rustls-0_20` crate feature, which the existing `rustls` feature now aliases. ### Changed - Handler functions can now receive up to 16 extractor parameters. - The `Compress` middleware no longer compresses image or video content. - Hide sensitive header values in `HttpRequest`'s `Debug` output. -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. -## 4.3.1 - 2023-02-26 +## 4.3.1 ### Added @@ -25,7 +152,7 @@ [#2969]: https://github.com/actix/actix-web/pull/2969 -## 4.3.0 - 2023-01-21 +## 4.3.0 ### Added @@ -48,7 +175,7 @@ [#2949]: https://github.com/actix/actix-web/pull/2949 [#2961]: https://github.com/actix/actix-web/pull/2961 -## 4.2.1 - 2022-09-12 +## 4.2.1 ### Fixed @@ -56,7 +183,7 @@ [#2871]: https://github.com/actix/actix-web/pull/2871 -## 4.2.0 - 2022-09-11 +## 4.2.0 ### Added @@ -72,7 +199,7 @@ [#2752]: https://github.com/actix/actix-web/pull/2752 [#2786]: https://github.com/actix/actix-web/pull/2786 -## 4.1.0 - 2022-06-11 +## 4.1.0 ### Added @@ -95,13 +222,13 @@ [#2742]: https://github.com/actix/actix-web/pull/2742 [#2743]: https://github.com/actix/actix-web/pull/2743 -## 4.0.1 - 2022-02-25 +## 4.0.1 ### Fixed - Use stable version in readme example. -## 4.0.0 - 2022-02-25 +## 4.0.0 ### Dependencies @@ -379,7 +506,7 @@
4.0.0 Pre-Releases -## 4.0.0-rc.3 - 2022-02-08 +## 4.0.0-rc.3 ### Changed @@ -393,7 +520,7 @@ [#2625]: https://github.com/actix/actix-web/pull/2625 [#2635]: https://github.com/actix/actix-web/pull/2635 -## 4.0.0-rc.2 - 2022-02-02 +## 4.0.0-rc.2 ### Added @@ -405,7 +532,7 @@ [#2619]: https://github.com/actix/actix-web/pull/2619 -## 4.0.0-rc.1 - 2022-01-31 +## 4.0.0-rc.1 ### Changed @@ -419,7 +546,7 @@ [#2601]: https://github.com/actix/actix-web/pull/2601 [#2611]: https://github.com/actix/actix-web/pull/2611 -## 4.0.0-beta.21 - 2022-01-21 +## 4.0.0-beta.21 ### Added @@ -436,7 +563,7 @@ [#2591]: https://github.com/actix/actix-web/pull/2591 [#2594]: https://github.com/actix/actix-web/pull/2594 -## 4.0.0-beta.20 - 2022-01-14 +## 4.0.0-beta.20 ### Added @@ -458,7 +585,7 @@ [#2582]: https://github.com/actix/actix-web/pull/2582 [#2584]: https://github.com/actix/actix-web/pull/2584 -## 4.0.0-beta.19 - 2022-01-04 +## 4.0.0-beta.19 ### Added @@ -483,7 +610,7 @@ [#2501]: https://github.com/actix/actix-web/pull/2501 [#2565]: https://github.com/actix/actix-web/pull/2565 -## 4.0.0-beta.18 - 2021-12-29 +## 4.0.0-beta.18 ### Changed @@ -497,7 +624,7 @@ [#2555]: https://github.com/actix/actix-web/pull/2555 [`rustsec-2020-0071`]: https://rustsec.org/advisories/RUSTSEC-2020-0071.html -## 4.0.0-beta.17 - 2021-12-29 +## 4.0.0-beta.17 ### Added @@ -520,7 +647,7 @@ [#2552]: https://github.com/actix/actix-web/pull/2552 [#2554]: https://github.com/actix/actix-web/pull/2554 -## 4.0.0-beta.16 - 2021-12-27 +## 4.0.0-beta.16 ### Changed @@ -530,7 +657,7 @@ [#2523]: https://github.com/actix/actix-web/pull/2523 [#2526]: https://github.com/actix/actix-web/pull/2526 -## 4.0.0-beta.15 - 2021-12-17 +## 4.0.0-beta.15 ### Added @@ -558,7 +685,7 @@ [#2516]: https://github.com/actix/actix-web/pull/2516 [#2518]: https://github.com/actix/actix-web/pull/2518 -## 4.0.0-beta.14 - 2021-12-11 +## 4.0.0-beta.14 ### Added @@ -603,7 +730,7 @@ [#2493]: https://github.com/actix/actix-web/pull/2493 [#2499]: https://github.com/actix/actix-web/pull/2499 -## 4.0.0-beta.13 - 2021-11-30 +## 4.0.0-beta.13 ### Changed @@ -611,7 +738,7 @@ [#2474]: https://github.com/actix/actix-web/pull/2474 -## 4.0.0-beta.12 - 2021-11-22 +## 4.0.0-beta.12 ### Changed @@ -628,7 +755,7 @@ [#2446]: https://github.com/actix/actix-web/pull/2446 [#2448]: https://github.com/actix/actix-web/pull/2448 -## 4.0.0-beta.11 - 2021-11-15 +## 4.0.0-beta.11 ### Added @@ -642,7 +769,7 @@ [#2423]: https://github.com/actix/actix-web/pull/2423 [#2442]: https://github.com/actix/actix-web/pull/2442 -## 4.0.0-beta.10 - 2021-10-20 +## 4.0.0-beta.10 ### Added @@ -669,7 +796,7 @@ [#2409]: https://github.com/actix/actix-web/pull/2409 [#2414]: https://github.com/actix/actix-web/pull/2414 -## 4.0.0-beta.9 - 2021-09-09 +## 4.0.0-beta.9 ### Added @@ -692,7 +819,7 @@ [#2344]: https://github.com/actix/actix-web/pull/2344 [#2379]: https://github.com/actix/actix-web/pull/2379 -## 4.0.0-beta.8 - 2021-06-26 +## 4.0.0-beta.8 ### Added @@ -719,7 +846,7 @@ [#2282]: https://github.com/actix/actix-web/pull/2282 [#2288]: https://github.com/actix/actix-web/pull/2288 -## 4.0.0-beta.7 - 2021-06-17 +## 4.0.0-beta.7 ### Added @@ -748,7 +875,7 @@ [#2253]: https://github.com/actix/actix-web/pull/2253 [#2246]: https://github.com/actix/actix-web/pull/2246 -## 4.0.0-beta.6 - 2021-04-17 +## 4.0.0-beta.6 ### Added @@ -762,7 +889,7 @@ [#2065]: https://github.com/actix/actix-web/pull/2065 [#2148]: https://github.com/actix/actix-web/pull/2148 -## 4.0.0-beta.5 - 2021-04-02 +## 4.0.0-beta.5 ### Added @@ -788,7 +915,7 @@ [#2097]: https://github.com/actix/actix-web/pull/2097 [#2112]: https://github.com/actix/actix-web/pull/2112 -## 4.0.0-beta.4 - 2021-03-09 +## 4.0.0-beta.4 ### Changed @@ -798,11 +925,11 @@ [#1981]: https://github.com/actix/actix-web/pull/1981 [#2010]: https://github.com/actix/actix-web/pull/2010 -## 4.0.0-beta.3 - 2021-02-10 +## 4.0.0-beta.3 - Update `actix-web-codegen` to `0.5.0-beta.1`. -## 4.0.0-beta.2 - 2021-02-10 +## 4.0.0-beta.2 ### Added @@ -840,7 +967,7 @@ [#1933]: https://github.com/actix/actix-web/pull/1933 [#1957]: https://github.com/actix/actix-web/pull/1957 -## 4.0.0-beta.1 - 2021-01-07 +## 4.0.0-beta.1 ### Added @@ -874,7 +1001,7 @@
-## 3.3.3 - 2021-12-18 +## 3.3.3 ### Changed @@ -882,7 +1009,7 @@ [#2529]: https://github.com/actix/actix-web/pull/2529 -## 3.3.2 - 2020-12-01 +## 3.3.2 ### Fixed @@ -894,11 +1021,11 @@ [#1798]: https://github.com/actix/actix-web/pull/1798 [#1803]: https://github.com/actix/actix-web/pull/1803 -## 3.3.1 - 2020-11-29 +## 3.3.1 - Ensure `actix-http` dependency uses same `serde_urlencoded`. -## 3.3.0 - 2020-11-25 +## 3.3.0 ### Added @@ -911,7 +1038,7 @@ [#1773]: https://github.com/actix/actix-web/pull/1773 [#1788]: https://github.com/actix/actix-web/pull/1788 -## 3.2.0 - 2020-10-30 +## 3.2.0 ### Added @@ -936,7 +1063,7 @@ [#1757]: https://github.com/actix/actix-web/pull/1757 [#1749]: https://github.com/actix/actix-web/pull/1749 -## 3.1.0 - 2020-09-29 +## 3.1.0 ### Changed @@ -951,7 +1078,7 @@ [#1708]: https://github.com/actix/actix-web/pull/1708 [#1710]: https://github.com/actix/actix-web/pull/1710 -## 3.0.2 - 2020-09-15 +## 3.0.2 ### Fixed @@ -959,7 +1086,7 @@ [#1678]: https://github.com/actix/actix-web/pull/1678 -## 3.0.1 - 2020-09-13 +## 3.0.1 ### Changed @@ -967,11 +1094,11 @@ [#1673]: https://github.com/actix/actix-web/pull/1673 -## 3.0.0 - 2020-09-11 +## 3.0.0 - No significant changes from `3.0.0-beta.4`. -## 3.0.0-beta.4 - 2020-09-09 +## 3.0.0-beta.4 ### Added @@ -989,13 +1116,13 @@ [#1634]: https://github.com/actix/actix-web/pull/1634 [#1655]: https://github.com/actix/actix-web/pull/1655 -## 3.0.0-beta.3 - 2020-08-17 +## 3.0.0-beta.3 ### Changed - Update `rustls` to 0.18 -## 3.0.0-beta.2 - 2020-08-17 +## 3.0.0-beta.2 ### Changed @@ -1015,7 +1142,7 @@ [#1618]: https://github.com/actix/actix-web/pull/1618 [#1621]: https://github.com/actix/actix-web/pull/1621 -## 3.0.0-beta.1 - 2020-07-13 +## 3.0.0-beta.1 ### Added @@ -1033,7 +1160,7 @@ - `NormalizePath` improved consistency when path needs slashes added _and_ removed. -## 3.0.0-alpha.3 - 2020-05-21 +## 3.0.0-alpha.3 ### Added @@ -1049,7 +1176,7 @@ [#1485]: https://github.com/actix/actix-web/pull/1485 [#1509]: https://github.com/actix/actix-web/pull/1509 -## [3.0.0-alpha.2] - 2020-05-08 +## 3.0.0-alpha.2 ### Changed @@ -1063,7 +1190,7 @@ [#1452]: https://github.com/actix/actix-web/pull/1452 [#1486]: https://github.com/actix/actix-web/pull/1486 -## [3.0.0-alpha.1] - 2020-03-11 +## 3.0.0-alpha.1 ### Added @@ -1080,7 +1207,7 @@ [#1308]: https://github.com/actix/actix-web/pull/1308 -## [2.0.0] - 2019-12-25 +## 2.0.0 ### Changed @@ -1090,7 +1217,7 @@ - Allow to specify multi-patterns for resources -## [2.0.0-rc] - 2019-12-20 +## 2.0.0-rc ### Changed @@ -1108,31 +1235,31 @@ - Fix `AppConfig::secure()` is always false. #1202 -## [2.0.0-alpha.6] - 2019-12-15 +## 2.0.0-alpha.6 ### Fixed - Fixed compilation with default features off -## [2.0.0-alpha.5] - 2019-12-13 +## 2.0.0-alpha.5 ### Added - Add test server, `test::start()` and `test::start_with()` -## [2.0.0-alpha.4] - 2019-12-08 +## 2.0.0-alpha.4 ### Deleted - Delete HttpServer::run(), it is not useful with async/await -## [2.0.0-alpha.3] - 2019-12-07 +## 2.0.0-alpha.3 ### Changed - Migrate to tokio 0.2 -## [2.0.0-alpha.1] - 2019-11-22 +## 2.0.0-alpha.1 ### Changed @@ -1140,7 +1267,7 @@ - Remove implementation of `Responder` for `()`. (#1167) -## [1.0.9] - 2019-11-14 +## 1.0.9 ### Added @@ -1150,7 +1277,7 @@ - Support `Host` guards when the `Host` header is unset (e.g. HTTP/2 requests) (#1129) -## [1.0.8] - 2019-09-25 +## 1.0.8 ### Added @@ -1168,13 +1295,13 @@ - Use actix-testing for testing utils -## [1.0.7] - 2019-08-29 +## 1.0.7 ### Fixed - Request Extensions leak #1062 -## [1.0.6] - 2019-08-28 +## 1.0.6 ### Added @@ -1196,7 +1323,7 @@ - Update url to 2.1 -## [1.0.5] - 2019-07-18 +## 1.0.5 ### Added @@ -1208,7 +1335,7 @@ - Restored logging of errors through the `Logger` middleware -## [1.0.4] - 2019-07-17 +## 1.0.4 ### Added @@ -1220,7 +1347,7 @@ - Upgrade `rand` dependency version to 0.7 -## [1.0.3] - 2019-06-28 +## 1.0.3 ### Added @@ -1230,7 +1357,7 @@ - Use `encoding_rs` crate instead of unmaintained `encoding` crate -## [1.0.2] - 2019-06-17 +## 1.0.2 ### Changed @@ -1238,7 +1365,7 @@ - Move identity middleware to `actix-identity` crate. -## [1.0.1] - 2019-06-17 +## 1.0.1 ### Added @@ -1262,7 +1389,7 @@ - HttpRequest::url_for is broken with nested scopes #915 -## [1.0.0] - 2019-06-05 +## 1.0.0 ### Added @@ -1284,7 +1411,7 @@ - Clear http requests pool on app service drop #860 -## [1.0.0-rc] - 2019-05-18 +## 1.0.0-rc ### Added @@ -1299,7 +1426,7 @@ - Codegen with parameters in the path only resolves the first registered endpoint #841 -## [1.0.0-beta.4] - 2019-05-12 +## 1.0.0-beta.4 ### Added @@ -1310,7 +1437,7 @@ - `App::configure` take an `FnOnce` instead of `Fn` - Upgrade actix-net crates -## [1.0.0-beta.3] - 2019-05-04 +## 1.0.0-beta.3 ### Added @@ -1334,7 +1461,7 @@ - `App::data_factory()` is deleted. -## [1.0.0-beta.2] - 2019-04-24 +## 1.0.0-beta.2 ### Added @@ -1356,7 +1483,7 @@ - Fix async web::Data factory handling -## [1.0.0-beta.1] - 2019-04-20 +## 1.0.0-beta.1 ### Added @@ -1380,7 +1507,7 @@ - Fixed `TestRequest::app_data()` -## [1.0.0-alpha.6] - 2019-04-14 +## 1.0.0-alpha.6 ### Changed @@ -1392,7 +1519,7 @@ - Make extractor config type explicit. Add `FromRequest::Config` associated type. -## [1.0.0-alpha.5] - 2019-04-12 +## 1.0.0-alpha.5 ### Added @@ -1402,7 +1529,7 @@ - Removed native-tls support -## [1.0.0-alpha.4] - 2019-04-08 +## 1.0.0-alpha.4 ### Added @@ -1424,7 +1551,7 @@ - Fix body propagation in Response::from_error. #760 -## [1.0.0-alpha.3] - 2019-04-02 +## 1.0.0-alpha.3 ### Changed @@ -1438,7 +1565,7 @@ - Removed unused `actix_web::web::md()` -## [1.0.0-alpha.2] - 2019-03-29 +## 1.0.0-alpha.2 ### Added @@ -1450,7 +1577,7 @@ - Multipart::Field renamed to MultipartField -## [1.0.0-alpha.1] - 2019-03-28 +## 1.0.0-alpha.1 ### Changed diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 4322fb871..085e89371 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -1,34 +1,73 @@ [package] name = "actix-web" -version = "4.3.1" -authors = [ - "Nikolay Kim ", - "Rob Ede ", -] +version = "4.12.1" description = "Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust" +authors = ["Nikolay Kim ", "Rob Ede "] keywords = ["actix", "http", "web", "framework", "async"] categories = [ - "network-programming", - "asynchronous", - "web-programming::http-server", - "web-programming::websocket" + "network-programming", + "asynchronous", + "web-programming::http-server", + "web-programming::websocket", ] homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" -license = "MIT OR Apache-2.0" -edition = "2021" +repository = "https://github.com/actix/actix-web" +license.workspace = true +edition.workspace = true +rust-version.workspace = true [package.metadata.docs.rs] -# features that docs.rs will build with -features = ["macros", "openssl", "rustls", "compress-brotli", "compress-gzip", "compress-zstd", "cookies", "secure-cookies"] -rustdoc-args = ["--cfg", "docsrs"] +features = [ + "macros", + "openssl", + "rustls-0_20", + "rustls-0_21", + "rustls-0_22", + "rustls-0_23", + "compress-brotli", + "compress-gzip", + "compress-zstd", + "cookies", + "secure-cookies", +] -[lib] -name = "actix_web" -path = "src/lib.rs" +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_http::*", + "actix_router::*", + "actix_rt::*", + "actix_server::*", + "actix_service::*", + "actix_utils::*", + "actix_web_codegen::*", + "bytes::*", + "cookie::*", + "cookie", + "futures_core::*", + "http::*", + "language_tags::*", + "mime::*", + "openssl::*", + "rustls::*", + "serde_json::*", + "serde_urlencoded::*", + "serde::*", + "tokio::*", + "url::*", +] [features] -default = ["macros", "compress-brotli", "compress-gzip", "compress-zstd", "cookies", "http2"] +default = [ + "macros", + "compress-brotli", + "compress-gzip", + "compress-zstd", + "cookies", + "http2", + "unicode", + "compat", + "ws", +] # Brotli algorithm content-encoding support compress-brotli = ["actix-http/compress-brotli", "__compress"] @@ -38,86 +77,119 @@ compress-gzip = ["actix-http/compress-gzip", "__compress"] compress-zstd = ["actix-http/compress-zstd", "__compress"] # Routing and runtime proc macros -macros = ["actix-macros", "actix-web-codegen"] +macros = ["dep:actix-macros", "dep:actix-web-codegen"] # Cookies support -cookies = ["cookie"] +cookies = ["dep:cookie"] # Secure & signed cookies secure-cookies = ["cookies", "cookie/secure"] +# HTTP/2 support (including h2c) http2 = ["actix-http/http2"] -# TLS via OpenSSL -openssl = ["http2", "actix-http/openssl", "actix-tls/accept", "actix-tls/openssl"] +# WebSocket support +ws = ["actix-http/ws"] -# TLS via Rustls -rustls = ["http2", "actix-http/rustls", "actix-tls/accept", "actix-tls/rustls"] +# TLS via OpenSSL +openssl = ["__tls", "http2", "actix-http/openssl", "actix-tls/accept", "actix-tls/openssl"] + +# TLS via Rustls v0.20 +rustls = ["rustls-0_20"] +# TLS via Rustls v0.20 +rustls-0_20 = ["__tls", "http2", "actix-http/rustls-0_20", "actix-tls/accept", "actix-tls/rustls-0_20"] +# TLS via Rustls v0.21 +rustls-0_21 = ["__tls", "http2", "actix-http/rustls-0_21", "actix-tls/accept", "actix-tls/rustls-0_21"] +# TLS via Rustls v0.22 +rustls-0_22 = ["__tls", "http2", "actix-http/rustls-0_22", "actix-tls/accept", "actix-tls/rustls-0_22"] +# TLS via Rustls v0.23 +rustls-0_23 = ["__tls", "http2", "actix-http/rustls-0_23", "actix-tls/accept", "actix-tls/rustls-0_23"] + +# Full unicode support +unicode = ["dep:regex", "actix-router/unicode"] # Internal (PRIVATE!) features used to aid testing and checking feature status. # Don't rely on these whatsoever. They may disappear at anytime. __compress = [] +# Internal (PRIVATE!) features used to aid checking feature status. +# Don't rely on these whatsoever. They may disappear at anytime. +__tls = [] + # io-uring feature only available for Linux OSes. experimental-io-uring = ["actix-server/io-uring"] +# Feature group which, when disabled, helps migrate code to v5.0. +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"] + [dependencies] actix-codec = "0.5" actix-macros = { version = "0.2.3", optional = true } actix-rt = { version = "2.6", default-features = false } -actix-server = "2" +actix-server = "2.6" actix-service = "2" +actix-tls = { version = "3.4", default-features = false, optional = true } actix-utils = "3" -actix-tls = { version = "3", default-features = false, optional = true } -actix-http = { version = "3.3", features = ["ws"] } -actix-router = "0.5" -actix-web-codegen = { version = "4.2", optional = true } +actix-http = "3.11.2" +actix-router = { version = "0.5.3", default-features = false, features = ["http"] } +actix-web-codegen = { version = "4.3", optional = true, default-features = false } -ahash = "0.8" bytes = "1" bytestring = "1" cfg-if = "1" cookie = { version = "0.16", features = ["percent-encode"], optional = true } -derive_more = "0.99.8" +derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] } encoding_rs = "0.8" +foldhash = "0.1" futures-core = { version = "0.3.17", default-features = false } futures-util = { version = "0.3.17", default-features = false } +impl-more = "0.1.4" itoa = "1" language-tags = "0.3" log = "0.4" mime = "0.3" -once_cell = "1.5" +once_cell = "1.21" pin-project-lite = "0.2.7" -regex = "1.5.5" +regex = { version = "1.5.5", optional = true } +regex-lite = "0.1" 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"] } -url = "2.1" +tracing = "0.1.30" +url = "2.5.4" [dev-dependencies] actix-files = "0.6" -actix-test = { version = "0.1", features = ["openssl", "rustls"] } +actix-test = { version = "0.1", features = ["openssl", "rustls-0_23"] } awc = { version = "3", features = ["openssl"] } -brotli = "3.3.3" -const-str = "0.5" +brotli = "8" +const-str = "0.5" # TODO(MSRV 1.77): update to 0.6 +core_affinity = "0.8" criterion = { version = "0.5", features = ["html_reports"] } -env_logger = "0.10" +env_logger = "0.11" flate2 = "1.0.13" futures-util = { version = "0.3.17", default-features = false, features = ["std"] } -rand = "0.8" -rcgen = "0.11" -rustls-pemfile = "1" -serde = { version = "1.0", features = ["derive"] } +rand = "0.9" +rcgen = "0.13" +rustls-pki-types = "1.13.1" +serde = { version = "1", features = ["derive"] } static_assertions = "1" tls-openssl = { package = "openssl", version = "0.10.55" } -tls-rustls = { package = "rustls", version = "0.20" } -tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros"] } -zstd = "0.12" +tls-rustls = { package = "rustls", version = "0.23" } +tokio = { version = "1.38.2", features = ["rt-multi-thread", "macros"] } +tokio-util = "0.7" +zstd = "0.13" + +[lints] +workspace = true [[test]] name = "test_server" diff --git a/actix-web/MIGRATION-3.0.md b/actix-web/MIGRATION-3.0.md index 89255e434..5596c4929 100644 --- a/actix-web/MIGRATION-3.0.md +++ b/actix-web/MIGRATION-3.0.md @@ -3,7 +3,6 @@ - The return type for `ServiceRequest::app_data::()` was changed from returning a `Data` to simply a `T`. To access a `Data` use `ServiceRequest::app_data::>()`. - 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. diff --git a/actix-web/MIGRATION-4.0.md b/actix-web/MIGRATION-4.0.md index 0f0bd3a53..1574ce724 100644 --- a/actix-web/MIGRATION-4.0.md +++ b/actix-web/MIGRATION-4.0.md @@ -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 @@ -372,13 +372,13 @@ You may need to review the [guidance on shared mutable state](https://docs.rs/ac HttpServer::new(|| { - App::new() - .data(MyState::default()) -- .service(hander) +- .service(handler) + let my_state: Data = Data::new(MyState::default()); + + App::new() + .app_data(my_state) -+ .service(hander) ++ .service(handler) }) ``` diff --git a/actix-web/README.md b/actix-web/README.md index cc2d9abe0..2704399d4 100644 --- a/actix-web/README.md +++ b/actix-web/README.md @@ -5,7 +5,20 @@

-[![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.3.1)](https://docs.rs/actix-web/4.3.1) ![MSRV](https://img.shields.io/badge/rustc-1.65+-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.3.1/status.svg)](https://deps.rs/crate/actix-web/4.3.1)
[![CI](https://github.com/actix/actix-web/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-web/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/actix/actix-web/branch/master/graph/badge.svg)](https://codecov.io/gh/actix/actix-web) ![downloads](https://img.shields.io/crates/d/actix-web.svg) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) + + +[![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.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) +
+[![CI](https://github.com/actix/actix-web/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-web/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/actix/actix-web/graph/badge.svg?token=dSwOnp9QCv)](https://codecov.io/gh/actix/actix-web) +![downloads](https://img.shields.io/crates/d/actix-web.svg) +[![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) + +

@@ -24,14 +37,14 @@ - 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.65+ +- Runs on stable Rust 1.72+ ## 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 @@ -65,23 +78,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 @@ -96,4 +109,4 @@ This project is licensed under either of the following licenses, at your option: ## Code of Conduct -Contribution to the actix-web repo is organized under the terms of the Contributor Covenant. The Actix team promises to intervene to uphold that code of conduct. +Contribution to the `actix/actix-web` repo is organized under the terms of the Contributor Covenant. The Actix team promises to intervene to uphold that code of conduct. diff --git a/actix-web/benches/responder.rs b/actix-web/benches/responder.rs index c675eadff..489515e40 100644 --- a/actix-web/benches/responder.rs +++ b/actix-web/benches/responder.rs @@ -2,11 +2,9 @@ use std::{future::Future, time::Instant}; use actix_http::body::BoxBody; use actix_utils::future::{ready, Ready}; -use actix_web::{ - error, http::StatusCode, test::TestRequest, Error, HttpRequest, HttpResponse, Responder, -}; +use actix_web::{http::StatusCode, test::TestRequest, Error, HttpRequest, HttpResponse, Responder}; use criterion::{criterion_group, criterion_main, Criterion}; -use futures_util::future::{join_all, Either}; +use futures_util::future::join_all; // responder simulate the old responder trait. trait FutureResponder { @@ -16,9 +14,6 @@ trait FutureResponder { fn future_respond_to(self, req: &HttpRequest) -> Self::Future; } -// a simple option responder type. -struct OptionResponder(Option); - // a simple wrapper type around string struct StringResponder(String); @@ -34,22 +29,6 @@ impl FutureResponder for StringResponder { } } -impl FutureResponder for OptionResponder -where - T: FutureResponder, - T::Future: Future>, -{ - type Error = Error; - type Future = Either>>; - - fn future_respond_to(self, req: &HttpRequest) -> Self::Future { - match self.0 { - Some(t) => Either::Left(t.future_respond_to(req)), - None => Either::Right(ready(Err(error::ErrorInternalServerError("err")))), - } - } -} - impl Responder for StringResponder { type Body = BoxBody; @@ -60,17 +39,6 @@ impl Responder for StringResponder { } } -impl Responder for OptionResponder { - type Body = BoxBody; - - fn respond_to(self, req: &HttpRequest) -> HttpResponse { - match self.0 { - Some(t) => t.respond_to(req).map_into_boxed_body(), - None => HttpResponse::from_error(error::ErrorInternalServerError("err")), - } - } -} - fn future_responder(c: &mut Criterion) { let rt = actix_rt::System::new(); let req = TestRequest::default().to_http_request(); diff --git a/actix-web/benches/server.rs b/actix-web/benches/server.rs index 2c9f71dc5..0d45c9403 100644 --- a/actix-web/benches/server.rs +++ b/actix-web/benches/server.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use actix_web::{web, App, HttpResponse}; use awc::Client; use criterion::{criterion_group, criterion_main, Criterion}; diff --git a/actix-web/examples/basic.rs b/actix-web/examples/basic.rs index 60715f477..b8bc09821 100644 --- a/actix-web/examples/basic.rs +++ b/actix-web/examples/basic.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use actix_web::{get, middleware, web, App, HttpRequest, HttpResponse, HttpServer}; #[get("/resource1/{name}/index.html")] @@ -22,6 +20,8 @@ async fn no_params() -> &'static str { async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + log::info!("starting HTTP server at http://localhost:8080"); + HttpServer::new(|| { App::new() .wrap(middleware::DefaultHeaders::new().add(("X-Version", "0.2"))) diff --git a/actix-web/examples/from_fn.rs b/actix-web/examples/from_fn.rs new file mode 100644 index 000000000..a6006d23c --- /dev/null +++ b/actix-web/examples/from_fn.rs @@ -0,0 +1,128 @@ +//! Shows a few of ways to use the `from_fn` middleware. + +use std::{collections::HashMap, io, rc::Rc, time::Duration}; + +use actix_web::{ + body::MessageBody, + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + http::header::{self, HeaderValue, Range}, + middleware::{from_fn, Logger, Next}, + web::{self, Header, Query}, + App, Error, HttpResponse, HttpServer, +}; +use tracing::info; + +async fn noop(req: ServiceRequest, next: Next) -> Result, Error> { + next.call(req).await +} + +async fn print_range_header( + range_header: Option>, + req: ServiceRequest, + next: Next, +) -> Result, Error> { + if let Some(Header(range)) = range_header { + println!("Range: {range}"); + } else { + println!("No Range header"); + } + + next.call(req).await +} + +async fn mutate_body_type( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + let res = next.call(req).await?; + Ok(res.map_into_left_body::<()>()) +} + +async fn mutate_body_type_with_extractors( + string_body: String, + query: Query>, + req: ServiceRequest, + next: Next, +) -> Result, Error> { + println!("body is: {string_body}"); + println!("query string: {query:?}"); + + let res = next.call(req).await?; + + Ok(res.map_body(move |_, _| string_body)) +} + +async fn timeout_10secs( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + match tokio::time::timeout(Duration::from_secs(10), next.call(req)).await { + Ok(res) => res, + Err(_err) => Err(actix_web::error::ErrorRequestTimeout("")), + } +} + +struct MyMw(bool); + +impl MyMw { + async fn mw_cb( + &self, + req: ServiceRequest, + next: Next, + ) -> Result, Error> { + let mut res = match self.0 { + true => req.into_response("short-circuited").map_into_right_body(), + false => next.call(req).await?.map_into_left_body(), + }; + + res.headers_mut() + .insert(header::WARNING, HeaderValue::from_static("42")); + + Ok(res) + } + + pub fn into_middleware( + self, + ) -> impl Transform< + S, + ServiceRequest, + Response = ServiceResponse, + Error = Error, + InitError = (), + > + where + S: Service, Error = Error> + 'static, + B: MessageBody + 'static, + { + let this = Rc::new(self); + from_fn(move |req, next| { + let this = Rc::clone(&this); + async move { Self::mw_cb(&this, req, next).await } + }) + } +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let bind = ("127.0.0.1", 8080); + info!("staring server at http://{}:{}", &bind.0, &bind.1); + + HttpServer::new(|| { + App::new() + .wrap(from_fn(noop)) + .wrap(from_fn(print_range_header)) + .wrap(from_fn(mutate_body_type)) + .wrap(from_fn(mutate_body_type_with_extractors)) + .wrap(from_fn(timeout_10secs)) + // switch bool to true to observe early response + .wrap(MyMw(false).into_middleware()) + .wrap(Logger::default()) + .default_service(web::to(HttpResponse::Ok)) + }) + .workers(1) + .bind(bind)? + .run() + .await +} diff --git a/actix-web/examples/macroless.rs b/actix-web/examples/macroless.rs index d3589da21..78ffd45c1 100644 --- a/actix-web/examples/macroless.rs +++ b/actix-web/examples/macroless.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use actix_web::{middleware, rt, web, App, HttpRequest, HttpServer}; async fn index(req: HttpRequest) -> &'static str { diff --git a/actix-web/examples/middleware_from_fn.rs b/actix-web/examples/middleware_from_fn.rs new file mode 100644 index 000000000..da92ef05b --- /dev/null +++ b/actix-web/examples/middleware_from_fn.rs @@ -0,0 +1,127 @@ +//! Shows a couple of ways to use the `from_fn` middleware. + +use std::{collections::HashMap, io, rc::Rc, time::Duration}; + +use actix_web::{ + body::MessageBody, + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + http::header::{self, HeaderValue, Range}, + middleware::{from_fn, Logger, Next}, + web::{self, Header, Query}, + App, Error, HttpResponse, HttpServer, +}; + +async fn noop(req: ServiceRequest, next: Next) -> Result, Error> { + next.call(req).await +} + +async fn print_range_header( + range_header: Option>, + req: ServiceRequest, + next: Next, +) -> Result, Error> { + if let Some(Header(range)) = range_header { + println!("Range: {range}"); + } else { + println!("No Range header"); + } + + next.call(req).await +} + +async fn mutate_body_type( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + let res = next.call(req).await?; + Ok(res.map_into_left_body::<()>()) +} + +async fn mutate_body_type_with_extractors( + string_body: String, + query: Query>, + req: ServiceRequest, + next: Next, +) -> Result, Error> { + println!("body is: {string_body}"); + println!("query string: {query:?}"); + + let res = next.call(req).await?; + + Ok(res.map_body(move |_, _| string_body)) +} + +async fn timeout_10secs( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + match tokio::time::timeout(Duration::from_secs(10), next.call(req)).await { + Ok(res) => res, + Err(_err) => Err(actix_web::error::ErrorRequestTimeout("")), + } +} + +struct MyMw(bool); + +impl MyMw { + async fn mw_cb( + &self, + req: ServiceRequest, + next: Next, + ) -> Result, Error> { + let mut res = match self.0 { + true => req.into_response("short-circuited").map_into_right_body(), + false => next.call(req).await?.map_into_left_body(), + }; + + res.headers_mut() + .insert(header::WARNING, HeaderValue::from_static("42")); + + Ok(res) + } + + pub fn into_middleware( + self, + ) -> impl Transform< + S, + ServiceRequest, + Response = ServiceResponse, + Error = Error, + InitError = (), + > + where + S: Service, Error = Error> + 'static, + B: MessageBody + 'static, + { + let this = Rc::new(self); + from_fn(move |req, next| { + let this = Rc::clone(&this); + async move { Self::mw_cb(&this, req, next).await } + }) + } +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let bind = ("127.0.0.1", 8080); + log::info!("staring server at http://{}:{}", &bind.0, &bind.1); + + HttpServer::new(|| { + App::new() + .wrap(from_fn(noop)) + .wrap(from_fn(print_range_header)) + .wrap(from_fn(mutate_body_type)) + .wrap(from_fn(mutate_body_type_with_extractors)) + .wrap(from_fn(timeout_10secs)) + // switch bool to true to observe early response + .wrap(MyMw(false).into_middleware()) + .wrap(Logger::default()) + .default_service(web::to(HttpResponse::Ok)) + }) + .workers(1) + .bind(bind)? + .run() + .await +} diff --git a/actix-web/examples/on-connect.rs b/actix-web/examples/on-connect.rs index dc9273b46..ae337757c 100644 --- a/actix-web/examples/on-connect.rs +++ b/actix-web/examples/on-connect.rs @@ -2,7 +2,7 @@ //! properties and pass them to a handler through request-local data. //! //! For an example of extracting a client TLS certificate, see: -//! +//! use std::{any::Any, io, net::SocketAddr}; diff --git a/actix-web/examples/uds.rs b/actix-web/examples/uds.rs index 15e28ba1d..e854bb3b1 100644 --- a/actix-web/examples/uds.rs +++ b/actix-web/examples/uds.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use actix_web::{get, web, HttpRequest}; #[cfg(unix)] use actix_web::{middleware, App, Error, HttpResponse, HttpServer}; diff --git a/actix-web/examples/worker-cpu-pin.rs b/actix-web/examples/worker-cpu-pin.rs new file mode 100644 index 000000000..58e060821 --- /dev/null +++ b/actix-web/examples/worker-cpu-pin.rs @@ -0,0 +1,41 @@ +use std::{ + io, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + thread, +}; + +use actix_web::{middleware, web, App, HttpServer}; + +async fn hello() -> &'static str { + "Hello world!" +} + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let core_ids = core_affinity::get_core_ids().unwrap(); + let n_core_ids = core_ids.len(); + let next_core_id = Arc::new(AtomicUsize::new(0)); + + HttpServer::new(move || { + let pin = Arc::clone(&next_core_id).fetch_add(1, Ordering::AcqRel); + log::info!( + "setting CPU affinity for worker {}: pinning to core {}", + thread::current().name().unwrap(), + pin, + ); + core_affinity::set_for_current(core_ids[pin]); + + App::new() + .wrap(middleware::Logger::default()) + .service(web::resource("/").get(hello)) + }) + .bind(("127.0.0.1", 8080))? + .workers(n_core_ids) + .run() + .await +} diff --git a/actix-web/src/app.rs b/actix-web/src/app.rs index a4cd8d819..f12d39979 100644 --- a/actix-web/src/app.rs +++ b/actix-web/src/app.rs @@ -39,7 +39,7 @@ impl App { let factory_ref = Rc::new(RefCell::new(None)); App { - endpoint: AppEntry::new(factory_ref.clone()), + endpoint: AppEntry::new(Rc::clone(&factory_ref)), data_factories: Vec::new(), services: Vec::new(), default: None, @@ -112,8 +112,8 @@ where /// }) /// ``` #[doc(alias = "manage")] - pub fn app_data(mut self, ext: U) -> Self { - self.extensions.insert(ext); + pub fn app_data(mut self, data: U) -> Self { + self.extensions.insert(data); self } @@ -129,6 +129,8 @@ where /// /// Data items are constructed during application initialization, before the server starts /// accepting requests. + /// + /// The returned data value `D` is wrapped as [`Data`]. pub fn data_factory(mut self, data: F) -> Self where F: Fn() -> Out + 'static, @@ -141,8 +143,8 @@ where let fut = data(); async move { match fut.await { - Err(e) => { - log::error!("Can not construct data instance: {:?}", e); + Err(err) => { + log::error!("Can not construct data instance: {err:?}"); Err(()) } Ok(data) => { @@ -232,7 +234,6 @@ where /// /// * *Resource* is an entry in resource table which corresponds to requested URL. /// * *Scope* is a set of resources with common root path. - /// * "StaticFiles" is a service for static files support pub fn service(mut self, factory: F) -> Self where F: HttpServiceFactory + 'static, @@ -268,9 +269,9 @@ where + 'static, U::InitError: fmt::Debug, { - let svc = svc - .into_factory() - .map_init_err(|e| log::error!("Can not construct default service: {:?}", e)); + let svc = svc.into_factory().map_init_err(|err| { + log::error!("Can not construct default service: {err:?}"); + }); self.default = Some(Rc::new(boxed::factory(svc))); @@ -469,7 +470,6 @@ mod tests { Method, StatusCode, }, middleware::DefaultHeaders, - service::ServiceRequest, test::{call_service, init_service, read_body, try_init_service, TestRequest}, web, HttpRequest, HttpResponse, }; diff --git a/actix-web/src/app_service.rs b/actix-web/src/app_service.rs index 513e7a8a1..7aa16b790 100644 --- a/actix-web/src/app_service.rs +++ b/actix-web/src/app_service.rs @@ -71,7 +71,7 @@ where }); // create App config to pass to child services - let mut config = AppService::new(config, default.clone()); + let mut config = AppService::new(config, Rc::clone(&default)); // register services mem::take(&mut *self.services.borrow_mut()) @@ -112,11 +112,7 @@ where let endpoint_fut = self.endpoint.new_service(()); // take extensions or create new one as app data container. - let mut app_data = self - .extensions - .borrow_mut() - .take() - .unwrap_or_else(Extensions::new); + let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default(); Box::pin(async move { // async data factories @@ -267,8 +263,9 @@ impl ServiceFactory for AppRoutingFactory { let guards = guards.borrow_mut().take().unwrap_or_default(); let factory_fut = factory.new_service(()); async move { - let service = factory_fut.await?; - Ok((path, guards, service)) + factory_fut + .await + .map(move |service| (path, guards, service)) } })); diff --git a/actix-web/src/config.rs b/actix-web/src/config.rs index fba0c2717..0e856f574 100644 --- a/actix-web/src/config.rs +++ b/actix-web/src/config.rs @@ -68,7 +68,7 @@ impl AppService { pub(crate) fn clone_config(&self) -> Self { AppService { config: self.config.clone(), - default: self.default.clone(), + default: Rc::clone(&self.default), services: Vec::new(), root: false, } @@ -81,7 +81,7 @@ impl AppService { /// Returns default handler factory. pub fn default_service(&self) -> Rc { - self.default.clone() + Rc::clone(&self.default) } /// Register HTTP service. @@ -148,7 +148,7 @@ impl AppConfig { #[cfg(test)] pub(crate) fn set_host(&mut self, host: &str) { - self.host = host.to_owned(); + host.clone_into(&mut self.host); } } diff --git a/actix-web/src/data.rs b/actix-web/src/data.rs index 423dd598c..088df55d2 100644 --- a/actix-web/src/data.rs +++ b/actix-web/src/data.rs @@ -3,7 +3,7 @@ use std::{any::type_name, ops::Deref, sync::Arc}; use actix_http::Extensions; use actix_utils::future::{err, ok, Ready}; use futures_core::future::LocalBoxFuture; -use serde::Serialize; +use serde::{de, Serialize}; use crate::{dev::Payload, error, Error, FromRequest, HttpRequest}; @@ -32,8 +32,8 @@ pub(crate) type FnDataFactory = /// Since the Actix Web router layers application data, the returned object will reference the /// "closest" instance of the type. For example, if an `App` stores a `u32`, a nested `Scope` /// also stores a `u32`, and the delegated request handler falls within that `Scope`, then -/// extracting a `web::>` for that handler will return the `Scope`'s instance. -/// However, using the same router set up and a request that does not get captured by the `Scope`, +/// extracting a `web::Data` for that handler will return the `Scope`'s instance. However, +/// using the same router set up and a request that does not get captured by the `Scope`, /// `web::>` would return the `App`'s instance. /// /// If route data is not set for a handler, using `Data` extractor would cause a `500 Internal @@ -69,7 +69,7 @@ pub(crate) type FnDataFactory = /// HttpResponse::Ok() /// } /// -/// /// Alteratively, use the `HttpRequest::app_data` method to access data in a handler. +/// /// Alternatively, use the `HttpRequest::app_data` method to access data in a handler. /// async fn index_alt(req: HttpRequest) -> impl Responder { /// let data = req.app_data::>>().unwrap(); /// let mut my_data = data.lock().unwrap(); @@ -128,6 +128,12 @@ impl From> for Data { } } +impl Default for Data { + fn default() -> Self { + Data::new(T::default()) + } +} + impl Serialize for Data where T: Serialize, @@ -139,6 +145,17 @@ where self.0.serialize(serializer) } } +impl<'de, T> de::Deserialize<'de> for Data +where + T: de::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + Ok(Data::new(T::deserialize(deserializer)?)) + } +} impl FromRequest for Data { type Error = Error; @@ -167,7 +184,7 @@ impl FromRequest for Data { impl DataFactory for Data { fn create(&self, extensions: &mut Extensions) -> bool { - extensions.insert(Data(self.0.clone())); + extensions.insert(Data(Arc::clone(&self.0))); true } } diff --git a/actix-web/src/error/error.rs b/actix-web/src/error/error.rs index 3a5a128f6..670a58a00 100644 --- a/actix-web/src/error/error.rs +++ b/actix-web/src/error/error.rs @@ -60,6 +60,12 @@ impl From for Error { } } +impl From> for Error { + fn from(value: Box) -> Self { + Error { cause: value } + } +} + impl From for Response { fn from(err: Error) -> Response { err.error_response().into() diff --git a/actix-web/src/error/mod.rs b/actix-web/src/error/mod.rs index 91a6bcc3f..c25aa89da 100644 --- a/actix-web/src/error/mod.rs +++ b/actix-web/src/error/mod.rs @@ -21,6 +21,7 @@ mod response_error; pub(crate) use self::macros::{downcast_dyn, downcast_get_type_id}; pub use self::{error::Error, internal::*, response_error::ResponseError}; +pub use crate::types::EitherExtractError; /// A convenience [`Result`](std::result::Result) for Actix Web operations. /// @@ -29,7 +30,7 @@ pub type Result = std::result::Result; /// An error representing a problem running a blocking task on a thread pool. #[derive(Debug, Display, Error)] -#[display(fmt = "Blocking thread pool is shut down unexpectedly")] +#[display("Blocking thread pool is shut down unexpectedly")] #[non_exhaustive] pub struct BlockingError; @@ -40,15 +41,15 @@ impl ResponseError for crate::error::BlockingError {} #[non_exhaustive] pub enum UrlGenerationError { /// Resource not found. - #[display(fmt = "Resource not found")] + #[display("Resource not found")] ResourceNotFound, /// Not all URL parameters covered. - #[display(fmt = "Not all URL parameters covered")] + #[display("Not all URL parameters covered")] NotEnoughElements, /// URL parse error. - #[display(fmt = "{}", _0)] + #[display("{}", _0)] ParseError(UrlParseError), } @@ -59,39 +60,39 @@ impl ResponseError for UrlGenerationError {} #[non_exhaustive] pub enum UrlencodedError { /// Can not decode chunked transfer encoding. - #[display(fmt = "Can not decode chunked transfer encoding.")] + #[display("Can not decode chunked transfer encoding.")] Chunked, /// Payload size is larger than allowed. (default limit: 256kB). #[display( - fmt = "URL encoded payload is larger ({} bytes) than allowed (limit: {} bytes).", + "URL encoded payload is larger ({} bytes) than allowed (limit: {} bytes).", size, limit )] Overflow { size: usize, limit: usize }, /// Payload size is now known. - #[display(fmt = "Payload size is now known.")] + #[display("Payload size is now known.")] UnknownLength, /// Content type error. - #[display(fmt = "Content type error.")] + #[display("Content type error.")] ContentType, /// Parse error. - #[display(fmt = "Parse error: {}.", _0)] + #[display("Parse error: {}.", _0)] Parse(FormDeError), /// Encoding error. - #[display(fmt = "Encoding error.")] + #[display("Encoding error.")] Encoding, /// Serialize error. - #[display(fmt = "Serialize error: {}.", _0)] + #[display("Serialize error: {}.", _0)] Serialize(FormError), /// Payload error. - #[display(fmt = "Error that occur during reading payload: {}.", _0)] + #[display("Error that occur during reading payload: {}.", _0)] Payload(PayloadError), } @@ -100,6 +101,7 @@ impl ResponseError for UrlencodedError { match self { Self::Overflow { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::UnknownLength => StatusCode::LENGTH_REQUIRED, + Self::ContentType => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::Payload(err) => err.status_code(), _ => StatusCode::BAD_REQUEST, } @@ -112,30 +114,30 @@ impl ResponseError for UrlencodedError { pub enum JsonPayloadError { /// Payload size is bigger than allowed & content length header set. (default: 2MB) #[display( - fmt = "JSON payload ({} bytes) is larger than allowed (limit: {} bytes).", + "JSON payload ({} bytes) is larger than allowed (limit: {} bytes).", length, limit )] OverflowKnownLength { length: usize, limit: usize }, /// Payload size is bigger than allowed but no content length header set. (default: 2MB) - #[display(fmt = "JSON payload has exceeded limit ({} bytes).", limit)] + #[display("JSON payload has exceeded limit ({} bytes).", limit)] Overflow { limit: usize }, /// Content type error - #[display(fmt = "Content type error")] + #[display("Content type error")] ContentType, /// Deserialize error - #[display(fmt = "Json deserialize error: {}", _0)] + #[display("Json deserialize error: {}", _0)] Deserialize(JsonError), /// Serialize error - #[display(fmt = "Json serialize error: {}", _0)] + #[display("Json serialize error: {}", _0)] Serialize(JsonError), /// Payload error - #[display(fmt = "Error that occur during reading payload: {}", _0)] + #[display("Error that occur during reading payload: {}", _0)] Payload(PayloadError), } @@ -165,7 +167,7 @@ impl ResponseError for JsonPayloadError { #[non_exhaustive] pub enum PathError { /// Deserialize error - #[display(fmt = "Path deserialize error: {}", _0)] + #[display("Path deserialize error: {}", _0)] Deserialize(serde::de::value::Error), } @@ -181,7 +183,7 @@ impl ResponseError for PathError { #[non_exhaustive] pub enum QueryPayloadError { /// Query deserialize error. - #[display(fmt = "Query deserialize error: {}", _0)] + #[display("Query deserialize error: {}", _0)] Deserialize(serde::de::value::Error), } @@ -195,20 +197,20 @@ impl ResponseError for QueryPayloadError { #[derive(Debug, Display, Error, From)] #[non_exhaustive] pub enum ReadlinesError { - #[display(fmt = "Encoding error")] + #[display("Encoding error")] /// Payload size is bigger than allowed. (default: 256kB) EncodingError, /// Payload error. - #[display(fmt = "Error that occur during reading payload: {}", _0)] + #[display("Error that occur during reading payload: {}", _0)] Payload(PayloadError), /// Line limit exceeded. - #[display(fmt = "Line limit exceeded")] + #[display("Line limit exceeded")] LimitOverflow, /// ContentType error. - #[display(fmt = "Content-type error")] + #[display("Content-type error")] ContentTypeError(ContentTypeError), } @@ -232,7 +234,7 @@ mod tests { let resp = UrlencodedError::UnknownLength.error_response(); assert_eq!(resp.status(), StatusCode::LENGTH_REQUIRED); let resp = UrlencodedError::ContentType.error_response(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); } #[test] diff --git a/actix-web/src/error/response_error.rs b/actix-web/src/error/response_error.rs index f5d8cf467..ab3ec59b4 100644 --- a/actix-web/src/error/response_error.rs +++ b/actix-web/src/error/response_error.rs @@ -7,7 +7,6 @@ use std::{ io::{self, Write as _}, }; -use actix_http::Response; use bytes::BytesMut; use crate::{ @@ -126,20 +125,24 @@ impl ResponseError for actix_http::error::PayloadError { } } -impl ResponseError for actix_http::ws::ProtocolError {} - impl ResponseError for actix_http::error::ContentTypeError { fn status_code(&self) -> StatusCode { StatusCode::BAD_REQUEST } } +#[cfg(feature = "ws")] impl ResponseError for actix_http::ws::HandshakeError { fn error_response(&self) -> HttpResponse { - Response::from(self).map_into_boxed_body().into() + actix_http::Response::from(self) + .map_into_boxed_body() + .into() } } +#[cfg(feature = "ws")] +impl ResponseError for actix_http::ws::ProtocolError {} + #[cfg(test)] mod tests { use super::*; diff --git a/actix-web/src/extract.rs b/actix-web/src/extract.rs index a2afd3827..249b56114 100644 --- a/actix-web/src/extract.rs +++ b/actix-web/src/extract.rs @@ -175,8 +175,8 @@ where let res = ready!(this.fut.poll(cx)); match res { Ok(t) => Poll::Ready(Ok(Some(t))), - Err(e) => { - log::debug!("Error for Option extractor: {}", e.into()); + Err(err) => { + log::debug!("Error for Option extractor: {}", err.into()); Poll::Ready(Ok(None)) } } @@ -217,8 +217,8 @@ where /// /// extract `Thing` from request /// async fn index(supplied_thing: Result) -> String { /// match supplied_thing { -/// Ok(thing) => format!("Got thing: {:?}", thing), -/// Err(e) => format!("Error extracting thing: {}", e) +/// Ok(thing) => format!("Got thing: {thing:?}"), +/// Err(err) => format!("Error extracting thing: {err}"), /// } /// } /// @@ -355,7 +355,7 @@ mod tuple_from_req { Poll::Ready(Ok(output)) => { let _ = this.$T.as_mut().project_replace(ExtractFuture::Done { output }); }, - Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err.into())), Poll::Pending => ready = false, }, ExtractProj::Done { .. } => {}, diff --git a/actix-web/src/guard/acceptable.rs b/actix-web/src/guard/acceptable.rs index a31494a18..8fa7165c8 100644 --- a/actix-web/src/guard/acceptable.rs +++ b/actix-web/src/guard/acceptable.rs @@ -20,7 +20,7 @@ use crate::http::header::Accept; pub struct Acceptable { mime: mime::Mime, - /// Wether to match `*/*` mime type. + /// Whether to match `*/*` mime type. /// /// Defaults to false because it's not very useful otherwise. match_star_star: bool, diff --git a/actix-web/src/guard/host.rs b/actix-web/src/guard/host.rs index f05c81183..835662346 100644 --- a/actix-web/src/guard/host.rs +++ b/actix-web/src/guard/host.rs @@ -1,8 +1,8 @@ -use actix_http::{header, uri::Uri, RequestHead}; +use actix_http::{header, uri::Uri, RequestHead, Version}; use super::{Guard, GuardContext}; -/// Creates a guard that matches requests targetting a specific host. +/// Creates a guard that matches requests targeting a specific host. /// /// # Matching Host /// This guard will: @@ -66,6 +66,7 @@ fn get_host_uri(req: &RequestHead) -> Option { req.headers .get(header::HOST) .and_then(|host_value| host_value.to_str().ok()) + .filter(|_| req.version < Version::HTTP_2) .or_else(|| req.uri.host()) .and_then(|host| host.parse().ok()) } @@ -123,6 +124,38 @@ mod tests { use super::*; use crate::test::TestRequest; + #[test] + fn host_not_from_header_if_http2() { + let req = TestRequest::default() + .uri("www.rust-lang.org") + .insert_header(( + header::HOST, + header::HeaderValue::from_static("www.example.com"), + )) + .to_srv_request(); + + let host = Host("www.example.com"); + assert!(host.check(&req.guard_ctx())); + + let host = Host("www.rust-lang.org"); + assert!(!host.check(&req.guard_ctx())); + + let req = TestRequest::default() + .version(actix_http::Version::HTTP_2) + .uri("www.rust-lang.org") + .insert_header(( + header::HOST, + header::HeaderValue::from_static("www.example.com"), + )) + .to_srv_request(); + + let host = Host("www.example.com"); + assert!(!host.check(&req.guard_ctx())); + + let host = Host("www.rust-lang.org"); + assert!(host.check(&req.guard_ctx())); + } + #[test] fn host_from_header() { let req = TestRequest::default() diff --git a/actix-web/src/guard/mod.rs b/actix-web/src/guard/mod.rs index 35294a3c4..41609953a 100644 --- a/actix-web/src/guard/mod.rs +++ b/actix-web/src/guard/mod.rs @@ -110,6 +110,12 @@ impl<'a> GuardContext<'a> { pub fn header(&self) -> Option { H::parse(self.req).ok() } + + /// Counterpart to [HttpRequest::app_data](crate::HttpRequest::app_data). + #[inline] + pub fn app_data(&self) -> Option<&T> { + self.req.app_data() + } } /// Interface for routing guards. @@ -380,7 +386,7 @@ impl Guard for HeaderGuard { #[cfg(test)] mod tests { - use actix_http::{header, Method}; + use actix_http::Method; use super::*; use crate::test::TestRequest; @@ -512,4 +518,18 @@ mod tests { .to_srv_request(); assert!(guard.check(&req.guard_ctx())); } + + #[test] + fn app_data() { + const TEST_VALUE: u32 = 42; + let guard = fn_guard(|ctx| dbg!(ctx.app_data::()) == Some(&TEST_VALUE)); + + let req = TestRequest::default().app_data(TEST_VALUE).to_srv_request(); + assert!(guard.check(&req.guard_ctx())); + + let req = TestRequest::default() + .app_data(TEST_VALUE * 2) + .to_srv_request(); + assert!(!guard.check(&req.guard_ctx())); + } } diff --git a/actix-web/src/handler.rs b/actix-web/src/handler.rs index 0c5e58e28..200858a93 100644 --- a/actix-web/src/handler.rs +++ b/actix-web/src/handler.rs @@ -10,22 +10,28 @@ use crate::{ /// The interface for request handlers. /// /// # What Is A Request Handler +/// /// In short, a handler is just an async function that receives request-based arguments, in any /// order, and returns something that can be converted to a response. /// /// In particular, a request handler has three requirements: +/// /// 1. It is an async function (or a function/closure that returns an appropriate future); /// 1. The function parameters (up to 12) implement [`FromRequest`]; /// 1. The async function (or future) resolves to a type that can be converted into an -/// [`HttpResponse`] (i.e., it implements the [`Responder`] trait). +/// [`HttpResponse`] (i.e., it implements the [`Responder`] trait). /// /// /// # Compiler Errors +/// /// If you get the error `the trait Handler<_> is not implemented`, then your handler does not -/// fulfill the _first_ of the above requirements. Missing other requirements manifest as errors on -/// implementing [`FromRequest`] and [`Responder`], respectively. +/// fulfill the _first_ of the above requirements. (It could also mean that you're attempting to use +/// a macro-routed handler in a manual routing context like `web::get().to(handler)`, which is not +/// supported). Breaking the other requirements manifests as errors on implementing [`FromRequest`] +/// and [`Responder`], respectively. /// /// # How Do Handlers Receive Variable Numbers Of Arguments +/// /// Rest assured there is no macro magic here; it's just traits. /// /// The first thing to note is that [`FromRequest`] is implemented for tuples (up to 12 in length). @@ -40,6 +46,7 @@ use crate::{ /// destructures the tuple into its component types and calls your handler function with them. /// /// In pseudo-code the process looks something like this: +/// /// ```ignore /// async fn my_handler(body: String, state: web::Data) -> impl Responder { /// ... @@ -63,7 +70,7 @@ use crate::{ /// This is the source code for the 2-parameter implementation of `Handler` to help illustrate the /// bounds of the handler call after argument extraction: /// ```ignore -/// impl Handler<(Arg1, Arg2)> for Func +/// impl Handler<(Arg1, Arg2)> for Func /// where /// Func: Fn(Arg1, Arg2) -> Fut + Clone + 'static, /// Fut: Future, @@ -167,7 +174,7 @@ mod tests { async fn handler_min() {} #[rustfmt::skip] - #[allow(clippy::too_many_arguments, clippy::just_underscores_and_digits)] + #[allow(clippy::too_many_arguments, clippy::just_underscores_and_digits, clippy::let_unit_value)] async fn handler_max( _01: (), _02: (), _03: (), _04: (), _05: (), _06: (), _07: (), _08: (), _09: (), _10: (), _11: (), _12: (), diff --git a/actix-web/src/helpers.rs b/actix-web/src/helpers.rs index 1d2679fce..c7b33a083 100644 --- a/actix-web/src/helpers.rs +++ b/actix-web/src/helpers.rs @@ -10,7 +10,7 @@ use bytes::BufMut; /// perform a remaining length check before writing. pub(crate) struct MutWriter<'a, B>(pub(crate) &'a mut B); -impl<'a, B> io::Write for MutWriter<'a, B> +impl io::Write for MutWriter<'_, B> where B: BufMut, { diff --git a/actix-web/src/http/header/accept_encoding.rs b/actix-web/src/http/header/accept_encoding.rs index cc80e7bb0..19d649926 100644 --- a/actix-web/src/http/header/accept_encoding.rs +++ b/actix-web/src/http/header/accept_encoding.rs @@ -149,7 +149,7 @@ impl AcceptEncoding { /// Extracts the most preferable encoding, accounting for [q-factor weighting]. /// - /// If no q-factors are provided, the first encoding is chosen. Note that items without + /// If no q-factors are provided, we prefer brotli > zstd > gzip. Note that items without /// q-factors are given the maximum preference value. /// /// As per the spec, returns [`Preference::Any`] if acceptable list is empty. Though, if this is @@ -167,6 +167,7 @@ impl AcceptEncoding { let mut max_item = None; let mut max_pref = Quality::ZERO; + let mut max_rank = 0; // uses manual max lookup loop since we want the first occurrence in the case of same // preference but `Iterator::max_by_key` would give us the last occurrence @@ -174,9 +175,13 @@ impl AcceptEncoding { for pref in &self.0 { // only change if strictly greater // equal items, even while unsorted, still have higher preference if they appear first - if pref.quality > max_pref { + + let rank = encoding_rank(pref); + + if (pref.quality, rank) > (max_pref, max_rank) { max_pref = pref.quality; max_item = Some(pref.item.clone()); + max_rank = rank; } } @@ -203,6 +208,8 @@ impl AcceptEncoding { /// Returns a sorted list of encodings from highest to lowest precedence, accounting /// for [q-factor weighting]. /// + /// If no q-factors are provided, we prefer brotli > zstd > gzip. + /// /// [q-factor weighting]: https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 pub fn ranked(&self) -> Vec> { self.ranked_items().map(|q| q.item).collect() @@ -210,21 +217,44 @@ impl AcceptEncoding { fn ranked_items(&self) -> impl Iterator>> { if self.0.is_empty() { - return vec![].into_iter(); + return Vec::new().into_iter(); } let mut types = self.0.clone(); // use stable sort so items with equal q-factor retain listed order types.sort_by(|a, b| { - // sort by q-factor descending - b.quality.cmp(&a.quality) + // sort by q-factor descending then server ranking descending + + b.quality + .cmp(&a.quality) + .then(encoding_rank(b).cmp(&encoding_rank(a))) }); types.into_iter() } } +/// Returns server-defined encoding ranking. +fn encoding_rank(qv: &QualityItem>) -> u8 { + // ensure that q=0 items are never sorted above identity encoding + // invariant: sorting methods calling this fn use first-on-equal approach + if qv.quality == Quality::ZERO { + return 0; + } + + match qv.item { + Preference::Specific(Encoding::Known(ContentEncoding::Brotli)) => 5, + Preference::Specific(Encoding::Known(ContentEncoding::Zstd)) => 4, + Preference::Specific(Encoding::Known(ContentEncoding::Gzip)) => 3, + Preference::Specific(Encoding::Known(ContentEncoding::Deflate)) => 2, + Preference::Any => 0, + Preference::Specific(Encoding::Known(ContentEncoding::Identity)) => 0, + Preference::Specific(Encoding::Known(_)) => 1, + Preference::Specific(Encoding::Unknown(_)) => 1, + } +} + /// Returns true if "identity" is an acceptable encoding. /// /// Internal algorithm relies on item list being in descending order of quality. @@ -377,11 +407,11 @@ mod tests { ); assert_eq!( test.negotiate([Encoding::gzip(), Encoding::brotli(), Encoding::identity()].iter()), - Some(Encoding::gzip()) + Some(Encoding::brotli()) ); assert_eq!( test.negotiate([Encoding::brotli(), Encoding::gzip(), Encoding::identity()].iter()), - Some(Encoding::gzip()) + Some(Encoding::brotli()) ); } @@ -398,6 +428,9 @@ mod tests { let test = accept_encoding!("br", "gzip", "*"); assert_eq!(test.ranked(), vec![enc("br"), enc("gzip"), enc("*")]); + + let test = accept_encoding!("gzip", "br", "*"); + assert_eq!(test.ranked(), vec![enc("br"), enc("gzip"), enc("*")]); } #[test] @@ -420,5 +453,8 @@ mod tests { let test = accept_encoding!("br", "gzip", "*"); assert_eq!(test.preference().unwrap(), enc("br")); + + let test = accept_encoding!("gzip", "br", "*"); + assert_eq!(test.preference().unwrap(), enc("br")); } } diff --git a/actix-web/src/http/header/content_disposition.rs b/actix-web/src/http/header/content_disposition.rs index 0606f5aef..c836b1073 100644 --- a/actix-web/src/http/header/content_disposition.rs +++ b/actix-web/src/http/header/content_disposition.rs @@ -13,7 +13,10 @@ use std::fmt::{self, Write}; use once_cell::sync::Lazy; +#[cfg(feature = "unicode")] use regex::Regex; +#[cfg(not(feature = "unicode"))] +use regex_lite::Regex; use super::{ExtendedValue, Header, TryIntoHeaderValue, Writer}; use crate::http::header; @@ -151,7 +154,7 @@ impl DispositionParam { #[inline] pub fn as_name(&self) -> Option<&str> { match self { - DispositionParam::Name(ref name) => Some(name.as_str()), + DispositionParam::Name(name) => Some(name.as_str()), _ => None, } } @@ -160,7 +163,7 @@ impl DispositionParam { #[inline] pub fn as_filename(&self) -> Option<&str> { match self { - DispositionParam::Filename(ref filename) => Some(filename.as_str()), + DispositionParam::Filename(filename) => Some(filename.as_str()), _ => None, } } @@ -169,7 +172,7 @@ impl DispositionParam { #[inline] pub fn as_filename_ext(&self) -> Option<&ExtendedValue> { match self { - DispositionParam::FilenameExt(ref value) => Some(value), + DispositionParam::FilenameExt(value) => Some(value), _ => None, } } @@ -203,11 +206,11 @@ impl DispositionParam { } } -/// A *Content-Disposition* header. It is compatible to be used either as -/// [a response header for the main body](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_response_header_for_the_main_body) -/// as (re)defined in [RFC 6266](https://datatracker.ietf.org/doc/html/rfc6266), or as -/// [a header for a multipart body](https://mdn.io/Content-Disposition#As_a_header_for_a_multipart_body) -/// as (re)defined in [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7578). +/// `Content-Disposition` header. +/// +/// It is compatible to be used either as [a response header for the main body][use_main_body] +/// as (re)defined in [RFC 6266], or as [a header for a multipart body][use_multipart] as +/// (re)defined in [RFC 7587]. /// /// In a regular HTTP response, the *Content-Disposition* response header is a header indicating if /// the content is expected to be displayed *inline* in the browser, that is, as a Web page or as @@ -264,7 +267,7 @@ impl DispositionParam { /// parameters: vec![DispositionParam::FilenameExt(ExtendedValue { /// charset: Charset::Iso_8859_1, // The character set for the bytes of the filename /// language_tag: None, // The optional language tag (see `language-tag` crate) -/// value: b"\xa9 Copyright 1989.txt".to_vec(), // the actual bytes of the filename +/// value: b"\xA9 Ferris 2011.txt".to_vec(), // the actual bytes of the filename /// })], /// }; /// assert!(cd1.is_attachment()); @@ -302,6 +305,11 @@ impl DispositionParam { /// change to match local file system conventions if applicable, and do not use directory path /// information that may be present. /// See [RFC 2183 §2.3](https://datatracker.ietf.org/doc/html/rfc2183#section-2.3). +/// +/// [use_main_body]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_response_header_for_the_main_body +/// [RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266 +/// [use_multipart]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_header_for_a_multipart_body +/// [RFC 7587]: https://datatracker.ietf.org/doc/html/rfc7578 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContentDisposition { /// The disposition type @@ -490,7 +498,7 @@ impl Header for ContentDisposition { } fn parse(msg: &T) -> Result { - if let Some(h) = msg.headers().get(&Self::name()) { + if let Some(h) = msg.headers().get(Self::name()) { Self::from_raw(h) } else { Err(crate::error::ParseError::Header) diff --git a/actix-web/src/http/header/content_length.rs b/actix-web/src/http/header/content_length.rs index ad16dc409..557c7c9f5 100644 --- a/actix-web/src/http/header/content_length.rs +++ b/actix-web/src/http/header/content_length.rs @@ -126,7 +126,7 @@ mod tests { use std::fmt; use super::*; - use crate::{http::header::Header, test::TestRequest, HttpRequest}; + use crate::{test::TestRequest, HttpRequest}; fn req_from_raw_headers, V: AsRef<[u8]>>( header_lines: I, diff --git a/actix-web/src/http/header/range.rs b/actix-web/src/http/header/range.rs index 2326bb19c..4a5d95d93 100644 --- a/actix-web/src/http/header/range.rs +++ b/actix-web/src/http/header/range.rs @@ -107,16 +107,16 @@ impl ByteRangeSpec { /// satisfiable if they meet the following conditions: /// /// > If a valid byte-range-set includes at least one byte-range-spec with a first-byte-pos that - /// is less than the current length of the representation, or at least one - /// suffix-byte-range-spec with a non-zero suffix-length, then the byte-range-set - /// is satisfiable. Otherwise, the byte-range-set is unsatisfiable. + /// > is less than the current length of the representation, or at least one + /// > suffix-byte-range-spec with a non-zero suffix-length, then the byte-range-set is + /// > satisfiable. Otherwise, the byte-range-set is unsatisfiable. /// /// The function also computes remainder ranges based on the RFC: /// /// > If the last-byte-pos value is absent, or if the value is greater than or equal to the - /// current length of the representation data, the byte range is interpreted as the remainder - /// of the representation (i.e., the server replaces the value of last-byte-pos with a value - /// that is one less than the current length of the selected representation). + /// > current length of the representation data, the byte range is interpreted as the remainder + /// > of the representation (i.e., the server replaces the value of last-byte-pos with a value + /// > that is one less than the current length of the selected representation). /// /// [RFC 7233 §2.1]: https://datatracker.ietf.org/doc/html/rfc7233 pub fn to_satisfiable_range(&self, full_length: u64) -> Option<(u64, u64)> { @@ -270,7 +270,7 @@ impl Header for Range { #[inline] fn parse(msg: &T) -> Result { - header::from_one_raw_str(msg.headers().get(&Self::name())) + header::from_one_raw_str(msg.headers().get(Self::name())) } } diff --git a/actix-web/src/info.rs b/actix-web/src/info.rs index c5d9638f4..59d72b708 100644 --- a/actix-web/src/info.rs +++ b/actix-web/src/info.rs @@ -21,6 +21,20 @@ fn unquote(val: &str) -> &str { val.trim().trim_start_matches('"').trim_end_matches('"') } +/// Remove port and IPv6 square brackets from a peer specification. +fn bare_address(val: &str) -> &str { + if val.starts_with('[') { + val.split("]:") + .next() + .map(|s| s.trim_start_matches('[').trim_end_matches(']')) + // this indicates that the IPv6 address is malformed so shouldn't + // usually happen, but if it does, just return the original input + .unwrap_or(val) + } else { + val.split(':').next().unwrap_or(val) + } +} + /// Extracts and trims first value for given header name. fn first_header_value<'a>(req: &'a RequestHead, name: &'_ HeaderName) -> Option<&'a str> { let hdr = req.headers.get(name)?.to_str().ok()?; @@ -100,7 +114,7 @@ impl ConnectionInfo { // --- https://datatracker.ietf.org/doc/html/rfc7239#section-5.2 match name.trim().to_lowercase().as_str() { - "for" => realip_remote_addr.get_or_insert_with(|| unquote(val)), + "for" => realip_remote_addr.get_or_insert_with(|| bare_address(unquote(val))), "proto" => scheme.get_or_insert_with(|| unquote(val)), "host" => host.get_or_insert_with(|| unquote(val)), "by" => { @@ -144,7 +158,7 @@ impl ConnectionInfo { /// The address is resolved through the following, in order: /// - `Forwarded` header /// - `X-Forwarded-For` header - /// - peer address of opened socket (same as [`remote_addr`](Self::remote_addr)) + /// - peer address of opened socket (same as [`peer_addr`](Self::peer_addr)) /// /// # Security /// Do not use this function for security purposes unless you can be sure that the `Forwarded` @@ -221,7 +235,7 @@ impl FromRequest for ConnectionInfo { /// # let _svc = actix_web::web::to(handler); /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Display)] -#[display(fmt = "{}", _0)] +#[display("{}", _0)] pub struct PeerAddr(pub SocketAddr); impl PeerAddr { @@ -233,7 +247,7 @@ impl PeerAddr { #[derive(Debug, Display, Error)] #[non_exhaustive] -#[display(fmt = "Missing peer address")] +#[display("Missing peer address")] pub struct MissingPeerAddr; impl ResponseError for MissingPeerAddr {} @@ -368,16 +382,25 @@ mod tests { .insert_header((header::FORWARDED, r#"for="192.0.2.60:8080""#)) .to_http_request(); let info = req.connection_info(); - assert_eq!(info.realip_remote_addr(), Some("192.0.2.60:8080")); + assert_eq!(info.realip_remote_addr(), Some("192.0.2.60")); } #[test] fn forwarded_for_ipv6() { + let req = TestRequest::default() + .insert_header((header::FORWARDED, r#"for="[2001:db8:cafe::17]""#)) + .to_http_request(); + let info = req.connection_info(); + assert_eq!(info.realip_remote_addr(), Some("2001:db8:cafe::17")); + } + + #[test] + fn forwarded_for_ipv6_with_port() { let req = TestRequest::default() .insert_header((header::FORWARDED, r#"for="[2001:db8:cafe::17]:4711""#)) .to_http_request(); let info = req.connection_info(); - assert_eq!(info.realip_remote_addr(), Some("[2001:db8:cafe::17]:4711")); + assert_eq!(info.realip_remote_addr(), Some("2001:db8:cafe::17")); } #[test] diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index e982a43b1..ee251320e 100644 --- a/actix-web/src/lib.rs +++ b/actix-web/src/lib.rs @@ -64,21 +64,21 @@ //! - `compress-gzip` - gzip and deflate content encoding compression support (enabled by default) //! - `compress-zstd` - zstd content encoding compression support (enabled by default) //! - `openssl` - HTTPS support via `openssl` crate, supports `HTTP/2` -//! - `rustls` - HTTPS support via `rustls` crate, supports `HTTP/2` +//! - `rustls` - HTTPS support via `rustls` 0.20 crate, supports `HTTP/2` +//! - `rustls-0_21` - HTTPS support via `rustls` 0.21 crate, supports `HTTP/2` +//! - `rustls-0_22` - HTTPS support via `rustls` 0.22 crate, supports `HTTP/2` +//! - `rustls-0_23` - HTTPS support via `rustls` 0.23 crate, supports `HTTP/2` //! - `secure-cookies` - secure cookies support -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] -#![allow(clippy::uninlined_format_args)] #![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 actix_http::{body, HttpMessage}; #[cfg(feature = "cookies")] #[doc(inline)] pub use cookie; - +pub use mime; mod app; mod app_service; mod config; @@ -104,6 +104,7 @@ mod scope; mod server; mod service; pub mod test; +mod thin_data; pub(crate) mod types; pub mod web; @@ -143,5 +144,6 @@ codegen_reexport!(delete); codegen_reexport!(trace); codegen_reexport!(connect); codegen_reexport!(options); +codegen_reexport!(scope); pub(crate) type BoxError = Box; diff --git a/actix-web/src/middleware/authors-guide.md b/actix-web/src/middleware/authors-guide.md index 64bad15c2..e073caf73 100644 --- a/actix-web/src/middleware/authors-guide.md +++ b/actix-web/src/middleware/authors-guide.md @@ -2,16 +2,79 @@ ## What Is A Middleware? +Middleware in Actix Web is a powerful mechanism that allows you to add additional behavior to request/response processing. It enables you to: + +- Pre-process incoming requests (e.g., path normalization, authentication) +- Post-process outgoing responses (e.g., logging, compression) +- Modify application state through ServiceRequest +- Access external services (e.g., sessions, caching) + +Middleware is registered for each App, Scope, or Resource and executed in the reverse order of registration. This means the last registered middleware is the first to process the request. + ## Middleware Traits +Actix Web's middleware system is built on two main traits: + +1. `Transform`: The builder trait that creates the actual Service. It's responsible for: + - Creating new middleware instances + - Assembling the middleware chain + - Handling initialization errors + +2. `Service`: The trait that represents the actual middleware functionality. It: + - Processes requests and responses + - Can modify both request and response + - Can short-circuit request processing + - Must be implemented for the middleware to work + ## Understanding Body Types +When working with middleware, it's important to understand body types: + +- Middleware can work with different body types for requests and responses +- The `MessageBody` trait is used to handle different body types +- You can use `EitherBody` when you need to handle multiple body types +- Be careful with body consumption - once a body is consumed, it cannot be read again + ## Best Practices +1. Keep middleware focused and single-purpose +2. Handle errors appropriately and propagate them correctly +3. Be mindful of performance impact +4. Use appropriate body types and handle them correctly +5. Consider middleware ordering carefully +6. Document your middleware's behavior and requirements +7. Test your middleware thoroughly + ## Error Propagation +Proper error handling is crucial in middleware: + +1. Always propagate errors from the inner service +2. Use appropriate error types +3. Handle initialization errors +4. Consider using custom error types for specific middleware errors +5. Document error conditions and handling + ## When To (Not) Use Middleware +Use middleware when you need to: + +- Add cross-cutting concerns +- Modify requests/responses globally +- Add authentication/authorization +- Add logging or monitoring +- Handle compression or caching + +Avoid middleware when: + +- The functionality is specific to a single route +- The operation is better handled by a service +- The overhead would be too high +- The functionality can be implemented more simply + ## Author's References - `EitherBody` + when is middleware appropriate: https://discord.com/channels/771444961383153695/952016890723729428 +- Actix Web Documentation: https://docs.rs/actix-web +- Service Trait Documentation: https://docs.rs/actix-service +- MessageBody Trait Documentation: https://docs.rs/actix-web/latest/actix_web/body/trait.MessageBody.html diff --git a/actix-web/src/middleware/compat.rs b/actix-web/src/middleware/compat.rs index 7df510a5c..963dfdabb 100644 --- a/actix-web/src/middleware/compat.rs +++ b/actix-web/src/middleware/compat.rs @@ -38,15 +38,6 @@ pub struct Compat { transform: T, } -#[cfg(test)] -impl Compat { - pub(crate) fn noop() -> Self { - Self { - transform: super::Noop, - } - } -} - impl Compat { /// Wrap a middleware to give it broader compatibility. pub fn new(middleware: T) -> Self { @@ -152,7 +143,7 @@ mod tests { use crate::{ dev::ServiceRequest, http::StatusCode, - middleware::{self, Condition, Logger}, + middleware::{self, Condition, Identity, Logger}, test::{self, call_service, init_service, TestRequest}, web, App, HttpResponse, }; @@ -225,7 +216,7 @@ mod tests { async fn compat_noop_is_noop() { let srv = test::ok_service(); - let mw = Compat::noop() + let mw = Compat::new(Identity) .new_transform(srv.into_service()) .await .unwrap(); diff --git a/actix-web/src/middleware/compress.rs b/actix-web/src/middleware/compress.rs index a55b46264..7f0d8a4fb 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -33,7 +33,7 @@ use crate::{ /// considered in this selection process. /// /// # Pre-compressed Payload -/// If you are serving some data is already using a compressed representation (e.g., a gzip +/// If you are serving some data that is already using a compressed representation (e.g., a gzip /// compressed HTML file from disk) you can signal this to `Compress` by setting an appropriate /// `Content-Encoding` header. In addition to preventing double compressing the payload, this header /// is required by the spec when using compressed representations and will inform the client that @@ -191,8 +191,10 @@ where None => true, Some(hdr) => { match hdr.to_str().ok().and_then(|hdr| hdr.parse::().ok()) { - Some(mime) if mime.type_().as_str() == "image" => false, - Some(mime) if mime.type_().as_str() == "video" => false, + Some(mime) if mime.type_() == mime::IMAGE => { + matches!(mime.subtype(), mime::SVG) + } + Some(mime) if mime.type_() == mime::VIDEO => false, _ => true, } } @@ -373,7 +375,7 @@ mod tests { .default_service(web::to(move || { HttpResponse::Ok() .insert_header((header::VARY, "x-test")) - .finish() + .body(TEXT_DATA) })) }) .await; @@ -429,4 +431,47 @@ mod tests { assert_successful_identity_res_with_content_type(&res, "image/jpeg"); assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes()); } + + #[actix_rt::test] + async fn prevents_compression_empty() { + let app = test::init_service({ + App::new() + .wrap(Compress::default()) + .default_service(web::to(move || HttpResponse::Ok().finish())) + }) + .await; + + let req = test::TestRequest::default() + .insert_header((header::ACCEPT_ENCODING, "gzip")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); + assert!(test::read_body(res).await.is_empty()); + } +} + +#[cfg(feature = "compress-brotli")] +#[cfg(test)] +mod tests_brotli { + use super::*; + use crate::{test, web, App}; + + #[actix_rt::test] + async fn prevents_compression_empty() { + let app = test::init_service({ + App::new() + .wrap(Compress::default()) + .default_service(web::to(move || HttpResponse::Ok().finish())) + }) + .await; + + let req = test::TestRequest::default() + .insert_header((header::ACCEPT_ENCODING, "br")) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); + assert!(test::read_body(res).await.is_empty()); + } } diff --git a/actix-web/src/middleware/condition.rs b/actix-web/src/middleware/condition.rs index 5e106c11f..5ee4467d9 100644 --- a/actix-web/src/middleware/condition.rs +++ b/actix-web/src/middleware/condition.rs @@ -135,13 +135,13 @@ mod tests { use super::*; use crate::{ body::BoxBody, - dev::{ServiceRequest, ServiceResponse}, + dev::ServiceRequest, error::Result, http::{ header::{HeaderValue, CONTENT_TYPE}, StatusCode, }, - middleware::{self, ErrorHandlerResponse, ErrorHandlers}, + middleware::{self, ErrorHandlerResponse, ErrorHandlers, Identity}, test::{self, TestRequest}, web::Bytes, HttpResponse, @@ -158,7 +158,7 @@ mod tests { #[test] fn compat_with_builtin_middleware() { - let _ = Condition::new(true, middleware::Compat::noop()); + let _ = Condition::new(true, middleware::Compat::new(Identity)); let _ = Condition::new(true, middleware::Logger::default()); let _ = Condition::new(true, middleware::Compress::default()); let _ = Condition::new(true, middleware::NormalizePath::trim()); diff --git a/actix-web/src/middleware/default_headers.rs b/actix-web/src/middleware/default_headers.rs index b5a5a6998..2669a047e 100644 --- a/actix-web/src/middleware/default_headers.rs +++ b/actix-web/src/middleware/default_headers.rs @@ -141,7 +141,7 @@ where actix_service::forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { - let inner = self.inner.clone(); + let inner = Rc::clone(&self.inner); let fut = self.service.call(req); DefaultHeaderFuture { @@ -190,8 +190,6 @@ mod tests { use super::*; use crate::{ - dev::ServiceRequest, - http::header::CONTENT_TYPE, test::{self, TestRequest}, HttpResponse, }; diff --git a/actix-web/src/middleware/err_handlers.rs b/actix-web/src/middleware/err_handlers.rs index e640bba08..649c1a97a 100644 --- a/actix-web/src/middleware/err_handlers.rs +++ b/actix-web/src/middleware/err_handlers.rs @@ -8,7 +8,7 @@ use std::{ }; use actix_service::{Service, Transform}; -use ahash::AHashMap; +use foldhash::HashMap as FoldHashMap; use futures_core::{future::LocalBoxFuture, ready}; use pin_project_lite::pin_project; @@ -185,7 +185,7 @@ pub struct ErrorHandlers { handlers: Handlers, } -type Handlers = Rc>>>; +type Handlers = Rc>>>; impl Default for ErrorHandlers { fn default() -> Self { @@ -220,16 +220,20 @@ impl ErrorHandlers { /// [`.handler()`][ErrorHandlers::handler]) will fall back on this. /// /// Note that this will overwrite any default handlers previously set by calling - /// [`.default_handler_client()`][ErrorHandlers::default_handler_client] or - /// [`.default_handler_server()`][ErrorHandlers::default_handler_server], but not any set by - /// calling [`.handler()`][ErrorHandlers::handler]. + /// [`default_handler_client()`] or [`.default_handler_server()`], but not any set by calling + /// [`.handler()`]. + /// + /// [`default_handler_client()`]: ErrorHandlers::default_handler_client + /// [`.default_handler_server()`]: ErrorHandlers::default_handler_server + /// [`.handler()`]: ErrorHandlers::handler pub fn default_handler(self, handler: F) -> Self where F: Fn(ServiceResponse) -> Result> + 'static, { let handler = Rc::new(handler); + let handler2 = Rc::clone(&handler); Self { - default_server: Some(handler.clone()), + default_server: Some(handler2), default_client: Some(handler), ..self } @@ -288,7 +292,7 @@ where type Future = LocalBoxFuture<'static, Result>; fn new_transform(&self, service: S) -> Self::Future { - let handlers = self.handlers.clone(); + let handlers = Rc::clone(&self.handlers); let default_client = self.default_client.clone(); let default_server = self.default_server.clone(); Box::pin(async move { @@ -323,7 +327,7 @@ where actix_service::forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { - let handlers = self.handlers.clone(); + let handlers = Rc::clone(&self.handlers); let default_client = self.default_client.clone(); let default_server = self.default_server.clone(); let fut = self.service.call(req); @@ -407,10 +411,7 @@ mod tests { use super::*; use crate::{ body, - http::{ - header::{HeaderValue, CONTENT_TYPE}, - StatusCode, - }, + http::header::{HeaderValue, CONTENT_TYPE}, test::{self, TestRequest}, }; diff --git a/actix-web/src/middleware/from_fn.rs b/actix-web/src/middleware/from_fn.rs new file mode 100644 index 000000000..608833319 --- /dev/null +++ b/actix-web/src/middleware/from_fn.rs @@ -0,0 +1,349 @@ +use std::{future::Future, marker::PhantomData, rc::Rc}; + +use actix_service::boxed::{self, BoxFuture, RcService}; +use actix_utils::future::{ready, Ready}; +use futures_core::future::LocalBoxFuture; + +use crate::{ + body::MessageBody, + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, FromRequest, +}; + +/// Wraps an async function to be used as a middleware. +/// +/// # Examples +/// +/// The wrapped function should have the following form: +/// +/// ``` +/// # use actix_web::{ +/// # App, Error, +/// # body::MessageBody, +/// # dev::{ServiceRequest, ServiceResponse, Service as _}, +/// # }; +/// use actix_web::middleware::{self, Next}; +/// +/// async fn my_mw( +/// req: ServiceRequest, +/// next: Next, +/// ) -> Result, Error> { +/// // pre-processing +/// next.call(req).await +/// // post-processing +/// } +/// # App::new().wrap(middleware::from_fn(my_mw)); +/// ``` +/// +/// Then use in an app builder like this: +/// +/// ``` +/// use actix_web::{ +/// App, Error, +/// dev::{ServiceRequest, ServiceResponse, Service as _}, +/// }; +/// use actix_web::middleware::from_fn; +/// # use actix_web::middleware::Next; +/// # async fn my_mw(req: ServiceRequest, next: Next) -> Result, Error> { +/// # next.call(req).await +/// # } +/// +/// App::new() +/// .wrap(from_fn(my_mw)) +/// # ; +/// ``` +/// +/// It is also possible to write a middleware that automatically uses extractors, similar to request +/// handlers, by declaring them as the first parameters. As usual, **take care with extractors that +/// consume the body stream**, since handlers will no longer be able to read it again without +/// putting the body "back" into the request object within your middleware. +/// +/// ``` +/// # use std::collections::HashMap; +/// # use actix_web::{ +/// # App, Error, +/// # body::MessageBody, +/// # dev::{ServiceRequest, ServiceResponse}, +/// # http::header::{Accept, Date}, +/// # web::{Header, Query}, +/// # }; +/// use actix_web::middleware::Next; +/// +/// async fn my_extracting_mw( +/// accept: Header, +/// query: Query>, +/// req: ServiceRequest, +/// next: Next, +/// ) -> Result, Error> { +/// // pre-processing +/// next.call(req).await +/// // post-processing +/// } +/// # App::new().wrap(actix_web::middleware::from_fn(my_extracting_mw)); +pub fn from_fn(mw_fn: F) -> MiddlewareFn { + MiddlewareFn { + mw_fn: Rc::new(mw_fn), + _phantom: PhantomData, + } +} + +/// Middleware transform for [`from_fn`]. +#[allow(missing_debug_implementations)] +pub struct MiddlewareFn { + mw_fn: Rc, + _phantom: PhantomData, +} + +impl Transform for MiddlewareFn +where + S: Service, Error = Error> + 'static, + F: Fn(ServiceRequest, Next) -> Fut + 'static, + Fut: Future, Error>>, + B2: MessageBody, +{ + type Response = ServiceResponse; + type Error = Error; + type Transform = MiddlewareFnService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(MiddlewareFnService { + service: boxed::rc_service(service), + mw_fn: Rc::clone(&self.mw_fn), + _phantom: PhantomData, + })) + } +} + +/// Middleware service for [`from_fn`]. +#[allow(missing_debug_implementations)] +pub struct MiddlewareFnService { + service: RcService, Error>, + mw_fn: Rc, + _phantom: PhantomData<(B, Es)>, +} + +impl Service for MiddlewareFnService +where + F: Fn(ServiceRequest, Next) -> Fut, + Fut: Future, Error>>, + B2: MessageBody, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = Fut; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + (self.mw_fn)( + req, + Next:: { + service: Rc::clone(&self.service), + }, + ) + } +} + +macro_rules! impl_middleware_fn_service { + ($($ext_type:ident),*) => { + impl Transform for MiddlewareFn + where + S: Service, Error = Error> + 'static, + F: Fn($($ext_type),*, ServiceRequest, Next) -> Fut + 'static, + $($ext_type: FromRequest + 'static,)* + Fut: Future, Error>> + 'static, + B: MessageBody + 'static, + B2: MessageBody + 'static, + { + type Response = ServiceResponse; + type Error = Error; + type Transform = MiddlewareFnService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(MiddlewareFnService { + service: boxed::rc_service(service), + mw_fn: Rc::clone(&self.mw_fn), + _phantom: PhantomData, + })) + } + } + + impl Service + for MiddlewareFnService + where + F: Fn( + $($ext_type),*, + ServiceRequest, + Next + ) -> Fut + 'static, + $($ext_type: FromRequest + 'static,)* + Fut: Future, Error>> + 'static, + B2: MessageBody + 'static, + { + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + #[allow(nonstandard_style)] + fn call(&self, mut req: ServiceRequest) -> Self::Future { + let mw_fn = Rc::clone(&self.mw_fn); + let service = Rc::clone(&self.service); + + Box::pin(async move { + let ($($ext_type,)*) = req.extract::<($($ext_type,)*)>().await?; + + (mw_fn)($($ext_type),*, req, Next:: { service }).await + }) + } + } + }; +} + +impl_middleware_fn_service!(E1); +impl_middleware_fn_service!(E1, E2); +impl_middleware_fn_service!(E1, E2, E3); +impl_middleware_fn_service!(E1, E2, E3, E4); +impl_middleware_fn_service!(E1, E2, E3, E4, E5); +impl_middleware_fn_service!(E1, E2, E3, E4, E5, E6); +impl_middleware_fn_service!(E1, E2, E3, E4, E5, E6, E7); +impl_middleware_fn_service!(E1, E2, E3, E4, E5, E6, E7, E8); +impl_middleware_fn_service!(E1, E2, E3, E4, E5, E6, E7, E8, E9); + +/// Wraps the "next" service in the middleware chain. +#[allow(missing_debug_implementations)] +pub struct Next { + service: RcService, Error>, +} + +impl Next { + /// Equivalent to `Service::call(self, req)`. + pub fn call(&self, req: ServiceRequest) -> >::Future { + Service::call(self, req) + } +} + +impl Service for Next { + type Response = ServiceResponse; + type Error = Error; + type Future = BoxFuture>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + self.service.call(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + http::header::{self, HeaderValue}, + middleware::{Compat, Logger}, + test, web, App, HttpResponse, + }; + + async fn noop(req: ServiceRequest, next: Next) -> Result, Error> { + next.call(req).await + } + + async fn add_res_header( + req: ServiceRequest, + next: Next, + ) -> Result, Error> { + let mut res = next.call(req).await?; + res.headers_mut() + .insert(header::WARNING, HeaderValue::from_static("42")); + Ok(res) + } + + async fn mutate_body_type( + req: ServiceRequest, + next: Next, + ) -> Result, Error> { + let res = next.call(req).await?; + Ok(res.map_into_left_body::<()>()) + } + + struct MyMw(bool); + + impl MyMw { + async fn mw_cb( + &self, + req: ServiceRequest, + next: Next, + ) -> Result, Error> { + let mut res = match self.0 { + true => req.into_response("short-circuited").map_into_right_body(), + false => next.call(req).await?.map_into_left_body(), + }; + res.headers_mut() + .insert(header::WARNING, HeaderValue::from_static("42")); + Ok(res) + } + + pub fn into_middleware( + self, + ) -> impl Transform< + S, + ServiceRequest, + Response = ServiceResponse, + Error = Error, + InitError = (), + > + where + S: Service, Error = Error> + 'static, + B: MessageBody + 'static, + { + let this = Rc::new(self); + from_fn(move |req, next| { + let this = Rc::clone(&this); + async move { Self::mw_cb(&this, req, next).await } + }) + } + } + + #[actix_rt::test] + async fn compat_compat() { + let _ = App::new().wrap(Compat::new(from_fn(noop))); + let _ = App::new().wrap(Compat::new(from_fn(mutate_body_type))); + } + + #[actix_rt::test] + async fn permits_different_in_and_out_body_types() { + let app = test::init_service( + App::new() + .wrap(from_fn(mutate_body_type)) + .wrap(from_fn(add_res_header)) + .wrap(Logger::default()) + .wrap(from_fn(noop)) + .default_service(web::to(HttpResponse::NotFound)), + ) + .await; + + let req = test::TestRequest::default().to_request(); + let res = test::call_service(&app, req).await; + assert!(res.headers().contains_key(header::WARNING)); + } + + #[actix_rt::test] + async fn closure_capture_and_return_from_fn() { + let app = test::init_service( + App::new() + .wrap(Logger::default()) + .wrap(MyMw(true).into_middleware()) + .wrap(Logger::default()), + ) + .await; + + let req = test::TestRequest::default().to_request(); + let res = test::call_service(&app, req).await; + assert!(res.headers().contains_key(header::WARNING)); + } +} diff --git a/actix-web/src/middleware/noop.rs b/actix-web/src/middleware/identity.rs similarity index 57% rename from actix-web/src/middleware/noop.rs rename to actix-web/src/middleware/identity.rs index ae7da1d81..de374a57b 100644 --- a/actix-web/src/middleware/noop.rs +++ b/actix-web/src/middleware/identity.rs @@ -2,35 +2,39 @@ use actix_utils::future::{ready, Ready}; -use crate::dev::{Service, Transform}; +use crate::dev::{forward_ready, Service, Transform}; /// A no-op middleware that passes through request and response untouched. -pub(crate) struct Noop; +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct Identity; -impl, Req> Transform for Noop { +impl, Req> Transform for Identity { type Response = S::Response; type Error = S::Error; - type Transform = NoopService; + type Transform = IdentityMiddleware; type InitError = (); type Future = Ready>; + #[inline] fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(NoopService { service })) + ready(Ok(IdentityMiddleware { service })) } } #[doc(hidden)] -pub(crate) struct NoopService { +pub struct IdentityMiddleware { service: S, } -impl, Req> Service for NoopService { +impl, Req> Service for IdentityMiddleware { type Response = S::Response; type Error = S::Error; type Future = S::Future; - crate::dev::forward_ready!(service); + forward_ready!(service); + #[inline] fn call(&self, req: Req) -> Self::Future { self.service.call(req) } diff --git a/actix-web/src/middleware/logger.rs b/actix-web/src/middleware/logger.rs index 06d26617a..e258775c8 100644 --- a/actix-web/src/middleware/logger.rs +++ b/actix-web/src/middleware/logger.rs @@ -16,9 +16,12 @@ use actix_service::{Service, Transform}; use actix_utils::future::{ready, Ready}; use bytes::Bytes; use futures_core::ready; -use log::{debug, warn}; +use log::{debug, warn, Level}; use pin_project_lite::pin_project; -use regex::{Regex, RegexSet}; +#[cfg(feature = "unicode")] +use regex::Regex; +#[cfg(not(feature = "unicode"))] +use regex_lite::Regex; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use crate::{ @@ -87,8 +90,9 @@ pub struct Logger(Rc); struct Inner { format: Format, exclude: HashSet, - exclude_regex: RegexSet, + exclude_regex: Vec, log_target: Cow<'static, str>, + log_level: Level, } impl Logger { @@ -97,8 +101,9 @@ impl Logger { Logger(Rc::new(Inner { format: Format::new(format), exclude: HashSet::new(), - exclude_regex: RegexSet::empty(), + exclude_regex: Vec::new(), log_target: Cow::Borrowed(module_path!()), + log_level: Level::Info, })) } @@ -114,10 +119,7 @@ impl Logger { /// Ignore and do not log access info for paths that match regex. pub fn exclude_regex>(mut self, path: T) -> Self { let inner = Rc::get_mut(&mut self.0).unwrap(); - let mut patterns = inner.exclude_regex.patterns().to_vec(); - patterns.push(path.into()); - let regex_set = RegexSet::new(patterns).unwrap(); - inner.exclude_regex = regex_set; + inner.exclude_regex.push(Regex::new(&path.into()).unwrap()); self } @@ -139,6 +141,23 @@ impl Logger { self } + /// Sets the log level to `level`. + /// + /// By default, the log level is `Level::Info`. + /// + /// # Examples + /// Using `.log_level(Level::Debug)` would have this effect on request logs: + /// ```diff + /// - [2015-10-21T07:28:00Z INFO actix_web::middleware::logger] 127.0.0.1 "GET / HTTP/1.1" 200 88 "-" "dmc/1.0" 0.001985 + /// + [2015-10-21T07:28:00Z DEBUG actix_web::middleware::logger] 127.0.0.1 "GET / HTTP/1.1" 200 88 "-" "dmc/1.0" 0.001985 + /// ^^^^^^ + /// ``` + pub fn log_level(mut self, level: log::Level) -> Self { + let inner = Rc::get_mut(&mut self.0).unwrap(); + inner.log_level = level; + self + } + /// Register a function that receives a ServiceRequest and returns a String for use in the /// log line. The label passed as the first argument should match a replacement substring in /// the logger format like `%{label}xi`. @@ -240,8 +259,9 @@ impl Default for Logger { Logger(Rc::new(Inner { format: Format::default(), exclude: HashSet::new(), - exclude_regex: RegexSet::empty(), + exclude_regex: Vec::new(), log_target: Cow::Borrowed(module_path!()), + log_level: Level::Info, })) } } @@ -276,7 +296,7 @@ where ready(Ok(LoggerMiddleware { service, - inner: self.0.clone(), + inner: Rc::clone(&self.0), })) } } @@ -300,7 +320,11 @@ where fn call(&self, req: ServiceRequest) -> Self::Future { let excluded = self.inner.exclude.contains(req.path()) - || self.inner.exclude_regex.is_match(req.path()); + || self + .inner + .exclude_regex + .iter() + .any(|r| r.is_match(req.path())); if excluded { LoggerResponse { @@ -308,6 +332,7 @@ where format: None, time: OffsetDateTime::now_utc(), log_target: Cow::Borrowed(""), + log_level: self.inner.log_level, _phantom: PhantomData, } } else { @@ -323,6 +348,7 @@ where format: Some(format), time: now, log_target: self.inner.log_target.clone(), + log_level: self.inner.log_level, _phantom: PhantomData, } } @@ -340,6 +366,7 @@ pin_project! { time: OffsetDateTime, format: Option, log_target: Cow<'static, str>, + log_level: Level, _phantom: PhantomData, } } @@ -356,7 +383,7 @@ where let res = match ready!(this.fut.poll(cx)) { Ok(res) => res, - Err(e) => return Poll::Ready(Err(e)), + Err(err) => return Poll::Ready(Err(err)), }; if let Some(error) = res.response().error() { @@ -386,6 +413,7 @@ where let time = *this.time; let format = this.format.take(); let log_target = this.log_target.clone(); + let log_level = *this.log_level; Poll::Ready(Ok(res.map_body(move |_, body| StreamLog { body, @@ -393,6 +421,7 @@ where format, size: 0, log_target, + log_level, }))) } } @@ -405,6 +434,7 @@ pin_project! { size: usize, time: OffsetDateTime, log_target: Cow<'static, str>, + log_level: Level } impl PinnedDrop for StreamLog { @@ -417,8 +447,9 @@ pin_project! { Ok(()) }; - log::info!( + log::log!( target: this.log_target.as_ref(), + this.log_level, "{}", FormatDisplay(&render) ); } @@ -618,13 +649,9 @@ impl FormatText { FormatText::ResponseHeader(ref name) => { let s = if let Some(val) = res.headers().get(name) { - if let Ok(s) = val.to_str() { - s - } else { - "-" - } + String::from_utf8_lossy(val.as_bytes()).into_owned() } else { - "-" + "-".to_owned() }; *self = FormatText::Str(s.to_string()) } @@ -666,15 +693,11 @@ impl FormatText { FormatText::RequestTime => *self = FormatText::Str(now.format(&Rfc3339).unwrap()), FormatText::RequestHeader(ref name) => { let s = if let Some(val) = req.headers().get(name) { - if let Ok(s) = val.to_str() { - s - } else { - "-" - } + String::from_utf8_lossy(val.as_bytes()).into_owned() } else { - "-" + "-".to_owned() }; - *self = FormatText::Str(s.to_string()); + *self = FormatText::Str(s); } FormatText::RemoteAddr => { let s = if let Some(peer) = req.connection_info().peer_addr() { @@ -708,7 +731,7 @@ impl FormatText { /// Converter to get a String from something that writes to a Formatter. pub(crate) struct FormatDisplay<'a>(&'a dyn Fn(&mut fmt::Formatter<'_>) -> Result<(), fmt::Error>); -impl<'a> fmt::Display for FormatDisplay<'a> { +impl fmt::Display for FormatDisplay<'_> { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { (self.0)(fmt) } @@ -716,7 +739,7 @@ impl<'a> fmt::Display for FormatDisplay<'a> { #[cfg(test)] mod tests { - use actix_service::{IntoService, Service, Transform}; + use actix_service::IntoService; use actix_utils::future::ok; use super::*; diff --git a/actix-web/src/middleware/mod.rs b/actix-web/src/middleware/mod.rs index 8dbd1ff2c..4b5b3e896 100644 --- a/actix-web/src/middleware/mod.rs +++ b/actix-web/src/middleware/mod.rs @@ -15,10 +15,47 @@ //! - Access external services (e.g., [sessions](https://docs.rs/actix-session), etc.) //! //! Middleware is registered for each [`App`], [`Scope`](crate::Scope), or -//! [`Resource`](crate::Resource) and executed in opposite order as registration. In general, a -//! middleware is a pair of types that implements the [`Service`] trait and [`Transform`] trait, -//! respectively. The [`new_transform`] and [`call`] methods must return a [`Future`], though it -//! can often be [an immediately-ready one](actix_utils::future::Ready). +//! [`Resource`](crate::Resource) and executed in opposite order as registration. +//! +//! # Simple Middleware +//! +//! In many cases, you can model your middleware as an async function via the [`from_fn()`] helper +//! that provides a natural interface for implementing your desired behaviors. +//! +//! ``` +//! # use actix_web::{ +//! # App, Error, +//! # body::MessageBody, +//! # dev::{ServiceRequest, ServiceResponse, Service as _}, +//! # }; +//! use actix_web::middleware::{self, Next}; +//! +//! async fn my_mw( +//! req: ServiceRequest, +//! next: Next, +//! ) -> Result, Error> { +//! // pre-processing +//! +//! // invoke the wrapped middleware or service +//! let res = next.call(req).await?; +//! +//! // post-processing +//! +//! Ok(res) +//! } +//! +//! App::new() +//! .wrap(middleware::from_fn(my_mw)); +//! ``` +//! +//! ## Complex Middleware +//! +//! In the more general ase, a middleware is a pair of types that implements the [`Service`] trait +//! and [`Transform`] trait, respectively. The [`new_transform`] and [`call`] methods must return a +//! [`Future`], though it can often be [an immediately-ready one](actix_utils::future::Ready). +//! +//! All the built-in middleware use this pattern with pairs of builder (`Transform`) + +//! implementation (`Service`) types. //! //! # Ordering //! @@ -33,13 +70,13 @@ //! //! # fn main() { //! # // These aren't snake_case, because they are supposed to be unit structs. -//! # let MiddlewareA = middleware::Compress::default(); -//! # let MiddlewareB = middleware::Compress::default(); -//! # let MiddlewareC = middleware::Compress::default(); +//! # type MiddlewareA = middleware::Compress; +//! # type MiddlewareB = middleware::Compress; +//! # type MiddlewareC = middleware::Compress; //! let app = App::new() -//! .wrap(MiddlewareA) -//! .wrap(MiddlewareB) -//! .wrap(MiddlewareC) +//! .wrap(MiddlewareA::default()) +//! .wrap(MiddlewareB::default()) +//! .wrap(MiddlewareC::default()) //! .service(service); //! # } //! ``` @@ -67,12 +104,12 @@ //! Response //! ``` //! The request _first_ gets processed by the middleware specified _last_ - `MiddlewareC`. It passes -//! the request (modified a modified one) to the next middleware - `MiddlewareB` - _or_ directly +//! the request (possibly a modified one) to the next middleware - `MiddlewareB` - _or_ directly //! responds to the request (e.g. when the request was invalid or an error occurred). `MiddlewareB` //! processes the request as well and passes it to `MiddlewareA`, which then passes it to the //! [`Service`]. In the [`Service`], the extractors will run first. They don't pass the request on, //! but only view it (see [`FromRequest`]). After the [`Service`] responds to the request, the -//! response it passed back through `MiddlewareA`, `MiddlewareB`, and `MiddlewareC`. +//! response is passed back through `MiddlewareA`, `MiddlewareB`, and `MiddlewareC`. //! //! As you register middleware using [`wrap`][crate::App::wrap] and [`wrap_fn`][crate::App::wrap_fn] //! in the [`App`] builder, imagine wrapping layers around an inner [`App`]. The first middleware @@ -196,18 +233,6 @@ //! # } //! ``` //! -//! # Simpler Middleware -//! -//! In many cases, you _can_ actually use an async function via a helper that will provide a more -//! natural flow for your behavior. -//! -//! The experimental `actix_web_lab` crate provides a [`from_fn`][lab_from_fn] utility which allows -//! an async fn to be wrapped and used in the same way as other middleware. See the -//! [`from_fn`][lab_from_fn] docs for more info and examples of it's use. -//! -//! While [`from_fn`][lab_from_fn] is experimental currently, it's likely this helper will graduate -//! to Actix Web in some form, so feedback is appreciated. -//! //! [`Future`]: std::future::Future //! [`App`]: crate::App //! [`FromRequest`]: crate::FromRequest @@ -215,34 +240,32 @@ //! [`Transform`]: crate::dev::Transform //! [`call`]: crate::dev::Service::call() //! [`new_transform`]: crate::dev::Transform::new_transform() -//! [lab_from_fn]: https://docs.rs/actix-web-lab/latest/actix_web_lab/middleware/fn.from_fn.html +//! [`from_fn`]: crate mod compat; +#[cfg(feature = "__compress")] +mod compress; mod condition; mod default_headers; mod err_handlers; +mod from_fn; +mod identity; mod logger; -#[cfg(test)] -mod noop; mod normalize; -#[cfg(test)] -pub(crate) use self::noop::Noop; +#[cfg(feature = "__compress")] +pub use self::compress::Compress; pub use self::{ compat::Compat, condition::Condition, default_headers::DefaultHeaders, err_handlers::{ErrorHandlerResponse, ErrorHandlers}, + from_fn::{from_fn, Next}, + identity::Identity, logger::Logger, normalize::{NormalizePath, TrailingSlash}, }; -#[cfg(feature = "__compress")] -mod compress; - -#[cfg(feature = "__compress")] -pub use self::compress::Compress; - #[cfg(test)] mod tests { use super::*; diff --git a/actix-web/src/middleware/normalize.rs b/actix-web/src/middleware/normalize.rs index afcc0faac..482107ecb 100644 --- a/actix-web/src/middleware/normalize.rs +++ b/actix-web/src/middleware/normalize.rs @@ -4,7 +4,10 @@ use actix_http::uri::{PathAndQuery, Uri}; use actix_service::{Service, Transform}; use actix_utils::future::{ready, Ready}; use bytes::Bytes; +#[cfg(feature = "unicode")] use regex::Regex; +#[cfg(not(feature = "unicode"))] +use regex_lite::Regex; use crate::{ service::{ServiceRequest, ServiceResponse}, @@ -205,7 +208,6 @@ mod tests { use super::*; use crate::{ - dev::ServiceRequest, guard::fn_guard, test::{call_service, init_service, TestRequest}, web, App, HttpResponse, diff --git a/actix-web/src/redirect.rs b/actix-web/src/redirect.rs index f9e9f2d7a..bd29a1403 100644 --- a/actix-web/src/redirect.rs +++ b/actix-web/src/redirect.rs @@ -171,7 +171,7 @@ impl Responder for Redirect { } else { log::error!( "redirect target location can not be converted to header value: {:?}", - self.to + self.to, ); } @@ -182,7 +182,7 @@ impl Responder for Redirect { #[cfg(test)] mod tests { use super::*; - use crate::{dev::Service, http::StatusCode, test, App}; + use crate::{dev::Service, test, App}; #[actix_rt::test] async fn absolute_redirects() { diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index ece36a388..3a3e539bf 100644 --- a/actix-web/src/request.rs +++ b/actix-web/src/request.rs @@ -1,6 +1,9 @@ use std::{ cell::{Ref, RefCell, RefMut}, - fmt, net, + collections::HashMap, + fmt, + hash::{BuildHasher, Hash}, + net, rc::Rc, str, }; @@ -91,6 +94,35 @@ impl HttpRequest { &self.head().uri } + /// Returns request's original full URL. + /// + /// Reconstructed URL is best-effort, using [`connection_info`](HttpRequest::connection_info()) + /// to get forwarded scheme & host. + /// + /// ``` + /// use actix_web::test::TestRequest; + /// let req = TestRequest::with_uri("http://10.1.2.3:8443/api?id=4&name=foo") + /// .insert_header(("host", "example.com")) + /// .to_http_request(); + /// + /// assert_eq!( + /// req.full_url().as_str(), + /// "http://example.com/api?id=4&name=foo", + /// ); + /// ``` + pub fn full_url(&self) -> url::Url { + let info = self.connection_info(); + let scheme = info.scheme(); + let host = info.host(); + let path_and_query = self + .uri() + .path_and_query() + .map(|paq| paq.as_str()) + .unwrap_or("/"); + + url::Url::parse(&format!("{scheme}://{host}{path_and_query}")).unwrap() + } + /// Read the Request method. #[inline] pub fn method(&self) -> &Method { @@ -213,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", ¶ms); // <- 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( + &self, + name: &str, + elements: &HashMap, + ) -> Result + where + K: std::borrow::Borrow + Eq + Hash, + V: AsRef, + 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`) 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( + &self, + name: &str, + elements: I, + ) -> Result + where + I: IntoIterator, + K: std::borrow::Borrow + Eq + Hash, + V: AsRef, + { + 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 @@ -235,8 +337,10 @@ impl HttpRequest { /// /// For expanded client connection information, use [`connection_info`] instead. /// - /// Will only return None when called in unit tests unless [`TestRequest::peer_addr`] is used. + /// Will only return `None` when server is listening on [UDS socket] or when called in unit + /// tests unless [`TestRequest::peer_addr`] is used. /// + /// [UDS socket]: crate::HttpServer::bind_uds /// [`TestRequest::peer_addr`]: crate::test::TestRequest::peer_addr /// [`connection_info`]: Self::connection_info #[inline] @@ -519,11 +623,13 @@ impl HttpRequestPool { #[cfg(test)] mod tests { + use std::collections::HashMap; + use bytes::Bytes; use super::*; use crate::{ - dev::{ResourceDef, ResourceMap, Service}, + dev::{ResourceDef, Service}, http::{header, StatusCode}, test::{self, call_service, init_service, read_body, TestRequest}, web, App, HttpResponse, @@ -607,6 +713,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", ¶ms); + assert_eq!( + url.ok().unwrap().as_str(), + "http://www.actix.rs/user/test.html" + ); + + params.remove("ext"); + assert_eq!( + req.url_for_map("index", ¶ms), + 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"); @@ -963,4 +1122,27 @@ mod tests { assert!(format!("{:?}", req).contains(location_header)); } + + #[test] + fn check_full_url() { + let req = TestRequest::with_uri("/api?id=4&name=foo").to_http_request(); + assert_eq!( + req.full_url().as_str(), + "http://localhost:8080/api?id=4&name=foo", + ); + + let req = TestRequest::with_uri("https://example.com/api?id=4&name=foo").to_http_request(); + assert_eq!( + req.full_url().as_str(), + "https://example.com/api?id=4&name=foo", + ); + + let req = TestRequest::with_uri("http://10.1.2.3:8443/api?id=4&name=foo") + .insert_header(("host", "example.com")) + .to_http_request(); + assert_eq!( + req.full_url().as_str(), + "http://example.com/api?id=4&name=foo", + ); + } } diff --git a/actix-web/src/resource.rs b/actix-web/src/resource.rs index 95185b80a..aee0dff93 100644 --- a/actix-web/src/resource.rs +++ b/actix-web/src/resource.rs @@ -28,9 +28,9 @@ use crate::{ /// /// Resource in turn has at least one route. Route consists of an handlers objects and list of /// guards (objects that implement `Guard` trait). Resources and routes uses builder-like pattern -/// for configuration. During request handling, resource object iterate through all routes and check -/// guards for specific route, if request matches all guards, route considered matched and route -/// handler get called. +/// for configuration. During request handling, the resource object iterates through all routes +/// and checks guards for the specific route, if the request matches all the guards, then the route +/// is considered matched and the route handler gets called. /// /// # Examples /// ``` @@ -62,14 +62,14 @@ pub struct Resource { impl Resource { /// Constructs new resource that matches a `path` pattern. pub fn new(path: T) -> Resource { - let fref = Rc::new(RefCell::new(None)); + let factory_ref = Rc::new(RefCell::new(None)); Resource { routes: Vec::new(), rdef: path.patterns(), name: None, - endpoint: ResourceEndpoint::new(fref.clone()), - factory_ref: fref, + endpoint: ResourceEndpoint::new(Rc::clone(&factory_ref)), + factory_ref, guards: Vec::new(), app_data: None, default: boxed::factory(fn_service(|req: ServiceRequest| async { @@ -358,10 +358,9 @@ where U::InitError: fmt::Debug, { // create and configure default resource - self.default = boxed::factory( - f.into_factory() - .map_init_err(|e| log::error!("Can not construct default service: {:?}", e)), - ); + self.default = boxed::factory(f.into_factory().map_init_err(|err| { + log::error!("Can not construct default service: {err:?}"); + })); self } @@ -540,20 +539,14 @@ mod tests { use std::time::Duration; use actix_rt::time::sleep; - use actix_service::Service; use actix_utils::future::ok; use super::*; use crate::{ - guard, - http::{ - header::{self, HeaderValue}, - Method, StatusCode, - }, + http::{header::HeaderValue, Method, StatusCode}, middleware::DefaultHeaders, - service::{ServiceRequest, ServiceResponse}, test::{call_service, init_service, TestRequest}, - web, App, Error, HttpMessage, HttpResponse, + App, HttpMessage, }; #[test] @@ -777,7 +770,7 @@ mod tests { data3: web::Data| { assert_eq!(**data1, 10); assert_eq!(**data2, '*'); - let error = std::f64::EPSILON; + let error = f64::EPSILON; assert!((**data3 - 1.0).abs() < error); HttpResponse::Ok() }, diff --git a/actix-web/src/response/builder.rs b/actix-web/src/response/builder.rs index 2c06941cd..6ea23b42e 100644 --- a/actix-web/src/response/builder.rs +++ b/actix-web/src/response/builder.rs @@ -11,7 +11,7 @@ use futures_core::Stream; use serde::Serialize; use crate::{ - body::{BodyStream, BoxBody, MessageBody}, + body::{BodyStream, BoxBody, MessageBody, SizedStream}, dev::Extensions, error::{Error, JsonPayloadError}, http::{ @@ -64,7 +64,7 @@ impl HttpResponseBuilder { Ok((key, value)) => { parts.headers.insert(key, value); } - Err(e) => self.error = Some(e.into()), + Err(err) => self.error = Some(err.into()), }; } @@ -86,7 +86,7 @@ impl HttpResponseBuilder { if let Some(parts) = self.inner() { match header.try_into_pair() { Ok((key, value)) => parts.headers.append(key, value), - Err(e) => self.error = Some(e.into()), + Err(err) => self.error = Some(err.into()), }; } @@ -210,7 +210,7 @@ impl HttpResponseBuilder { Ok(value) => { parts.headers.insert(header::CONTENT_TYPE, value); } - Err(e) => self.error = Some(e.into()), + Err(err) => self.error = Some(err.into()), }; } self @@ -318,13 +318,35 @@ impl HttpResponseBuilder { /// Set a streaming body and build the `HttpResponse`. /// /// `HttpResponseBuilder` can not be used after this call. + /// + /// If `Content-Type` is not set, then it is automatically set to `application/octet-stream`. + /// + /// If `Content-Length` is set, then [`no_chunking()`](Self::no_chunking) is automatically called. #[inline] pub fn streaming(&mut self, stream: S) -> HttpResponse where S: Stream> + 'static, E: Into + 'static, { - self.body(BodyStream::new(stream)) + // Set mime type to application/octet-stream if it is not set + if let Some(parts) = self.inner() { + if !parts.headers.contains_key(header::CONTENT_TYPE) { + self.insert_header((header::CONTENT_TYPE, mime::APPLICATION_OCTET_STREAM)); + } + } + + let content_length = self + .inner() + .and_then(|parts| parts.headers.get(header::CONTENT_LENGTH)) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + + if let Some(len) = content_length { + self.no_chunking(len); + self.body(SizedStream::new(len, stream)) + } else { + self.body(BodyStream::new(stream)) + } } /// Set a JSON body and build the `HttpResponse`. @@ -408,10 +430,7 @@ mod tests { use super::*; use crate::{ body, - http::{ - header::{self, HeaderValue, CONTENT_TYPE}, - StatusCode, - }, + http::header::{HeaderValue, CONTENT_TYPE}, test::assert_body_eq, }; @@ -466,7 +485,7 @@ mod tests { // content type override let res = HttpResponse::Ok() .insert_header((CONTENT_TYPE, "text/json")) - .json(&vec!["v1", "v2", "v3"]); + .json(["v1", "v2", "v3"]); let ct = res.headers().get(CONTENT_TYPE).unwrap(); assert_eq!(ct, HeaderValue::from_static("text/json")); assert_body_eq!(res, br#"["v1","v2","v3"]"#); diff --git a/actix-web/src/response/customize_responder.rs b/actix-web/src/response/customize_responder.rs index aad0039e0..6a43ac5e6 100644 --- a/actix-web/src/response/customize_responder.rs +++ b/actix-web/src/response/customize_responder.rs @@ -7,7 +7,7 @@ use actix_http::{ use crate::{HttpRequest, HttpResponse, Responder}; -/// Allows overriding status code and headers for a [`Responder`]. +/// Allows overriding status code and headers (including cookies) for a [`Responder`]. /// /// Created by calling the [`customize`](Responder::customize) method on a [`Responder`] type. pub struct CustomizeResponder { @@ -137,6 +137,29 @@ impl CustomizeResponder { Some(&mut self.inner) } } + + /// Appends a `cookie` to the final response. + /// + /// # Errors + /// + /// Final response will be an error if `cookie` cannot be converted into a valid header value. + #[cfg(feature = "cookies")] + pub fn add_cookie(mut self, cookie: &crate::cookie::Cookie<'_>) -> Self { + use actix_http::header::{TryIntoHeaderValue as _, SET_COOKIE}; + + if let Some(inner) = self.inner() { + match cookie.to_string().try_into_value() { + Ok(val) => { + inner.append_headers.append(SET_COOKIE, val); + } + Err(err) => { + self.error = Some(err.into()); + } + } + } + + self + } } impl Responder for CustomizeResponder @@ -175,10 +198,8 @@ mod tests { use super::*; use crate::{ - http::{ - header::{HeaderValue, CONTENT_TYPE}, - StatusCode, - }, + cookie::Cookie, + http::header::{HeaderValue, CONTENT_TYPE}, test::TestRequest, }; @@ -212,6 +233,22 @@ mod tests { to_bytes(res.into_body()).await.unwrap(), Bytes::from_static(b"test"), ); + + let res = "test" + .to_string() + .customize() + .add_cookie(&Cookie::new("name", "value")) + .respond_to(&req); + + assert!(res.status().is_success()); + assert_eq!( + res.cookies().collect::>>(), + vec![Cookie::new("name", "value")], + ); + assert_eq!( + to_bytes(res.into_body()).await.unwrap(), + Bytes::from_static(b"test"), + ); } #[actix_rt::test] diff --git a/actix-web/src/response/http_codes.rs b/actix-web/src/response/http_codes.rs index db5f392c9..1427ee16a 100644 --- a/actix-web/src/response/http_codes.rs +++ b/actix-web/src/response/http_codes.rs @@ -6,7 +6,8 @@ use crate::{HttpResponse, HttpResponseBuilder}; macro_rules! static_resp { ($name:ident, $status:expr) => { - #[allow(non_snake_case, missing_docs)] + #[allow(non_snake_case)] + #[doc = concat!("Creates a new response builder with the status code `", stringify!($status), "`.")] pub fn $name() -> HttpResponseBuilder { HttpResponseBuilder::new($status) } diff --git a/actix-web/src/response/mod.rs b/actix-web/src/response/mod.rs index 11fd28301..16bdc619c 100644 --- a/actix-web/src/response/mod.rs +++ b/actix-web/src/response/mod.rs @@ -5,8 +5,6 @@ mod responder; #[allow(clippy::module_inception)] mod response; -#[cfg(feature = "cookies")] -pub use self::response::CookieIter; pub use self::{ builder::HttpResponseBuilder, customize_responder::CustomizeResponder, responder::Responder, response::HttpResponse, diff --git a/actix-web/src/response/responder.rs b/actix-web/src/response/responder.rs index 7d0b0e585..82bc38d6b 100644 --- a/actix-web/src/response/responder.rs +++ b/actix-web/src/response/responder.rs @@ -131,6 +131,23 @@ where } } +// Note: see https://github.com/actix/actix-web/issues/1108 for reasoning why Responder is not +// implemented for `()`, and https://github.com/actix/actix-web/pull/3560 for discussion about this +// impl and the decision not to include a similar one for `Option<()>`. +impl Responder for Result<(), E> +where + E: Into, +{ + type Body = BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + match self { + Ok(()) => HttpResponse::new(StatusCode::NO_CONTENT), + Err(err) => HttpResponse::from_error(err.into()), + } + } +} + impl Responder for (R, StatusCode) { type Body = R::Body; @@ -188,15 +205,11 @@ impl_into_string_responder!(Cow<'_, str>); pub(crate) mod tests { use actix_http::body::to_bytes; use actix_service::Service; - use bytes::{Bytes, BytesMut}; use super::*; use crate::{ error, - http::{ - header::{HeaderValue, CONTENT_TYPE}, - StatusCode, - }, + http::header::{HeaderValue, CONTENT_TYPE}, test::{assert_body_eq, init_service, TestRequest}, web, App, }; diff --git a/actix-web/src/response/response.rs b/actix-web/src/response/response.rs index fbd87e10c..e16dc0cd9 100644 --- a/actix-web/src/response/response.rs +++ b/actix-web/src/response/response.rs @@ -399,7 +399,7 @@ mod tests { use static_assertions::assert_impl_all; use super::*; - use crate::http::header::{HeaderValue, COOKIE}; + use crate::http::header::COOKIE; assert_impl_all!(HttpResponse: Responder); assert_impl_all!(HttpResponse: Responder); diff --git a/actix-web/src/rmap.rs b/actix-web/src/rmap.rs index 462f3b313..ee86d271b 100644 --- a/actix-web/src/rmap.rs +++ b/actix-web/src/rmap.rs @@ -1,12 +1,14 @@ use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, cell::RefCell, + collections::HashMap, fmt::Write as _, + hash::{BuildHasher, Hash}, rc::{Rc, Weak}, }; use actix_router::ResourceDef; -use ahash::AHashMap; +use foldhash::HashMap as FoldHashMap; use url::Url; use crate::{error::UrlGenerationError, request::HttpRequest}; @@ -19,7 +21,7 @@ pub struct ResourceMap { /// Named resources within the tree or, for external resources, it points to isolated nodes /// outside the tree. - named: AHashMap>, + named: FoldHashMap>, parent: RefCell>, @@ -32,7 +34,7 @@ impl ResourceMap { pub fn new(root: ResourceDef) -> Self { ResourceMap { pattern: root, - named: AHashMap::default(), + named: FoldHashMap::default(), parent: RefCell::new(Weak::new()), nodes: Some(Vec::new()), } @@ -86,7 +88,7 @@ impl ResourceMap { } else { let new_node = Rc::new(ResourceMap { pattern: pattern.clone(), - named: AHashMap::default(), + named: FoldHashMap::default(), parent: RefCell::new(Weak::new()), nodes: None, }); @@ -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( + &self, + req: &HttpRequest, + name: &str, + elements: &HashMap, + ) -> Result + where + K: Borrow + Eq + Hash, + V: AsRef, + 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( + &self, + req: &HttpRequest, + name: &str, + elements: I, + ) -> Result + where + I: IntoIterator, + K: Borrow + Eq + Hash, + V: AsRef, + { + let elements = elements.into_iter().collect::>(); + self.url_for_map(req, name, &elements) + } + + fn url_from_path(&self, req: &HttpRequest, path: String) -> Result { let (base, path): (Cow<'_, _>, _) = if path.starts_with('/') { // build full URL from connection info parts and resource path let conn = req.connection_info(); diff --git a/actix-web/src/route.rs b/actix-web/src/route.rs index 674d0082a..e05e6be52 100644 --- a/actix-web/src/route.rs +++ b/actix-web/src/route.rs @@ -77,7 +77,7 @@ impl ServiceFactory for Route { fn new_service(&self, _: ()) -> Self::Future { let fut = self.service.new_service(()); - let guards = self.guards.clone(); + let guards = Rc::clone(&self.guards); Box::pin(async move { let service = fut.await?; @@ -92,7 +92,8 @@ pub struct RouteService { } impl RouteService { - // TODO: does this need to take &mut ? + // TODO(breaking): remove pass by ref mut + #[allow(clippy::needless_pass_by_ref_mut)] pub fn check(&self, req: &mut ServiceRequest) -> bool { let guard_ctx = req.guard_ctx(); diff --git a/actix-web/src/rt.rs b/actix-web/src/rt.rs index 629ffe3a0..e370e2c0b 100644 --- a/actix-web/src/rt.rs +++ b/actix-web/src/rt.rs @@ -5,6 +5,7 @@ //! architecture in [`actix-rt`]'s docs. //! //! # Running Actix Web Without Macros +//! //! ```no_run //! use actix_web::{middleware, rt, web, App, HttpRequest, HttpServer}; //! @@ -25,6 +26,7 @@ //! ``` //! //! # Running Actix Web Using `#[tokio::main]` +//! //! If you need to run something that uses Tokio's work stealing functionality alongside Actix Web, //! you can run Actix Web under `#[tokio::main]`. The [`Server`](crate::dev::Server) object returned //! from [`HttpServer::run`](crate::HttpServer::run) can also be [`spawn`]ed, if preferred. @@ -32,6 +34,10 @@ //! Note that `actix` actor support (and therefore WebSocket support through `actix-web-actors`) //! still require `#[actix_web::main]` since they require a [`System`] to be set up. //! +//! Also note that calls to this module's [`spawn()`] re-export require an `#[actix_web::main]` +//! runtime (or a manually configured `LocalSet`) since it makes calls into to the current thread's +//! `LocalSet`, which `#[tokio::main]` does not set up. +//! //! ```no_run //! use actix_web::{get, middleware, rt, web, App, HttpRequest, HttpServer}; //! diff --git a/actix-web/src/scope.rs b/actix-web/src/scope.rs index e7c4e047a..e317349da 100644 --- a/actix-web/src/scope.rs +++ b/actix-web/src/scope.rs @@ -213,7 +213,6 @@ where /// /// * *Resource* is an entry in resource table which corresponds to requested URL. /// * *Scope* is a set of resources with common root path. - /// * "StaticFiles" is a service for static files support /// /// ``` /// use actix_web::{web, App, HttpRequest}; @@ -279,7 +278,9 @@ where { // create and configure default resource self.default = Some(Rc::new(boxed::factory(f.into_factory().map_init_err( - |e| log::error!("Can not construct default service: {:?}", e), + |err| { + log::error!("Can not construct default service: {err:?}"); + }, )))); self @@ -470,8 +471,9 @@ impl ServiceFactory for ScopeFactory { let guards = guards.borrow_mut().take().unwrap_or_default(); let factory_fut = factory.new_service(()); async move { - let service = factory_fut.await?; - Ok((path, guards, service)) + factory_fut + .await + .map(move |service| (path, guards, service)) } })); @@ -547,7 +549,6 @@ impl ServiceFactory for ScopeEndpoint { #[cfg(test)] mod tests { - use actix_service::Service; use actix_utils::future::ok; use bytes::Bytes; @@ -559,7 +560,6 @@ mod tests { Method, StatusCode, }, middleware::DefaultHeaders, - service::{ServiceRequest, ServiceResponse}, test::{assert_body_eq, call_service, init_service, read_body, TestRequest}, web, App, HttpMessage, HttpRequest, HttpResponse, }; diff --git a/actix-web/src/server.rs b/actix-web/src/server.rs index a540da7c8..2bd7c4463 100644 --- a/actix-web/src/server.rs +++ b/actix-web/src/server.rs @@ -1,13 +1,15 @@ use std::{ any::Any, - cmp, fmt, io, + cmp, fmt, + future::Future, + io, marker::PhantomData, net, sync::{Arc, Mutex}, time::Duration, }; -#[cfg(any(feature = "openssl", feature = "rustls"))] +#[cfg(feature = "__tls")] use actix_http::TlsAcceptorConfig; use actix_http::{body::MessageBody, Extensions, HttpService, KeepAlive, Request, Response}; use actix_server::{Server, ServerBuilder}; @@ -16,8 +18,6 @@ use actix_service::{ }; #[cfg(feature = "openssl")] use actix_tls::accept::openssl::reexports::{AlpnError, SslAcceptor, SslAcceptorBuilder}; -#[cfg(feature = "rustls")] -use actix_tls::accept::rustls::reexports::ServerConfig as RustlsServerConfig; use crate::{config::AppConfig, Error}; @@ -31,7 +31,8 @@ struct Config { keep_alive: KeepAlive, client_request_timeout: Duration, client_disconnect_timeout: Duration, - #[cfg(any(feature = "openssl", feature = "rustls"))] + h1_allow_half_closed: bool, + #[allow(dead_code)] // only dead when no TLS features are enabled tls_handshake_timeout: Option, } @@ -66,6 +67,7 @@ struct Config { /// .await /// } /// ``` +#[must_use] pub struct HttpServer where F: Fn() -> I + Send + Clone + 'static, @@ -101,6 +103,12 @@ where B: MessageBody + 'static, { /// Create new HTTP server with application factory + /// + /// # Worker Count + /// + /// The `factory` will be instantiated multiple times in most configurations. See + /// [`bind()`](Self::bind()) docs for more on how worker count and bind address resolution + /// causes multiple server factory instantiations. pub fn new(factory: F) -> Self { HttpServer { factory, @@ -109,7 +117,7 @@ where keep_alive: KeepAlive::default(), client_request_timeout: Duration::from_secs(5), client_disconnect_timeout: Duration::from_secs(1), - #[cfg(any(feature = "rustls", feature = "openssl"))] + h1_allow_half_closed: true, tls_handshake_timeout: None, })), backlog: 1024, @@ -122,7 +130,18 @@ where /// Sets number of workers to start (per bind address). /// - /// By default, the number of available physical CPUs is used as the worker count. + /// The default worker count is the determined by [`std::thread::available_parallelism()`]. See + /// its documentation to determine what behavior you should expect when server is run. + /// + /// Note that the server factory passed to [`new`](Self::new()) will be instantiated **at least + /// once per worker**. See [`bind()`](Self::bind()) docs for more on how worker count and bind + /// address resolution causes multiple server factory instantiations. + /// + /// `num` must be greater than 0. + /// + /// # Panics + /// + /// Panics if `num` is 0. pub fn workers(mut self, num: usize) -> Self { self.builder = self.builder.workers(num); self @@ -170,7 +189,7 @@ where /// By default max connections is set to a 256. #[allow(unused_variables)] pub fn max_connection_rate(self, num: usize) -> Self { - #[cfg(any(feature = "rustls", feature = "openssl"))] + #[cfg(feature = "__tls")] actix_tls::accept::max_concurrent_tls_connect(num); self } @@ -179,7 +198,7 @@ where /// /// One thread pool is set up **per worker**; not shared across workers. /// - /// By default set to 512 divided by the number of workers. + /// By default, set to 512 divided by [available parallelism](std::thread::available_parallelism()). pub fn worker_max_blocking_threads(mut self, num: usize) -> Self { self.builder = self.builder.worker_max_blocking_threads(num); self @@ -222,8 +241,8 @@ where /// Defines a timeout for TLS handshake. If the TLS handshake does not complete within this /// time, the connection is closed. /// - /// By default handshake timeout is set to 3000 milliseconds. - #[cfg(any(feature = "openssl", feature = "rustls"))] + /// By default, the handshake timeout is 3 seconds. + #[cfg(feature = "__tls")] pub fn tls_handshake_timeout(self, dur: Duration) -> Self { self.config .lock() @@ -240,6 +259,18 @@ where self.client_disconnect_timeout(Duration::from_millis(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(self, allow: bool) -> Self { + self.config.lock().unwrap().h1_allow_half_closed = allow; + self + } + /// Sets function that will be called once before each connection is handled. /// /// It will receive a `&std::any::Any`, which contains underlying connection type and an @@ -247,23 +278,23 @@ where /// /// # Connection Types /// - `actix_tls::accept::openssl::TlsStream` when using OpenSSL. - /// - `actix_tls::accept::rustls::TlsStream` when using Rustls. + /// - `actix_tls::accept::rustls_0_20::TlsStream` when using + /// Rustls v0.20. + /// - `actix_tls::accept::rustls_0_21::TlsStream` when using + /// Rustls v0.21. + /// - `actix_tls::accept::rustls_0_22::TlsStream` when using + /// Rustls v0.22. + /// - `actix_tls::accept::rustls_0_23::TlsStream` when using + /// Rustls v0.23. /// - `actix_web::rt::net::TcpStream` when no encryption is used. /// /// See the `on_connect` example for additional details. - pub fn on_connect(self, f: CB) -> HttpServer + pub fn on_connect(mut self, f: CB) -> HttpServer where CB: Fn(&dyn Any, &mut Extensions) + Send + Sync + 'static, { - HttpServer { - factory: self.factory, - config: self.config, - backlog: self.backlog, - sockets: self.sockets, - builder: self.builder, - on_connect_fn: Some(Arc::new(f)), - _phantom: PhantomData, - } + self.on_connect_fn = Some(Arc::new(f)); + self } /// Sets server host name. @@ -291,6 +322,37 @@ where self } + /// Specify shutdown signal from a future. + /// + /// Using this method will prevent OS signal handlers being set up. + /// + /// Typically, a `CancellationToken` will be used, but any future _can_ be. + /// + /// # Examples + /// + /// ```no_run + /// use actix_web::{App, HttpServer}; + /// use tokio_util::sync::CancellationToken; + /// + /// # #[actix_web::main] + /// # async fn main() -> std::io::Result<()> { + /// let stop_signal = CancellationToken::new(); + /// + /// HttpServer::new(move || App::new()) + /// .shutdown_signal(stop_signal.cancelled_owned()) + /// .bind(("127.0.0.1", 8080))? + /// .run() + /// .await + /// # } + /// ``` + pub fn shutdown_signal(mut self, shutdown_signal: Fut) -> Self + where + Fut: Future + Send + 'static, + { + self.builder = self.builder.shutdown_signal(shutdown_signal); + self + } + /// Sets timeout for graceful worker shutdown of workers. /// /// After receiving a stop signal, workers have this much time to finish serving requests. @@ -319,23 +381,41 @@ where /// Resolves socket address(es) and binds server to created listener(s). /// /// # Hostname Resolution - /// When `addr` includes a hostname, it is possible for this method to bind to both the IPv4 and - /// IPv6 addresses that result from a DNS lookup. You can test this by passing `localhost:8080` - /// and noting that the server binds to `127.0.0.1:8080` _and_ `[::1]:8080`. To bind additional - /// addresses, call this method multiple times. + /// + /// When `addrs` includes a hostname, it is possible for this method to bind to both the IPv4 + /// and IPv6 addresses that result from a DNS lookup. You can test this by passing + /// `localhost:8080` and noting that the server binds to `127.0.0.1:8080` _and_ `[::1]:8080`. To + /// bind additional addresses, call this method multiple times. /// /// Note that, if a DNS lookup is required, resolving hostnames is a blocking operation. /// + /// # Worker Count + /// + /// The `factory` will be instantiated multiple times in most scenarios. The number of + /// instantiations is number of [`workers`](Self::workers()) × number of sockets resolved by + /// `addrs`. + /// + /// For example, if you've manually set [`workers`](Self::workers()) to 2, and use `127.0.0.1` + /// as the bind `addrs`, then `factory` will be instantiated twice. However, using `localhost` + /// as the bind `addrs` can often resolve to both `127.0.0.1` (IPv4) _and_ `::1` (IPv6), causing + /// the `factory` to be instantiated 4 times (2 workers × 2 bind addresses). + /// + /// Using a bind address of `0.0.0.0`, which signals to use all interfaces, may also multiple + /// the number of instantiations in a similar way. + /// /// # Typical Usage + /// /// In general, use `127.0.0.1:` when testing locally and `0.0.0.0:` when deploying /// (with or without a reverse proxy or load balancer) so that the server is accessible. /// /// # Errors + /// /// Returns an `io::Error` if: /// - `addrs` cannot be resolved into one or more socket addresses; /// - all the resolved socket addresses are already bound. /// /// # Example + /// /// ``` /// # use actix_web::{App, HttpServer}; /// # fn inner() -> std::io::Result<()> { @@ -356,6 +436,8 @@ where /// Resolves socket address(es) and binds server to created listener(s) for plaintext HTTP/1.x /// or HTTP/2 connections. + /// + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. #[cfg(feature = "http2")] pub fn bind_auto_h2c(mut self, addrs: A) -> io::Result { let sockets = bind_addrs(addrs, self.backlog)?; @@ -368,20 +450,77 @@ where } /// Resolves socket address(es) and binds server to created listener(s) for TLS connections - /// using Rustls. + /// using Rustls v0.20. /// - /// See [`bind()`](Self::bind) for more details on `addrs` argument. + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. /// /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. - #[cfg(feature = "rustls")] + #[cfg(feature = "rustls-0_20")] pub fn bind_rustls( mut self, addrs: A, - config: RustlsServerConfig, + config: actix_tls::accept::rustls_0_20::reexports::ServerConfig, ) -> io::Result { let sockets = bind_addrs(addrs, self.backlog)?; for lst in sockets { - self = self.listen_rustls_inner(lst, config.clone())?; + self = self.listen_rustls_0_20_inner(lst, config.clone())?; + } + Ok(self) + } + + /// Resolves socket address(es) and binds server to created listener(s) for TLS connections + /// using Rustls v0.21. + /// + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_21")] + pub fn bind_rustls_021( + mut self, + addrs: A, + config: actix_tls::accept::rustls_0_21::reexports::ServerConfig, + ) -> io::Result { + let sockets = bind_addrs(addrs, self.backlog)?; + for lst in sockets { + self = self.listen_rustls_0_21_inner(lst, config.clone())?; + } + Ok(self) + } + + /// Resolves socket address(es) and binds server to created listener(s) for TLS connections + /// using Rustls v0.22. + /// + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_22")] + pub fn bind_rustls_0_22( + mut self, + addrs: A, + config: actix_tls::accept::rustls_0_22::reexports::ServerConfig, + ) -> io::Result { + let sockets = bind_addrs(addrs, self.backlog)?; + for lst in sockets { + self = self.listen_rustls_0_22_inner(lst, config.clone())?; + } + Ok(self) + } + + /// Resolves socket address(es) and binds server to created listener(s) for TLS connections + /// using Rustls v0.23. + /// + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_23")] + pub fn bind_rustls_0_23( + mut self, + addrs: A, + config: actix_tls::accept::rustls_0_23::reexports::ServerConfig, + ) -> io::Result { + let sockets = bind_addrs(addrs, self.backlog)?; + for lst in sockets { + self = self.listen_rustls_0_23_inner(lst, config.clone())?; } Ok(self) } @@ -389,7 +528,7 @@ where /// Resolves socket address(es) and binds server to created listener(s) for TLS connections /// using OpenSSL. /// - /// See [`bind()`](Self::bind) for more details on `addrs` argument. + /// See [`bind()`](Self::bind()) for more details on `addrs` argument. /// /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. #[cfg(feature = "openssl")] @@ -412,7 +551,7 @@ where /// No changes are made to `lst`'s configuration. Ensure it is configured properly before /// passing ownership to `listen()`. pub fn listen(mut self, lst: net::TcpListener) -> io::Result { - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let factory = self.factory.clone(); let addr = lst.local_addr().unwrap(); @@ -433,6 +572,7 @@ where .keep_alive(cfg.keep_alive) .client_request_timeout(cfg.client_request_timeout) .client_disconnect_timeout(cfg.client_disconnect_timeout) + .h1_allow_half_closed(cfg.h1_allow_half_closed) .local_addr(addr); if let Some(handler) = on_connect_fn.clone() { @@ -456,7 +596,7 @@ where /// Binds to existing listener for accepting incoming plaintext HTTP/1.x or HTTP/2 connections. #[cfg(feature = "http2")] pub fn listen_auto_h2c(mut self, lst: net::TcpListener) -> io::Result { - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let factory = self.factory.clone(); let addr = lst.local_addr().unwrap(); @@ -477,6 +617,7 @@ where .keep_alive(cfg.keep_alive) .client_request_timeout(cfg.client_request_timeout) .client_disconnect_timeout(cfg.client_disconnect_timeout) + .h1_allow_half_closed(cfg.h1_allow_half_closed) .local_addr(addr); if let Some(handler) = on_connect_fn.clone() { @@ -497,28 +638,44 @@ where Ok(self) } - /// Binds to existing listener for accepting incoming TLS connection requests using Rustls. + /// Binds to existing listener for accepting incoming TLS connection requests using Rustls + /// v0.20. /// /// See [`listen()`](Self::listen) for more details on the `lst` argument. /// /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. - #[cfg(feature = "rustls")] + #[cfg(feature = "rustls-0_20")] pub fn listen_rustls( self, lst: net::TcpListener, - config: RustlsServerConfig, + config: actix_tls::accept::rustls_0_20::reexports::ServerConfig, ) -> io::Result { - self.listen_rustls_inner(lst, config) + self.listen_rustls_0_20_inner(lst, config) } - #[cfg(feature = "rustls")] - fn listen_rustls_inner( + /// Binds to existing listener for accepting incoming TLS connection requests using Rustls + /// v0.21. + /// + /// See [`listen()`](Self::listen()) for more details on the `lst` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_21")] + pub fn listen_rustls_0_21( + self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_21::reexports::ServerConfig, + ) -> io::Result { + self.listen_rustls_0_21_inner(lst, config) + } + + #[cfg(feature = "rustls-0_20")] + fn listen_rustls_0_20_inner( mut self, lst: net::TcpListener, - config: RustlsServerConfig, + config: actix_tls::accept::rustls_0_20::reexports::ServerConfig, ) -> io::Result { let factory = self.factory.clone(); - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let addr = lst.local_addr().unwrap(); self.sockets.push(Socket { addr, @@ -536,6 +693,7 @@ where let svc = HttpService::build() .keep_alive(c.keep_alive) .client_request_timeout(c.client_request_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) .client_disconnect_timeout(c.client_disconnect_timeout); let svc = if let Some(handler) = on_connect_fn.clone() { @@ -562,6 +720,192 @@ where Ok(self) } + #[cfg(feature = "rustls-0_21")] + fn listen_rustls_0_21_inner( + mut self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_21::reexports::ServerConfig, + ) -> io::Result { + let factory = self.factory.clone(); + let cfg = Arc::clone(&self.config); + let addr = lst.local_addr().unwrap(); + self.sockets.push(Socket { + addr, + scheme: "https", + }); + + let on_connect_fn = self.on_connect_fn.clone(); + + self.builder = + self.builder + .listen(format!("actix-web-service-{}", addr), lst, move || { + let c = cfg.lock().unwrap(); + let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); + + let svc = HttpService::build() + .keep_alive(c.keep_alive) + .client_request_timeout(c.client_request_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) + .client_disconnect_timeout(c.client_disconnect_timeout); + + let svc = if let Some(handler) = on_connect_fn.clone() { + svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) + } else { + svc + }; + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + let acceptor_config = match c.tls_handshake_timeout { + Some(dur) => TlsAcceptorConfig::default().handshake_timeout(dur), + None => TlsAcceptorConfig::default(), + }; + + svc.finish(map_config(fac, move |_| { + AppConfig::new(true, host.clone(), addr) + })) + .rustls_021_with_config(config.clone(), acceptor_config) + })?; + + Ok(self) + } + + /// Binds to existing listener for accepting incoming TLS connection requests using Rustls + /// v0.22. + /// + /// See [`listen()`](Self::listen()) for more details on the `lst` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_22")] + pub fn listen_rustls_0_22( + self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_22::reexports::ServerConfig, + ) -> io::Result { + self.listen_rustls_0_22_inner(lst, config) + } + + #[cfg(feature = "rustls-0_22")] + fn listen_rustls_0_22_inner( + mut self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_22::reexports::ServerConfig, + ) -> io::Result { + let factory = self.factory.clone(); + let cfg = Arc::clone(&self.config); + let addr = lst.local_addr().unwrap(); + self.sockets.push(Socket { + addr, + scheme: "https", + }); + + let on_connect_fn = self.on_connect_fn.clone(); + + self.builder = + self.builder + .listen(format!("actix-web-service-{}", addr), lst, move || { + let c = cfg.lock().unwrap(); + let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); + + let svc = HttpService::build() + .keep_alive(c.keep_alive) + .client_request_timeout(c.client_request_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) + .client_disconnect_timeout(c.client_disconnect_timeout); + + let svc = if let Some(handler) = on_connect_fn.clone() { + svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) + } else { + svc + }; + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + let acceptor_config = match c.tls_handshake_timeout { + Some(dur) => TlsAcceptorConfig::default().handshake_timeout(dur), + None => TlsAcceptorConfig::default(), + }; + + svc.finish(map_config(fac, move |_| { + AppConfig::new(true, host.clone(), addr) + })) + .rustls_0_22_with_config(config.clone(), acceptor_config) + })?; + + Ok(self) + } + + /// Binds to existing listener for accepting incoming TLS connection requests using Rustls + /// v0.23. + /// + /// See [`listen()`](Self::listen()) for more details on the `lst` argument. + /// + /// ALPN protocols "h2" and "http/1.1" are added to any configured ones. + #[cfg(feature = "rustls-0_23")] + pub fn listen_rustls_0_23( + self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_23::reexports::ServerConfig, + ) -> io::Result { + self.listen_rustls_0_23_inner(lst, config) + } + + #[cfg(feature = "rustls-0_23")] + fn listen_rustls_0_23_inner( + mut self, + lst: net::TcpListener, + config: actix_tls::accept::rustls_0_23::reexports::ServerConfig, + ) -> io::Result { + let factory = self.factory.clone(); + let cfg = Arc::clone(&self.config); + let addr = lst.local_addr().unwrap(); + self.sockets.push(Socket { + addr, + scheme: "https", + }); + + let on_connect_fn = self.on_connect_fn.clone(); + + self.builder = + self.builder + .listen(format!("actix-web-service-{}", addr), lst, move || { + let c = cfg.lock().unwrap(); + let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); + + let svc = HttpService::build() + .keep_alive(c.keep_alive) + .client_request_timeout(c.client_request_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) + .client_disconnect_timeout(c.client_disconnect_timeout); + + let svc = if let Some(handler) = on_connect_fn.clone() { + svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) + } else { + svc + }; + + let fac = factory() + .into_factory() + .map_err(|err| err.into().error_response()); + + let acceptor_config = match c.tls_handshake_timeout { + Some(dur) => TlsAcceptorConfig::default().handshake_timeout(dur), + None => TlsAcceptorConfig::default(), + }; + + svc.finish(map_config(fac, move |_| { + AppConfig::new(true, host.clone(), addr) + })) + .rustls_0_23_with_config(config.clone(), acceptor_config) + })?; + + Ok(self) + } + /// Binds to existing listener for accepting incoming TLS connection requests using OpenSSL. /// /// See [`listen()`](Self::listen) for more details on the `lst` argument. @@ -583,8 +927,9 @@ where acceptor: SslAcceptor, ) -> io::Result { let factory = self.factory.clone(); - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let addr = lst.local_addr().unwrap(); + self.sockets.push(Socket { addr, scheme: "https", @@ -602,6 +947,7 @@ where .keep_alive(c.keep_alive) .client_request_timeout(c.client_request_timeout) .client_disconnect_timeout(c.client_disconnect_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) .local_addr(addr); let svc = if let Some(handler) = on_connect_fn.clone() { @@ -640,7 +986,7 @@ where use actix_rt::net::UnixStream; use actix_service::{fn_service, ServiceFactoryExt as _}; - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let factory = self.factory.clone(); let socket_addr = net::SocketAddr::new(net::IpAddr::V4(net::Ipv4Addr::new(127, 0, 0, 1)), 8080); @@ -670,6 +1016,7 @@ where .keep_alive(c.keep_alive) .client_request_timeout(c.client_request_timeout) .client_disconnect_timeout(c.client_disconnect_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) .finish(map_config(fac, move |_| config.clone())), ) }, @@ -685,10 +1032,11 @@ where use actix_rt::net::UnixStream; use actix_service::{fn_service, ServiceFactoryExt as _}; - let cfg = self.config.clone(); + let cfg = Arc::clone(&self.config); let factory = self.factory.clone(); let socket_addr = net::SocketAddr::new(net::IpAddr::V4(net::Ipv4Addr::new(127, 0, 0, 1)), 8080); + self.sockets.push(Socket { scheme: "http", addr: socket_addr, @@ -710,6 +1058,7 @@ where let mut svc = HttpService::build() .keep_alive(c.keep_alive) .client_request_timeout(c.client_request_timeout) + .h1_allow_half_closed(c.h1_allow_half_closed) .client_disconnect_timeout(c.client_disconnect_timeout); if let Some(handler) = on_connect_fn.clone() { @@ -767,7 +1116,7 @@ fn bind_addrs(addrs: impl net::ToSocketAddrs, backlog: u32) -> io::Result err = Some(e), + Err(error) => err = Some(error), } } @@ -776,10 +1125,7 @@ fn bind_addrs(addrs: impl net::ToSocketAddrs, backlog: u32) -> io::Result io::Result Option { self.head().peer_addr @@ -665,6 +662,7 @@ where /// ``` #[macro_export] macro_rules! services { + () => {()}; ($($x:expr),+ $(,)?) => { ($($x,)+) } @@ -703,7 +701,7 @@ mod tests { use crate::{ guard, http, test::{self, init_service, TestRequest}, - web, App, HttpResponse, + web, App, }; #[actix_rt::test] @@ -873,4 +871,40 @@ mod tests { let req = test::TestRequest::default().to_request(); let _res = test::call_service(&app, req).await; } + + #[test] + fn define_services_macro_with_multiple_arguments() { + let result = services!(1, 2, 3); + assert_eq!(result, (1, 2, 3)); + } + + #[test] + fn define_services_macro_with_single_argument() { + let result = services!(1); + assert_eq!(result, (1,)); + } + + #[test] + fn define_services_macro_with_no_arguments() { + let result = services!(); + let () = result; + } + + #[test] + fn define_services_macro_with_trailing_comma() { + let result = services!(1, 2, 3,); + assert_eq!(result, (1, 2, 3)); + } + + #[test] + fn define_services_macro_with_comments_in_arguments() { + let result = services!( + 1, // First comment + 2, // Second comment + 3 // Third comment + ); + + // Assert that comments are ignored and it correctly returns a tuple. + assert_eq!(result, (1, 2, 3)); + } } diff --git a/actix-web/src/test/mod.rs b/actix-web/src/test/mod.rs index 5e647956b..86cb60956 100644 --- a/actix-web/src/test/mod.rs +++ b/actix-web/src/test/mod.rs @@ -1,6 +1,6 @@ //! Various helpers for Actix applications to use during testing. //! -//! # Creating A Test Service +//! # Initializing A Test Service //! - [`init_service`] //! //! # Off-The-Shelf Test Services @@ -49,6 +49,7 @@ pub use self::{ /// Must be used inside an async test. Works for both `ServiceRequest` and `HttpRequest`. /// /// # Examples +/// /// ``` /// use actix_web::{http::StatusCode, HttpResponse}; /// diff --git a/actix-web/src/test/test_request.rs b/actix-web/src/test/test_request.rs index 5491af0ac..f178d6f43 100644 --- a/actix-web/src/test/test_request.rs +++ b/actix-web/src/test/test_request.rs @@ -86,76 +86,77 @@ impl Default for TestRequest { #[allow(clippy::wrong_self_convention)] impl TestRequest { - /// Create TestRequest and set request uri - pub fn with_uri(path: &str) -> TestRequest { - TestRequest::default().uri(path) + /// Constructs test request and sets request URI. + pub fn with_uri(uri: &str) -> TestRequest { + TestRequest::default().uri(uri) } - /// Create TestRequest and set method to `Method::GET` + /// Constructs test request with GET method. pub fn get() -> TestRequest { TestRequest::default().method(Method::GET) } - /// Create TestRequest and set method to `Method::POST` + /// Constructs test request with POST method. pub fn post() -> TestRequest { TestRequest::default().method(Method::POST) } - /// Create TestRequest and set method to `Method::PUT` + /// Constructs test request with PUT method. pub fn put() -> TestRequest { TestRequest::default().method(Method::PUT) } - /// Create TestRequest and set method to `Method::PATCH` + /// Constructs test request with PATCH method. pub fn patch() -> TestRequest { TestRequest::default().method(Method::PATCH) } - /// Create TestRequest and set method to `Method::DELETE` + /// Constructs test request with DELETE method. pub fn delete() -> TestRequest { TestRequest::default().method(Method::DELETE) } - /// Set HTTP version of this request + /// Sets HTTP version of this request. pub fn version(mut self, ver: Version) -> Self { self.req.version(ver); self } - /// Set HTTP method of this request + /// Sets method of this request. pub fn method(mut self, meth: Method) -> Self { self.req.method(meth); self } - /// Set HTTP URI of this request + /// Sets URI of this request. pub fn uri(mut self, path: &str) -> Self { self.req.uri(path); self } - /// Insert a header, replacing any that were set with an equivalent field name. + /// Inserts a header, replacing any that were set with an equivalent field name. pub fn insert_header(mut self, header: impl TryIntoHeaderPair) -> Self { self.req.insert_header(header); self } - /// Append a header, keeping any that were set with an equivalent field name. + /// Appends a header, keeping any that were set with an equivalent field name. pub fn append_header(mut self, header: impl TryIntoHeaderPair) -> Self { self.req.append_header(header); self } - /// Set cookie for this request. + /// Sets cookie for this request. #[cfg(feature = "cookies")] pub fn cookie(mut self, cookie: Cookie<'_>) -> Self { self.cookies.add(cookie.into_owned()); self } - /// Set request path pattern parameter. + /// Sets request path pattern parameter. /// /// # Examples + /// /// ``` /// use actix_web::test::TestRequest; /// @@ -171,19 +172,19 @@ impl TestRequest { self } - /// Set peer addr. + /// Sets peer address. pub fn peer_addr(mut self, addr: SocketAddr) -> Self { self.peer_addr = Some(addr); self } - /// Set request payload. + /// Sets request payload. pub fn set_payload(mut self, data: impl Into) -> Self { self.req.set_payload(data); self } - /// Serialize `data` to a URL encoded form and set it as the request payload. + /// Serializes `data` to a URL encoded form and set it as the request payload. /// /// The `Content-Type` header is set to `application/x-www-form-urlencoded`. pub fn set_form(mut self, data: impl Serialize) -> Self { @@ -194,7 +195,7 @@ impl TestRequest { self } - /// Serialize `data` to JSON and set it as the request payload. + /// Serializes `data` to JSON and set it as the request payload. /// /// The `Content-Type` header is set to `application/json`. pub fn set_json(mut self, data: impl Serialize) -> Self { @@ -204,27 +205,33 @@ impl TestRequest { self } - /// Set application data. This is equivalent of `App::data()` method - /// for testing purpose. - pub fn data(mut self, data: T) -> Self { - self.app_data.insert(Data::new(data)); - self - } - - /// Set application data. This is equivalent of `App::app_data()` method - /// for testing purpose. + /// Inserts application data. + /// + /// This is equivalent of `App::app_data()` method for testing purpose. pub fn app_data(mut self, data: T) -> Self { self.app_data.insert(data); self } + /// Inserts application data. + /// + /// This is equivalent of `App::data()` method for testing purpose. + #[doc(hidden)] + pub fn data(mut self, data: T) -> Self { + self.app_data.insert(Data::new(data)); + self + } + + /// Sets resource map. #[cfg(test)] - /// Set request config pub(crate) fn rmap(mut self, rmap: ResourceMap) -> Self { self.rmap = rmap; self } + /// Finalizes test request. + /// + /// This request builder will be useless after calling `finish()`. fn finish(&mut self) -> Request { // mut used when cookie feature is enabled #[allow(unused_mut)] @@ -251,14 +258,14 @@ impl TestRequest { req } - /// Complete request creation and generate `Request` instance + /// Finalizes request creation and returns `Request` instance. pub fn to_request(mut self) -> Request { let mut req = self.finish(); req.head_mut().peer_addr = self.peer_addr; req } - /// Complete request creation and generate `ServiceRequest` instance + /// Finalizes request creation and returns `ServiceRequest` instance. pub fn to_srv_request(mut self) -> ServiceRequest { let (mut head, payload) = self.finish().into_parts(); head.peer_addr = self.peer_addr; @@ -279,12 +286,12 @@ impl TestRequest { ) } - /// Complete request creation and generate `ServiceResponse` instance + /// Finalizes request creation and returns `ServiceResponse` instance. pub fn to_srv_response(self, res: HttpResponse) -> ServiceResponse { self.to_srv_request().into_response(res) } - /// Complete request creation and generate `HttpRequest` instance + /// Finalizes request creation and returns `HttpRequest` instance. pub fn to_http_request(mut self) -> HttpRequest { let (mut head, _) = self.finish().into_parts(); head.peer_addr = self.peer_addr; @@ -302,7 +309,7 @@ impl TestRequest { ) } - /// Complete request creation and generate `HttpRequest` and `Payload` instances + /// Finalizes request creation and returns `HttpRequest` and `Payload` pair. pub fn to_http_parts(mut self) -> (HttpRequest, Payload) { let (mut head, payload) = self.finish().into_parts(); head.peer_addr = self.peer_addr; @@ -322,7 +329,7 @@ impl TestRequest { (req, payload) } - /// Complete request creation, calls service and waits for response future completion. + /// Finalizes request creation, calls service, and waits for response future completion. pub async fn send_request(self, app: &S) -> S::Response where S: Service, Error = E>, @@ -343,7 +350,7 @@ mod tests { use std::time::SystemTime; use super::*; - use crate::{http::header, test::init_service, web, App, Error, HttpResponse, Responder}; + use crate::{http::header, test::init_service, web, App, Error, Responder}; #[actix_rt::test] async fn test_basics() { diff --git a/actix-web/src/thin_data.rs b/actix-web/src/thin_data.rs new file mode 100644 index 000000000..a9cd4e3a4 --- /dev/null +++ b/actix-web/src/thin_data.rs @@ -0,0 +1,121 @@ +use std::any::type_name; + +use actix_utils::future::{ready, Ready}; + +use crate::{dev::Payload, error, FromRequest, HttpRequest}; + +/// Application data wrapper and extractor for cheaply-cloned types. +/// +/// Similar to the [`Data`] wrapper but for `Clone`/`Copy` types that are already an `Arc` internally, +/// share state using some other means when cloned, or is otherwise static data that is very cheap +/// to clone. +/// +/// Unlike `Data`, this wrapper clones `T` during extraction. Therefore, it is the user's +/// responsibility to ensure that clones of `T` do actually share the same state, otherwise state +/// may be unexpectedly different across multiple requests. +/// +/// Note that if your type is literally an `Arc` then it's recommended to use the +/// [`Data::from(arc)`][data_from_arc] conversion instead. +/// +/// # Examples +/// +/// ``` +/// use actix_web::{ +/// web::{self, ThinData}, +/// App, HttpResponse, Responder, +/// }; +/// +/// // Use the `ThinData` extractor to access a database connection pool. +/// async fn index(ThinData(db_pool): ThinData) -> impl Responder { +/// // database action ... +/// +/// HttpResponse::Ok() +/// } +/// +/// # type DbPool = (); +/// let db_pool = DbPool::default(); +/// +/// App::new() +/// .app_data(ThinData(db_pool.clone())) +/// .service(web::resource("/").get(index)) +/// # ; +/// ``` +/// +/// [`Data`]: crate::web::Data +/// [data_from_arc]: crate::web::Data#impl-From>-for-Data +#[derive(Debug, Clone)] +pub struct ThinData(pub T); + +impl_more::impl_as_ref!(ThinData => T); +impl_more::impl_as_mut!(ThinData => T); +impl_more::impl_deref_and_mut!( in ThinData => T); + +impl FromRequest for ThinData { + type Error = crate::Error; + type Future = Ready>; + + #[inline] + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + ready(req.app_data::().cloned().ok_or_else(|| { + log::debug!( + "Failed to extract `ThinData<{}>` for `{}` handler. For the ThinData extractor to work \ + correctly, wrap the data with `ThinData()` and pass it to `App::app_data()`. \ + Ensure that types align in both the set and retrieve calls.", + type_name::(), + req.match_name().unwrap_or(req.path()) + ); + + error::ErrorInternalServerError( + "Requested application data is not configured correctly. \ + View/enable debug logs for more details.", + ) + })) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::{ + http::StatusCode, + test::{call_service, init_service, TestRequest}, + web, App, HttpResponse, + }; + + type TestT = Arc>; + + #[actix_rt::test] + async fn thin_data() { + let test_data = TestT::default(); + + let app = init_service(App::new().app_data(ThinData(test_data.clone())).service( + web::resource("/").to(|td: ThinData| { + *td.lock().unwrap() += 1; + HttpResponse::Ok() + }), + )) + .await; + + for _ in 0..3 { + let req = TestRequest::default().to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + } + + assert_eq!(*test_data.lock().unwrap(), 3); + } + + #[actix_rt::test] + async fn thin_data_missing() { + let app = init_service( + App::new().service(web::resource("/").to(|_: ThinData| HttpResponse::Ok())), + ) + .await; + + let req = TestRequest::default().to_request(); + let resp = call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/actix-web/src/types/either.rs b/actix-web/src/types/either.rs index db244fd9a..d8b3f1180 100644 --- a/actix-web/src/types/either.rs +++ b/actix-web/src/types/either.rs @@ -238,7 +238,7 @@ where match res { Ok(bytes) => { let fallback = bytes.clone(); - let left = L::from_request(this.req, &mut payload_from_bytes(bytes)); + let left = L::from_request(this.req, &mut dev::Payload::from(bytes)); EitherExtractState::Left { left, fallback } } Err(err) => break Err(EitherExtractError::Bytes(err)), @@ -251,7 +251,7 @@ where Err(left_err) => { let right = R::from_request( this.req, - &mut payload_from_bytes(mem::take(fallback)), + &mut dev::Payload::from(mem::take(fallback)), ); EitherExtractState::Right { left_err: Some(left_err), @@ -276,21 +276,12 @@ where } } -fn payload_from_bytes(bytes: Bytes) -> dev::Payload { - let (_, mut h1_payload) = actix_http::h1::Payload::create(true); - h1_payload.unread_data(bytes); - dev::Payload::from(h1_payload) -} - #[cfg(test)] mod tests { use serde::{Deserialize, Serialize}; use super::*; - use crate::{ - test::TestRequest, - web::{Form, Json}, - }; + use crate::test::TestRequest; #[derive(Debug, Clone, Serialize, Deserialize)] struct TestForm { diff --git a/actix-web/src/types/form.rs b/actix-web/src/types/form.rs index 7096b1e9c..d6381b990 100644 --- a/actix-web/src/types/form.rs +++ b/actix-web/src/types/form.rs @@ -418,7 +418,7 @@ mod tests { use super::*; use crate::{ http::{ - header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}, + header::{HeaderValue, CONTENT_TYPE}, StatusCode, }, test::{assert_body_eq, TestRequest}, diff --git a/actix-web/src/types/header.rs b/actix-web/src/types/header.rs index 8b1740e6c..977dc032e 100644 --- a/actix-web/src/types/header.rs +++ b/actix-web/src/types/header.rs @@ -2,7 +2,7 @@ use std::{fmt, ops}; -use actix_utils::future::{err, ok, Ready}; +use actix_utils::future::{ready, Ready}; use crate::{ dev::Payload, error::ParseError, extract::FromRequest, http::header::Header as ParseHeader, @@ -66,8 +66,8 @@ where #[inline] fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { match ParseHeader::parse(req) { - Ok(header) => ok(Header(header)), - Err(e) => err(e), + Ok(header) => ready(Ok(Header(header))), + Err(err) => ready(Err(err)), } } } diff --git a/actix-web/src/types/html.rs b/actix-web/src/types/html.rs new file mode 100644 index 000000000..c370ee07b --- /dev/null +++ b/actix-web/src/types/html.rs @@ -0,0 +1,66 @@ +//! Semantic HTML responder. See [`Html`]. + +use crate::{ + http::{ + header::{self, ContentType, TryIntoHeaderValue}, + StatusCode, + }, + HttpRequest, HttpResponse, Responder, +}; + +/// Semantic HTML responder. +/// +/// When used as a responder, creates a 200 OK response, sets the correct HTML content type, and +/// uses the string passed to [`Html::new()`] as the body. +/// +/// ``` +/// # use actix_web::web::Html; +/// Html::new("

Hello, World!

") +/// # ; +/// ``` +#[derive(Debug, Clone, PartialEq, Hash)] +pub struct Html(String); + +impl Html { + /// Constructs a new `Html` responder. + pub fn new(html: impl Into) -> Self { + Self(html.into()) + } +} + +impl Responder for Html { + type Body = String; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + let mut res = HttpResponse::with_body(StatusCode::OK, self.0); + res.headers_mut().insert( + header::CONTENT_TYPE, + ContentType::html().try_into_value().unwrap(), + ); + res + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::TestRequest; + + #[test] + fn responder() { + let req = TestRequest::default().to_http_request(); + + let res = Html::new("

Hello, World!

"); + let res = res.respond_to(&req); + + assert!(res.status().is_success()); + assert!(res + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + .starts_with("text/html")); + assert!(res.body().starts_with("

")); + } +} diff --git a/actix-web/src/types/json.rs b/actix-web/src/types/json.rs index 59c95da4c..58464f360 100644 --- a/actix-web/src/types/json.rs +++ b/actix-web/src/types/json.rs @@ -328,14 +328,19 @@ impl JsonBody { ctype_required: bool, ) -> Self { // check content-type - let can_parse_json = if let Ok(Some(mime)) = req.mime_type() { - mime.subtype() == mime::JSON - || mime.suffix() == Some(mime::JSON) - || ctype_fn.map_or(false, |predicate| predicate(mime)) - } else { - // if `ctype_required` is false, assume payload is - // json even when content-type header is missing - !ctype_required + let can_parse_json = match (ctype_required, req.mime_type()) { + (true, Ok(Some(mime))) => { + mime.subtype() == mime::JSON + || mime.suffix() == Some(mime::JSON) + || ctype_fn.is_some_and(|predicate| predicate(mime)) + } + + // if content-type is expected but not parsable as mime type, bail + (true, _) => false, + + // if content-type validation is disabled, assume payload is JSON + // even when content-type header is missing or invalid mime type + (false, _) => true, }; if !can_parse_json { @@ -393,7 +398,7 @@ impl JsonBody { _res: PhantomData, } } - JsonBody::Error(e) => JsonBody::Error(e), + JsonBody::Error(err) => JsonBody::Error(err), } } } @@ -429,7 +434,7 @@ impl Future for JsonBody { } } }, - JsonBody::Error(e) => Poll::Ready(Err(e.take().unwrap())), + JsonBody::Error(err) => Poll::Ready(Err(err.take().unwrap())), } } } @@ -611,7 +616,7 @@ mod tests { } )); - let (req, mut pl) = TestRequest::default() + let (mut req, mut pl) = TestRequest::default() .insert_header(( header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"), @@ -619,6 +624,7 @@ mod tests { .set_payload(Bytes::from_static(&[0u8; 1000])) .to_http_parts(); + req.head_mut().headers_mut().remove(header::CONTENT_LENGTH); let json = JsonBody::::new(&req, &mut pl, None, true) .limit(100) .await; @@ -725,6 +731,25 @@ mod tests { assert!(s.is_ok()) } + #[actix_rt::test] + async fn test_json_ignoring_content_type() { + let (req, mut pl) = TestRequest::default() + .insert_header(( + header::CONTENT_LENGTH, + header::HeaderValue::from_static("16"), + )) + .insert_header(( + header::CONTENT_TYPE, + header::HeaderValue::from_static("invalid/value"), + )) + .set_payload(Bytes::from_static(b"{\"name\": \"test\"}")) + .app_data(JsonConfig::default().content_type_required(false)) + .to_http_parts(); + + let s = Json::::from_request(&req, &mut pl).await; + assert!(s.is_ok()); + } + #[actix_rt::test] async fn test_with_config_in_data_wrapper() { let (req, mut pl) = TestRequest::default() diff --git a/actix-web/src/types/mod.rs b/actix-web/src/types/mod.rs index 792edd650..5334c46af 100644 --- a/actix-web/src/types/mod.rs +++ b/actix-web/src/types/mod.rs @@ -3,6 +3,7 @@ mod either; mod form; mod header; +mod html; mod json; mod path; mod payload; @@ -10,9 +11,10 @@ mod query; mod readlines; pub use self::{ - either::Either, + either::{Either, EitherExtractError}, form::{Form, FormConfig, UrlEncoded}, header::Header, + html::Html, json::{Json, JsonBody, JsonConfig}, path::{Path, PathConfig}, payload::{Payload, PayloadConfig}, diff --git a/actix-web/src/types/path.rs b/actix-web/src/types/path.rs index cc87bb80f..5f22568cc 100644 --- a/actix-web/src/types/path.rs +++ b/actix-web/src/types/path.rs @@ -89,8 +89,8 @@ where ); if let Some(error_handler) = error_handler { - let e = PathError::Deserialize(err); - (error_handler)(e, req) + let err = PathError::Deserialize(err); + (error_handler)(err, req) } else { ErrorNotFound(err) } @@ -159,7 +159,7 @@ mod tests { use crate::{error, http, test::TestRequest, HttpResponse}; #[derive(Deserialize, Debug, Display)] - #[display(fmt = "MyStruct({}, {})", key, value)] + #[display("MyStruct({}, {})", key, value)] struct MyStruct { key: String, value: String, diff --git a/actix-web/src/types/payload.rs b/actix-web/src/types/payload.rs index abb4e6b7f..e4db37d0b 100644 --- a/actix-web/src/types/payload.rs +++ b/actix-web/src/types/payload.rs @@ -440,13 +440,11 @@ impl Future for HttpMessageBody { #[cfg(test)] mod tests { - use bytes::Bytes; - use super::*; use crate::{ - http::{header, StatusCode}, + http::StatusCode, test::{call_service, init_service, read_body, TestRequest}, - web, App, Responder, + App, Responder, }; #[actix_rt::test] diff --git a/actix-web/src/types/query.rs b/actix-web/src/types/query.rs index e71b886f2..e5505131e 100644 --- a/actix-web/src/types/query.rs +++ b/actix-web/src/types/query.rs @@ -2,7 +2,7 @@ use std::{fmt, ops, sync::Arc}; -use actix_utils::future::{err, ok, Ready}; +use actix_utils::future::{ok, ready, Ready}; use serde::de::DeserializeOwned; use crate::{dev::Payload, error::QueryPayloadError, Error, FromRequest, HttpRequest}; @@ -118,8 +118,8 @@ impl FromRequest for Query { serde_urlencoded::from_str::(req.query_string()) .map(|val| ok(Query(val))) - .unwrap_or_else(move |e| { - let e = QueryPayloadError::Deserialize(e); + .unwrap_or_else(move |err| { + let err = QueryPayloadError::Deserialize(err); log::debug!( "Failed during Query extractor deserialization. \ @@ -127,13 +127,13 @@ impl FromRequest for Query { req.path() ); - let e = if let Some(error_handler) = error_handler { - (error_handler)(e, req) + let err = if let Some(error_handler) = error_handler { + (error_handler)(err, req) } else { - e.into() + err.into() }; - err(e) + ready(Err(err)) }) } } diff --git a/actix-web/src/web.rs b/actix-web/src/web.rs index 204313752..92d05eb74 100644 --- a/actix-web/src/web.rs +++ b/actix-web/src/web.rs @@ -2,6 +2,7 @@ //! //! # Request Extractors //! - [`Data`]: Application data item +//! - [`ThinData`]: Cheap-to-clone application data item //! - [`ReqData`]: Request-local data item //! - [`Path`]: URL path parameters / dynamic segments //! - [`Query`]: URL query parameters @@ -22,7 +23,8 @@ use actix_router::IntoPatterns; pub use bytes::{Buf, BufMut, Bytes, BytesMut}; pub use crate::{ - config::ServiceConfig, data::Data, redirect::Redirect, request_data::ReqData, types::*, + config::ServiceConfig, data::Data, redirect::Redirect, request_data::ReqData, + thin_data::ThinData, types::*, }; use crate::{ error::BlockingError, http::Method, service::WebService, FromRequest, Handler, Resource, @@ -36,7 +38,7 @@ use crate::{ /// /// A dynamic segment is specified in the form `{identifier}`, where the identifier can be used /// later in a request handler to access the matched value for that segment. This is done by looking -/// up the identifier in the `Path` object returned by [`HttpRequest.match_info()`] method. +/// up the identifier in the `Path` object returned by [`HttpRequest::match_info()`](crate::HttpRequest::match_info) method. /// /// By default, each segment matches the regular expression `[^{}/]+`. /// diff --git a/actix-web/tests/compression.rs b/actix-web/tests/compression.rs index b911b9d1f..61ff1bff5 100644 --- a/actix-web/tests/compression.rs +++ b/actix-web/tests/compression.rs @@ -96,7 +96,7 @@ async fn negotiate_encoding_gzip() { let req = srv .post("/static") - .insert_header((header::ACCEPT_ENCODING, "gzip,br,zstd")) + .insert_header((header::ACCEPT_ENCODING, "gzip, br;q=0.8, zstd;q=0.5")) .send(); let mut res = req.await.unwrap(); @@ -109,7 +109,7 @@ async fn negotiate_encoding_gzip() { let mut res = srv .post("/static") .no_decompress() - .insert_header((header::ACCEPT_ENCODING, "gzip,br,zstd")) + .insert_header((header::ACCEPT_ENCODING, "gzip, br;q=0.8, zstd;q=0.5")) .send() .await .unwrap(); @@ -123,9 +123,11 @@ async fn negotiate_encoding_gzip() { async fn negotiate_encoding_br() { let srv = test_server!(); + // check that brotli content-encoding header is returned + let req = srv .post("/static") - .insert_header((header::ACCEPT_ENCODING, "br,zstd,gzip")) + .insert_header((header::ACCEPT_ENCODING, "br, zstd, gzip")) .send(); let mut res = req.await.unwrap(); @@ -135,10 +137,26 @@ async fn negotiate_encoding_br() { let bytes = res.body().await.unwrap(); assert_eq!(bytes, Bytes::from_static(LOREM)); + // check that brotli is preferred even when later in (q-less) list + + let req = srv + .post("/static") + .insert_header((header::ACCEPT_ENCODING, "gzip, zstd, br")) + .send(); + + let mut res = req.await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers().get(header::CONTENT_ENCODING).unwrap(), "br"); + + let bytes = res.body().await.unwrap(); + assert_eq!(bytes, Bytes::from_static(LOREM)); + + // check that returned content is actually brotli encoded + let mut res = srv .post("/static") .no_decompress() - .insert_header((header::ACCEPT_ENCODING, "br,zstd,gzip")) + .insert_header((header::ACCEPT_ENCODING, "br, zstd, gzip")) .send() .await .unwrap(); @@ -154,7 +172,7 @@ async fn negotiate_encoding_zstd() { let req = srv .post("/static") - .insert_header((header::ACCEPT_ENCODING, "zstd,gzip,br")) + .insert_header((header::ACCEPT_ENCODING, "zstd, gzip, br;q=0.8")) .send(); let mut res = req.await.unwrap(); @@ -167,7 +185,7 @@ async fn negotiate_encoding_zstd() { let mut res = srv .post("/static") .no_decompress() - .insert_header((header::ACCEPT_ENCODING, "zstd,gzip,br")) + .insert_header((header::ACCEPT_ENCODING, "zstd, gzip, br;q=0.8")) .send() .await .unwrap(); @@ -207,7 +225,7 @@ async fn gzip_no_decompress() { // don't decompress response body .no_decompress() // signal that we want a compressed body - .insert_header((header::ACCEPT_ENCODING, "gzip,br,zstd")) + .insert_header((header::ACCEPT_ENCODING, "gzip, br;q=0.8, zstd;q=0.5")) .send(); let mut res = req.await.unwrap(); diff --git a/actix-web/tests/test_httpserver.rs b/actix-web/tests/test_httpserver.rs index 861d76d93..5fd7d7190 100644 --- a/actix-web/tests/test_httpserver.rs +++ b/actix-web/tests/test_httpserver.rs @@ -1,15 +1,10 @@ -#![allow(clippy::uninlined_format_args)] - #[cfg(feature = "openssl")] extern crate tls_openssl as openssl; -#[cfg(any(unix, feature = "openssl"))] -use { - actix_web::{web, App, HttpResponse, HttpServer}, - std::{sync::mpsc, thread, time::Duration}, -}; +use std::{sync::mpsc, thread, time::Duration}; + +use actix_web::{web, App, HttpResponse, HttpServer}; -#[cfg(unix)] #[actix_rt::test] async fn test_start() { let addr = actix_test::unused_addr(); @@ -55,6 +50,27 @@ async fn test_start() { let response = client.get(host.clone()).send().await.unwrap(); assert!(response.status().is_success()); + // Attempt to start a second server using the same address. + let result = HttpServer::new(|| { + App::new().service( + web::resource("/").route(web::to(|| async { HttpResponse::Ok().body("test") })), + ) + }) + .workers(1) + .backlog(1) + .max_connections(10) + .max_connection_rate(10) + .keep_alive(Duration::from_secs(10)) + .client_request_timeout(Duration::from_secs(5)) + .client_disconnect_timeout(Duration::ZERO) + .server_hostname("localhost") + .system_exit() + .disable_signals() + .bind(format!("{}", addr)); + + // This should fail: the address is in use. + assert!(result.is_err()); + srv.stop(false).await; } @@ -66,9 +82,11 @@ fn ssl_acceptor() -> openssl::ssl::SslAcceptorBuilder { x509::X509, }; - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_pem(); + 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 = X509::from_pem(cert_file.as_bytes()).unwrap(); let key = PKey::private_key_from_pem(key_file.as_bytes()).unwrap(); diff --git a/actix-web/tests/test_server.rs b/actix-web/tests/test_server.rs index 8ce889396..343b7f104 100644 --- a/actix-web/tests/test_server.rs +++ b/actix-web/tests/test_server.rs @@ -1,6 +1,6 @@ #[cfg(feature = "openssl")] extern crate tls_openssl as openssl; -#[cfg(feature = "rustls")] +#[cfg(feature = "rustls-0_23")] extern crate tls_rustls as rustls; use std::{ @@ -25,7 +25,7 @@ use openssl::{ ssl::{SslAcceptor, SslMethod}, x509::X509, }; -use rand::{distributions::Alphanumeric, Rng as _}; +use rand::distr::{Alphanumeric, SampleString as _}; mod utils; @@ -34,9 +34,11 @@ const STR: &str = const_str::repeat!(S, 100); #[cfg(feature = "openssl")] fn openssl_config() -> SslAcceptor { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_pem(); + 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 = X509::from_pem(cert_file.as_bytes()).unwrap(); let key = PKey::private_key_from_pem(key_file.as_bytes()).unwrap(); @@ -186,11 +188,7 @@ async fn body_gzip_large() { #[actix_rt::test] async fn test_body_gzip_large_random() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(70_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 70_000); let srv_data = data.clone(); let srv = actix_test::start_with(actix_test::config().h1(), move || { @@ -430,11 +428,7 @@ async fn test_zstd_encoding() { #[actix_rt::test] async fn test_zstd_encoding_large() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(320_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 320_000); let srv = actix_test::start_with(actix_test::config().h1(), || { App::new().service( @@ -527,11 +521,7 @@ async fn test_gzip_encoding_large() { #[actix_rt::test] async fn test_reading_gzip_encoding_large_random() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(60_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 60_000); let srv = actix_test::start_with(actix_test::config().h1(), || { App::new().service(web::resource("/").route(web::to(move |body: Bytes| async { @@ -597,11 +587,7 @@ async fn test_reading_deflate_encoding_large() { #[actix_rt::test] async fn test_reading_deflate_encoding_large_random() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(160_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 160_000); let srv = actix_test::start_with(actix_test::config().h1(), || { App::new().service(web::resource("/").route(web::to(move |body: Bytes| async { @@ -646,11 +632,7 @@ async fn test_brotli_encoding() { #[actix_rt::test] async fn test_brotli_encoding_large() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(320_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 320_000); let srv = actix_test::start_with(actix_test::config().h1(), || { App::new().service( @@ -704,46 +686,30 @@ async fn test_brotli_encoding_large_openssl() { srv.stop().await; } -#[cfg(feature = "rustls")] +#[cfg(feature = "rustls-0_23")] mod plus_rustls { - use std::io::BufReader; - - use rustls::{Certificate, PrivateKey, ServerConfig as RustlsServerConfig}; - use rustls_pemfile::{certs, pkcs8_private_keys}; + use rustls::{pki_types::PrivateKeyDer, ServerConfig as RustlsServerConfig}; + use rustls_pki_types::PrivatePkcs8KeyDer; use super::*; fn tls_config() -> RustlsServerConfig { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_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) - .unwrap() - .into_iter() - .map(Certificate) - .collect(); - let mut keys = pkcs8_private_keys(key_file).unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = + rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); RustlsServerConfig::builder() - .with_safe_defaults() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKey(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap() } #[actix_rt::test] async fn test_reading_deflate_encoding_large_random_rustls() { - let data = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(160_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 160_000); - let srv = actix_test::start_with(actix_test::config().rustls(tls_config()), || { + let srv = actix_test::start_with(actix_test::config().rustls_0_23(tls_config()), || { App::new().service(web::resource("/").route(web::to(|bytes: Bytes| async { // echo decompressed request body back in response HttpResponse::Ok() diff --git a/actix-web/tests/test_streaming_response.rs b/actix-web/tests/test_streaming_response.rs new file mode 100644 index 000000000..1c22da38b --- /dev/null +++ b/actix-web/tests/test_streaming_response.rs @@ -0,0 +1,115 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use actix_web::{ + http::header::{self, HeaderValue}, + HttpResponse, +}; +use bytes::Bytes; +use futures_core::Stream; + +struct FixedSizeStream { + data: Vec, + yielded: bool, +} + +impl FixedSizeStream { + fn new(size: usize) -> Self { + Self { + data: vec![0u8; size], + yielded: false, + } + } +} + +impl Stream for FixedSizeStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + if self.yielded { + Poll::Ready(None) + } else { + self.yielded = true; + let data = std::mem::take(&mut self.data); + Poll::Ready(Some(Ok(Bytes::from(data)))) + } + } +} + +#[actix_rt::test] +async fn test_streaming_response_with_content_length() { + let stream = FixedSizeStream::new(100); + + let resp = HttpResponse::Ok() + .append_header((header::CONTENT_LENGTH, "100")) + .streaming(stream); + + assert_eq!( + resp.headers().get(header::CONTENT_LENGTH), + Some(&HeaderValue::from_static("100")), + "Content-Length should be preserved when explicitly set" + ); + + let has_chunked = resp + .headers() + .get(header::TRANSFER_ENCODING) + .map(|v| v.to_str().unwrap_or("")) + .unwrap_or("") + .contains("chunked"); + + assert!( + !has_chunked, + "chunked should not be used when Content-Length is provided" + ); + + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/octet-stream")), + "Content-Type should default to application/octet-stream" + ); +} + +#[actix_rt::test] +async fn test_streaming_response_default_content_type() { + let stream = FixedSizeStream::new(50); + + let resp = HttpResponse::Ok().streaming(stream); + + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/octet-stream")), + "Content-Type should default to application/octet-stream" + ); +} + +#[actix_rt::test] +async fn test_streaming_response_user_defined_content_type() { + let stream = FixedSizeStream::new(25); + + let resp = HttpResponse::Ok() + .insert_header((header::CONTENT_TYPE, "text/plain")) + .streaming(stream); + + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain")), + "User-defined Content-Type should be preserved" + ); +} + +#[actix_rt::test] +async fn test_streaming_response_empty_stream() { + let stream = FixedSizeStream::new(0); + + let resp = HttpResponse::Ok() + .append_header((header::CONTENT_LENGTH, "0")) + .streaming(stream); + + assert_eq!( + resp.headers().get(header::CONTENT_LENGTH), + Some(&HeaderValue::from_static("0")), + "Content-Length 0 should be preserved for empty streams" + ); +} diff --git a/awc/CHANGES.md b/awc/CHANGES.md index f5bddd04d..7d09212c2 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -1,22 +1,70 @@ # Changes -## Unreleased - 2023-xx-xx +## Unreleased -- Minimum supported Rust version (MSRV) is now 1.65 due to transitive `time` dependency. +- Minimum supported Rust version (MSRV) is now 1.88. -## 3.1.1 - 2023-02-26 +## 3.8.1 + +- Fix a bug where `GO_AWAY` errors did not stop connections from returning to the pool. + +## 3.8.0 + +- Add `hickory-dns` crate feature (off-by-default). +- The `trust-dns` crate feature now delegates DNS resolution to `hickory-dns`. + +## 3.7.0 + +- Update `brotli` dependency to `8`. + +## 3.6.0 + +- Prevent panics on connection pool drop when Tokio runtime is shutdown early. +- Do not send `Host` header on HTTP/2 requests, as it is not required, and some web servers may reject it. +- Update `brotli` dependency to `7`. +- Minimum supported Rust version (MSRV) is now 1.75. + +## 3.5.1 + +- Fix WebSocket `Host` request header value when using a non-default port. + +## 3.5.0 + +- Add `rustls-0_23`, `rustls-0_23-webpki-roots`, and `rustls-0_23-native-roots` crate features. +- Add `awc::Connector::rustls_0_23()` constructor. +- Fix `rustls-0_22-native-roots` root store lookup. +- Update `brotli` dependency to `6`. +- Minimum supported Rust version (MSRV) is now 1.72. + +## 3.4.0 + +- Add `rustls-0_22-webpki-roots` and `rustls-0_22-native-roots` crate feature. +- Add `awc::Connector::rustls_0_22()` method. + +## 3.3.0 + +- Update `trust-dns-resolver` dependency to `0.23`. +- Updated `zstd` dependency to `0.13`. + +## 3.2.0 + +- Add `awc::Connector::rustls_021()` method for Rustls v0.21 support behind new `rustls-0_21` crate feature. +- Add `rustls-0_20` crate feature, which the existing `rustls` feature now aliases. +- Minimum supported Rust version (MSRV) is now 1.68 due to transitive `time` dependency. + +## 3.1.1 ### Changed - `client::Connect` is now public to allow tunneling connection with `client::Connector`. -## 3.1.0 - 2023-01-21 +## 3.1.0 ### Changed - Minimum supported Rust version (MSRV) is now 1.59 due to transitive `time` dependency. -## 3.0.1 - 2022-08-25 +## 3.0.1 ### Changed @@ -28,7 +76,7 @@ [#2840]: https://github.com/actix/actix-web/pull/2840 -## 3.0.0 - 2022-03-07 +## 3.0.0 ### Dependencies @@ -130,23 +178,23 @@

3.0.0 Pre-Releases -## 3.0.0-beta.21 - 2022-02-16 +## 3.0.0-beta.21 - No significant changes since `3.0.0-beta.20`. -## 3.0.0-beta.20 - 2022-01-31 +## 3.0.0-beta.20 - No significant changes since `3.0.0-beta.19`. -## 3.0.0-beta.19 - 2022-01-21 +## 3.0.0-beta.19 - No significant changes since `3.0.0-beta.18`. -## 3.0.0-beta.18 - 2022-01-04 +## 3.0.0-beta.18 - Minimum supported Rust version (MSRV) is now 1.54. -## 3.0.0-beta.17 - 2021-12-29 +## 3.0.0-beta.17 ### Changed @@ -159,7 +207,7 @@ [#2555]: https://github.com/actix/actix-web/pull/2555 [`rustsec-2020-0071`]: https://rustsec.org/advisories/RUSTSEC-2020-0071.html -## 3.0.0-beta.16 - 2021-12-29 +## 3.0.0-beta.16 - `*::send_json` and `*::send_form` methods now receive `impl Serialize`. [#2553] - `FrozenClientRequest::extra_header` now uses receives an `impl TryIntoHeaderPair`. [#2553] @@ -167,7 +215,7 @@ [#2553]: https://github.com/actix/actix-web/pull/2553 -## 3.0.0-beta.15 - 2021-12-27 +## 3.0.0-beta.15 - Rename `Connector::{ssl => openssl}`. [#2503] - Improve `Client` instantiation efficiency when using `openssl` by only building connectors once. [#2503] @@ -180,37 +228,37 @@ [#2503]: https://github.com/actix/actix-web/pull/2503 [#2546]: https://github.com/actix/actix-web/pull/2546 -## 3.0.0-beta.14 - 2021-12-17 +## 3.0.0-beta.14 - Add `ClientBuilder::add_default_header` and deprecate `ClientBuilder::header`. [#2510] [#2510]: https://github.com/actix/actix-web/pull/2510 -## 3.0.0-beta.13 - 2021-12-11 +## 3.0.0-beta.13 - No significant changes since `3.0.0-beta.12`. -## 3.0.0-beta.12 - 2021-11-30 +## 3.0.0-beta.12 - Update `actix-tls` to `3.0.0-rc.1`. [#2474] [#2474]: https://github.com/actix/actix-web/pull/2474 -## 3.0.0-beta.11 - 2021-11-22 +## 3.0.0-beta.11 - No significant changes from `3.0.0-beta.10`. -## 3.0.0-beta.10 - 2021-11-15 +## 3.0.0-beta.10 - No significant changes from `3.0.0-beta.9`. -## 3.0.0-beta.9 - 2021-10-20 +## 3.0.0-beta.9 - Updated rustls to v0.20. [#2414] [#2414]: https://github.com/actix/actix-web/pull/2414 -## 3.0.0-beta.8 - 2021-09-09 +## 3.0.0-beta.8 ### Changed @@ -218,7 +266,7 @@ [#2310]: https://github.com/actix/actix-web/pull/2310 -## 3.0.0-beta.7 - 2021-06-26 +## 3.0.0-beta.7 ### Changed @@ -226,11 +274,11 @@ [#2250]: https://github.com/actix/actix-web/pull/2250 -## 3.0.0-beta.6 - 2021-06-17 +## 3.0.0-beta.6 - No significant changes since 3.0.0-beta.5. -## 3.0.0-beta.5 - 2021-04-17 +## 3.0.0-beta.5 ### Removed @@ -238,7 +286,7 @@ [#2148]: https://github.com/actix/actix-web/pull/2148 -## 3.0.0-beta.4 - 2021-04-02 +## 3.0.0-beta.4 ### Added @@ -255,7 +303,7 @@ [#2114]: https://github.com/actix/actix-web/pull/2114 [#2116]: https://github.com/actix/actix-web/pull/2116 -## 3.0.0-beta.3 - 2021-03-08 +## 3.0.0-beta.3 ### Added @@ -278,7 +326,7 @@ [#2024]: https://github.com/actix/actix-web/pull/2024 [#2050]: https://github.com/actix/actix-web/pull/2050 -## 3.0.0-beta.2 - 2021-02-10 +## 3.0.0-beta.2 ### Added @@ -301,7 +349,7 @@ [#1905]: https://github.com/actix/actix-web/pull/1905 [#1969]: https://github.com/actix/actix-web/pull/1969 -## 3.0.0-beta.1 - 2021-01-07 +## 3.0.0-beta.1 ### Changed @@ -313,13 +361,13 @@
-## 2.0.3 - 2020-11-29 +## 2.0.3 ### Fixed - Ensure `actix-http` dependency uses same `serde_urlencoded`. -## 2.0.2 - 2020-11-25 +## 2.0.2 ### Changed @@ -327,7 +375,7 @@ [#1773]: https://github.com/actix/actix-web/pull/1773 -## 2.0.1 - 2020-10-30 +## 2.0.1 ### Changed @@ -342,37 +390,37 @@ [#1760]: https://github.com/actix/actix-web/pull/1760 [#1744]: https://github.com/actix/actix-web/pull/1744 -## 2.0.0 - 2020-09-11 +## 2.0.0 ### Changed - `Client::build` was renamed to `Client::builder`. -## 2.0.0-beta.4 - 2020-09-09 +## 2.0.0-beta.4 ### Changed - Update actix-codec & actix-tls dependencies. -## 2.0.0-beta.3 - 2020-08-17 +## 2.0.0-beta.3 ### Changed - Update `rustls` to 0.18 -## 2.0.0-beta.2 - 2020-07-21 +## 2.0.0-beta.2 ### Changed - Update `actix-http` dependency to 2.0.0-beta.2 -## [2.0.0-beta.1] - 2020-07-14 +## 2.0.0-beta.1 ### Changed - Update `actix-http` dependency to 2.0.0-beta.1 -## [2.0.0-alpha.2] - 2020-05-21 +## 2.0.0-alpha.2 ### Changed @@ -382,42 +430,42 @@ [#1422]: https://github.com/actix/actix-web/pull/1422 -## [2.0.0-alpha.1] - 2020-03-11 +## 2.0.0-alpha.1 - Update `actix-http` dependency to 2.0.0-alpha.2 - Update `rustls` dependency to 0.17 - ClientBuilder accepts initial_window_size and initial_connection_window_size HTTP2 configuration - ClientBuilder allowing to set max_http_version to limit HTTP version to be used -## [1.0.1] - 2019-12-15 +## 1.0.1 - Fix compilation with default features off -## [1.0.0] - 2019-12-13 +## 1.0.0 - Release -## [1.0.0-alpha.3] +## 1.0.0-alpha.3 - Migrate to `std::future` -## [0.2.8] - 2019-11-06 +## 0.2.8 - Add support for setting query from Serialize type for client request. -## [0.2.7] - 2019-09-25 +## 0.2.7 ### Added - Remaining getter methods for `ClientRequest`'s private `head` field #1101 -## [0.2.6] - 2019-09-12 +## 0.2.6 ### Added - Export frozen request related types. -## [0.2.5] - 2019-09-11 +## 0.2.5 ### Added @@ -427,7 +475,7 @@ - Ensure that the `Host` header is set when initiating a WebSocket client connection. -## [0.2.4] - 2019-08-13 +## 0.2.4 ### Changed @@ -435,13 +483,13 @@ - Update serde_urlencoded to "0.6.1" -## [0.2.3] - 2019-08-01 +## 0.2.3 ### Added - Add `rustls` support -## [0.2.2] - 2019-07-01 +## 0.2.2 ### Changed @@ -449,13 +497,13 @@ - Upgrade `rand` dependency version to 0.7 -## [0.2.1] - 2019-06-05 +## 0.2.1 ### Added - Add license files -## [0.2.0] - 2019-05-12 +## 0.2.0 ### Added @@ -465,7 +513,7 @@ - Upgrade actix-http dependency. -## [0.1.1] - 2019-04-19 +## 0.1.1 ### Added @@ -475,17 +523,17 @@ - `ClientRequest::if_true()` and `ClientRequest::if_some()` use instance instead of ref -## [0.1.0] - 2019-04-16 +## 0.1.0 - No changes -## [0.1.0-alpha.6] - 2019-04-14 +## 0.1.0-alpha.6 ### Changed - Do not set default headers for websocket request -## [0.1.0-alpha.5] - 2019-04-12 +## 0.1.0-alpha.5 ### Changed @@ -495,13 +543,13 @@ - Add Debug impl for BoxedSocket -## [0.1.0-alpha.4] - 2019-04-08 +## 0.1.0-alpha.4 ### Changed - Update actix-http dependency -## [0.1.0-alpha.3] - 2019-04-02 +## 0.1.0-alpha.3 ### Added @@ -517,7 +565,7 @@ - Renamed `ClientRequest::close_connection()` to `ClientRequest::force_close()` -## [0.1.0-alpha.2] - 2019-03-29 +## 0.1.0-alpha.2 ### Added @@ -535,6 +583,6 @@ - Export `ws` sub-module with websockets related types -## [0.1.0-alpha.1] - 2019-03-28 +## 0.1.0-alpha.1 - Initial impl diff --git a/awc/Cargo.toml b/awc/Cargo.toml index daec84ab9..5386b7994 100644 --- a/awc/Cargo.toml +++ b/awc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "awc" -version = "3.1.1" +version = "3.8.1" authors = ["Nikolay Kim "] description = "Async HTTP and WebSocket client library" keywords = ["actix", "http", "framework", "async", "web"] @@ -11,26 +11,66 @@ categories = [ "web-programming::websocket", ] homepage = "https://actix.rs" -repository = "https://github.com/actix/actix-web.git" +repository = "https://github.com/actix/actix-web" license = "MIT OR Apache-2.0" edition = "2021" -[lib] -name = "awc" -path = "src/lib.rs" - [package.metadata.docs.rs] -# features that docs.rs will build with -features = ["openssl", "rustls", "compress-brotli", "compress-gzip", "compress-zstd", "cookies"] +features = [ + "cookies", + "openssl", + "rustls-0_20", + "rustls-0_21", + "rustls-0_22-webpki-roots", + "rustls-0_23-webpki-roots", + "compress-brotli", + "compress-gzip", + "compress-zstd", +] + +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "actix_codec::*", + "actix_http::*", + "actix_rt::*", + "actix_service::*", + "actix_tls::*", + "bytes::*", + "cookie::*", + "cookie", + "futures_core::*", + "h2::*", + "http::*", + "openssl::*", + "rustls::*", + "serde_json::*", + "serde_urlencoded::*", + "serde::*", + "tokio::*", +] [features] default = ["compress-brotli", "compress-gzip", "compress-zstd", "cookies"] -# openssl +# TLS via OpenSSL openssl = ["tls-openssl", "actix-tls/openssl"] -# rustls -rustls = ["tls-rustls", "actix-tls/rustls"] +# TLS via Rustls v0.20 +rustls = ["rustls-0_20"] +# TLS via Rustls v0.20 +rustls-0_20 = ["tls-rustls-0_20", "actix-tls/rustls-0_20"] +# TLS via Rustls v0.21 +rustls-0_21 = ["tls-rustls-0_21", "actix-tls/rustls-0_21"] +# TLS via Rustls v0.22 (WebPKI roots) +rustls-0_22-webpki-roots = ["tls-rustls-0_22", "actix-tls/rustls-0_22-webpki-roots"] +# TLS via Rustls v0.22 (Native roots) +rustls-0_22-native-roots = ["tls-rustls-0_22", "actix-tls/rustls-0_22-native-roots"] +# TLS via Rustls v0.23 +rustls-0_23 = ["tls-rustls-0_23", "actix-tls/rustls-0_23"] +# TLS via Rustls v0.23 (WebPKI roots) +rustls-0_23-webpki-roots = ["rustls-0_23", "actix-tls/rustls-0_23-webpki-roots"] +# TLS via Rustls v0.23 (Native roots) +rustls-0_23-native-roots = ["rustls-0_23", "actix-tls/rustls-0_23-native-roots"] # Brotli algorithm content-encoding support compress-brotli = ["actix-http/compress-brotli", "__compress"] @@ -39,13 +79,15 @@ compress-gzip = ["actix-http/compress-gzip", "__compress"] # Zstd algorithm content-encoding support compress-zstd = ["actix-http/compress-zstd", "__compress"] -# cookie parsing and cookie jar -cookies = ["cookie"] +# Cookie parsing and cookie jar +cookies = ["dep:cookie"] -# trust-dns as dns resolver -trust-dns = ["trust-dns-resolver"] +# Use `hickory-dns-resolver` crate as DNS resolver +hickory-dns = ["dep:hickory-resolver"] +# Use `trust-dns-resolver` crate as DNS resolver (deprecated, use `hickory-dns`) +trust-dns = ["hickory-dns"] -# Internal (PRIVATE!) features used to aid testing and cheking feature status. +# Internal (PRIVATE!) features used to aid testing and checking feature status. # Don't rely on these whatsoever. They may disappear at anytime. __compress = [] @@ -56,58 +98,65 @@ dangerous-h2c = [] [dependencies] actix-codec = "0.5" -actix-service = "2" -actix-http = { version = "3.3", features = ["http2", "ws"] } +actix-http = { version = "3.10", features = ["http2", "ws"] } actix-rt = { version = "2.1", default-features = false } -actix-tls = { version = "3", features = ["connect", "uri"] } +actix-service = "2" +actix-tls = { version = "3.4", features = ["connect", "uri"] } actix-utils = "3" -base64 = "0.21" +base64 = "0.22" bytes = "1" cfg-if = "1" -derive_more = "0.99.5" +derive_more = { version = "2", features = ["display", "error", "from"] } futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } futures-util = { version = "0.3.17", default-features = false, features = ["alloc", "sink"] } -h2 = "0.3.17" +h2 = "0.3.27" http = "0.2.7" itoa = "1" -log =" 0.4" +log = "0.4" mime = "0.3" percent-encoding = "2.1" pin-project-lite = "0.2" -rand = "0.8" +rand = "0.9" serde = "1.0" serde_json = "1.0" serde_urlencoded = "0.7" -tokio = { version = "1.24.2", features = ["sync"] } +tokio = { version = "1.38.2", features = ["sync"] } cookie = { version = "0.16", features = ["percent-encode"], optional = true } tls-openssl = { package = "openssl", version = "0.10.55", optional = true } -tls-rustls = { package = "rustls", version = "0.20", optional = true, features = ["dangerous_configuration"] } +tls-rustls-0_20 = { package = "rustls", version = "0.20", optional = true, features = ["dangerous_configuration"] } +tls-rustls-0_21 = { package = "rustls", version = "0.21", optional = true, features = ["dangerous_configuration"] } +tls-rustls-0_22 = { package = "rustls", version = "0.22", optional = true } +tls-rustls-0_23 = { package = "rustls", version = "0.23", optional = true, default-features = false } -trust-dns-resolver = { version = "0.22", optional = true } +hickory-resolver = { version = "0.25", optional = true, features = ["system-config", "tokio"] } [dev-dependencies] -actix-http = { version = "3", features = ["openssl"] } +actix-http = { version = "3.7", features = ["openssl"] } actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" -actix-test = { version = "0.1", features = ["openssl", "rustls"] } -actix-tls = { version = "3", features = ["openssl", "rustls"] } +actix-test = { version = "0.1", features = ["openssl", "rustls-0_23"] } +actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23"] } actix-utils = "3" actix-web = { version = "4", features = ["openssl"] } -brotli = "3.3.3" -const-str = "0.5" -env_logger = "0.10" +brotli = "8" +const-str = "0.5" # TODO(MSRV 1.77): update to 0.6 +env_logger = "0.11" flate2 = "1.0.13" futures-util = { version = "0.3.17", default-features = false } static_assertions = "1.1" -rcgen = "0.11" -rustls-pemfile = "1" -tokio = { version = "1.24.2", features = ["rt-multi-thread", "macros"] } -zstd = "0.12" +rcgen = "0.13" +rustls-pki-types = "1.13.1" +tokio = { version = "1.38.2", features = ["rt-multi-thread", "macros"] } +zstd = "0.13" +tls-rustls-0_23 = { package = "rustls", version = "0.23" } # add rustls 0.23 with default features to make aws_lc_rs work in tests [[example]] name = "client" -required-features = ["rustls"] +required-features = ["rustls-0_23-webpki-roots"] + +[lints] +workspace = true diff --git a/awc/README.md b/awc/README.md index 1a480df12..6e91eab3e 100644 --- a/awc/README.md +++ b/awc/README.md @@ -1,20 +1,22 @@ -# awc (Actix Web Client) +# `awc` (Actix Web Client) > Async HTTP and WebSocket client library. + + [![crates.io](https://img.shields.io/crates/v/awc?label=latest)](https://crates.io/crates/awc) -[![Documentation](https://docs.rs/awc/badge.svg?version=3.1.1)](https://docs.rs/awc/3.1.1) +[![Documentation](https://docs.rs/awc/badge.svg?version=3.8.1)](https://docs.rs/awc/3.8.1) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/awc) -[![Dependency Status](https://deps.rs/crate/awc/3.1.1/status.svg)](https://deps.rs/crate/awc/3.1.1) +[![Dependency Status](https://deps.rs/crate/awc/3.8.1/status.svg)](https://deps.rs/crate/awc/3.8.1) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) -## Documentation & Resources + -- [API Documentation](https://docs.rs/awc) -- [Example Project](https://github.com/actix/examples/tree/master/https-tls/awc-https) -- Minimum Supported Rust Version (MSRV): 1.65 +## Examples -## Example +[Example project using TLS-enabled client →](https://github.com/actix/examples/tree/main/https-tls/awc-https) + +Basic usage: ```rust use actix_rt::System; diff --git a/awc/examples/client.rs b/awc/examples/client.rs index 26edcfd62..b6eb919c7 100644 --- a/awc/examples/client.rs +++ b/awc/examples/client.rs @@ -1,25 +1,39 @@ -#![allow(clippy::uninlined_format_args)] +//! Demonstrates construction and usage of a TLS-capable HTTP client. -use std::error::Error as StdError; +extern crate tls_rustls_0_23 as rustls; -#[tokio::main] +use std::{error::Error as StdError, sync::Arc}; + +use actix_tls::connect::rustls_0_23::webpki_roots_cert_store; +use rustls::ClientConfig; + +#[actix_rt::main] async fn main() -> Result<(), Box> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - // construct request builder - let client = awc::Client::new(); + let mut config = ClientConfig::builder() + .with_root_certificates(webpki_roots_cert_store()) + .with_no_client_auth(); + + let protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + config.alpn_protocols = protos; + + // construct request builder with TLS support + let client = awc::Client::builder() + .connector(awc::Connector::new().rustls_0_23(Arc::new(config))) + .finish(); // configure request let request = client .get("https://www.rust-lang.org/") - .append_header(("User-Agent", "Actix-web")); + .append_header(("User-Agent", "awc/3.0")); - println!("Request: {:?}", request); + println!("Request: {request:?}"); let mut response = request.send().await?; // server response head - println!("Response: {:?}", response); + println!("Response: {response:?}"); // read response body let body = response.body().await?; diff --git a/awc/src/any_body.rs b/awc/src/any_body.rs index 08f5cc25e..ef0edfb9e 100644 --- a/awc/src/any_body.rs +++ b/awc/src/any_body.rs @@ -163,6 +163,7 @@ mod tests { use super::*; + #[allow(dead_code)] struct PinType(PhantomPinned); impl MessageBody for PinType { diff --git a/awc/src/builder.rs b/awc/src/builder.rs index a54960382..5aae394f8 100644 --- a/awc/src/builder.rs +++ b/awc/src/builder.rs @@ -37,6 +37,12 @@ pub struct ClientBuilder { } impl ClientBuilder { + /// Create a new ClientBuilder with default settings + /// + /// Note: If the `rustls-0_23` feature is enabled and neither `rustls-0_23-native-roots` nor + /// `rustls-0_23-webpki-roots` are enabled, this ClientBuilder will build without TLS. In order + /// to enable TLS in this scenario, a custom `Connector` _must_ be added to the builder before + /// finishing construction. #[allow(clippy::new_ret_no_self)] pub fn new() -> ClientBuilder< impl Service< diff --git a/awc/src/client/connection.rs b/awc/src/client/connection.rs index 64075eae8..8164e2b59 100644 --- a/awc/src/client/connection.rs +++ b/awc/src/client/connection.rs @@ -380,8 +380,6 @@ mod test { use std::{ future::Future, net, - pin::Pin, - task::{Context, Poll}, time::{Duration, Instant}, }; @@ -394,6 +392,8 @@ mod test { #[actix_rt::test] async fn test_h2_connection_drop() { + env_logger::try_init().ok(); + let addr = "127.0.0.1:0".parse::().unwrap(); let listener = net::TcpListener::bind(addr).unwrap(); let local = listener.local_addr().unwrap(); @@ -428,8 +428,15 @@ mod test { if this.start_from.elapsed() > Duration::from_secs(10) { panic!("connection should be gone and can not be ready"); } else { - let _ = this.interval.poll_tick(cx); - Poll::Pending + match this.interval.poll_tick(cx) { + Poll::Ready(_) => { + // prevents spurious test hang + this.interval.reset(); + + Poll::Pending + } + Poll::Pending => Poll::Pending, + } } } Err(_) => Poll::Ready(()), diff --git a/awc/src/client/connector.rs b/awc/src/client/connector.rs index 1ecaf9947..0ce542365 100644 --- a/awc/src/client/connector.rs +++ b/awc/src/client/connector.rs @@ -40,23 +40,38 @@ enum OurTlsConnector { /// Provided because building the OpenSSL context on newer versions can be very slow. /// This prevents unnecessary calls to `.build()` while constructing the client connector. #[cfg(feature = "openssl")] - #[allow(dead_code)] // false positive; used in build_ssl + #[allow(dead_code)] // false positive; used in build_tls OpensslBuilder(actix_tls::connect::openssl::reexports::SslConnectorBuilder), - #[cfg(feature = "rustls")] - Rustls(std::sync::Arc), + #[cfg(feature = "rustls-0_20")] + #[allow(dead_code)] // false positive; used in build_tls + Rustls020(std::sync::Arc), + + #[cfg(feature = "rustls-0_21")] + #[allow(dead_code)] // false positive; used in build_tls + Rustls021(std::sync::Arc), + + #[cfg(any( + feature = "rustls-0_22-webpki-roots", + feature = "rustls-0_22-native-roots", + ))] + #[allow(dead_code)] // false positive; used in build_tls + Rustls022(std::sync::Arc), + + #[cfg(feature = "rustls-0_23")] + #[allow(dead_code)] // false positive; used in build_tls + Rustls023(std::sync::Arc), } /// Manages HTTP client network connectivity. /// -/// The `Connector` type uses a builder-like combinator pattern for service -/// construction that finishes by calling the `.finish()` method. +/// The `Connector` type uses a builder-like combinator pattern for service construction that +/// finishes by calling the `.finish()` method. /// -/// ```ignore +/// ```no_run /// use std::time::Duration; -/// use actix_http::client::Connector; /// -/// let connector = Connector::new() +/// let connector = awc::Connector::new() /// .timeout(Duration::from_secs(5)) /// .finish(); /// ``` @@ -69,6 +84,14 @@ pub struct Connector { } impl Connector<()> { + /// Create a new connector with default TLS settings + /// + /// # Panics + /// + /// - When the `rustls-0_23-webpki-roots` or `rustls-0_23-native-roots` features are enabled + /// and no default crypto provider has been loaded, this method will panic. + /// - When the `rustls-0_23-native-roots` or `rustls-0_22-native-roots` features are enabled + /// and the runtime system has no native root certificates, this method will panic. #[allow(clippy::new_ret_no_self, clippy::let_unit_value)] pub fn new() -> Connector< impl Service< @@ -80,56 +103,114 @@ impl Connector<()> { Connector { connector: TcpConnector::new(resolver::resolver()).service(), config: ConnectorConfig::default(), - tls: Self::build_ssl(vec![b"h2".to_vec(), b"http/1.1".to_vec()]), + tls: Self::build_tls(vec![b"h2".to_vec(), b"http/1.1".to_vec()]), } } - /// Provides an empty TLS connector when no TLS feature is enabled. - #[cfg(not(any(feature = "openssl", feature = "rustls")))] - fn build_ssl(_: Vec>) -> OurTlsConnector { - OurTlsConnector::None - } + cfg_if::cfg_if! { + if #[cfg(any(feature = "rustls-0_23-webpki-roots", feature = "rustls-0_23-native-roots"))] { + /// Build TLS connector with Rustls v0.23, based on supplied ALPN protocols. + /// + /// Note that if other TLS crate features are enabled, Rustls v0.23 will be used. + fn build_tls(protocols: Vec>) -> OurTlsConnector { + use actix_tls::connect::rustls_0_23::{self, reexports::ClientConfig}; - /// Build TLS connector with rustls, based on supplied ALPN protocols - /// - /// Note that if both `openssl` and `rustls` features are enabled, rustls will be used. - #[cfg(feature = "rustls")] - fn build_ssl(protocols: Vec>) -> OurTlsConnector { - use actix_tls::connect::rustls::{reexports::ClientConfig, webpki_roots_cert_store}; + cfg_if::cfg_if! { + if #[cfg(feature = "rustls-0_23-webpki-roots")] { + let certs = rustls_0_23::webpki_roots_cert_store(); + } else if #[cfg(feature = "rustls-0_23-native-roots")] { + let certs = rustls_0_23::native_roots_cert_store().expect("Failed to find native root certificates"); + } + } - let mut config = ClientConfig::builder() - .with_safe_defaults() - .with_root_certificates(webpki_roots_cert_store()) - .with_no_client_auth(); + let mut config = ClientConfig::builder() + .with_root_certificates(certs) + .with_no_client_auth(); - config.alpn_protocols = protocols; + config.alpn_protocols = protocols; - OurTlsConnector::Rustls(std::sync::Arc::new(config)) - } + OurTlsConnector::Rustls023(std::sync::Arc::new(config)) + } + } else if #[cfg(any(feature = "rustls-0_22-webpki-roots", feature = "rustls-0_22-native-roots"))] { + /// Build TLS connector with Rustls v0.22, based on supplied ALPN protocols. + fn build_tls(protocols: Vec>) -> OurTlsConnector { + use actix_tls::connect::rustls_0_22::{self, reexports::ClientConfig}; - /// Build TLS connector with openssl, based on supplied ALPN protocols - #[cfg(all(feature = "openssl", not(feature = "rustls")))] - fn build_ssl(protocols: Vec>) -> OurTlsConnector { - use actix_tls::connect::openssl::reexports::{SslConnector, SslMethod}; - use bytes::{BufMut, BytesMut}; + cfg_if::cfg_if! { + if #[cfg(feature = "rustls-0_22-webpki-roots")] { + let certs = rustls_0_22::webpki_roots_cert_store(); + } else if #[cfg(feature = "rustls-0_22-native-roots")] { + let certs = rustls_0_22::native_roots_cert_store().expect("Failed to find native root certificates"); + } + } - let mut alpn = BytesMut::with_capacity(20); - for proto in &protocols { - alpn.put_u8(proto.len() as u8); - alpn.put(proto.as_slice()); + let mut config = ClientConfig::builder() + .with_root_certificates(certs) + .with_no_client_auth(); + + config.alpn_protocols = protocols; + + OurTlsConnector::Rustls022(std::sync::Arc::new(config)) + } + } else if #[cfg(feature = "rustls-0_21")] { + /// Build TLS connector with Rustls v0.21, based on supplied ALPN protocols. + fn build_tls(protocols: Vec>) -> OurTlsConnector { + use actix_tls::connect::rustls_0_21::{reexports::ClientConfig, webpki_roots_cert_store}; + + let mut config = ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(webpki_roots_cert_store()) + .with_no_client_auth(); + + config.alpn_protocols = protocols; + + OurTlsConnector::Rustls021(std::sync::Arc::new(config)) + } + } else if #[cfg(feature = "rustls-0_20")] { + /// Build TLS connector with Rustls v0.20, based on supplied ALPN protocols. + fn build_tls(protocols: Vec>) -> OurTlsConnector { + use actix_tls::connect::rustls_0_20::{reexports::ClientConfig, webpki_roots_cert_store}; + + let mut config = ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(webpki_roots_cert_store()) + .with_no_client_auth(); + + config.alpn_protocols = protocols; + + OurTlsConnector::Rustls020(std::sync::Arc::new(config)) + } + } else if #[cfg(feature = "openssl")] { + /// Build TLS connector with OpenSSL, based on supplied ALPN protocols. + fn build_tls(protocols: Vec>) -> OurTlsConnector { + use actix_tls::connect::openssl::reexports::{SslConnector, SslMethod}; + use bytes::{BufMut, BytesMut}; + + let mut alpn = BytesMut::with_capacity(20); + for proto in &protocols { + alpn.put_u8(proto.len() as u8); + alpn.put(proto.as_slice()); + } + + let mut ssl = SslConnector::builder(SslMethod::tls()).unwrap(); + if let Err(err) = ssl.set_alpn_protos(&alpn) { + log::error!("Can not set ALPN protocol: {err:?}"); + } + + OurTlsConnector::OpensslBuilder(ssl) + } + } else { + /// Provides an empty TLS connector when no TLS feature is enabled, or when only the + /// `rustls-0_23` crate feature is enabled. + fn build_tls(_: Vec>) -> OurTlsConnector { + OurTlsConnector::None + } } - - let mut ssl = SslConnector::builder(SslMethod::tls()).unwrap(); - if let Err(err) = ssl.set_alpn_protos(&alpn) { - log::error!("Can not set ALPN protocol: {:?}", err); - } - - OurTlsConnector::OpensslBuilder(ssl) } } impl Connector { - /// Use custom connector. + /// Sets custom connector. pub fn connector(self, connector: S1) -> Connector where Io1: ActixStream + fmt::Debug + 'static, @@ -158,21 +239,28 @@ where + Clone + 'static, { - /// Tcp connection timeout, i.e. max time to connect to remote host including dns name - /// resolution. Set to 5 second by default. + /// Sets TCP connection timeout. + /// + /// This is the max time allowed to connect to remote host, including DNS name resolution. + /// + /// By default, the timeout is 5 seconds. pub fn timeout(mut self, timeout: Duration) -> Self { self.config.timeout = timeout; self } - /// Tls handshake timeout, i.e. max time to do tls handshake with remote host after tcp - /// connection established. Set to 5 second by default. + /// Sets TLS handshake timeout. + /// + /// This is the max time allowed to perform the TLS handshake with remote host after TCP + /// connection is established. + /// + /// By default, the timeout is 5 seconds. pub fn handshake_timeout(mut self, timeout: Duration) -> Self { self.config.handshake_timeout = timeout; self } - /// Use custom OpenSSL `SslConnector` instance. + /// Sets custom OpenSSL `SslConnector` instance. #[cfg(feature = "openssl")] pub fn openssl( mut self, @@ -191,13 +279,54 @@ where self } - /// Use custom Rustls `ClientConfig` instance. - #[cfg(feature = "rustls")] + /// Sets custom Rustls v0.20 `ClientConfig` instance. + #[cfg(feature = "rustls-0_20")] pub fn rustls( mut self, - connector: std::sync::Arc, + connector: std::sync::Arc, ) -> Self { - self.tls = OurTlsConnector::Rustls(connector); + self.tls = OurTlsConnector::Rustls020(connector); + self + } + + /// Sets custom Rustls v0.21 `ClientConfig` instance. + #[cfg(feature = "rustls-0_21")] + pub fn rustls_021( + mut self, + connector: std::sync::Arc, + ) -> Self { + self.tls = OurTlsConnector::Rustls021(connector); + self + } + + /// Sets custom Rustls v0.22 `ClientConfig` instance. + #[cfg(any( + feature = "rustls-0_22-webpki-roots", + feature = "rustls-0_22-native-roots", + ))] + pub fn rustls_0_22( + mut self, + connector: std::sync::Arc, + ) -> Self { + self.tls = OurTlsConnector::Rustls022(connector); + self + } + + /// Sets custom Rustls v0.23 `ClientConfig` instance. + /// + /// In order to enable ALPN, set the `.alpn_protocols` field on the ClientConfig to the + /// following: + /// + /// ```no_run + /// vec![b"h2".to_vec(), b"http/1.1".to_vec()] + /// # ; + /// ``` + #[cfg(feature = "rustls-0_23")] + pub fn rustls_0_23( + mut self, + connector: std::sync::Arc, + ) -> Self { + self.tls = OurTlsConnector::Rustls023(connector); self } @@ -212,12 +341,12 @@ where unimplemented!("actix-http client only supports versions http/1.1 & http/2") } }; - self.tls = Connector::build_ssl(versions); + self.tls = Connector::build_tls(versions); self } - /// Sets the initial window size (in octets) for HTTP/2 stream-level flow control for - /// received data. + /// Sets the initial window size (in bytes) for HTTP/2 stream-level flow control for received + /// data. /// /// The default value is 65,535 and is good for APIs, but not for big objects. pub fn initial_window_size(mut self, size: u32) -> Self { @@ -225,7 +354,7 @@ where self } - /// Sets the initial window size (in octets) for HTTP/2 connection-level flow control for + /// Sets the initial window size (in bytes) for HTTP/2 connection-level flow control for /// received data. /// /// The default value is 65,535 and is good for APIs, but not for big objects. @@ -324,6 +453,7 @@ where use actix_tls::connect::Connection; use actix_utils::future::{ready, Ready}; + #[allow(non_local_definitions)] impl IntoConnectionIo for TcpConnection> { fn into_connection_io(self) -> (Box, Protocol) { let io = self.into_parts().0; @@ -374,13 +504,15 @@ where use actix_tls::connect::openssl::{reexports::AsyncSslStream, TlsConnector}; + #[allow(non_local_definitions)] impl IntoConnectionIo for TcpConnection> { fn into_connection_io(self) -> (Box, Protocol) { let sock = self.into_parts().0; let h2 = sock .ssl() .selected_alpn_protocol() - .map_or(false, |protos| protos.windows(2).any(|w| w == H2)); + .is_some_and(|protos| protos.windows(2).any(|w| w == H2)); + if h2 { (Box::new(sock), Protocol::Http2) } else { @@ -405,12 +537,13 @@ where unreachable!("OpenSSL builder is built before this match."); } - #[cfg(feature = "rustls")] - OurTlsConnector::Rustls(tls) => { + #[cfg(feature = "rustls-0_20")] + OurTlsConnector::Rustls020(tls) => { const H2: &[u8] = b"h2"; - use actix_tls::connect::rustls::{reexports::AsyncTlsStream, TlsConnector}; + use actix_tls::connect::rustls_0_20::{reexports::AsyncTlsStream, TlsConnector}; + #[allow(non_local_definitions)] impl IntoConnectionIo for TcpConnection> { fn into_connection_io(self) -> (Box, Protocol) { let sock = self.into_parts().0; @@ -418,7 +551,116 @@ where .get_ref() .1 .alpn_protocol() - .map_or(false, |protos| protos.windows(2).any(|w| w == H2)); + .is_some_and(|protos| protos.windows(2).any(|w| w == H2)); + + if h2 { + (Box::new(sock), Protocol::Http2) + } else { + (Box::new(sock), Protocol::Http1) + } + } + } + + let handshake_timeout = self.config.handshake_timeout; + + let tls_service = TlsConnectorService { + tcp_service: tcp_service_inner, + tls_service: TlsConnector::service(tls), + timeout: handshake_timeout, + }; + + Some(actix_service::boxed::rc_service(tls_service)) + } + + #[cfg(feature = "rustls-0_21")] + OurTlsConnector::Rustls021(tls) => { + const H2: &[u8] = b"h2"; + + use actix_tls::connect::rustls_0_21::{reexports::AsyncTlsStream, TlsConnector}; + + #[allow(non_local_definitions)] + impl IntoConnectionIo for TcpConnection> { + fn into_connection_io(self) -> (Box, Protocol) { + let sock = self.into_parts().0; + let h2 = sock + .get_ref() + .1 + .alpn_protocol() + .is_some_and(|protos| protos.windows(2).any(|w| w == H2)); + + if h2 { + (Box::new(sock), Protocol::Http2) + } else { + (Box::new(sock), Protocol::Http1) + } + } + } + + let handshake_timeout = self.config.handshake_timeout; + + let tls_service = TlsConnectorService { + tcp_service: tcp_service_inner, + tls_service: TlsConnector::service(tls), + timeout: handshake_timeout, + }; + + Some(actix_service::boxed::rc_service(tls_service)) + } + + #[cfg(any( + feature = "rustls-0_22-webpki-roots", + feature = "rustls-0_22-native-roots", + ))] + OurTlsConnector::Rustls022(tls) => { + const H2: &[u8] = b"h2"; + + use actix_tls::connect::rustls_0_22::{reexports::AsyncTlsStream, TlsConnector}; + + #[allow(non_local_definitions)] + impl IntoConnectionIo for TcpConnection> { + fn into_connection_io(self) -> (Box, Protocol) { + let sock = self.into_parts().0; + let h2 = sock + .get_ref() + .1 + .alpn_protocol() + .is_some_and(|protos| protos.windows(2).any(|w| w == H2)); + + if h2 { + (Box::new(sock), Protocol::Http2) + } else { + (Box::new(sock), Protocol::Http1) + } + } + } + + let handshake_timeout = self.config.handshake_timeout; + + let tls_service = TlsConnectorService { + tcp_service: tcp_service_inner, + tls_service: TlsConnector::service(tls), + timeout: handshake_timeout, + }; + + Some(actix_service::boxed::rc_service(tls_service)) + } + + #[cfg(feature = "rustls-0_23")] + OurTlsConnector::Rustls023(tls) => { + const H2: &[u8] = b"h2"; + + use actix_tls::connect::rustls_0_23::{reexports::AsyncTlsStream, TlsConnector}; + + #[allow(non_local_definitions)] + impl IntoConnectionIo for TcpConnection> { + fn into_connection_io(self) -> (Box, Protocol) { + let sock = self.into_parts().0; + let h2 = sock + .get_ref() + .1 + .alpn_protocol() + .is_some_and(|protos| protos.windows(2).any(|w| w == H2)); + if h2 { (Box::new(sock), Protocol::Http2) } else { @@ -498,6 +740,17 @@ where /// service for establish tcp connection and do client tls handshake. /// operation is canceled when timeout limit reached. +#[cfg(any( + feature = "dangerous-h2c", + feature = "openssl", + feature = "rustls-0_20", + feature = "rustls-0_21", + feature = "rustls-0_22-webpki-roots", + feature = "rustls-0_22-native-roots", + feature = "rustls-0_23", + feature = "rustls-0_23-webpki-roots", + feature = "rustls-0_23-native-roots" +))] struct TlsConnectorService { /// TCP connection is canceled on `TcpConnectorInnerService`'s timeout setting. tcp_service: Tcp, @@ -508,6 +761,15 @@ struct TlsConnectorService { timeout: Duration, } +#[cfg(any( + feature = "dangerous-h2c", + feature = "openssl", + feature = "rustls-0_20", + feature = "rustls-0_21", + feature = "rustls-0_22-webpki-roots", + feature = "rustls-0_22-native-roots", + feature = "rustls-0_23", +))] impl Service for TlsConnectorService where Tcp: @@ -775,7 +1037,7 @@ where } } -#[cfg(not(feature = "trust-dns"))] +#[cfg(not(feature = "hickory-dns"))] mod resolver { use super::*; @@ -784,25 +1046,25 @@ mod resolver { } } -#[cfg(feature = "trust-dns")] +#[cfg(feature = "hickory-dns")] mod resolver { - use std::{cell::RefCell, net::SocketAddr}; + use std::{cell::OnceCell, net::SocketAddr}; use actix_tls::connect::Resolve; - use futures_core::future::LocalBoxFuture; - use trust_dns_resolver::{ + use hickory_resolver::{ config::{ResolverConfig, ResolverOpts}, + name_server::TokioConnectionProvider, system_conf::read_system_conf, - TokioAsyncResolver, + TokioResolver, }; use super::*; pub(super) fn resolver() -> Resolver { // new type for impl Resolve trait for TokioAsyncResolver. - struct TrustDnsResolver(TokioAsyncResolver); + struct HickoryDnsResolver(TokioResolver); - impl Resolve for TrustDnsResolver { + impl Resolve for HickoryDnsResolver { fn lookup<'a>( &'a self, host: &'a str, @@ -824,34 +1086,29 @@ mod resolver { // resolver struct is cached in thread local so new clients can reuse the existing instance thread_local! { - static TRUST_DNS_RESOLVER: RefCell> = RefCell::new(None); + static HICKORY_DNS_RESOLVER: OnceCell = const { OnceCell::new() }; } - // get from thread local or construct a new trust-dns resolver. - TRUST_DNS_RESOLVER.with(|local| { - let resolver = local.borrow().as_ref().map(Clone::clone); - - match resolver { - Some(resolver) => resolver, - - None => { + // get from thread local or construct a new hickory dns resolver. + HICKORY_DNS_RESOLVER.with(|local| { + local + .get_or_init(|| { let (cfg, opts) = match read_system_conf() { Ok((cfg, opts)) => (cfg, opts), - Err(e) => { - log::error!("TRust-DNS can not load system config: {}", e); + Err(err) => { + log::error!("Hickory DNS can not load system config: {err}"); (ResolverConfig::default(), ResolverOpts::default()) } }; - let resolver = TokioAsyncResolver::tokio(cfg, opts).unwrap(); + let resolver = + TokioResolver::builder_with_config(cfg, TokioConnectionProvider::default()) + .with_options(opts) + .build(); - // box trust dns resolver and put it in thread local. - let resolver = Resolver::custom(TrustDnsResolver(resolver)); - *local.borrow_mut() = Some(resolver.clone()); - - resolver - } - } + Resolver::custom(HickoryDnsResolver(resolver)) + }) + .clone() }) } } diff --git a/awc/src/client/error.rs b/awc/src/client/error.rs index d351e1067..6cb595770 100644 --- a/awc/src/client/error.rs +++ b/awc/src/client/error.rs @@ -12,40 +12,40 @@ use crate::BoxError; #[non_exhaustive] pub enum ConnectError { /// SSL feature is not enabled - #[display(fmt = "SSL is not supported")] + #[display("SSL is not supported")] SslIsNotSupported, /// SSL error #[cfg(feature = "openssl")] - #[display(fmt = "{}", _0)] + #[display("{}", _0)] SslError(OpensslError), /// Failed to resolve the hostname - #[display(fmt = "Failed resolving hostname: {}", _0)] + #[display("Failed resolving hostname: {}", _0)] Resolver(Box), /// No dns records - #[display(fmt = "No DNS records found for the input")] + #[display("No DNS records found for the input")] NoRecords, /// Http2 error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] H2(h2::Error), /// Connecting took too long - #[display(fmt = "Timeout while establishing connection")] + #[display("Timeout while establishing connection")] Timeout, /// Connector has been disconnected - #[display(fmt = "Internal error: connector has been disconnected")] + #[display("Internal error: connector has been disconnected")] Disconnected, /// Unresolved host name - #[display(fmt = "Connector received `Connect` method with unresolved host")] + #[display("Connector received `Connect` method with unresolved host")] Unresolved, /// Connection io error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Io(io::Error), } @@ -54,11 +54,11 @@ impl std::error::Error for ConnectError {} impl From for ConnectError { fn from(err: actix_tls::connect::ConnectError) -> ConnectError { match err { - actix_tls::connect::ConnectError::Resolver(e) => ConnectError::Resolver(e), + actix_tls::connect::ConnectError::Resolver(err) => ConnectError::Resolver(err), actix_tls::connect::ConnectError::NoRecords => ConnectError::NoRecords, actix_tls::connect::ConnectError::InvalidInput => panic!(), actix_tls::connect::ConnectError::Unresolved => ConnectError::Unresolved, - actix_tls::connect::ConnectError::Io(e) => ConnectError::Io(e), + actix_tls::connect::ConnectError::Io(err) => ConnectError::Io(err), } } } @@ -66,16 +66,16 @@ impl From for ConnectError { #[derive(Debug, Display, From)] #[non_exhaustive] pub enum InvalidUrl { - #[display(fmt = "Missing URL scheme")] + #[display("Missing URL scheme")] MissingScheme, - #[display(fmt = "Unknown URL scheme")] + #[display("Unknown URL scheme")] UnknownScheme, - #[display(fmt = "Missing host name")] + #[display("Missing host name")] MissingHost, - #[display(fmt = "URL parse error: {}", _0)] + #[display("URL parse error: {}", _0)] HttpError(http::Error), } @@ -86,11 +86,11 @@ impl std::error::Error for InvalidUrl {} #[non_exhaustive] pub enum SendRequestError { /// Invalid URL - #[display(fmt = "Invalid URL: {}", _0)] + #[display("Invalid URL: {}", _0)] Url(InvalidUrl), /// Failed to connect to host - #[display(fmt = "Failed to connect to host: {}", _0)] + #[display("Failed to connect to host: {}", _0)] Connect(ConnectError), /// Error sending request @@ -100,26 +100,26 @@ pub enum SendRequestError { Response(ParseError), /// Http error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Http(HttpError), /// Http2 error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] H2(h2::Error), /// Response took too long - #[display(fmt = "Timeout while waiting for response")] + #[display("Timeout while waiting for response")] Timeout, /// Tunnels are not supported for HTTP/2 connection - #[display(fmt = "Tunnels are not supported for http2 connection")] + #[display("Tunnels are not supported for http2 connection")] TunnelNotSupported, /// Error sending request body Body(BoxError), /// Other errors that can occur after submitting a request. - #[display(fmt = "{:?}: {}", _1, _0)] + #[display("{:?}: {}", _1, _0)] Custom(BoxError, Box), } @@ -130,15 +130,15 @@ impl std::error::Error for SendRequestError {} #[non_exhaustive] pub enum FreezeRequestError { /// Invalid URL - #[display(fmt = "Invalid URL: {}", _0)] + #[display("Invalid URL: {}", _0)] Url(InvalidUrl), /// HTTP error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Http(HttpError), /// Other errors that can occur after submitting a request. - #[display(fmt = "{:?}: {}", _1, _0)] + #[display("{:?}: {}", _1, _0)] Custom(BoxError, Box), } diff --git a/awc/src/client/h1proto.rs b/awc/src/client/h1proto.rs index c756179a4..3f4c9f979 100644 --- a/awc/src/client/h1proto.rs +++ b/awc/src/client/h1proto.rs @@ -56,7 +56,7 @@ where headers.insert(HOST, value); } }, - Err(e) => log::error!("Can not set HOST header {}", e), + Err(err) => log::error!("Can not set HOST header {err}"), } } } diff --git a/awc/src/client/h2proto.rs b/awc/src/client/h2proto.rs index d18db1410..2e0da2e4f 100644 --- a/awc/src/client/h2proto.rs +++ b/awc/src/client/h2proto.rs @@ -12,7 +12,7 @@ use h2::{ SendStream, }; use http::{ - header::{HeaderValue, CONNECTION, CONTENT_LENGTH, TRANSFER_ENCODING}, + header::{HeaderValue, CONNECTION, CONTENT_LENGTH, HOST, TRANSFER_ENCODING}, request::Request, Method, Version, }; @@ -97,7 +97,7 @@ where // TODO: consider skipping other headers according to: // https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.2 // omit HTTP/1.x only headers - CONNECTION | TRANSFER_ENCODING => continue, + CONNECTION | TRANSFER_ENCODING | HOST => continue, CONTENT_LENGTH if skip_len => continue, // DATE => has_date = true, _ => {} @@ -106,9 +106,9 @@ where } let res = poll_fn(|cx| io.poll_ready(cx)).await; - if let Err(e) = res { - io.on_release(e.is_io()); - return Err(SendRequestError::from(e)); + if let Err(err) = res { + io.on_release(err.is_io() || err.is_go_away()); + return Err(SendRequestError::from(err)); } let resp = match io.send_request(req, eof) { @@ -120,9 +120,9 @@ where } fut.await.map_err(SendRequestError::from)? } - Err(e) => { - io.on_release(e.is_io()); - return Err(e.into()); + Err(err) => { + io.on_release(err.is_io() || err.is_go_away()); + return Err(err.into()); } }; @@ -169,8 +169,8 @@ where let len = b.len(); let bytes = b.split_to(std::cmp::min(cap, len)); - if let Err(e) = send.send_data(bytes, false) { - return Err(e.into()); + if let Err(err) = send.send_data(bytes, false) { + return Err(err.into()); } if !b.is_empty() { send.reserve_capacity(b.len()); @@ -179,7 +179,7 @@ where } continue; } - Some(Err(e)) => return Err(e.into()), + Some(Err(err)) => return Err(err.into()), } } } diff --git a/awc/src/client/pool.rs b/awc/src/client/pool.rs index 2cf1f3ace..29b15ee2d 100644 --- a/awc/src/client/pool.rs +++ b/awc/src/client/pool.rs @@ -31,7 +31,7 @@ use super::{ Connect, }; -#[derive(Hash, Eq, PartialEq, Clone, Debug)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Key { authority: Authority, } @@ -42,8 +42,8 @@ impl From for Key { } } +/// Connections pool to reuse I/O per [`Authority`]. #[doc(hidden)] -/// Connections pool for reuse Io type for certain [`http::uri::Authority`] as key. pub struct ConnectionPool where Io: AsyncWrite + Unpin + 'static, @@ -52,7 +52,7 @@ where inner: ConnectionPoolInner, } -/// wrapper type for check the ref count of Rc. +/// Wrapper type for check the ref count of Rc. pub struct ConnectionPoolInner(Rc>) where Io: AsyncWrite + Unpin + 'static; @@ -63,7 +63,7 @@ where { fn new(config: ConnectorConfig) -> Self { let permits = Arc::new(Semaphore::new(config.limit)); - let available = RefCell::new(HashMap::default()); + let available = RefCell::new(HashMap::new()); Self(Rc::new(ConnectionPoolInnerPriv { config, @@ -72,11 +72,13 @@ where })) } - /// spawn a async for graceful shutdown h1 Io type with a timeout. + /// Spawns a graceful shutdown task for the underlying I/O with a timeout. fn close(&self, conn: ConnectionInnerType) { if let Some(timeout) = self.config.disconnect_timeout { if let ConnectionInnerType::H1(io) = conn { - actix_rt::spawn(CloseConnection::new(io, timeout)); + if tokio::runtime::Handle::try_current().is_ok() { + actix_rt::spawn(CloseConnection::new(io, timeout)); + } } } } @@ -173,12 +175,14 @@ where }; // acquire an owned permit and carry it with connection - let permit = inner.permits.clone().acquire_owned().await.map_err(|_| { - ConnectError::Io(io::Error::new( - io::ErrorKind::Other, - "failed to acquire semaphore on client connection pool", - )) - })?; + let permit = Arc::clone(&inner.permits) + .acquire_owned() + .await + .map_err(|_| { + ConnectError::Io(io::Error::other( + "Failed to acquire semaphore on client connection pool", + )) + })?; let conn = { let mut conn = None; @@ -374,12 +378,11 @@ impl Acquired { #[cfg(test)] mod test { - use std::{cell::Cell, io}; + use std::cell::Cell; use http::Uri; use super::*; - use crate::client::connection::ConnectionType; /// A stream type that always returns pending on async read. /// diff --git a/awc/src/error.rs b/awc/src/error.rs index 0104e5fe8..d2d6d71c4 100644 --- a/awc/src/error.rs +++ b/awc/src/error.rs @@ -18,35 +18,35 @@ pub use crate::client::{ConnectError, FreezeRequestError, InvalidUrl, SendReques #[derive(Debug, Display, From)] pub enum WsClientError { /// Invalid response status - #[display(fmt = "Invalid response status")] + #[display("Invalid response status")] InvalidResponseStatus(StatusCode), /// Invalid upgrade header - #[display(fmt = "Invalid upgrade header")] + #[display("Invalid upgrade header")] InvalidUpgradeHeader, /// Invalid connection header - #[display(fmt = "Invalid connection header")] + #[display("Invalid connection header")] InvalidConnectionHeader(HeaderValue), /// Missing Connection header - #[display(fmt = "Missing Connection header")] + #[display("Missing Connection header")] MissingConnectionHeader, /// Missing Sec-Websocket-Accept header - #[display(fmt = "Missing Sec-Websocket-Accept header")] + #[display("Missing Sec-Websocket-Accept header")] MissingWebSocketAcceptHeader, /// Invalid challenge response - #[display(fmt = "Invalid challenge response")] + #[display("Invalid challenge response")] InvalidChallengeResponse([u8; 28], HeaderValue), /// Protocol error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] Protocol(WsProtocolError), /// Send request error - #[display(fmt = "{}", _0)] + #[display("{}", _0)] SendRequest(SendRequestError), } @@ -68,13 +68,13 @@ impl From for WsClientError { #[derive(Debug, Display, From)] pub enum JsonPayloadError { /// Content type error - #[display(fmt = "Content type error")] + #[display("Content type error")] ContentType, /// Deserialize error - #[display(fmt = "Json deserialize error: {}", _0)] + #[display("Json deserialize error: {}", _0)] Deserialize(JsonError), /// Payload error - #[display(fmt = "Error that occur during reading payload: {}", _0)] + #[display("Error that occur during reading payload: {}", _0)] Payload(PayloadError), } diff --git a/awc/src/frozen.rs b/awc/src/frozen.rs index 8f3244997..862405234 100644 --- a/awc/src/frozen.rs +++ b/awc/src/frozen.rs @@ -49,7 +49,7 @@ impl FrozenClientRequest { where B: MessageBody + 'static, { - RequestSender::Rc(self.head.clone(), None).send_body( + RequestSender::Rc(Rc::clone(&self.head), None).send_body( self.addr, self.response_decompress, self.timeout, @@ -60,7 +60,7 @@ impl FrozenClientRequest { /// Send a json body. pub fn send_json(&self, value: &T) -> SendClientRequest { - RequestSender::Rc(self.head.clone(), None).send_json( + RequestSender::Rc(Rc::clone(&self.head), None).send_json( self.addr, self.response_decompress, self.timeout, @@ -71,7 +71,7 @@ impl FrozenClientRequest { /// Send an urlencoded body. pub fn send_form(&self, value: &T) -> SendClientRequest { - RequestSender::Rc(self.head.clone(), None).send_form( + RequestSender::Rc(Rc::clone(&self.head), None).send_form( self.addr, self.response_decompress, self.timeout, @@ -86,7 +86,7 @@ impl FrozenClientRequest { S: Stream> + 'static, E: Into + 'static, { - RequestSender::Rc(self.head.clone(), None).send_stream( + RequestSender::Rc(Rc::clone(&self.head), None).send_stream( self.addr, self.response_decompress, self.timeout, @@ -97,7 +97,7 @@ impl FrozenClientRequest { /// Send an empty body. pub fn send(&self) -> SendClientRequest { - RequestSender::Rc(self.head.clone(), None).send( + RequestSender::Rc(Rc::clone(&self.head), None).send( self.addr, self.response_decompress, self.timeout, @@ -147,8 +147,8 @@ impl FrozenSendBuilder { /// Complete request construction and send a body. pub fn send_body(self, body: impl MessageBody + 'static) -> SendClientRequest { - if let Some(e) = self.err { - return e.into(); + if let Some(err) = self.err { + return err.into(); } RequestSender::Rc(self.req.head, Some(self.extra_headers)).send_body( @@ -177,8 +177,8 @@ impl FrozenSendBuilder { /// Complete request construction and send an urlencoded body. pub fn send_form(self, value: impl Serialize) -> SendClientRequest { - if let Some(e) = self.err { - return e.into(); + if let Some(err) = self.err { + return err.into(); } RequestSender::Rc(self.req.head, Some(self.extra_headers)).send_form( @@ -196,8 +196,8 @@ impl FrozenSendBuilder { S: Stream> + 'static, E: Into + 'static, { - if let Some(e) = self.err { - return e.into(); + if let Some(err) = self.err { + return err.into(); } RequestSender::Rc(self.req.head, Some(self.extra_headers)).send_stream( @@ -211,8 +211,8 @@ impl FrozenSendBuilder { /// Complete request construction and send an empty body. pub fn send(self) -> SendClientRequest { - if let Some(e) = self.err { - return e.into(); + if let Some(err) = self.err { + return err.into(); } RequestSender::Rc(self.req.head, Some(self.extra_headers)).send( diff --git a/awc/src/lib.rs b/awc/src/lib.rs index ce2dfb34f..360b3db0e 100644 --- a/awc/src/lib.rs +++ b/awc/src/lib.rs @@ -100,17 +100,15 @@ //! # } //! ``` -#![deny(rust_2018_idioms, nonstandard_style)] -#![warn(future_incompatible)] +#![allow(unknown_lints)] // temp: #[allow(non_local_definitions)] #![allow( clippy::type_complexity, clippy::borrow_interior_mutable_const, - clippy::needless_doctest_main, - clippy::uninlined_format_args + clippy::needless_doctest_main )] #![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 actix_http::body; #[cfg(feature = "cookies")] diff --git a/awc/src/middleware/redirect.rs b/awc/src/middleware/redirect.rs index c38d6ad92..b2cf9c45b 100644 --- a/awc/src/middleware/redirect.rs +++ b/awc/src/middleware/redirect.rs @@ -78,7 +78,7 @@ where RedirectServiceFuture::Tunnel { fut } } ConnectRequest::Client(head, body, addr) => { - let connector = self.connector.clone(); + let connector = Rc::clone(&self.connector); let max_redirect_times = self.max_redirect_times; // backup the uri and method for reuse schema and authority. @@ -303,10 +303,7 @@ mod tests { use actix_web::{web, App, Error, HttpRequest, HttpResponse}; use super::*; - use crate::{ - http::{header::HeaderValue, StatusCode}, - ClientBuilder, - }; + use crate::{http::header::HeaderValue, ClientBuilder}; #[actix_rt::test] async fn basic_redirect() { diff --git a/awc/src/request.rs b/awc/src/request.rs index 4e34e5771..71ea2ef1e 100644 --- a/awc/src/request.rs +++ b/awc/src/request.rs @@ -83,7 +83,7 @@ impl ClientRequest { { match Uri::try_from(uri) { Ok(uri) => self.head.uri = uri, - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -152,7 +152,7 @@ impl ClientRequest { Ok((key, value)) => { self.head.headers.insert(key, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; self @@ -166,7 +166,7 @@ impl ClientRequest { self.head.headers.insert(key, value); } } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; self @@ -185,7 +185,7 @@ impl ClientRequest { pub fn append_header(mut self, header: impl TryIntoHeaderPair) -> Self { match header.try_into_pair() { Ok((key, value)) => self.head.headers.append(key, value), - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }; self @@ -217,7 +217,7 @@ impl ClientRequest { Ok(value) => { self.head.headers.insert(header::CONTENT_TYPE, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -299,7 +299,7 @@ impl ClientRequest { match Uri::from_parts(parts) { Ok(uri) => self.head.uri = uri, - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } } @@ -309,10 +309,7 @@ impl ClientRequest { /// Freeze request builder and construct `FrozenClientRequest`, /// which could be used for sending same request multiple times. pub fn freeze(self) -> Result { - let slf = match self.prep_for_sending() { - Ok(slf) => slf, - Err(e) => return Err(e.into()), - }; + let slf = self.prep_for_sending()?; let request = FrozenClientRequest { head: Rc::new(slf.head), @@ -332,7 +329,7 @@ impl ClientRequest { { let slf = match self.prep_for_sending() { Ok(slf) => slf, - Err(e) => return e.into(), + Err(err) => return err.into(), }; RequestSender::Owned(slf.head).send_body( @@ -348,7 +345,7 @@ impl ClientRequest { pub fn send_json(self, value: &T) -> SendClientRequest { let slf = match self.prep_for_sending() { Ok(slf) => slf, - Err(e) => return e.into(), + Err(err) => return err.into(), }; RequestSender::Owned(slf.head).send_json( @@ -366,7 +363,7 @@ impl ClientRequest { pub fn send_form(self, value: &T) -> SendClientRequest { let slf = match self.prep_for_sending() { Ok(slf) => slf, - Err(e) => return e.into(), + Err(err) => return err.into(), }; RequestSender::Owned(slf.head).send_form( @@ -386,7 +383,7 @@ impl ClientRequest { { let slf = match self.prep_for_sending() { Ok(slf) => slf, - Err(e) => return e.into(), + Err(err) => return err.into(), }; RequestSender::Owned(slf.head).send_stream( @@ -402,7 +399,7 @@ impl ClientRequest { pub fn send(self) -> SendClientRequest { let slf = match self.prep_for_sending() { Ok(slf) => slf, - Err(e) => return e.into(), + Err(err) => return err.into(), }; RequestSender::Owned(slf.head).send( @@ -415,8 +412,8 @@ impl ClientRequest { // allow unused mut when cookies feature is disabled fn prep_for_sending(#[allow(unused_mut)] mut self) -> Result { - if let Some(e) = self.err { - return Err(e.into()); + if let Some(err) = self.err { + return Err(err.into()); } // validate uri diff --git a/awc/src/responses/json_body.rs b/awc/src/responses/json_body.rs index 3912324b6..e9c03d81a 100644 --- a/awc/src/responses/json_body.rs +++ b/awc/src/responses/json_body.rs @@ -118,7 +118,7 @@ mod tests { use static_assertions::assert_impl_all; use super::*; - use crate::{http::header, test::TestResponse}; + use crate::test::TestResponse; assert_impl_all!(JsonBody: Unpin); diff --git a/awc/src/responses/response_body.rs b/awc/src/responses/response_body.rs index 8d9d1274a..0ff58341f 100644 --- a/awc/src/responses/response_body.rs +++ b/awc/src/responses/response_body.rs @@ -110,7 +110,7 @@ mod tests { use static_assertions::assert_impl_all; use super::*; - use crate::{http::header, test::TestResponse}; + use crate::test::TestResponse; assert_impl_all!(ResponseBody<()>: Unpin); diff --git a/awc/src/sender.rs b/awc/src/sender.rs index c2191d11b..0015743bd 100644 --- a/awc/src/sender.rs +++ b/awc/src/sender.rs @@ -54,8 +54,8 @@ impl From for FreezeRequestError { impl From for SendRequestError { fn from(err: PrepForSendingError) -> SendRequestError { match err { - PrepForSendingError::Url(e) => SendRequestError::Url(e), - PrepForSendingError::Http(e) => SendRequestError::Http(e), + PrepForSendingError::Url(err) => SendRequestError::Url(err), + PrepForSendingError::Http(err) => SendRequestError::Http(err), PrepForSendingError::Json(err) => { SendRequestError::Custom(Box::new(err), Box::new("json serialization error")) } @@ -122,8 +122,8 @@ impl Future for SendClientRequest { Poll::Ready(res) } - SendClientRequest::Err(ref mut e) => match e.take() { - Some(e) => Poll::Ready(Err(e)), + SendClientRequest::Err(ref mut err) => match err.take() { + Some(err) => Poll::Ready(Err(err)), None => panic!("Attempting to call completed future"), }, } @@ -147,8 +147,8 @@ impl Future for SendClientRequest { .poll(cx) .map_ok(|res| res.into_client_response()._timeout(delay.take())) } - SendClientRequest::Err(ref mut e) => match e.take() { - Some(e) => Poll::Ready(Err(e)), + SendClientRequest::Err(ref mut err) => match err.take() { + Some(err) => Poll::Ready(Err(err)), None => panic!("Attempting to call completed future"), }, } @@ -156,20 +156,20 @@ impl Future for SendClientRequest { } impl From for SendClientRequest { - fn from(e: SendRequestError) -> Self { - SendClientRequest::Err(Some(e)) + fn from(err: SendRequestError) -> Self { + SendClientRequest::Err(Some(err)) } } impl From for SendClientRequest { - fn from(e: HttpError) -> Self { - SendClientRequest::Err(Some(e.into())) + fn from(err: HttpError) -> Self { + SendClientRequest::Err(Some(err.into())) } } impl From for SendClientRequest { - fn from(e: PrepForSendingError) -> Self { - SendClientRequest::Err(Some(e.into())) + fn from(err: PrepForSendingError) -> Self { + SendClientRequest::Err(Some(err.into())) } } @@ -219,8 +219,8 @@ impl RequestSender { Err(err) => return PrepForSendingError::Json(err).into(), }; - if let Err(e) = self.set_header_if_none(header::CONTENT_TYPE, "application/json") { - return e.into(); + if let Err(err) = self.set_header_if_none(header::CONTENT_TYPE, "application/json") { + return err.into(); } self.send_body(addr, response_decompress, timeout, config, body) @@ -291,7 +291,7 @@ impl RequestSender { Ok(value) => { head.headers.insert(key, value); } - Err(e) => return Err(e.into()), + Err(err) => return Err(err.into()), } } } @@ -304,7 +304,7 @@ impl RequestSender { let h = extra_headers.get_or_insert(HeaderMap::new()); h.insert(key, v) } - Err(e) => return Err(e.into()), + Err(err) => return Err(err.into()), }; } } diff --git a/awc/src/test.rs b/awc/src/test.rs index 96ae1f0a1..a7ed3faf0 100644 --- a/awc/src/test.rs +++ b/awc/src/test.rs @@ -65,9 +65,7 @@ impl TestResponse { /// Set response's payload pub fn set_payload>(mut self, data: B) -> Self { - let (_, mut payload) = h1::Payload::create(true); - payload.unread_data(data.into()); - self.payload = Some(payload.into()); + self.payload = Some(Payload::from(data.into())); self } @@ -103,7 +101,7 @@ mod tests { use actix_http::header::HttpDate; use super::*; - use crate::{cookie, http::header}; + use crate::http::header; #[test] fn test_basics() { diff --git a/awc/src/ws.rs b/awc/src/ws.rs index 836ff2024..3ce1d286a 100644 --- a/awc/src/ws.rs +++ b/awc/src/ws.rs @@ -64,7 +64,7 @@ pub struct WebsocketsRequest { } impl WebsocketsRequest { - /// Create new WebSocket connection + /// Create new WebSocket connection. pub(crate) fn new(uri: U, config: ClientConfig) -> Self where Uri: TryFrom, @@ -82,7 +82,7 @@ impl WebsocketsRequest { match Uri::try_from(uri) { Ok(uri) => head.uri = uri, - Err(e) => err = Some(e.into()), + Err(error) => err = Some(error.into()), } WebsocketsRequest { @@ -143,7 +143,7 @@ impl WebsocketsRequest { { match HeaderValue::try_from(origin) { Ok(value) => self.origin = Some(value), - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -177,9 +177,9 @@ impl WebsocketsRequest { Ok(value) => { self.head.headers.append(key, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }, - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -196,9 +196,9 @@ impl WebsocketsRequest { Ok(value) => { self.head.headers.insert(key, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), }, - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -217,11 +217,11 @@ impl WebsocketsRequest { Ok(value) => { self.head.headers.insert(key, value); } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } } } - Err(e) => self.err = Some(e.into()), + Err(err) => self.err = Some(err.into()), } self } @@ -253,12 +253,13 @@ impl WebsocketsRequest { pub async fn connect( mut self, ) -> Result<(ClientResponse, Framed), WsClientError> { - if let Some(e) = self.err.take() { - return Err(e.into()); + if let Some(err) = self.err.take() { + return Err(err.into()); } - // validate uri + // validate URI let uri = &self.head.uri; + if uri.host().is_none() { return Err(InvalidUrl::MissingHost.into()); } else if uri.scheme().is_none() { @@ -273,9 +274,12 @@ impl WebsocketsRequest { } if !self.head.headers.contains_key(header::HOST) { + let hostname = uri.host().unwrap(); + let port = uri.port(); + self.head.headers.insert( header::HOST, - HeaderValue::from_str(uri.host().unwrap()).unwrap(), + HeaderValue::from_str(&Host { hostname, port }.to_string()).unwrap(), ); } @@ -322,7 +326,7 @@ impl WebsocketsRequest { // Generate a random key for the `Sec-WebSocket-Key` header which is a base64-encoded // (see RFC 4648 §4) value that, when decoded, is 16 bytes in length (RFC 6455 §1.3). - let sec_key: [u8; 16] = rand::random(); + let sec_key = rand::random::<[u8; 16]>(); let key = BASE64_STANDARD.encode(sec_key); self.head.headers.insert( @@ -434,6 +438,25 @@ impl fmt::Debug for WebsocketsRequest { } } +/// Formatter for host (hostname+port) header values. +struct Host<'a> { + hostname: &'a str, + port: Option>, +} + +impl fmt::Display for Host<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.hostname)?; + + if let Some(port) = &self.port { + f.write_str(":")?; + f.write_str(port.as_str())?; + } + + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/awc/tests/test_client.rs b/awc/tests/test_client.rs index 6d1459ac0..43c83891a 100644 --- a/awc/tests/test_client.rs +++ b/awc/tests/test_client.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - use std::{ collections::HashMap, convert::Infallible, @@ -22,7 +20,7 @@ use base64::prelude::*; use bytes::Bytes; use cookie::Cookie; use futures_util::stream; -use rand::Rng; +use rand::distr::{Alphanumeric, SampleString as _}; mod utils; @@ -518,11 +516,7 @@ async fn client_gzip_encoding_large() { #[cfg(feature = "compress-gzip")] #[actix_rt::test] async fn client_gzip_encoding_large_random() { - let data = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(100_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 100_000); let srv = actix_test::start(|| { App::new().service(web::resource("/").route(web::to(|data: Bytes| async { @@ -564,11 +558,7 @@ async fn client_brotli_encoding() { #[cfg(feature = "compress-brotli")] #[actix_rt::test] async fn client_brotli_encoding_large_random() { - let data = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(70_000) - .map(char::from) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 70_000); let srv = actix_test::start(|| { App::new().service(web::resource("/").route(web::to(|data: Bytes| async { @@ -609,11 +599,7 @@ async fn client_deflate_encoding() { #[actix_rt::test] async fn client_deflate_encoding_large_random() { - let data = rand::thread_rng() - .sample_iter(rand::distributions::Alphanumeric) - .map(char::from) - .take(70_000) - .collect::(); + let data = Alphanumeric.sample_string(&mut rand::rng(), 70_000); let srv = actix_test::start(|| { App::new().default_service(web::to(|body: Bytes| async { diff --git a/awc/tests/test_connector.rs b/awc/tests/test_connector.rs index b3eb97367..a8b7e98c1 100644 --- a/awc/tests/test_connector.rs +++ b/awc/tests/test_connector.rs @@ -13,9 +13,11 @@ use openssl::{ }; fn tls_config() -> SslAcceptor { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_pem(); + 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 = X509::from_pem(cert_file.as_bytes()).unwrap(); let key = PKey::private_key_from_pem(key_file.as_bytes()).unwrap(); diff --git a/awc/tests/test_rustls_client.rs b/awc/tests/test_rustls_client.rs index 652997de6..9ce6bd31f 100644 --- a/awc/tests/test_rustls_client.rs +++ b/awc/tests/test_rustls_client.rs @@ -1,66 +1,78 @@ -#![cfg(feature = "rustls")] +#![cfg(feature = "rustls-0_23-webpki-roots")] -extern crate tls_rustls as rustls; +extern crate tls_rustls_0_23 as rustls; -use std::{ - io::BufReader, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, - time::SystemTime, +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, }; use actix_http::HttpService; use actix_http_test::test_server; use actix_service::{fn_service, map_config, ServiceFactoryExt}; -use actix_tls::connect::rustls::webpki_roots_cert_store; +use actix_tls::connect::rustls_0_23::webpki_roots_cert_store; use actix_utils::future::ok; use actix_web::{dev::AppConfig, http::Version, web, App, HttpResponse}; -use rustls::{ - client::{ServerCertVerified, ServerCertVerifier}, - Certificate, ClientConfig, PrivateKey, ServerConfig, ServerName, -}; -use rustls_pemfile::{certs, pkcs8_private_keys}; +use rustls::{pki_types::ServerName, ClientConfig, ServerConfig}; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; fn tls_config() -> ServerConfig { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_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) - .unwrap() - .into_iter() - .map(Certificate) - .collect(); - let mut keys = pkcs8_private_keys(key_file).unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = + rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); ServerConfig::builder() - .with_safe_defaults() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKey(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap() } mod danger { + use rustls::{ + client::danger::{ServerCertVerified, ServerCertVerifier}, + pki_types::UnixTime, + }; + use super::*; + #[derive(Debug)] pub struct NoCertificateVerification; impl ServerCertVerifier for NoCertificateVerification { fn verify_server_cert( &self, - _end_entity: &Certificate, - _intermediates: &[Certificate], - _server_name: &ServerName, - _scts: &mut dyn Iterator, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, _ocsp_response: &[u8], - _now: SystemTime, + _now: UnixTime, ) -> Result { - Ok(ServerCertVerified::assertion()) + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::aws_lc_rs::default_provider() + .signature_verification_algorithms + .supported_schemes() } } } @@ -82,14 +94,13 @@ async fn test_connection_reuse_h2() { App::new().service(web::resource("/").route(web::to(HttpResponse::Ok))), |_| AppConfig::default(), )) - .rustls(tls_config()) + .rustls_0_23(tls_config()) .map_err(|_| ()), ) }) .await; let mut config = ClientConfig::builder() - .with_safe_defaults() .with_root_certificates(webpki_roots_cert_store()) .with_no_client_auth(); @@ -102,7 +113,7 @@ async fn test_connection_reuse_h2() { .set_certificate_verifier(Arc::new(danger::NoCertificateVerification)); let client = awc::Client::builder() - .connector(awc::Connector::new().rustls(Arc::new(config))) + .connector(awc::Connector::new().rustls_0_23(Arc::new(config))) .finish(); // req 1 diff --git a/awc/tests/test_ssl_client.rs b/awc/tests/test_ssl_client.rs index 5273c3fff..95d4c15f1 100644 --- a/awc/tests/test_ssl_client.rs +++ b/awc/tests/test_ssl_client.rs @@ -19,9 +19,11 @@ use openssl::{ }; fn tls_config() -> SslAcceptor { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); - let cert_file = cert.serialize_pem().unwrap(); - let key_file = cert.serialize_private_key_pem(); + 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 = X509::from_pem(cert_file.as_bytes()).unwrap(); let key = PKey::private_key_from_pem(key_file.as_bytes()).unwrap(); diff --git a/clippy.toml b/clippy.toml deleted file mode 100644 index 04371125d..000000000 --- a/clippy.toml +++ /dev/null @@ -1 +0,0 @@ -msrv = "1.65" diff --git a/deny.toml b/deny.toml new file mode 100644 index 000000000..f775089f4 --- /dev/null +++ b/deny.toml @@ -0,0 +1,45 @@ +[licenses] +confidence-threshold = 0.90 +allow = [ + "Apache-2.0", + "MIT", + "Unicode-3.0", + "ISC", + "CDLA-Permissive-2.0", + "BSD-3-Clause", + "Zlib", + "OpenSSL", + "MPL-2.0" +] +private = { ignore = true } + +# FIXME: old rustls introduces old ring which is not set license field properly. +[[licenses.clarify]] +crate = "ring" +expression = "MIT AND ISC AND OpenSSL" +license-files = [ + { path = "LICENSE", hash = 0xbd0eed23 } +] + +# FIXME: webpki is almost unmaintained and is not set license field properly. +# rustls has its own fork now so removing old rustls should resolve the issue. +[[licenses.clarify]] +crate = "webpki" +expression = "ISC" +license-files = [ + { path = "LICENSE", hash = 0x001c7e6c } +] + +[bans] +multiple-versions = "allow" + +[bans.build] +executables = "deny" + +[advisories] +# because of old rustls support: +ignore = [ + "RUSTSEC-2024-0336", + "RUSTSEC-2025-0009", + "RUSTSEC-2025-0010" +] diff --git a/justfile b/justfile new file mode 100644 index 000000000..ba8014f43 --- /dev/null +++ b/justfile @@ -0,0 +1,145 @@ +_list: + @just --list + +toolchain := "" + +# Format workspace. +fmt: + just --unstable --fmt + cargo +nightly fmt + fd --hidden --type=file --extension=md --extension=yml --exec-batch npx -y prettier --write + +# Downgrade dependencies necessary to run MSRV checks/tests. +[private] +downgrade-for-msrv: + # no downgrades currently needed + +msrv := ``` + cargo metadata --format-version=1 \ + | jq -r 'first(.packages[] | select(.source == null and .rust_version)) | .rust_version' \ + | sed -E 's/^1\.([0-9]{2})$/1\.\1\.0/' +``` +msrv_rustup := "+" + msrv +non_linux_all_features_list := ``` + cargo metadata --format-version=1 \ + | jq '.packages[] | select(.source == null) | .features | keys' \ + | jq -r --slurp \ + --arg exclusions "__tls,__compress,tokio-uring,io-uring,experimental-io-uring" \ + 'add | unique | . - ($exclusions | split(",")) | join(",")' +``` +all_crate_features := if os() == "linux" { "--all-features" } else { "--features='" + non_linux_all_features_list + "'" } + +[private] +check-min: + cargo hack --workspace check --no-default-features + +[private] +check-default: + cargo hack --workspace check + +# Check workspace. +check: && clippy + fd --hidden --type=file --extension=md --extension=yml --exec-batch npx -y prettier --check + +# Run Clippy over workspace. +clippy: + cargo {{ toolchain }} clippy --workspace --all-targets {{ all_crate_features }} + +# Run Clippy over workspace using MSRV. +clippy-msrv: downgrade-for-msrv + @just toolchain={{ msrv_rustup }} clippy + +# Test workspace code. +test: + cargo {{ toolchain }} test --lib --tests -p=actix-web-codegen --all-features + cargo {{ toolchain }} test --lib --tests -p=actix-multipart-derive --all-features + cargo {{ toolchain }} nextest run --no-tests=warn -p=actix-router --no-default-features + cargo {{ toolchain }} nextest run --no-tests=warn --workspace --exclude=actix-web-codegen --exclude=actix-multipart-derive {{ all_crate_features }} --filter-expr="not test(test_reading_deflate_encoding_large_random_rustls)" + +# Test workspace using MSRV. +test-msrv: downgrade-for-msrv + @just toolchain={{ msrv_rustup }} test + +# Test workspace docs. +test-docs: && doc + cargo {{ toolchain }} test --doc --workspace {{ all_crate_features }} --no-fail-fast -- --nocapture + +# Test workspace. +test-all: test test-docs + +# Test workspace and collect coverage info. +[private] +test-coverage: + cargo {{ toolchain }} llvm-cov nextest --no-tests=warn --no-report {{ all_crate_features }} + cargo {{ toolchain }} llvm-cov --doc --no-report {{ all_crate_features }} + +# Test workspace and generate Codecov report. +test-coverage-codecov: test-coverage + cargo {{ toolchain }} llvm-cov report --doctests --codecov --output-path=codecov.json + +# Test workspace and generate LCOV report. +test-coverage-lcov: test-coverage + cargo {{ toolchain }} llvm-cov report --doctests --lcov --output-path=lcov.info + +# Document crates in workspace. +doc *args: && doc-set-workspace-crates + rm -f "$(cargo metadata --format-version=1 | jq -r '.target_directory')/doc/crates.js" + RUSTDOCFLAGS="--cfg=docsrs -Dwarnings" cargo +nightly doc --no-deps --workspace {{ all_crate_features }} {{ args }} + +[private] +doc-set-workspace-crates: + #!/usr/bin/env bash + ( + echo "window.ALL_CRATES =" + cargo metadata --format-version=1 \ + | jq '[.packages[] | select(.source == null) | .targets | map(select(.doc) | .name)] | flatten' + echo ";" + ) > "$(cargo metadata --format-version=1 | jq -r '.target_directory')/doc/crates.js" + +# Document crates in workspace and watch for changes. +doc-watch: + @just doc --open + cargo watch -- just doc + +# Update READMEs from crate root documentation. +update-readmes: && fmt + cd ./actix-files && cargo rdme --force + cd ./actix-http-test && cargo rdme --force + cd ./actix-router && cargo rdme --force + cd ./actix-multipart && cargo rdme --force + cd ./actix-test && cargo rdme --force + +feature_combo_skip_list := if os() == "linux" { "__tls,__compress" } else { "__tls,__compress,experimental-io-uring" } + +# Checks compatibility of feature combinations. +check-feature-combinations: + cargo hack --workspace \ + --feature-powerset --depth=4 \ + --skip={{ feature_combo_skip_list }} \ + check + +# Check for unintentional external type exposure on all crates in workspace. +check-external-types-all toolchain="+nightly": + #!/usr/bin/env bash + set -euo pipefail + exit=0 + for f in $(find . -mindepth 2 -maxdepth 2 -name Cargo.toml | grep -vE "\-codegen/|\-derive/|\-macros/"); do + if ! just check-external-types-manifest "$f" {{ toolchain }}; then exit=1; fi + echo + echo + done + exit $exit + +# Check for unintentional external type exposure on all crates in workspace. +check-external-types-all-table toolchain="+nightly": + #!/usr/bin/env bash + set -euo pipefail + for f in $(find . -mindepth 2 -maxdepth 2 -name Cargo.toml | grep -vE "\-codegen/|\-derive/|\-macros/"); do + echo + echo "Checking for $f" + just check-external-types-manifest "$f" {{ toolchain }} --output-format=markdown-table + done + +# Check for unintentional external type exposure on a crate. +check-external-types-manifest manifest_path toolchain="+nightly" *extra_args="": + cargo {{ toolchain }} check-external-types --manifest-path "{{ manifest_path }}" {{ extra_args }} diff --git a/scripts/bump b/scripts/bump index b09d9d196..0b16466b4 100755 --- a/scripts/bump +++ b/scripts/bump @@ -1,11 +1,11 @@ -#!/bin/bash +#!/usr/bin/env bash # developed on macOS and probably doesn't work on Linux yet due to minor # differences in flags on sed # requires github cli tool for automatic release draft creation -set -euo pipefail +set -eEuo pipefail DIR=$1 @@ -93,9 +93,12 @@ fi # done; remove backup files rm -f $CARGO_MANIFEST.bak -rm -f $CHANGELOG_FILE.bak rm -f $README_FILE.bak +if [ -n "${CHANGELOG_FILE-}" ]; then + rm -f $CHANGELOG_FILE.bak +fi + echo "manifest, changelog, and readme updated" echo echo "check other references:" @@ -110,16 +113,23 @@ read -p "Update all references: (y/N) " UPDATE_REFERENCES UPDATE_REFERENCES="${UPDATE_REFERENCES:-n}" if [ "$UPDATE_REFERENCES" = 'y' ] || [ "$UPDATE_REFERENCES" = 'Y' ]; then + if [[ $NEW_VERSION == *".0.0" ]]; then + NEW_VERSION_SPEC="${NEW_VERSION%.0.0}" + elif [[ $NEW_VERSION == *".0" ]]; then + NEW_VERSION_SPEC="${NEW_VERSION%.0}" + else + NEW_VERSION_SPEC="$NEW_VERSION" + fi for f in $(fd Cargo.toml); do sed -i.bak -E \ - "s/^(${PACKAGE_NAME} ?= ?\")[^\"]+(\")$/\1${NEW_VERSION}\2/g" $f + "s/^(${PACKAGE_NAME} ?= ?\")[^\"]+(\")$/\1${NEW_VERSION_SPEC}\2/g" $f sed -i.bak -E \ - "s/^(${PACKAGE_NAME} ?=.*version ?= ?\")[^\"]+(\".*)$/\1${NEW_VERSION}\2/g" $f + "s/^(${PACKAGE_NAME} ?=.*version ?= ?\")[^\"]+(\".*)$/\1${NEW_VERSION_SPEC}\2/g" $f sed -i.bak -E \ - "s/^(.*package ?= ?\"${PACKAGE_NAME}\".*version ?= ?\")[^\"]+(\".*)$/\1${NEW_VERSION}\2/g" $f + "s/^(.*package ?= ?\"${PACKAGE_NAME}\".*version ?= ?\")[^\"]+(\".*)$/\1${NEW_VERSION_SPEC}\2/g" $f sed -i.bak -E \ - "s/^(.*version ?= ?\")[^\"]+(\".*package ?= ?\"${PACKAGE_NAME}\".*)$/\1${NEW_VERSION}\2/g" $f + "s/^(.*version ?= ?\")[^\"]+(\".*package ?= ?\"${PACKAGE_NAME}\".*)$/\1${NEW_VERSION_SPEC}\2/g" $f # remove backup file rm -f $f.bak @@ -128,11 +138,12 @@ if [ "$UPDATE_REFERENCES" = 'y' ] || [ "$UPDATE_REFERENCES" = 'Y' ]; then fi if [ $MACOS ]; then - printf "prepare $PACKAGE_NAME release $NEW_VERSION" | pbcopy + printf "chore($PACKAGE_NAME): prepare release $NEW_VERSION" | pbcopy + echo "placed the recommended commit message on the clipboard" else echo echo "commit message:" - echo "prepare $PACKAGE_NAME release $NEW_VERSION" + echo "chore($PACKAGE_NAME): prepare release $NEW_VERSION" fi SHORT_PACKAGE_NAME="$(echo $PACKAGE_NAME | sed 's/^actix-web-//' | sed 's/^actix-//')" @@ -158,3 +169,5 @@ if [ "$GH_RELEASE" = 'y' ] || [ "$GH_RELEASE" = 'Y' ]; then fi echo + +cargo update >/dev/null 2>&1 || true diff --git a/scripts/ci-test b/scripts/ci-test deleted file mode 100755 index bdea1283a..000000000 --- a/scripts/ci-test +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/sh - -# run tests matching what CI does for non-linux feature sets - -set -x - -EXIT=0 - -save_exit_code() { - eval $@ - local CMD_EXIT=$? - [ "$CMD_EXIT" = "0" ] || EXIT=$CMD_EXIT -} - -save_exit_code cargo test --lib --tests -p=actix-router --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-http --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-web --features=rustls,openssl -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-web-codegen --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=awc --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-http-test --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-test --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-files -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-multipart --all-features -- --nocapture -save_exit_code cargo test --lib --tests -p=actix-web-actors --all-features -- --nocapture - -save_exit_code cargo test --workspace --doc - -if [ "$EXIT" = "0" ]; then - PASSED="All tests passed!" - - if [ "$(command -v figlet)" ]; then - figlet "$PASSED" - else - echo "$PASSED" - fi -fi - -exit $EXIT diff --git a/scripts/free-disk-space.sh b/scripts/free-disk-space.sh new file mode 100755 index 000000000..2946cfcf6 --- /dev/null +++ b/scripts/free-disk-space.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The Azure provided machines typically have the following disk allocation: +# Total space: 85GB +# Allocated: 67 GB +# Free: 17 GB +# This script frees up 28 GB of disk space by deleting unneeded packages and +# large directories. +# The Flink end to end tests download and generate more than 17 GB of files, +# causing unpredictable behavior and build failures. + +echo "==============================================================================" +echo "Freeing up disk space on CI system" +echo "==============================================================================" + +echo "Listing 100 largest packages" +dpkg-query -Wf '${Installed-Size}\t${Package}\n' | sort -n | tail -n 100 +df -h + +echo "Removing large packages" +sudo apt-get remove -y '^dotnet-.*' +sudo apt-get remove -y 'php.*' +sudo apt-get remove -y '^mongodb-.*' +sudo apt-get remove -y '^mysql-.*' +sudo apt-get remove -y azure-cli google-cloud-sdk hhvm google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri +sudo apt-get autoremove -y +sudo apt-get clean +df -h + +echo "Removing large directories" +sudo rm -rf /usr/share/dotnet/ +sudo rm -rf /usr/local/graalvm/ +sudo rm -rf /usr/local/.ghcup/ +sudo rm -rf /usr/local/share/powershell +sudo rm -rf /usr/local/share/chromium +sudo rm -rf /usr/local/lib/android +sudo rm -rf /usr/local/lib/node_modules +df -h diff --git a/scripts/publish b/scripts/publish new file mode 100755 index 000000000..7d7347cdb --- /dev/null +++ b/scripts/publish @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -Euo pipefail + +for dir in $@; do + cd "$dir" + + cargo publish --dry-run + + read -p "Look okay? " + read -p "Sure? " + + cargo publish + + if [ $? -ne 0 ]; then + echo + read -p "Was the above error caused by cyclic dev-deps? Choosing yes will publish without a git backreference. (y/N) " publish_no_dev_deps + + if [[ "$publish_no_dev_deps" == "y" || "$publish_no_dev_deps" == "Y" ]]; then + cargo hack --no-dev-deps publish --allow-dirty + fi + fi + + cd .. +done