diff --git a/.clippy.toml b/.clippy.toml index 4f97157ed..ef8ae3555 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -3,6 +3,6 @@ disallowed-names = [ "e", # no single letter error bindings ] disallowed-methods = [ - { path = "std::cell::RefCell::default()", reason = "prefer explicit inner type default" }, - { path = "std::rc::Rc::default()", reason = "prefer explicit inner type default" }, + { path = "std::cell::RefCell::default()", reason = "prefer explicit inner type default (remove allow-invalid when rust-lang/rust-clippy/#8581 is fixed)", allow-invalid = true }, + { path = "std::rc::Rc::default()", reason = "prefer explicit inner type default (remove allow-invalid when rust-lang/rust-clippy/#8581 is fixed)", allow-invalid = true }, ] diff --git a/.cspell.yml b/.cspell.yml index 56a4216c2..55a01c301 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -2,11 +2,14 @@ version: "0.2" words: - actix - addrs + - ALPN - bytestring - httparse - - msrv + - 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/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 fd6bc6d73..cabf1c267 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -2,7 +2,7 @@ name: Benchmark on: push: - branches: [master] + branches: [main] permissions: contents: read @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust run: | diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index c6f56e67a..578ca2794 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -2,7 +2,7 @@ name: CI (post-merge) on: push: - branches: [master] + branches: [main] permissions: contents: read @@ -28,11 +28,11 @@ jobs: runs-on: ${{ matrix.target.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install nasm if: matrix.target.os == 'windows-latest' - uses: ilammy/setup-nasm@v1.5.2 + uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2 - name: Install OpenSSL if: matrix.target.os == 'windows-latest' @@ -44,12 +44,12 @@ jobs: echo "RUSTFLAGS=-C target-feature=+crt-static" >> $GITHUB_ENV - name: Install Rust (${{ matrix.version.name }}) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -71,19 +71,19 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Free Disk Space run: ./scripts/free-disk-space.sh - name: Setup mold linker - uses: rui314/setup-mold@v1 + uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 - name: Install just, cargo-hack - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just,cargo-hack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1adf6b41..aed186426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: merge_group: types: [checks_requested] push: - branches: [master] + branches: [main] permissions: contents: read @@ -18,7 +18,7 @@ concurrency: jobs: read_msrv: name: Read MSRV - uses: actions-rust-lang/msrv/.github/workflows/msrv.yml@v0.1.0 + uses: actions-rust-lang/msrv/.github/workflows/msrv.yml@b95a3a81b0efee6438b858b41a84aff627e01351 # v0.1.1 build_and_test: needs: read_msrv @@ -39,11 +39,11 @@ jobs: runs-on: ${{ matrix.target.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install nasm if: matrix.target.os == 'windows-latest' - uses: ilammy/setup-nasm@v1.5.2 + uses: ilammy/setup-nasm@72793074d3c8cdda771dba85f6deafe00623038b # v1.5.2 - name: Install OpenSSL if: matrix.target.os == 'windows-latest' @@ -56,15 +56,15 @@ jobs: - name: Setup mold linker if: matrix.target.os == 'ubuntu-latest' - uses: rui314/setup-mold@v1 + uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Install Rust (${{ matrix.version.name }}) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -79,25 +79,29 @@ jobs: run: just check-default - name: tests - timeout-minutes: 60 + timeout-minutes: 30 run: just test - name: CI cache clean run: cargo-ci-cache-clean + - name: deny check + if: matrix.version.name == 'stable' && matrix.target.os == 'ubuntu-latest' + uses: EmbarkStudios/cargo-deny-action@175dc7fd4fb85ec8f46948fb98f44db001149081 # v2.0.16 + io-uring: name: io-uring tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: nightly - name: tests (io-uring) - timeout-minutes: 60 + timeout-minutes: 30 run: > sudo bash -c "ulimit -Sl 512 && ulimit -Hl 512 && PATH=$PATH:/usr/share/rust/.cargo/bin && RUSTUP_TOOLCHAIN=stable cargo test --lib --tests -p=actix-files --all-features" @@ -105,15 +109,15 @@ jobs: name: doc tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: nightly - name: Install just - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6500c0adb..25bfa5b52 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,7 +2,7 @@ name: Coverage on: push: - branches: [master] + branches: [main] permissions: contents: read @@ -15,16 +15,16 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: nightly components: llvm-tools - name: Install just, cargo-llvm-cov, cargo-nextest - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just,cargo-llvm-cov,cargo-nextest @@ -32,7 +32,7 @@ jobs: run: just test-coverage-codecov - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: files: codecov.json fail_ci_if_error: true 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 index dfddd25cd..812abed11 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,10 +15,10 @@ jobs: fmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: nightly components: rustfmt @@ -33,15 +33,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: components: clippy - name: Check with Clippy - uses: giraffate/clippy-action@v1.0.1 + uses: giraffate/clippy-action@13b9d32482f25d29ead141b79e7e04e7900281e0 # v1.0.1 with: reporter: github-pr-check github_token: ${{ secrets.GITHUB_TOKEN }} @@ -52,10 +52,10 @@ jobs: lint-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: nightly components: rust-docs @@ -69,20 +69,20 @@ jobs: if: false # rustdoc mismatch currently runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rust (${{ vars.RUST_VERSION_EXTERNAL_TYPES }}) - uses: actions-rust-lang/setup-rust-toolchain@v1.12.0 + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 with: toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }} - name: Install just - uses: taiki-e/install-action@v2.50.10 + uses: taiki-e/install-action@0abfcd587b70a713fdaa7fb502c885e2112acb15 # v2.75.7 with: tool: just - name: Install cargo-check-external-types - uses: taiki-e/cache-cargo-install-action@v2.1.1 + uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6 with: tool: cargo-check-external-types diff --git a/.github/workflows/semver-checks.yml b/.github/workflows/semver-checks.yml new file mode 100644 index 000000000..dd5881815 --- /dev/null +++ b/.github/workflows/semver-checks.yml @@ -0,0 +1,86 @@ +name: Semver Checks + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + semver-checks: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + with: + toolchain: stable + + - name: Install cargo-semver-checks + uses: taiki-e/install-action@7a562dfa955aa2e4d5b0fd6ebd57ff9715c07b0b # v2.73.0 + with: + tool: cargo-semver-checks + + - name: Run cargo semver-checks + id: semver + shell: bash + run: | + set -o pipefail + + output_file="$(mktemp)" + + cargo semver-checks \ + --workspace \ + --release-type=patch \ + --baseline-rev "${{ github.event.pull_request.base.sha }}" \ + 2>&1 | tee "$output_file" + status=$? + + semver_type=patch + if grep -q "semver requires new major version" "$output_file"; then + semver_type=major + elif grep -q "semver requires new minor version" "$output_file"; then + semver_type=minor + elif grep -q "semver requires new patch version" "$output_file"; then + semver_type=patch + fi + + { + echo "exit_code=$status" + echo "output_file=$output_file" + echo "semver_type=$semver_type" + } >> "$GITHUB_OUTPUT" + + exit 0 + + - name: Summarize cargo semver-checks output + if: always() && steps.semver.outcome != 'skipped' + shell: bash + run: | + summary_file="${{ steps.semver.outputs.output_file }}" + status="${{ steps.semver.outputs.exit_code }}" + + { + echo "## cargo semver-checks" + echo + echo "- Base SHA: \`${{ github.event.pull_request.base.sha }}\`" + echo "- Head SHA: \`${{ github.event.pull_request.head.sha }}\`" + echo "- Required release: \`${{ steps.semver.outputs.semver_type }}\`" + echo "- cargo semver-checks exit code: \`$status\`" + + echo + echo "
Command output" + echo + echo '```text' + sed -n '1,200p' "$summary_file" + total_lines="$(wc -l < "$summary_file")" + if [ "$total_lines" -gt 200 ]; then + echo + echo "[truncated; showing first 200 of ${total_lines} lines]" + fi + echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index 3afa9f6f8..771502256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,29 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-rt", - "bitflags 2.9.0", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] +version = 4 [[package]] name = "actix-codec" @@ -31,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-sink", @@ -44,7 +21,7 @@ dependencies = [ [[package]] name = "actix-files" -version = "0.6.6" +version = "0.6.10" dependencies = [ "actix-http", "actix-rt", @@ -53,10 +30,11 @@ dependencies = [ "actix-test", "actix-utils", "actix-web", - "bitflags 2.9.0", + "bitflags 2.11.1", "bytes", "derive_more", "env_logger", + "filetime", "futures-core", "http-range", "log", @@ -71,7 +49,7 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.11.0" +version = "3.12.1" dependencies = [ "actix-codec", "actix-http-test", @@ -82,8 +60,9 @@ dependencies = [ "actix-utils", "actix-web", "async-stream", + "awc", "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.11.1", "brotli", "bytes", "bytestring", @@ -93,7 +72,7 @@ dependencies = [ "encoding_rs", "env_logger", "flate2", - "foldhash", + "foldhash 0.2.0", "futures-core", "futures-util", "h2", @@ -109,11 +88,11 @@ dependencies = [ "openssl", "percent-encoding", "pin-project-lite", - "rand 0.9.1", + "rand 0.10.1", "rcgen", "regex", - "rustls 0.23.27", - "rustls-pemfile", + "rustls 0.23.38", + "rustls-pki-types", "rustversion", "serde", "serde_json", @@ -147,7 +126,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "slab", - "socket2 0.5.9", + "socket2 0.6.3", "tokio", ] @@ -163,7 +142,7 @@ dependencies = [ [[package]] name = "actix-multipart" -version = "0.7.2" +version = "0.8.0" dependencies = [ "actix-http", "actix-multipart-derive", @@ -185,7 +164,7 @@ dependencies = [ "memchr", "mime", "multer", - "rand 0.9.1", + "rand 0.10.1", "serde", "serde_json", "serde_plain", @@ -196,7 +175,7 @@ dependencies = [ [[package]] name = "actix-multipart-derive" -version = "0.7.0" +version = "0.8.0" dependencies = [ "actix-multipart", "actix-web", @@ -219,12 +198,12 @@ dependencies = [ "bytes", "common-multipart-rfc7578", "futures-core", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" dependencies = [ "bytestring", "cfg-if", @@ -239,9 +218,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" dependencies = [ "actix-macros", "futures-core", @@ -261,7 +240,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.9", + "socket2 0.5.10", "tokio", "tokio-uring", "tracing", @@ -296,7 +275,7 @@ dependencies = [ "rustls 0.20.9", "rustls 0.21.12", "rustls 0.22.4", - "rustls 0.23.27", + "rustls 0.23.38", "serde", "serde_json", "serde_urlencoded", @@ -305,16 +284,16 @@ dependencies = [ [[package]] name = "actix-tls" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac453898d866cdbecdbc2334fe1738c747b4eba14a677261f2b768ba05329389" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" dependencies = [ "actix-rt", "actix-service", "actix-utils", "futures-core", "http 0.2.12", - "http 1.3.1", + "http 1.4.0", "impl-more", "openssl", "pin-project-lite", @@ -325,7 +304,7 @@ dependencies = [ "tokio-rustls 0.23.4", "tokio-rustls 0.24.1", "tokio-rustls 0.25.0", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.4", "tokio-util", "tracing", "webpki-roots 0.22.6", @@ -345,7 +324,7 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.11.0" +version = "4.13.0" dependencies = [ "actix-codec", "actix-files", @@ -372,7 +351,7 @@ dependencies = [ "encoding_rs", "env_logger", "flate2", - "foldhash", + "foldhash 0.2.0", "futures-core", "futures-util", "impl-more", @@ -383,17 +362,17 @@ dependencies = [ "once_cell", "openssl", "pin-project-lite", - "rand 0.9.1", + "rand 0.10.1", "rcgen", "regex", "regex-lite", - "rustls 0.23.27", - "rustls-pemfile", + "rustls 0.23.38", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.5.9", + "socket2 0.6.3", "static_assertions", "time", "tokio", @@ -403,28 +382,6 @@ dependencies = [ "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" @@ -443,20 +400,11 @@ dependencies = [ "trybuild", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -464,7 +412,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -476,7 +424,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -495,9 +443,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -525,9 +473,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -540,39 +488,45 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "assert_matches" version = "1.5.0" @@ -603,9 +557,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -614,13 +568,13 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "awc" -version = "3.7.0" +version = "3.8.2" dependencies = [ "actix-codec", "actix-http", @@ -644,6 +598,7 @@ dependencies = [ "futures-core", "futures-util", "h2", + "hickory-resolver", "http 0.2.12", "itoa", "log", @@ -651,27 +606,26 @@ dependencies = [ "openssl", "percent-encoding", "pin-project-lite", - "rand 0.9.1", + "rand 0.10.1", "rcgen", "rustls 0.20.9", "rustls 0.21.12", "rustls 0.22.4", - "rustls 0.23.27", - "rustls-pemfile", + "rustls 0.23.38", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "static_assertions", "tokio", - "trust-dns-resolver", "zstd", ] [[package]] name = "aws-lc-rs" -version = "1.13.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -679,32 +633,16 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.29.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ - "bindgen", "cc", "cmake", "dunce", "fs_extra", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.20.0" @@ -717,29 +655,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.0", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", - "which", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -748,9 +663,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -762,10 +677,19 @@ dependencies = [ ] [[package]] -name = "brotli" -version = "8.0.1" +name = "block-buffer" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-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", @@ -784,27 +708,27 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytesize" -version = "2.0.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "bytestring" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" dependencies = [ "bytes", ] @@ -817,29 +741,32 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.22" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] -name = "cexpr" -version = "0.6.0" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cfg-if" -version = "1.0.0" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "ciborium" @@ -874,35 +801,24 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" -version = "4.5.37" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -911,24 +827,24 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "common-multipart-rfc7578" @@ -939,11 +855,11 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "mime", "mime_guess", "rand 0.8.5", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -953,10 +869,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" [[package]] -name = "const-str" -version = "0.5.7" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[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" @@ -978,9 +909,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -1013,10 +944,19 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.4.2" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1033,7 +973,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools 0.10.5", + "itertools", "num-traits", "once_cell", "oorandom", @@ -1054,9 +994,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools 0.10.5", + "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" @@ -1093,21 +1039,30 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1154,36 +1109,38 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", + "rustc_version", "syn", "unicode-xid", ] @@ -1194,11 +1151,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1270,9 +1238,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -1280,9 +1248,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -1299,25 +1267,42 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "libz-sys", @@ -1336,6 +1321,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1353,9 +1344,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1368,24 +1359,24 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1394,15 +1385,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1411,21 +1402,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-test" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961fb6311645f46e2cdc2964a8bfae6743fd72315eaec181a71ae3eb2467113" +checksum = "32d24b40cb9018c6b0f9d891b74a86a777d5db37972a115016d1150257b1c793" dependencies = [ "futures-core", "futures-executor", @@ -1439,9 +1430,9 @@ dependencies = [ [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1449,7 +1440,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1465,25 +1455,39 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", ] [[package]] @@ -1496,23 +1500,17 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1529,19 +1527,29 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -1551,15 +1559,55 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hermit-abi" -version = "0.5.1" +name = "hickory-proto" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" +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.4", + "ring 0.17.14", + "thiserror 2.0.18", + "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.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] name = "hkdf" @@ -1576,16 +1624,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", + "digest 0.10.7", ] [[package]] @@ -1601,12 +1640,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1629,13 +1667,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "icu_collections" -version = "2.0.0" +name = "hybrid-array" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1643,9 +1691,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1656,11 +1704,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1671,42 +1718,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1714,6 +1757,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1722,19 +1771,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.4.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1759,12 +1798,14 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -1788,38 +1829,39 @@ dependencies = [ [[package]] name = "ipconfig" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.5.9", + "socket2 0.6.3", "widestring", - "windows-sys 0.48.0", - "winreg", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi 0.5.1", + "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -1830,39 +1872,30 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.13" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.13" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -1871,19 +1904,19 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", @@ -1896,31 +1929,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] -name = "libloading" -version = "0.8.6" +name = "libredox" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "cfg-if", - "windows-targets 0.52.6", + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1935,29 +1964,17 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-channel" @@ -1978,34 +1995,24 @@ checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "lru-cache" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -2023,31 +2030,43 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] @@ -2059,7 +2078,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.3.1", + "http 1.4.0", "httparse", "memchr", "mime", @@ -2067,21 +2086,11 @@ dependencies = [ "version_check", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -2094,28 +2103,29 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] [[package]] -name = "object" -version = "0.36.7" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ - "memchr", + "critical-section", + "portable-atomic", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oorandom" @@ -2131,11 +2141,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -2157,15 +2167,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.108" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -2175,9 +2185,9 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2185,47 +2195,47 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -2234,21 +2244,21 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" 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" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plotters" @@ -2285,31 +2295,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2331,9 +2341,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.32" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -2341,27 +2351,33 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -2376,12 +2392,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2401,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2410,23 +2437,29 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] -name = "rayon" -version = "1.10.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2434,9 +2467,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -2457,18 +2490,27 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.12" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.1", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2478,9 +2520,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2489,21 +2531,21 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "resolv-conf" -version = "0.7.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7c8f7f733062b66dc1c63f9db168ac0b97a9210e247fa90fdc9ad08f51b302" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "ring" @@ -2528,48 +2570,32 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted 0.9.0", "windows-sys 0.52.0", ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "bitflags 2.9.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "semver", ] [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2612,46 +2638,36 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.3", + "rustls-webpki 0.103.12", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.7.3" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", - "rustls-pemfile", "rustls-pki-types", "schannel", "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] @@ -2679,9 +2695,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -2691,9 +2707,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustversion-msrv" @@ -2703,9 +2719,9 @@ checksum = "b6ceb60223ee771fb5dfe462e29e5ee92bca9a7b9c555584f4d361045dae0e12" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2718,11 +2734,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2743,11 +2759,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -2756,28 +2772,44 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] [[package]] -name = "serde" -version = "1.0.219" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2786,14 +2818,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -2807,11 +2840,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -2828,13 +2861,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2844,8 +2877,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2856,27 +2889,31 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] -name = "slab" -version = "0.4.9" +name = "simd-adler32" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[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.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" @@ -2890,14 +2927,24 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.5.2" @@ -2912,9 +2959,9 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -2936,9 +2983,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2957,22 +3004,28 @@ dependencies = [ ] [[package]] -name = "target-triple" -version = "0.1.4" +name = "tagptr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" +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.19.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.2", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -2986,12 +3039,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -3000,7 +3053,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -3015,31 +3077,42 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.41" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3047,9 +3120,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3067,9 +3140,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3082,27 +3155,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.0" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ - "backtrace", "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.9", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3154,19 +3226,19 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.27", + "rustls 0.23.38", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -3190,9 +3262,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -3203,50 +3275,48 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.22" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", "toml_datetime", - "toml_write", + "toml_parser", + "toml_writer", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.1" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3256,9 +3326,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3267,64 +3337,18 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] -[[package]] -name = "trust-dns-proto" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna 0.4.0", - "ipnet", - "once_cell", - "rand 0.8.5", - "smallvec", - "thiserror", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "trust-dns-resolver" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6" -dependencies = [ - "cfg-if", - "futures-util", - "ipconfig", - "lru-cache", - "once_cell", - "parking_lot", - "rand 0.8.5", - "resolv-conf", - "smallvec", - "thiserror", - "tokio", - "tracing", - "trust-dns-proto", -] - [[package]] name = "trybuild" -version = "1.0.104" +version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae08be68c056db96f0e6c6dd820727cca756ced9e1f4cc7fdd20e2a55e23898" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" dependencies = [ "glob", "serde", @@ -3337,36 +3361,27 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-normalization" -version = "0.1.24" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -3380,7 +3395,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3398,13 +3413,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 1.0.3", + "idna", "percent-encoding", + "serde", ] [[package]] @@ -3419,6 +3435,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "v_htmlescape" version = "0.15.8" @@ -3449,50 +3476,46 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3500,31 +3523,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.77" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3561,35 +3618,23 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.0", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi" @@ -3609,11 +3654,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3623,12 +3668,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-targets 0.48.5", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -3637,31 +3708,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.52.6", -] - -[[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", + "windows-link", ] [[package]] @@ -3670,46 +3726,28 @@ 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_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "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", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[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_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_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" @@ -3722,48 +3760,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[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_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_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_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" @@ -3772,37 +3786,103 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.10" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "memchr", + "wit-bindgen-rust-macro", ] [[package]] -name = "winreg" -version = "0.50.0" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ - "bitflags 2.9.0", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yasna" @@ -3815,11 +3895,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3827,9 +3906,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3839,18 +3918,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -3859,18 +3938,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3880,15 +3959,15 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3897,9 +3976,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3908,15 +3987,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd" version = "0.13.3" @@ -3937,9 +4022,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 88a08f8cf..905a6ce81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ "actix-multipart-derive", "actix-router", "actix-test", - "actix-web-actors", "actix-web-codegen", "actix-web", "awc", @@ -19,7 +18,7 @@ homepage = "https://actix.rs" repository = "https://github.com/actix/actix-web" license = "MIT OR Apache-2.0" edition = "2021" -rust-version = "1.75" +rust-version = "1.88" [profile.dev] # Disabling debug info speeds up builds a bunch and we don't rely on it for debugging that much. @@ -39,7 +38,6 @@ actix-multipart-derive = { path = "actix-multipart-derive" } actix-router = { path = "actix-router" } actix-test = { path = "actix-test" } actix-web = { path = "actix-web" } -actix-web-actors = { path = "actix-web-actors" } actix-web-codegen = { path = "actix-web-codegen" } awc = { path = "awc" } diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index afb2d5d20..6f4056f57 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -2,6 +2,45 @@ ## Unreleased +- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] +- Fix handling of `bytes=0-` +- Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748] +- Fix invalid `Content-Encoding: identity` header in `NamedFile` range responses. [#3191] + +[#2615]: https://github.com/actix/actix-web/pull/2615 +[#2748]: https://github.com/actix/actix-web/issues/2748 +[#3191]: https://github.com/actix/actix-web/issues/3191 + +## 0.6.10 + +### Security Notice + +We addressed 2 vulnerabilities in this release: + +- Do not panic with empty Range header. +- Avoid serving CWD on invalid `Files::new` inputs. + +We encourage updating your `actix-files` version as soon as possible. + +### Other changes + +- Minimum supported Rust version (MSRV) is now 1.88. +- `PathBufWrap` & `UriSegmentError` made public. [#3694] + +[#3694]: https://github.com/actix/actix-web/pull/3694 + +## 0.6.9 + +- Correct `derive_more` dependency feature requirements. + +## 0.6.8 + +- Add `Files::with_permanent_redirect()` method. +- Change default redirect status code to 307 Temporary Redirect. + +## 0.6.7 + +- Add `{Files, NamedFile}::read_mode_threshold()` methods to allow faster synchronous reads of small files. - Minimum supported Rust version (MSRV) is now 1.75. ## 0.6.6 diff --git a/actix-files/Cargo.toml b/actix-files/Cargo.toml index b668793b0..65e91cafd 100644 --- a/actix-files/Cargo.toml +++ b/actix-files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-files" -version = "0.6.6" +version = "0.6.10" authors = ["Nikolay Kim ", "Rob Ede "] description = "Static file serving for Actix Web" keywords = ["actix", "http", "async", "futures"] @@ -24,7 +24,7 @@ actix-web = { version = "4", default-features = false } bitflags = "2" bytes = "1" -derive_more = { version = "2", features = ["display", "error", "from"] } +derive_more = { version = "2", features = ["deref", "deref_mut", "display", "error", "from"] } futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } http-range = "0.1.4" log = "0.4" @@ -37,13 +37,14 @@ v_htmlescape = "0.15.5" # experimental-io-uring [target.'cfg(target_os = "linux")'.dependencies] tokio-uring = { version = "0.5", optional = true, features = ["bytes"] } -actix-server = { version = "2.4", optional = true } # ensure matching tokio-uring versions +actix-server = { version = "2.4", optional = true } # ensure matching tokio-uring versions [dev-dependencies] actix-rt = "2.7" actix-test = "0.1" actix-web = "4" env_logger = "0.11" +filetime = "0.2" tempfile = "3.2" [lints] diff --git a/actix-files/README.md b/actix-files/README.md index f6d5143f5..7bc0b90d8 100644 --- a/actix-files/README.md +++ b/actix-files/README.md @@ -3,11 +3,11 @@ [![crates.io](https://img.shields.io/crates/v/actix-files?label=latest)](https://crates.io/crates/actix-files) -[![Documentation](https://docs.rs/actix-files/badge.svg?version=0.6.6)](https://docs.rs/actix-files/0.6.6) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +[![Documentation](https://docs.rs/actix-files/badge.svg?version=0.6.9)](https://docs.rs/actix-files/0.6.9) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-files.svg)
-[![dependency status](https://deps.rs/crate/actix-files/0.6.6/status.svg)](https://deps.rs/crate/actix-files/0.6.6) +[![dependency status](https://deps.rs/crate/actix-files/0.6.9/status.svg)](https://deps.rs/crate/actix-files/0.6.9) [![Download](https://img.shields.io/crates/d/actix-files.svg)](https://crates.io/crates/actix-files) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) 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 e762116e6..1ba4ce67e 100644 --- a/actix-files/src/error.rs +++ b/actix-files/src/error.rs @@ -21,6 +21,7 @@ impl ResponseError for FilesError { } } +/// Error which can occur with parsing/validating a request-uri path #[derive(Debug, PartialEq, Eq, Display)] #[non_exhaustive] pub enum UriSegmentError { diff --git a/actix-files/src/files.rs b/actix-files/src/files.rs index cfd3b9c22..1c7a1d902 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,8 @@ pub struct Files { use_guards: Option>, guards: Vec>, hidden_files: bool, + try_compressed: bool, + read_mode_threshold: u64, } impl fmt::Debug for Files { @@ -64,6 +67,7 @@ impl Clone for Files { index: self.index.clone(), show_index: self.show_index, redirect_to_slash: self.redirect_to_slash, + with_permanent_redirect: self.with_permanent_redirect, default: self.default.clone(), renderer: self.renderer.clone(), file_flags: self.file_flags, @@ -73,6 +77,8 @@ impl Clone for Files { use_guards: self.use_guards.clone(), guards: self.guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, + read_mode_threshold: self.read_mode_threshold, } } } @@ -92,6 +98,9 @@ impl Files { /// If the mount path is set as the root path `/`, services registered after this one will /// be inaccessible. Register more specific handlers and services first. /// + /// If `serve_from` cannot be canonicalized at startup, an error is logged and the original + /// path is preserved. Requests will return `404 Not Found` until the path exists. + /// /// `Files` utilizes the existing Tokio thread-pool for blocking filesystem operations. /// The number of running threads is adjusted over time as needed, up to a maximum of 512 times /// the number of server [workers](actix_web::HttpServer::workers), by default. @@ -101,7 +110,8 @@ impl Files { Ok(canon_dir) => canon_dir, Err(_) => { log::error!("Specified path is not a directory: {:?}", orig_dir); - PathBuf::new() + // Preserve original path so requests don't fall back to CWD. + orig_dir } }; @@ -111,6 +121,7 @@ impl Files { index: None, show_index: false, redirect_to_slash: false, + with_permanent_redirect: false, default: Rc::new(RefCell::new(None)), renderer: Rc::new(directory_listing), mime_override: None, @@ -119,6 +130,8 @@ impl Files { use_guards: None, guards: Vec::new(), hidden_files: false, + try_compressed: false, + read_mode_threshold: 0, } } @@ -141,6 +154,14 @@ impl Files { self } + /// Redirect with permanent redirect status code (308). + /// + /// By default redirect with temporary redirect status code (307). + pub fn with_permanent_redirect(mut self) -> Self { + self.with_permanent_redirect = true; + self + } + /// Set custom directory renderer. pub fn files_listing_renderer(mut self, f: F) -> Self where @@ -192,10 +213,14 @@ impl Files { self } - /// Set index file + /// Sets index file for directory requests. /// - /// Shows specific index file for directories instead of - /// showing files listing. + /// When a directory is requested, this value is appended to the directory's path on disk. + /// Therefore, the index file path is relative to the served directory (the `serve_from` path + /// passed to [`Files::new`]) and should not include the `serve_from` prefix. + /// + /// For example, to serve `./static/index.html` when mounting `Files::new("/", "./static")`, + /// configure it as `.index_file("index.html")` (not `.index_file("./static/index.html")`). /// /// If the index file is not found, files listing is shown as a fallback if /// [`Files::show_files_listing()`] is set. @@ -204,6 +229,23 @@ impl Files { self } + /// Sets the size threshold that determines file read mode (sync/async). + /// + /// When a file is smaller than the threshold (bytes), the reader will use synchronous + /// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the + /// main thread. + /// + /// Tweaking this value according to your expected usage may lead to significant performance + /// gains (or losses in other handlers, if `size` is too high). + /// + /// When the `experimental-io-uring` crate feature is enabled, file reads are always async. + /// + /// Default is 0, meaning all files are read asynchronously. + pub fn read_mode_threshold(mut self, size: u64) -> Self { + self.read_mode_threshold = size; + self + } + /// Specifies whether to use ETag or not. /// /// Default is true. @@ -316,6 +358,15 @@ impl Files { self.hidden_files = true; self } + + /// Attempts to search for a suitable pre-compressed version of a file on disk before falling + /// back to the uncompressed version. + /// + /// Currently, `.gz`, `.br`, and `.zst` files are supported. + pub fn try_compressed(mut self) -> Self { + self.try_compressed = true; + self + } } impl HttpServiceFactory for Files { @@ -367,6 +418,9 @@ impl ServiceFactory for Files { file_flags: self.file_flags, guards: self.use_guards.clone(), hidden_files: self.hidden_files, + try_compressed: self.try_compressed, + size_threshold: self.read_mode_threshold, + with_permanent_redirect: self.with_permanent_redirect, }; if let Some(ref default) = *self.default.borrow() { diff --git a/actix-files/src/lib.rs b/actix-files/src/lib.rs index 551a14fa4..bf5397ecf 100644 --- a/actix-files/src/lib.rs +++ b/actix-files/src/lib.rs @@ -14,7 +14,7 @@ #![warn(missing_docs, missing_debug_implementations)] #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] use std::path::Path; @@ -37,13 +37,12 @@ mod range; mod service; pub use self::{ - chunked::ChunkedReadFile, directory::Directory, files::Files, named::NamedFile, - range::HttpRange, service::FilesService, + chunked::ChunkedReadFile, directory::Directory, error::UriSegmentError, files::Files, + named::NamedFile, path_buf::PathBufWrap, range::HttpRange, service::FilesService, }; use self::{ directory::{directory_listing, DirectoryRenderer}, error::FilesError, - path_buf::PathBufWrap, }; type HttpService = BoxService; @@ -471,6 +470,24 @@ mod tests { assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); } + #[actix_rt::test] + async fn test_named_file_empty_range_headers() { + let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); + + for range in ["", "bytes="] { + let response = srv + .get("/tests/test.binary") + .insert_header((header::RANGE, range)) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + let content_range = response.headers().get(header::CONTENT_RANGE).unwrap(); + assert_eq!(content_range.to_str().unwrap(), "bytes */100"); + } + } + #[actix_rt::test] async fn test_named_file_content_range_headers() { let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); @@ -496,6 +513,30 @@ mod tests { assert_eq!(content_range.to_str().unwrap(), "bytes */100"); } + #[actix_rt::test] + async fn test_named_file_range_header_from_zero_to_end_returns_partial_content() { + let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); + + let response = srv + .get("/tests/test.binary") + .insert_header((header::RANGE, "bytes=0-")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + + let content_range = response.headers().get(header::CONTENT_RANGE).unwrap(); + assert_eq!(content_range.to_str().unwrap(), "bytes 0-99/100"); + + let content_length = response.headers().get(header::CONTENT_LENGTH).unwrap(); + assert_eq!(content_length.to_str().unwrap(), "100"); + + // Should be no transfer-encoding + let transfer_encoding = response.headers().get(header::TRANSFER_ENCODING); + assert!(transfer_encoding.is_none()); + } + #[actix_rt::test] async fn test_named_file_content_length_headers() { let srv = actix_test::start(|| App::new().service(Files::new("/", "."))); @@ -736,7 +777,21 @@ mod tests { .await; let req = TestRequest::with_uri("/tests").to_request(); let resp = test::call_service(&srv, req).await; - assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + + // should redirect if index present with permanent redirect + let srv = test::init_service( + App::new().service( + Files::new("/", ".") + .index_file("test.png") + .redirect_to_slash_directory() + .with_permanent_redirect(), + ), + ) + .await; + let req = TestRequest::with_uri("/tests").to_request(); + let resp = test::call_service(&srv, req).await; + assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT); // should redirect if files listing is enabled let srv = test::init_service( @@ -749,7 +804,7 @@ mod tests { .await; let req = TestRequest::with_uri("/tests").to_request(); let resp = test::call_service(&srv, req).await; - assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); // should not redirect if the path is wrong let req = TestRequest::with_uri("/not_existing").to_request(); @@ -767,6 +822,16 @@ mod tests { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + #[actix_rt::test] + async fn test_static_files_bad_directory_does_not_serve_cwd_files() { + let service = Files::new("/", "./missing").new_service(()).await.unwrap(); + + let req = TestRequest::with_uri("/Cargo.toml").to_srv_request(); + let resp = test::call_service(&service, req).await; + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + #[actix_rt::test] async fn test_default_handler_file_missing() { let st = Files::new("/", ".") diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index 9e4a37737..f1a4642d7 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -14,7 +14,7 @@ use actix_web::{ http::{ header::{ self, Charset, ContentDisposition, ContentEncoding, DispositionParam, DispositionType, - ExtendedValue, HeaderValue, + ExtendedValue, }, StatusCode, }, @@ -80,6 +80,7 @@ pub struct NamedFile { pub(crate) content_type: Mime, pub(crate) content_disposition: ContentDisposition, pub(crate) encoding: Option, + pub(crate) read_mode_threshold: u64, } #[cfg(not(feature = "experimental-io-uring"))] @@ -90,6 +91,55 @@ pub(crate) use tokio_uring::fs::File; use super::chunked; +pub(crate) fn get_content_type_and_disposition( + path: &Path, +) -> Result<(mime::Mime, ContentDisposition), io::Error> { + let filename = match path.file_name() { + Some(name) => name.to_string_lossy(), + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Provided path has no filename", + )); + } + }; + + let ct = mime_guess::from_path(path).first_or_octet_stream(); + + let disposition = match ct.type_() { + mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline, + mime::APPLICATION => match ct.subtype() { + mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, + name if name == "wasm" || name == "xhtml" => DispositionType::Inline, + _ => DispositionType::Attachment, + }, + _ => DispositionType::Attachment, + }; + + // replace special characters in filenames which could occur on some filesystems + let filename_s = filename + .replace('\n', "%0A") // \n line break + .replace('\x0B', "%0B") // \v vertical tab + .replace('\x0C', "%0C") // \f form feed + .replace('\r', "%0D"); // \r carriage return + let mut parameters = vec![DispositionParam::Filename(filename_s)]; + + if !filename.is_ascii() { + parameters.push(DispositionParam::FilenameExt(ExtendedValue { + charset: Charset::Ext(String::from("UTF-8")), + language_tag: None, + value: filename.into_owned().into_bytes(), + })) + } + + let cd = ContentDisposition { + disposition, + parameters, + }; + + Ok((ct, cd)) +} + impl NamedFile { /// Creates an instance from a previously opened file. /// @@ -116,52 +166,7 @@ impl NamedFile { // Get the name of the file and use it to construct default Content-Type // and Content-Disposition values - let (content_type, content_disposition) = { - let filename = match path.file_name() { - Some(name) => name.to_string_lossy(), - None => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Provided path has no filename", - )); - } - }; - - let ct = mime_guess::from_path(&path).first_or_octet_stream(); - - let disposition = match ct.type_() { - mime::IMAGE | mime::TEXT | mime::AUDIO | mime::VIDEO => DispositionType::Inline, - mime::APPLICATION => match ct.subtype() { - mime::JAVASCRIPT | mime::JSON => DispositionType::Inline, - name if name == "wasm" || name == "xhtml" => DispositionType::Inline, - _ => DispositionType::Attachment, - }, - _ => DispositionType::Attachment, - }; - - // replace special characters in filenames which could occur on some filesystems - let filename_s = filename - .replace('\n', "%0A") // \n line break - .replace('\x0B', "%0B") // \v vertical tab - .replace('\x0C', "%0C") // \f form feed - .replace('\r', "%0D"); // \r carriage return - let mut parameters = vec![DispositionParam::Filename(filename_s)]; - - if !filename.is_ascii() { - parameters.push(DispositionParam::FilenameExt(ExtendedValue { - charset: Charset::Ext(String::from("UTF-8")), - language_tag: None, - value: filename.into_owned().into_bytes(), - })) - } - - let cd = ContentDisposition { - disposition, - parameters, - }; - - (ct, cd) - }; + let (content_type, content_disposition) = get_content_type_and_disposition(&path)?; let md = { #[cfg(not(feature = "experimental-io-uring"))] @@ -200,6 +205,7 @@ impl NamedFile { encoding, status_code: StatusCode::OK, flags: Flags::default(), + read_mode_threshold: 0, }) } @@ -353,6 +359,23 @@ impl NamedFile { self } + /// Sets the size threshold that determines file read mode (sync/async). + /// + /// When a file is smaller than the threshold (bytes), the reader will use synchronous + /// (blocking) file reads. For larger files, it switches to async reads to avoid blocking the + /// main thread. + /// + /// Tweaking this value according to your expected usage may lead to significant performance + /// gains (or losses in other handlers, if `size` is too high). + /// + /// When the `experimental-io-uring` crate feature is enabled, file reads are always async. + /// + /// Default is 0, meaning all files are read asynchronously. + pub fn read_mode_threshold(mut self, size: u64) -> Self { + self.read_mode_threshold = size; + self + } + /// Specifies whether to return `ETag` header in response. /// /// Default is true. @@ -382,7 +405,9 @@ impl NamedFile { /// Creates an `ETag` in a format is similar to Apache's. pub(crate) fn etag(&self) -> Option { - self.modified.as_ref().map(|mtime| { + let mtime = self.modified?; + + Some({ let ino = { #[cfg(unix)] { @@ -398,22 +423,50 @@ impl NamedFile { } }; - let dur = mtime - .duration_since(UNIX_EPOCH) - .expect("modification time must be after epoch"); + // Don't panic for pre-epoch modification times. Encode the timestamp as seconds and + // sub-second nanoseconds relative to the UNIX epoch, allowing negative values. + let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) { + Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()), + Err(err) => { + let dur = err.duration(); + + // For timestamps before the epoch, represent the time as a negative seconds + // offset with positive nanoseconds (like POSIX timespec). + if dur.subsec_nanos() == 0 { + (-(dur.as_secs() as i64), 0) + } else { + ( + -(dur.as_secs() as i64) - 1, + 1_000_000_000 - dur.subsec_nanos(), + ) + } + } + }; header::EntityTag::new_strong(format!( "{:x}:{:x}:{:x}:{:x}", ino, self.md.len(), - dur.as_secs(), - dur.subsec_nanos() + secs as u64, + nanos )) }) } pub(crate) fn last_modified(&self) -> Option { - self.modified.map(|mtime| mtime.into()) + let mtime = self.modified?; + + // avoid panic in `httpdate` crate when formatting as an HTTP date + // see: https://github.com/actix/actix-web/issues/2748 + // + // httpdate supports dates in range [1970, 9999); see: + // https://github.com/seanmonstar/httpdate/blob/v1.0.3/src/date.rs + let dur = mtime.duration_since(UNIX_EPOCH).ok()?; + if dur.as_secs() >= 253_402_300_800 { + return None; + } + + Some(mtime.into()) } /// Creates an `HttpResponse` with file as a streaming body. @@ -440,7 +493,8 @@ impl NamedFile { res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str())); } - let reader = chunked::new_chunked_read(self.md.len(), 0, self.file); + let reader = + chunked::new_chunked_read(self.md.len(), 0, self.file, self.read_mode_threshold); return res.streaming(reader); } @@ -526,34 +580,18 @@ impl NamedFile { let mut length = self.md.len(); let mut offset = 0; + let mut ranged_req = false; // check for range header if let Some(ranges) = req.headers().get(header::RANGE) { if let Ok(ranges_header) = ranges.to_str() { - if let Ok(ranges) = HttpRange::parse(ranges_header, length) { - length = ranges[0].length; - offset = ranges[0].start; - - // When a Content-Encoding header is present in a 206 partial content response - // for video content, it prevents browser video players from starting playback - // before loading the whole video and also prevents seeking. - // - // See: https://github.com/actix/actix-web/issues/2815 - // - // The assumption of this fix is that the video player knows to not send an - // Accept-Encoding header for this request and that downstream middleware will - // not attempt compression for requests without it. - // - // TODO: Solve question around what to do if self.encoding is set and partial - // range is requested. Reject request? Ignoring self.encoding seems wrong, too. - // In practice, it should not come up. - if req.headers().contains_key(&header::ACCEPT_ENCODING) { - // don't allow compression middleware to modify partial content - res.insert_header(( - header::CONTENT_ENCODING, - HeaderValue::from_static("identity"), - )); - } + if let Some(range) = HttpRange::parse(ranges_header, length) + .ok() + .and_then(|ranges| ranges.first().copied()) + { + ranged_req = true; + length = range.length; + offset = range.start; res.insert_header(( header::CONTENT_RANGE, @@ -577,9 +615,9 @@ impl NamedFile { .map_into_boxed_body(); } - let reader = chunked::new_chunked_read(length, offset, self.file); + let reader = chunked::new_chunked_read(length, offset, self.file, self.read_mode_threshold); - if offset != 0 || length != self.md.len() { + if ranged_req { res.status(StatusCode::PARTIAL_CONTENT); } @@ -687,3 +725,14 @@ impl HttpServiceFactory for NamedFile { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_files_use_inline_content_disposition() { + let (_ct, cd) = get_content_type_and_disposition(Path::new("sound.mp3")).unwrap(); + assert_eq!(cd.disposition, DispositionType::Inline); + } +} 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..1ef616dcf 100644 --- a/actix-files/src/range.rs +++ b/actix-files/src/range.rs @@ -45,7 +45,7 @@ pub struct HttpRange { impl HttpRange { /// Parses Range HTTP header string as per RFC 2616. /// - /// `header` is HTTP Range header (e.g. `bytes=bytes=0-9`). + /// `header` is HTTP Range header (e.g. `bytes=0-9`). /// `size` is full size of response (file). pub fn parse(header: &str, size: u64) -> Result, ParseRangeErr> { let ranges = @@ -294,16 +294,11 @@ mod tests { let res = HttpRange::parse(header, size); - if res.is_err() { + if let Err(err) = res { if expected.is_empty() { continue; } else { - panic!( - "parse({}, {}) returned error {:?}", - header, - size, - res.unwrap_err() - ); + panic!("parse({header}, {size}) returned error {err:?}"); } } diff --git a/actix-files/src/service.rs b/actix-files/src/service.rs index 393ad9244..ae6725385 100644 --- a/actix-files/src/service.rs +++ b/actix-files/src/service.rs @@ -1,4 +1,9 @@ -use std::{fmt, io, ops::Deref, path::PathBuf, rc::Rc}; +use std::{ + fmt, io, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, +}; use actix_web::{ body::BoxBody, @@ -39,6 +44,9 @@ pub struct FilesServiceInner { pub(crate) file_flags: named::Flags, pub(crate) guards: Option>, pub(crate) hidden_files: bool, + pub(crate) try_compressed: bool, + pub(crate) size_threshold: u64, + pub(crate) with_permanent_redirect: bool, } impl fmt::Debug for FilesServiceInner { @@ -62,7 +70,12 @@ impl FilesService { } } - fn serve_named_file(&self, req: ServiceRequest, mut named_file: NamedFile) -> ServiceResponse { + fn serve_named_file_with_encoding( + &self, + req: ServiceRequest, + mut named_file: NamedFile, + encoding: header::ContentEncoding, + ) -> ServiceResponse { if let Some(ref mime_override) = self.mime_override { let new_disposition = mime_override(&named_file.content_type.type_()); named_file.content_disposition.disposition = new_disposition; @@ -70,10 +83,36 @@ impl FilesService { named_file.flags = self.file_flags; let (req, _) = req.into_parts(); - let res = named_file.into_response(&req); + let mut res = named_file + .read_mode_threshold(self.size_threshold) + .into_response(&req); + + let header_value = match encoding { + header::ContentEncoding::Brotli => Some("br"), + header::ContentEncoding::Gzip => Some("gzip"), + header::ContentEncoding::Zstd => Some("zstd"), + header::ContentEncoding::Identity => None, + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here + _ => unreachable!(), + }; + if let Some(header_value) = header_value { + res.headers_mut().insert( + header::CONTENT_ENCODING, + header::HeaderValue::from_static(header_value), + ); + // Response representation varies by Accept-Encoding when serving pre-compressed assets. + res.headers_mut().append( + header::VARY, + header::HeaderValue::from_static("accept-encoding"), + ); + } ServiceResponse::new(req, res) } + fn serve_named_file(&self, req: ServiceRequest, named_file: NamedFile) -> ServiceResponse { + self.serve_named_file_with_encoding(req, named_file, header::ContentEncoding::Identity) + } + fn show_index(&self, req: ServiceRequest, path: PathBuf) -> ServiceResponse { let dir = Directory::new(self.directory.clone(), path); @@ -134,6 +173,15 @@ impl Service for FilesService { // full file path let path = this.directory.join(&path_on_disk); + + // Try serving pre-compressed file even if the uncompressed file doesn't exist yet. + // Still handle directories (index/listing) through the normal branch below. + if this.try_compressed && !path.is_dir() { + if let Some((named_file, encoding)) = find_compressed(&req, &path).await { + return Ok(this.serve_named_file_with_encoding(req, named_file, encoding)); + } + } + if let Err(err) = path.canonicalize() { return this.handle_err(err, req).await; } @@ -145,16 +193,30 @@ impl Service for FilesService { { let redirect_to = format!("{}/", req.path()); - return Ok(req.into_response( - HttpResponse::Found() - .insert_header((header::LOCATION, redirect_to)) - .finish(), - )); + let response = if this.with_permanent_redirect { + HttpResponse::PermanentRedirect() + } else { + HttpResponse::TemporaryRedirect() + } + .insert_header((header::LOCATION, redirect_to)) + .finish(); + + return Ok(req.into_response(response)); } match this.index { Some(ref index) => { let named_path = path.join(index); + if this.try_compressed { + if let Some((named_file, encoding)) = + find_compressed(&req, &named_path).await + { + return Ok( + this.serve_named_file_with_encoding(req, named_file, encoding) + ); + } + } + // fallback to the uncompressed version match NamedFile::open_async(named_path).await { Ok(named_file) => Ok(this.serve_named_file(req, named_file)), Err(_) if this.show_index => Ok(this.show_index(req, path)), @@ -169,20 +231,91 @@ impl Service for FilesService { } } else { match NamedFile::open_async(&path).await { - Ok(mut named_file) => { - if let Some(ref mime_override) = this.mime_override { - let new_disposition = mime_override(&named_file.content_type.type_()); - named_file.content_disposition.disposition = new_disposition; - } - named_file.flags = this.file_flags; - - let (req, _) = req.into_parts(); - let res = named_file.into_response(&req); - Ok(ServiceResponse::new(req, res)) - } + Ok(named_file) => Ok(this.serve_named_file(req, named_file)), Err(err) => this.handle_err(err, req).await, } } }) } } + +/// Flate doesn't have an accepted file extension, so it is not included here. +const SUPPORTED_PRECOMPRESSION_ENCODINGS: &[header::ContentEncoding] = &[ + header::ContentEncoding::Brotli, + header::ContentEncoding::Gzip, + header::ContentEncoding::Zstd, + header::ContentEncoding::Identity, +]; + +/// Searches disk for an acceptable alternate encoding of the content at the given path, as +/// preferred by the request's `Accept-Encoding` header. Returns the corresponding `NamedFile` with +/// the most appropriate supported encoding, if any exist. +async fn find_compressed( + req: &ServiceRequest, + original_path: &Path, +) -> Option<(NamedFile, header::ContentEncoding)> { + use actix_web::HttpMessage; + use header::{AcceptEncoding, ContentEncoding, Encoding}; + + // Retrieve the content type and content disposition based on the original filename. If we + // can't get these successfully, don't even try to find a compressed file. + let (content_type, content_disposition) = + match crate::named::get_content_type_and_disposition(original_path) { + Ok(values) => values, + Err(_) => return None, + }; + + let accept_encoding = req.get_header::()?; + + let mut supported = SUPPORTED_PRECOMPRESSION_ENCODINGS + .iter() + .copied() + .map(Encoding::Known) + .collect::>(); + + // Only move the original content-type/disposition into the chosen compressed file once. + let mut content_type = Some(content_type); + let mut content_disposition = Some(content_disposition); + + loop { + // Select next acceptable encoding (honouring q=0 rejections) from remaining supported set. + let chosen = accept_encoding.negotiate(supported.iter())?; + + let encoding = match chosen { + Encoding::Known(enc) => enc, + // No supported encoding should ever be unknown here. + Encoding::Unknown(_) => return None, + }; + + // Identity indicates there is no acceptable pre-compressed representation. + if encoding == ContentEncoding::Identity { + return None; + } + + let extension = match encoding { + ContentEncoding::Brotli => ".br", + ContentEncoding::Gzip => ".gz", + ContentEncoding::Zstd => ".zst", + ContentEncoding::Identity => unreachable!(), + // Only variants in SUPPORTED_PRECOMPRESSION_ENCODINGS can occur here. + _ => unreachable!(), + }; + + let mut compressed_path = original_path.to_owned(); + let mut filename = compressed_path.file_name()?.to_owned(); + filename.push(extension); + compressed_path.set_file_name(filename); + + match NamedFile::open_async(&compressed_path).await { + Ok(mut named_file) => { + named_file.content_type = content_type.take().unwrap(); + named_file.content_disposition = content_disposition.take().unwrap(); + return Some((named_file, encoding)); + } + // Ignore errors while searching disk for a suitable encoding. + Err(_) => { + supported.retain(|enc| enc != &chosen); + } + } + } +} diff --git a/actix-files/tests/encoding.rs b/actix-files/tests/encoding.rs index 3c8bdb59b..72e72b913 100644 --- a/actix-files/tests/encoding.rs +++ b/actix-files/tests/encoding.rs @@ -36,6 +36,136 @@ async fn test_utf8_file_contents() { ); } +#[actix_web::test] +async fn test_compression_encodings() { + use actix_web::body::MessageBody; + + let utf8_txt_len = std::fs::metadata("./tests/utf8.txt").unwrap().len(); + let utf8_txt_br_len = std::fs::metadata("./tests/utf8.txt.br").unwrap().len(); + let utf8_txt_gz_len = std::fs::metadata("./tests/utf8.txt.gz").unwrap().len(); + + let srv = + test::init_service(App::new().service(Files::new("/", "./tests").try_compressed())).await; + + // Select the requested encoding when present + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("gzip")), + ); + assert_eq!( + res.headers().get(header::VARY), + Some(&HeaderValue::from_static("accept-encoding")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_gz_len), + ); + + // Select the highest priority encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip;q=0.6,br;q=0.8,*"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!( + res.headers().get(header::CONTENT_ENCODING), + Some(&HeaderValue::from_static("br")), + ); + assert_eq!( + res.headers().get(header::VARY), + Some(&HeaderValue::from_static("accept-encoding")), + ); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_br_len), + ); + + // Request encoding that doesn't exist on disk and fallback to no encoding + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("zstd"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_len), + ); + + // Do not select an encoding explicitly refused via q=0 + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("zstd;q=1, gzip;q=0"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + assert_eq!( + res.into_body().size(), + actix_web::body::BodySize::Sized(utf8_txt_len), + ); + + // Can still request a compressed file directly + let req = TestRequest::with_uri("/utf8.txt.gz").to_request(); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/gzip")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None,); + + // Don't try compressed files + let srv = test::init_service(App::new().service(Files::new("/", "./tests"))).await; + + let mut req = TestRequest::with_uri("/utf8.txt").to_request(); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("gzip"), + ); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/plain; charset=utf-8")), + ); + assert_eq!(res.headers().get(header::CONTENT_ENCODING), None); +} + #[actix_web::test] async fn partial_range_response_encoding() { let srv = test::init_service(App::new().default_service(web::to(|| async { @@ -51,15 +181,12 @@ async fn partial_range_response_encoding() { assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); - // range request with accept-encoding returns a content-encoding header + // range request with accept-encoding still returns no content-encoding header let req = TestRequest::with_uri("/") .append_header((header::RANGE, "bytes=10-20")) - .append_header((header::ACCEPT_ENCODING, "identity")) + .append_header((header::ACCEPT_ENCODING, "gzip")) .to_request(); let res = test::call_service(&srv, req).await; assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!( - res.headers().get(header::CONTENT_ENCODING).unwrap(), - "identity" - ); + assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); } diff --git a/actix-files/tests/pre_epoch_mtime.rs b/actix-files/tests/pre_epoch_mtime.rs new file mode 100644 index 000000000..490ca6e98 --- /dev/null +++ b/actix-files/tests/pre_epoch_mtime.rs @@ -0,0 +1,49 @@ +use std::time::UNIX_EPOCH; + +use actix_files::NamedFile; +use actix_web::{ + http::{header, StatusCode}, + test, web, App, +}; +use filetime::{set_file_mtime, FileTime}; +use tempfile::tempdir; + +#[actix_web::test] +async fn serves_file_with_pre_epoch_mtime() { + let dir = tempdir().unwrap(); + let path = dir.path().join("pre_epoch.txt"); + + std::fs::write(&path, b"hello").unwrap(); + + // set mtime to before UNIX epoch; this used to panic during ETag/Last-Modified generation + set_file_mtime(&path, FileTime::from_unix_time(-60, 0)).unwrap(); + + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert!( + mtime < UNIX_EPOCH, + "fixture mtime should be before UNIX_EPOCH" + ); + + let srv = { + let path = path.clone(); + test::init_service(App::new().default_service(web::to(move || { + let path = path.clone(); + async move { NamedFile::open_async(path).await.unwrap() } + }))) + .await + }; + + let req = test::TestRequest::with_uri("/").to_request(); + let res = test::call_service(&srv, req).await; + + assert_eq!(res.status(), StatusCode::OK); + + // ETag is still generated even for pre-epoch times. + assert!(res.headers().contains_key(header::ETAG)); + + // HTTP-date formatting in the httpdate crate does not support pre-epoch times. + assert!(!res.headers().contains_key(header::LAST_MODIFIED)); + + let body = test::read_body(res).await; + assert_eq!(&body[..], b"hello"); +} diff --git a/actix-files/tests/utf8.txt.br b/actix-files/tests/utf8.txt.br new file mode 100644 index 000000000..c06efd6c9 Binary files /dev/null and b/actix-files/tests/utf8.txt.br differ diff --git a/actix-files/tests/utf8.txt.gz b/actix-files/tests/utf8.txt.gz new file mode 100644 index 000000000..3fbf02264 Binary files /dev/null and b/actix-files/tests/utf8.txt.gz differ diff --git a/actix-http-test/CHANGES.md b/actix-http-test/CHANGES.md index 4d133e3ec..be19d251a 100644 --- a/actix-http-test/CHANGES.md +++ b/actix-http-test/CHANGES.md @@ -2,7 +2,7 @@ ## Unreleased -- Minimum supported Rust version (MSRV) is now 1.72. +- Minimum supported Rust version (MSRV) is now 1.88. ## 3.2.0 diff --git a/actix-http-test/Cargo.toml b/actix-http-test/Cargo.toml index 221a4c423..6ddee64cf 100644 --- a/actix-http-test/Cargo.toml +++ b/actix-http-test/Cargo.toml @@ -53,7 +53,7 @@ serde = "1" serde_json = "1" serde_urlencoded = "0.7" slab = "0.4" -socket2 = "0.5" +socket2 = "0.6" tls-openssl = { version = "0.10.55", package = "openssl", optional = true } tokio = { version = "1.38.2", features = ["sync"] } diff --git a/actix-http-test/README.md b/actix-http-test/README.md index 939028121..c242c8ab2 100644 --- a/actix-http-test/README.md +++ b/actix-http-test/README.md @@ -4,7 +4,7 @@ [![crates.io](https://img.shields.io/crates/v/actix-http-test?label=latest)](https://crates.io/crates/actix-http-test) [![Documentation](https://docs.rs/actix-http-test/badge.svg?version=3.2.0)](https://docs.rs/actix-http-test/3.2.0) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http-test)
[![Dependency Status](https://deps.rs/crate/actix-http-test/3.2.0/status.svg)](https://deps.rs/crate/actix-http-test/3.2.0) diff --git a/actix-http-test/src/lib.rs b/actix-http-test/src/lib.rs index a359cec09..e3ea69e5c 100644 --- a/actix-http-test/src/lib.rs +++ b/actix-http-test/src/lib.rs @@ -2,7 +2,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[cfg(feature = "openssl")] extern crate tls_openssl as openssl; diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index b770afd0c..478ef27b1 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -3,6 +3,52 @@ ## Unreleased - Add DEFLATE compression support for WebSocket. +- When configured, gracefully close HTTP/1 connections after early responses to unread request bodies. [#3967] +- Update `foldhash` dependency to `0.2`. + +[#3967]: https://github.com/actix/actix-web/issues/3967 + +## 3.12.1 + +**Notice: This release contains a security fix. Users are encouraged to update to this version ASAP.** + +- SECURITY: Reject HTTP/1 requests with ambiguous request framing from `Content-Length` and `Transfer-Encoding` headers to prevent request smuggling. +- Encode the HTTP/1 `Connection: Upgrade` header in Camel-Case when camel-case header formatting is enabled.[#3953] +- Fix `HeaderMap` iterators' `len()` and `size_hint()` implementations for multi-value headers. +- Update `rand` dependency to `0.10`. +- Update `sha1` dependency to `0.11`. +- Add `ServiceConfigBuilder::h1_write_buffer_size()` and `HttpServiceBuilder::h1_write_buffer_size()`. + +[#3953]: https://github.com/actix/actix-web/pull/3953 + +## 3.12.0 + +- Minimum supported Rust version (MSRV) is now 1.88. +- Increase default HTTP/2 flow control window sizes. [#3638] +- Expose configuration methods to improve upload throughput. [#3638] +- Fix truncated body ending without error when connection closed abnormally. [#3067] +- Add config/method for `TCP_NODELAY`. [#3918] +- Do not compress 206 Partial Content responses. [#3191] +- Fix lingering sockets and client stalls when responding early to dropped chunked request payloads. [#2972] + +[#3638]: https://github.com/actix/actix-web/issues/3638 +[#3067]: https://github.com/actix/actix-web/pull/3067 +[#3918]: https://github.com/actix/actix-web/pull/3918 +[#3191]: https://github.com/actix/actix-web/issues/3191 +[#2972]: https://github.com/actix/actix-web/issues/2972 + +## 3.11.2 + +- Properly wake Payload receivers when feeding errors or EOF. +- Add `ServiceConfigBuilder` type to facilitate future configuration extensions. +- Add a configuration option to allow/disallow half closed connections in HTTP/1. This defaults to allow, reverting the change made in 3.11.1. +- Shutdown connections when HTTP Responses are written without reading full Requests. + +## 3.11.1 + +- Prevent more hangs after client disconnects. +- More malformed WebSocket frames are now gracefully rejected. +- Using `TestRequest::set_payload()` now sets a Content-Length header. ## 3.11.0 diff --git a/actix-http/Cargo.toml b/actix-http/Cargo.toml index 45777800e..6c76421ae 100644 --- a/actix-http/Cargo.toml +++ b/actix-http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-http" -version = "3.11.0" +version = "3.12.1" authors = ["Nikolay Kim ", "Rob Ede "] description = "HTTP types and services for the Actix ecosystem" keywords = ["actix", "http", "framework", "async", "futures"] @@ -17,7 +17,6 @@ edition.workspace = true rust-version.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] features = [ "http2", "ws", @@ -105,7 +104,7 @@ bytes = "1" bytestring = "1" derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] } encoding_rs = "0.8" -foldhash = "0.1" +foldhash = "0.2" futures-core = { version = "0.3.17", default-features = false, features = ["alloc"] } http = "0.2.7" httparse = "1.5.1" @@ -121,13 +120,13 @@ tokio-util = { version = "0.7", features = ["io", "codec"] } tracing = { version = "0.1.30", default-features = false, features = ["log"] } # http2 -h2 = { version = "0.3.26", optional = true } +h2 = { version = "0.3.27", optional = true } # websockets base64 = { version = "0.22", optional = true } local-channel = { version = "0.1", optional = true } -rand = { version = "0.9", optional = true } -sha1 = { version = "0.10", optional = true } +rand = { version = "0.10.1", optional = true } +sha1 = { version = "0.11", optional = true } # openssl/rustls actix-tls = { version = "3.4", default-features = false, optional = true } @@ -142,6 +141,7 @@ actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23-webpki-roots"] } actix-web = "4" +awc = { version = "3", default-features = false, features = ["openssl"] } async-stream = "0.3" criterion = { version = "0.5", features = ["html_reports"] } @@ -152,14 +152,14 @@ memchr = "2.4" once_cell = "1.21" rcgen = "0.13" regex = "1.3" -rustls-pemfile = "2" +rustls-pki-types = "1.13.1" rustversion = "1" serde = { version = "1", features = ["derive"] } serde_json = "1.0" static_assertions = "1" tls-openssl = { package = "openssl", version = "0.10.55" } tls-rustls_023 = { package = "rustls", version = "0.23" } -tokio = { version = "1.38.2", features = ["net", "rt", "macros"] } +tokio = { version = "1.38.2", features = ["net", "rt", "macros", "sync"] } [lints] workspace = true diff --git a/actix-http/README.md b/actix-http/README.md index 382fd7418..421c904bb 100644 --- a/actix-http/README.md +++ b/actix-http/README.md @@ -5,11 +5,11 @@ [![crates.io](https://img.shields.io/crates/v/actix-http?label=latest)](https://crates.io/crates/actix-http) -[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.11.0)](https://docs.rs/actix-http/3.11.0) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.12.0)](https://docs.rs/actix-http/3.12.0) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http.svg)
-[![dependency status](https://deps.rs/crate/actix-http/3.11.0/status.svg)](https://deps.rs/crate/actix-http/3.11.0) +[![dependency status](https://deps.rs/crate/actix-http/3.12.0/status.svg)](https://deps.rs/crate/actix-http/3.12.0) [![Download](https://img.shields.io/crates/d/actix-http.svg)](https://crates.io/crates/actix-http) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) diff --git a/actix-http/examples/tls_rustls.rs b/actix-http/examples/tls_rustls.rs index 17303c556..a1db91b12 100644 --- a/actix-http/examples/tls_rustls.rs +++ b/actix-http/examples/tls_rustls.rs @@ -45,25 +45,14 @@ async fn main() -> io::Result<()> { fn rustls_config() -> rustls::ServerConfig { let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); - let cert_file = cert.pem(); - let key_file = key_pair.serialize_pem(); - - let cert_file = &mut io::BufReader::new(cert_file.as_bytes()); - let key_file = &mut io::BufReader::new(key_file.as_bytes()); - - let cert_chain = rustls_pemfile::certs(cert_file) - .collect::, _>>() - .unwrap(); - let mut keys = rustls_pemfile::pkcs8_private_keys(key_file) - .collect::, _>>() - .unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = rustls_pki_types::PrivateKeyDer::Pkcs8( + rustls_pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()), + ); let mut config = rustls::ServerConfig::builder() .with_no_client_auth() - .with_single_cert( - cert_chain, - rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)), - ) + .with_single_cert(cert_chain, key_der) .unwrap(); const H1_ALPN: &[u8] = b"http/1.1"; diff --git a/actix-http/examples/ws.rs b/actix-http/examples/ws.rs index af83e4c3d..9750a1a2a 100644 --- a/actix-http/examples/ws.rs +++ b/actix-http/examples/ws.rs @@ -82,29 +82,16 @@ impl Stream for Heartbeat { } fn tls_config() -> rustls::ServerConfig { - use std::io::BufReader; - - use rustls_pemfile::{certs, pkcs8_private_keys}; - let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); - let cert_file = cert.pem(); - let key_file = key_pair.serialize_pem(); - - let cert_file = &mut BufReader::new(cert_file.as_bytes()); - let key_file = &mut BufReader::new(key_file.as_bytes()); - - let cert_chain = certs(cert_file).collect::, _>>().unwrap(); - let mut keys = pkcs8_private_keys(key_file) - .collect::, _>>() - .unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = rustls_pki_types::PrivateKeyDer::Pkcs8( + rustls_pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()), + ); let mut config = rustls::ServerConfig::builder() .with_no_client_auth() - .with_single_cert( - cert_chain, - rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)), - ) + .with_single_cert(cert_chain, key_der) .unwrap(); config.alpn_protocols.push(b"http/1.1".to_vec()); diff --git a/actix-http/src/builder.rs b/actix-http/src/builder.rs index 916083a98..f14db068d 100644 --- a/actix-http/src/builder.rs +++ b/actix-http/src/builder.rs @@ -5,9 +5,12 @@ use actix_service::{IntoServiceFactory, Service, ServiceFactory}; use crate::{ body::{BoxBody, MessageBody}, + config::{ + DEFAULT_H1_WRITE_BUFFER_SIZE, DEFAULT_H2_CONN_WINDOW_SIZE, DEFAULT_H2_STREAM_WINDOW_SIZE, + }, h1::{self, ExpectHandler, H1Service, UpgradeHandler}, service::HttpService, - ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfig, + ConnectCallback, Extensions, KeepAlive, Request, Response, ServiceConfigBuilder, }; /// An HTTP service builder. @@ -17,8 +20,13 @@ pub struct HttpServiceBuilder { keep_alive: KeepAlive, client_request_timeout: Duration, client_disconnect_timeout: Duration, + tcp_nodelay: Option, secure: bool, local_addr: Option, + h1_allow_half_closed: bool, + h1_write_buffer_size: usize, + h2_conn_window_size: u32, + h2_stream_window_size: u32, expect: X, upgrade: Option, on_connect_ext: Option>>, @@ -38,8 +46,13 @@ where keep_alive: KeepAlive::default(), client_request_timeout: Duration::from_secs(5), client_disconnect_timeout: Duration::ZERO, + tcp_nodelay: None, secure: false, local_addr: None, + h1_allow_half_closed: true, + h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE, + h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE, + h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE, // dispatcher parts expect: ExpectHandler, @@ -118,12 +131,65 @@ where self } + /// Sets `TCP_NODELAY` value on accepted TCP connections. + pub fn tcp_nodelay(mut self, nodelay: bool) -> Self { + self.tcp_nodelay = Some(nodelay); + self + } + #[doc(hidden)] #[deprecated(since = "3.0.0", note = "Renamed to `client_disconnect_timeout`.")] pub fn client_disconnect(self, dur: Duration) -> Self { self.client_disconnect_timeout(dur) } + /// Sets whether HTTP/1 connections should support half-closures. + /// + /// Clients can choose to shutdown their writer-side of the connection after completing their + /// request and while waiting for the server response. Setting this to `false` will cause the + /// server to abort the connection handling as soon as it detects an EOF from the client. + /// + /// The default behavior is to allow, i.e. `true` + pub fn h1_allow_half_closed(mut self, allow: bool) -> Self { + self.h1_allow_half_closed = allow; + self + } + + /// Sets the maximum response write buffer size for HTTP/1 connections. + /// + /// Once the response buffer reaches this size, the dispatcher flushes it to the I/O stream. + /// + /// The default value is 32 KiB. + /// + /// # Panics + /// + /// Panics if `size` is 0. + pub fn h1_write_buffer_size(mut self, size: usize) -> Self { + assert!( + size > 0, + "HTTP/1 write buffer size must be greater than zero" + ); + + self.h1_write_buffer_size = size; + self + } + + /// Sets initial stream-level flow control window size for HTTP/2 connections. + /// + /// See [`ServiceConfigBuilder::h2_initial_window_size`] for more details. + pub fn h2_initial_window_size(mut self, size: u32) -> Self { + self.h2_stream_window_size = size; + self + } + + /// Sets initial connection-level flow control window size for HTTP/2 connections. + /// + /// See [`ServiceConfigBuilder::h2_initial_connection_window_size`] for more details. + pub fn h2_initial_connection_window_size(mut self, size: u32) -> Self { + self.h2_conn_window_size = size; + self + } + /// Provide service for `EXPECT: 100-Continue` support. /// /// Service get called with request that contains `EXPECT` header. @@ -140,8 +206,13 @@ where keep_alive: self.keep_alive, client_request_timeout: self.client_request_timeout, client_disconnect_timeout: self.client_disconnect_timeout, + tcp_nodelay: self.tcp_nodelay, secure: self.secure, local_addr: self.local_addr, + h1_allow_half_closed: self.h1_allow_half_closed, + h1_write_buffer_size: self.h1_write_buffer_size, + h2_conn_window_size: self.h2_conn_window_size, + h2_stream_window_size: self.h2_stream_window_size, expect: expect.into_factory(), upgrade: self.upgrade, on_connect_ext: self.on_connect_ext, @@ -164,8 +235,13 @@ where keep_alive: self.keep_alive, client_request_timeout: self.client_request_timeout, client_disconnect_timeout: self.client_disconnect_timeout, + tcp_nodelay: self.tcp_nodelay, secure: self.secure, local_addr: self.local_addr, + h1_allow_half_closed: self.h1_allow_half_closed, + h1_write_buffer_size: self.h1_write_buffer_size, + h2_conn_window_size: self.h2_conn_window_size, + h2_stream_window_size: self.h2_stream_window_size, expect: self.expect, upgrade: Some(upgrade.into_factory()), on_connect_ext: self.on_connect_ext, @@ -195,13 +271,18 @@ where S::InitError: fmt::Debug, S::Response: Into>, { - let cfg = ServiceConfig::new( - self.keep_alive, - self.client_request_timeout, - self.client_disconnect_timeout, - self.secure, - self.local_addr, - ); + let cfg = ServiceConfigBuilder::new() + .keep_alive(self.keep_alive) + .client_request_timeout(self.client_request_timeout) + .client_disconnect_timeout(self.client_disconnect_timeout) + .tcp_nodelay(self.tcp_nodelay) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .h1_write_buffer_size(self.h1_write_buffer_size) + .h2_initial_window_size(self.h2_stream_window_size) + .h2_initial_connection_window_size(self.h2_conn_window_size) + .build(); H1Service::with_config(cfg, service.into_factory()) .expect(self.expect) @@ -220,13 +301,18 @@ where B: MessageBody + 'static, { - let cfg = ServiceConfig::new( - self.keep_alive, - self.client_request_timeout, - self.client_disconnect_timeout, - self.secure, - self.local_addr, - ); + let cfg = ServiceConfigBuilder::new() + .keep_alive(self.keep_alive) + .client_request_timeout(self.client_request_timeout) + .client_disconnect_timeout(self.client_disconnect_timeout) + .tcp_nodelay(self.tcp_nodelay) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .h1_write_buffer_size(self.h1_write_buffer_size) + .h2_initial_window_size(self.h2_stream_window_size) + .h2_initial_connection_window_size(self.h2_conn_window_size) + .build(); crate::h2::H2Service::with_config(cfg, service.into_factory()) .on_connect_ext(self.on_connect_ext) @@ -242,13 +328,18 @@ where B: MessageBody + 'static, { - let cfg = ServiceConfig::new( - self.keep_alive, - self.client_request_timeout, - self.client_disconnect_timeout, - self.secure, - self.local_addr, - ); + let cfg = ServiceConfigBuilder::new() + .keep_alive(self.keep_alive) + .client_request_timeout(self.client_request_timeout) + .client_disconnect_timeout(self.client_disconnect_timeout) + .tcp_nodelay(self.tcp_nodelay) + .secure(self.secure) + .local_addr(self.local_addr) + .h1_allow_half_closed(self.h1_allow_half_closed) + .h1_write_buffer_size(self.h1_write_buffer_size) + .h2_initial_window_size(self.h2_stream_window_size) + .h2_initial_connection_window_size(self.h2_conn_window_size) + .build(); HttpService::with_config(cfg, service.into_factory()) .expect(self.expect) diff --git a/actix-http/src/config.rs b/actix-http/src/config.rs index b3b215da4..9c86b9d63 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,136 @@ use bytes::BytesMut; use crate::{date::DateService, KeepAlive}; +/// Default HTTP/2 initial connection-level flow control window size. +/// +/// Matches awc's defaults to avoid poor throughput on high-BDP links. +pub(crate) const DEFAULT_H2_CONN_WINDOW_SIZE: u32 = 1024 * 1024 * 2; // 2MiB + +/// Default HTTP/2 initial stream-level flow control window size. +/// +/// Matches awc's defaults to avoid poor throughput on high-BDP links. +pub(crate) const DEFAULT_H2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024; // 1MiB + +/// Default HTTP/1 response write buffer size. +pub(crate) const DEFAULT_H1_WRITE_BUFFER_SIZE: usize = 32_768; + +/// A builder for creating a [`ServiceConfig`] +#[derive(Default, Debug)] +pub struct ServiceConfigBuilder { + inner: Inner, +} + +impl ServiceConfigBuilder { + /// Creates a new, default, [`ServiceConfigBuilder`] + /// + /// It uses the following default values: + /// + /// - [`KeepAlive::default`] for the connection keep-alive setting + /// - 5 seconds for the client request timeout + /// - 0 seconds for the client shutdown timeout + /// - secure value of `false` + /// - [`None`] for the local address setting + /// - Allow for half closed HTTP/1 connections + pub fn new() -> Self { + Self::default() + } + + /// Sets the `secure` attribute for this configuration + pub fn secure(mut self, secure: bool) -> Self { + self.inner.secure = secure; + self + } + + /// Sets the local address for this configuration + pub fn local_addr(mut self, local_addr: Option) -> Self { + self.inner.local_addr = local_addr; + self + } + + /// Sets connection keep-alive setting + pub fn keep_alive(mut self, keep_alive: KeepAlive) -> Self { + self.inner.keep_alive = keep_alive; + self + } + + /// Sets the timeout for the client to finish sending the head of its first request + pub fn client_request_timeout(mut self, timeout: Duration) -> Self { + self.inner.client_request_timeout = timeout; + self + } + + /// Sets the timeout for cleanly disconnecting from the client after connection shutdown has + /// started + pub fn client_disconnect_timeout(mut self, timeout: Duration) -> Self { + self.inner.client_disconnect_timeout = timeout; + self + } + + /// Sets `TCP_NODELAY` preference for accepted TCP connections. + pub fn tcp_nodelay(mut self, nodelay: Option) -> Self { + self.inner.tcp_nodelay = nodelay; + self + } + + /// Sets whether HTTP/1 connections should support half-closures. + /// + /// Clients can choose to shutdown their writer-side of the connection after completing their + /// request and while waiting for the server response. Setting this to `false` will cause the + /// server to abort the connection handling as soon as it detects an EOF from the client + pub fn h1_allow_half_closed(mut self, allow: bool) -> Self { + self.inner.h1_allow_half_closed = allow; + self + } + + /// Sets the maximum response write buffer size for HTTP/1 connections. + /// + /// Once the response buffer reaches this size, the dispatcher flushes it to the I/O stream. + /// + /// The default value is 32 KiB. + /// + /// # Panics + /// + /// Panics if `size` is 0. + pub fn h1_write_buffer_size(mut self, size: usize) -> Self { + assert!( + size > 0, + "HTTP/1 write buffer size must be greater than zero" + ); + + self.inner.h1_write_buffer_size = size; + self + } + + /// Sets initial stream-level flow control window size for HTTP/2 connections. + /// + /// Higher values can improve upload performance on high-latency links at the cost of higher + /// worst-case memory usage per connection. + /// + /// The default value is 1MiB. + pub fn h2_initial_window_size(mut self, size: u32) -> Self { + self.inner.h2_stream_window_size = size; + self + } + + /// Sets initial connection-level flow control window size for HTTP/2 connections. + /// + /// Higher values can improve upload performance on high-latency links at the cost of higher + /// worst-case memory usage per connection. + /// + /// The default value is 2MiB. + pub fn h2_initial_connection_window_size(mut self, size: u32) -> Self { + self.inner.h2_conn_window_size = size; + self + } + + /// Builds a [`ServiceConfig`] from this [`ServiceConfigBuilder`] instance + pub fn build(self) -> ServiceConfig { + ServiceConfig(Rc::new(self.inner)) + } +} + /// HTTP service configuration. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ServiceConfig(Rc); #[derive(Debug)] @@ -18,19 +146,30 @@ struct Inner { client_request_timeout: Duration, client_disconnect_timeout: Duration, secure: bool, - local_addr: Option, + local_addr: Option, + tcp_nodelay: Option, date_service: DateService, + h1_allow_half_closed: bool, + h1_write_buffer_size: usize, + h2_conn_window_size: u32, + h2_stream_window_size: u32, } -impl Default for ServiceConfig { +impl Default for Inner { fn default() -> Self { - Self::new( - KeepAlive::default(), - Duration::from_secs(5), - Duration::ZERO, - false, - None, - ) + Self { + keep_alive: KeepAlive::default(), + client_request_timeout: Duration::from_secs(5), + client_disconnect_timeout: Duration::ZERO, + secure: false, + local_addr: None, + tcp_nodelay: None, + date_service: DateService::new(), + h1_allow_half_closed: true, + h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE, + h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE, + h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE, + } } } @@ -41,7 +180,7 @@ impl ServiceConfig { client_request_timeout: Duration, client_disconnect_timeout: Duration, secure: bool, - local_addr: Option, + local_addr: Option, ) -> ServiceConfig { ServiceConfig(Rc::new(Inner { keep_alive: keep_alive.normalize(), @@ -49,7 +188,12 @@ impl ServiceConfig { client_disconnect_timeout, secure, local_addr, + tcp_nodelay: None, date_service: DateService::new(), + h1_allow_half_closed: true, + h1_write_buffer_size: DEFAULT_H1_WRITE_BUFFER_SIZE, + h2_conn_window_size: DEFAULT_H2_CONN_WINDOW_SIZE, + h2_stream_window_size: DEFAULT_H2_STREAM_WINDOW_SIZE, })) } @@ -63,7 +207,7 @@ impl ServiceConfig { /// /// Returns `None` for connections via UDS (Unix Domain Socket). #[inline] - pub fn local_addr(&self) -> Option { + pub fn local_addr(&self) -> Option { self.0.local_addr } @@ -100,6 +244,35 @@ impl ServiceConfig { (timeout != Duration::ZERO).then(|| self.now() + timeout) } + /// Whether HTTP/1 connections should support half-closures. + /// + /// Clients can choose to shutdown their writer-side of the connection after completing their + /// request and while waiting for the server response. If this configuration is `false`, the + /// server will abort the connection handling as soon as it detects an EOF from the client + pub fn h1_allow_half_closed(&self) -> bool { + self.0.h1_allow_half_closed + } + + /// HTTP/1 response write buffer size (in bytes). + pub fn h1_write_buffer_size(&self) -> usize { + self.0.h1_write_buffer_size + } + + /// Returns configured `TCP_NODELAY` setting for accepted TCP connections. + pub fn tcp_nodelay(&self) -> Option { + self.0.tcp_nodelay + } + + /// HTTP/2 initial stream-level flow control window size (in bytes). + pub fn h2_initial_window_size(&self) -> u32 { + self.0.h2_stream_window_size + } + + /// HTTP/2 initial connection-level flow control window size (in bytes). + pub fn h2_initial_connection_window_size(&self) -> u32 { + self.0.h2_conn_window_size + } + pub(crate) fn now(&self) -> Instant { self.0.date_service.now() } diff --git a/actix-http/src/encoding/encoder.rs b/actix-http/src/encoding/encoder.rs index 0da95c462..f40338689 100644 --- a/actix-http/src/encoding/encoder.rs +++ b/actix-http/src/encoding/encoder.rs @@ -70,6 +70,7 @@ impl Encoder { let should_encode = !(head.headers().contains_key(&CONTENT_ENCODING) || head.status == StatusCode::SWITCHING_PROTOCOLS || head.status == StatusCode::NO_CONTENT + || head.status == StatusCode::PARTIAL_CONTENT || encoding == ContentEncoding::Identity); let body = match body.try_into_bytes() { diff --git a/actix-http/src/h1/codec.rs b/actix-http/src/h1/codec.rs index 2b452f8f8..13fc7e6c5 100644 --- a/actix-http/src/h1/codec.rs +++ b/actix-http/src/h1/codec.rs @@ -237,4 +237,18 @@ mod tests { assert_eq!(*req.method(), Method::POST); assert!(req.chunked().unwrap()); } + + #[actix_rt::test] + async fn test_http_request_rejects_content_length_and_chunked() { + let mut codec = Codec::default(); + let mut buf = BytesMut::from( + "POST /test HTTP/1.1\r\n\ + content-length: 11\r\n\ + transfer-encoding: chunked\r\n\r\n\ + 0\r\n\r\n\ + GET /test2 HTTP/1.1\r\n\r\n", + ); + + assert!(codec.decode(&mut buf).is_err()); + } } diff --git a/actix-http/src/h1/decoder.rs b/actix-http/src/h1/decoder.rs index af64e8802..5170ea4a1 100644 --- a/actix-http/src/h1/decoder.rs +++ b/actix-http/src/h1/decoder.rs @@ -275,6 +275,23 @@ impl MessageType for Request { // convert headers let mut length = msg.set_headers(&src.split_to(len).freeze(), &headers[..h_len], ver)?; + if msg.head().headers.contains_key(header::TRANSFER_ENCODING) { + if ver == Version::HTTP_10 { + debug!("Transfer-Encoding is not allowed in HTTP/1.0 requests"); + return Err(ParseError::Header); + } + + if !crate::HttpMessage::chunked(&msg)? { + debug!("request Transfer-Encoding must be chunked"); + return Err(ParseError::Header); + } + + if msg.head().headers.contains_key(header::CONTENT_LENGTH) { + debug!("both Content-Length and Transfer-Encoding are set"); + return Err(ParseError::Header); + } + } + // disallow HTTP/1.0 POST requests that do not contain a Content-Length headers // see https://datatracker.ietf.org/doc/html/rfc1945#section-7.2.2 if ver == Version::HTTP_10 && method == Method::POST && length.is_none() { @@ -1116,18 +1133,57 @@ mod tests { #[test] fn hrs_cl_and_te_http10() { - // in HTTP/1.0 transfer encoding is simply ignored so it's fine to have both - - let mut buf = BytesMut::from( + expect_parse_err!(&mut BytesMut::from( "GET / HTTP/1.0\r\n\ Host: example.com\r\n\ Content-Length: 3\r\n\ Transfer-Encoding: chunked\r\n\ \r\n\ 000", - ); + )); + } - parse_ready!(&mut buf); + #[test] + fn hrs_cl_and_chunked_te_http11() { + expect_parse_err!(&mut BytesMut::from( + "POST / HTTP/1.1\r\n\ + Host: example.com\r\n\ + Content-Length: 3\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 0\r\n\ + \r\n", + )); + + expect_parse_err!(&mut BytesMut::from( + "POST / HTTP/1.1\r\n\ + Host: example.com\r\n\ + Transfer-Encoding: chunked\r\n\ + Content-Length: 3\r\n\ + \r\n\ + 0\r\n\ + \r\n", + )); + } + + #[test] + fn hrs_identity_te_http11() { + expect_parse_err!(&mut BytesMut::from( + "POST / HTTP/1.1\r\n\ + Host: example.com\r\n\ + Transfer-Encoding: identity\r\n\ + \r\n\ + 0\r\n", + )); + + expect_parse_err!(&mut BytesMut::from( + "POST / HTTP/1.1\r\n\ + Host: example.com\r\n\ + Content-Length: 3\r\n\ + Transfer-Encoding: identity\r\n\ + \r\n\ + 0\r\n", + )); } #[test] @@ -1165,14 +1221,16 @@ mod tests { } #[test] - fn transfer_encoding_agrees() { + fn hrs_chunked_te_http11() { let mut buf = BytesMut::from( "GET /test HTTP/1.1\r\n\ Host: example.com\r\n\ - Content-Length: 3\r\n\ - Transfer-Encoding: identity\r\n\ + Transfer-Encoding: chunked\r\n\ \r\n\ - 0\r\n", + 1\r\n\ + a\r\n\ + 0\r\n\ + \r\n", ); let mut reader = MessageDecoder::::default(); @@ -1180,6 +1238,6 @@ mod tests { let mut pl = pl.unwrap(); let chunk = pl.decode(&mut buf).unwrap().unwrap(); - assert_eq!(chunk, PayloadItem::Chunk(Bytes::from_static(b"0\r\n"))); + assert_eq!(chunk, PayloadItem::Chunk(Bytes::from_static(b"a"))); } } diff --git a/actix-http/src/h1/dispatcher.rs b/actix-http/src/h1/dispatcher.rs index 00b51360e..6ef48b038 100644 --- a/actix-http/src/h1/dispatcher.rs +++ b/actix-http/src/h1/dispatcher.rs @@ -31,7 +31,7 @@ use crate::{ config::ServiceConfig, error::{DispatchError, ParseError, PayloadError}, service::HttpFlow, - Error, Extensions, OnConnectData, Request, Response, StatusCode, + ConnectionType, Error, Extensions, HttpMessage, OnConnectData, Request, Response, StatusCode, }; const LW_BUFFER_SIZE: usize = 1024; @@ -58,6 +58,9 @@ bitflags! { /// Set if write-half is disconnected. const WRITE_DISCONNECT = 0b0010_0000; + + /// Set while gracefully closing a connection after an early response. + const LINGER = 0b0100_0000; } } @@ -157,6 +160,8 @@ pin_project! { pub(super) state: State, // when Some(_) dispatcher is in state of receiving request payload payload: Option, + // true when current request uses chunked transfer encoding (drainable when payload is dropped) + payload_drainable: bool, messages: VecDeque, head_timer: TimerState, @@ -166,6 +171,7 @@ pin_project! { pub(super) io: Option, read_buf: BytesMut, write_buf: BytesMut, + h1_write_buffer_size: usize, codec: Codec, } } @@ -269,6 +275,7 @@ where state: State::None, payload: None, + payload_drainable: false, messages: VecDeque::new(), head_timer: TimerState::new(config.client_request_deadline().is_some()), @@ -278,6 +285,7 @@ where io: Some(io), read_buf: BytesMut::with_capacity(HW_BUFFER_SIZE), write_buf: BytesMut::with_capacity(HW_BUFFER_SIZE), + h1_write_buffer_size: config.h1_write_buffer_size(), codec: Codec::new(config), }, }, @@ -308,7 +316,10 @@ where if self.flags.contains(Flags::READ_DISCONNECT) { false } else if let Some(ref info) = self.payload { - info.need_read(cx) == PayloadStatus::Read + matches!( + info.need_read(cx), + PayloadStatus::Read | PayloadStatus::Dropped + ) } else { true } @@ -355,6 +366,65 @@ where io.poll_flush(cx) } + fn enter_linger(mut self: Pin<&mut Self>) { + let this = self.as_mut().project(); + this.flags.remove(Flags::KEEP_ALIVE); + this.flags.insert(Flags::LINGER | Flags::FINISHED); + } + + fn ensure_linger_timer(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> bool { + let this = self.as_mut().project(); + + if matches!(this.shutdown_timer, TimerState::Active { .. }) { + return true; + } + + if let Some(deadline) = this.config.client_disconnect_deadline() { + this.shutdown_timer + .set_and_init(cx, sleep_until(deadline.into()), line!()); + true + } else { + false + } + } + + fn poll_linger( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Result, DispatchError> { + if self.as_mut().poll_flush(cx)?.is_pending() { + return Ok(Poll::Pending); + } + + if !self.as_mut().ensure_linger_timer(cx) { + let this = self.as_mut().project(); + this.flags.remove(Flags::LINGER); + this.flags.insert(Flags::SHUTDOWN); + return Ok(Poll::Ready(())); + } + + loop { + let should_disconnect = self.as_mut().read_available(cx)?; + let this = self.as_mut().project(); + let mut progressed = false; + + if !this.read_buf.is_empty() { + this.read_buf.clear(); + progressed = true; + } + + if should_disconnect { + this.flags.remove(Flags::LINGER); + this.flags.insert(Flags::READ_DISCONNECT | Flags::SHUTDOWN); + return Ok(Poll::Ready(())); + } + + if !progressed { + return Ok(Poll::Pending); + } + } + } + fn send_response_inner( self: Pin<&mut Self>, res: Response<()>, @@ -379,36 +449,90 @@ where fn send_response( mut self: Pin<&mut Self>, - res: Response<()>, + mut res: Response<()>, body: B, ) -> Result<(), DispatchError> { + let close_after_response = { + let this = self.as_mut().project(); + should_close_after_response(this.payload.as_ref(), *this.payload_drainable) + }; + + if close_after_response { + res.head_mut().set_connection_type(ConnectionType::Close); + } + let size = self.as_mut().send_response_inner(res, &body)?; - let mut this = self.project(); - this.state.set(match size { + match size { BodySize::None | BodySize::Sized(0) => { - this.flags.insert(Flags::FINISHED); - State::None + let this = self.as_mut().project(); + + if close_after_response { + if this.config.client_disconnect_deadline().is_some() { + drop(this); + self.as_mut().enter_linger(); + } else { + self.as_mut() + .project() + .flags + .insert(Flags::SHUTDOWN | Flags::FINISHED); + } + } else { + this.flags.insert(Flags::FINISHED); + } + + self.as_mut().project().state.set(State::None); } - _ => State::SendPayload { body }, - }); + _ => self + .as_mut() + .project() + .state + .set(State::SendPayload { body }), + } Ok(()) } fn send_error_response( mut self: Pin<&mut Self>, - res: Response<()>, + mut res: Response<()>, body: BoxBody, ) -> Result<(), DispatchError> { + let close_after_response = { + let this = self.as_mut().project(); + should_close_after_response(this.payload.as_ref(), *this.payload_drainable) + }; + + if close_after_response { + res.head_mut().set_connection_type(ConnectionType::Close); + } + let size = self.as_mut().send_response_inner(res, &body)?; - let mut this = self.project(); - this.state.set(match size { + match size { BodySize::None | BodySize::Sized(0) => { - this.flags.insert(Flags::FINISHED); - State::None + let this = self.as_mut().project(); + + if close_after_response { + if this.config.client_disconnect_deadline().is_some() { + drop(this); + self.as_mut().enter_linger(); + } else { + self.as_mut() + .project() + .flags + .insert(Flags::SHUTDOWN | Flags::FINISHED); + } + } else { + this.flags.insert(Flags::FINISHED); + } + + self.as_mut().project().state.set(State::None); } - _ => State::SendErrorPayload { body }, - }); + _ => self + .as_mut() + .project() + .state + .set(State::SendErrorPayload { body }), + } Ok(()) } @@ -455,8 +579,11 @@ where // all messages are dealt with None => { - // start keep-alive if last request allowed it - this.flags.set(Flags::KEEP_ALIVE, this.codec.keep_alive()); + // start keep-alive only if request payload is fully read/drained + this.flags.set( + Flags::KEEP_ALIVE, + this.payload.is_none() && this.codec.keep_alive(), + ); return Ok(PollResponse::DoNothing); } @@ -493,7 +620,7 @@ where StateProj::SendPayload { mut body } => { // keep populate writer buffer until buffer size limit hit, // get blocked or finished. - while this.write_buf.len() < super::payload::MAX_BUFFER_SIZE { + while this.write_buf.len() < *this.h1_write_buffer_size { match body.as_mut().poll_next(cx) { Poll::Ready(Some(Ok(item))) => { this.codec @@ -503,10 +630,33 @@ where Poll::Ready(None) => { this.codec.encode(Message::Chunk(None), this.write_buf)?; + // if we have not yet pipelined to the next request, then + // this.payload was the payload for the request we just finished + // responding to. We can check to see if we finished reading it + // yet, and if not, shutdown the connection. + let close_after_response = should_close_after_response( + this.payload.as_ref(), + *this.payload_drainable, + ); + let not_pipelined = this.messages.is_empty(); + // payload stream finished. // set state to None and handle next message this.state.set(State::None); - this.flags.insert(Flags::FINISHED); + + if not_pipelined && close_after_response { + if this.config.client_disconnect_deadline().is_some() { + drop(this); + self.as_mut().enter_linger(); + } else { + self.as_mut() + .project() + .flags + .insert(Flags::SHUTDOWN | Flags::FINISHED); + } + } else { + this.flags.insert(Flags::FINISHED); + } continue 'res; } @@ -532,7 +682,7 @@ where // keep populate writer buffer until buffer size limit hit, // get blocked or finished. - while this.write_buf.len() < super::payload::MAX_BUFFER_SIZE { + while this.write_buf.len() < *this.h1_write_buffer_size { match body.as_mut().poll_next(cx) { Poll::Ready(Some(Ok(item))) => { this.codec @@ -542,10 +692,33 @@ where Poll::Ready(None) => { this.codec.encode(Message::Chunk(None), this.write_buf)?; - // payload stream finished + // if we have not yet pipelined to the next request, then + // this.payload was the payload for the request we just finished + // responding to. We can check to see if we finished reading it + // yet, and if not, shutdown the connection. + let close_after_response = should_close_after_response( + this.payload.as_ref(), + *this.payload_drainable, + ); + let not_pipelined = this.messages.is_empty(); + + // payload stream finished. // set state to None and handle next message this.state.set(State::None); - this.flags.insert(Flags::FINISHED); + + if not_pipelined && close_after_response { + if this.config.client_disconnect_deadline().is_some() { + drop(this); + self.as_mut().enter_linger(); + } else { + self.as_mut() + .project() + .flags + .insert(Flags::SHUTDOWN | Flags::FINISHED); + } + } else { + this.flags.insert(Flags::FINISHED); + } continue 'res; } @@ -710,12 +883,13 @@ where match this.codec.message_type() { // request has no payload - MessageType::None => {} + MessageType::None => *this.payload_drainable = false, // Request is upgradable. Add upgrade message and break. // Everything remaining in read buffer will be handed to // upgraded Request. MessageType::Stream if this.flow.upgrade.is_some() => { + *this.payload_drainable = false; this.messages.push_back(DispatcherMessage::Upgrade(req)); break; } @@ -730,6 +904,7 @@ where let (sender, payload) = Payload::create(false); *req.payload() = crate::Payload::H1 { payload }; *this.payload = Some(sender); + *this.payload_drainable = req.chunked().unwrap_or(false); } } @@ -759,6 +934,7 @@ where Message::Chunk(None) => { if let Some(mut payload) = this.payload.take() { payload.feed_eof(); + *this.payload_drainable = false; } else { error!("Internal server error: unexpected eof"); this.flags.insert(Flags::READ_DISCONNECT); @@ -900,14 +1076,20 @@ where let this = self.as_mut().project(); if let TimerState::Active { timer } = this.shutdown_timer { debug_assert!( - this.flags.contains(Flags::SHUTDOWN), - "shutdown flag should be set when timer is active", + this.flags.intersects(Flags::LINGER | Flags::SHUTDOWN), + "shutdown or linger flag should be set when timer is active", ); - // timed-out during shutdown; drop connection if timer.as_mut().poll(cx).is_ready() { - trace!("timed-out during shutdown"); - return Err(DispatchError::DisconnectTimeout); + if this.flags.contains(Flags::LINGER) { + trace!("timed-out during linger; shutting down connection"); + this.flags.remove(Flags::LINGER); + this.flags.insert(Flags::SHUTDOWN); + this.shutdown_timer.clear(line!()); + } else { + trace!("timed-out during shutdown"); + return Err(DispatchError::DisconnectTimeout); + } } } @@ -961,23 +1143,14 @@ where // // A Request head too large to parse is only checked on `httparse::Status::Partial`. - match this.payload { - // When dispatcher has a payload the responsibility of wake ups is shifted to - // `h1::payload::Payload` unless the payload is needing a read, in which case it - // might not have access to the waker and could result in the dispatcher - // getting stuck until timeout. - // - // Reason: - // Self wake up when there is payload would waste poll and/or result in - // over read. - // - // Case: - // When payload is (partial) dropped by user there is no need to do - // read anymore. At this case read_buf could always remain beyond - // MAX_BUFFER_SIZE and self wake up would be busy poll dispatcher and - // waste resources. - Some(ref p) if p.need_read(cx) != PayloadStatus::Read => {} - _ => cx.waker().wake_by_ref(), + match this.payload.as_ref().map(|p| p.need_read(cx)) { + // Payload consumer is alive but applying backpressure. Wait for its waker. + Some(PayloadStatus::Pause) => {} + + // Consumer dropped means drain/discard mode; keep polling to make progress. + Some(PayloadStatus::Dropped) | Some(PayloadStatus::Read) | None => { + cx.waker().wake_by_ref() + } } return Ok(false); @@ -991,7 +1164,11 @@ where match tokio_util::io::poll_read_buf(io.as_mut(), cx, this.read_buf) { Poll::Ready(Ok(n)) => { - this.flags.remove(Flags::FINISHED); + // When draining a dropped request payload, keep FINISHED set so the + // disconnect/keep-alive decision can be made once the payload is fully drained. + if !this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) { + this.flags.remove(Flags::FINISHED); + } if n == 0 { return Ok(true); @@ -1078,7 +1255,15 @@ where inner.as_mut().poll_timers(cx)?; - let poll = if inner.flags.contains(Flags::SHUTDOWN) { + let poll = if inner.flags.contains(Flags::LINGER) { + match inner.as_mut().poll_linger(cx)? { + Poll::Ready(()) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Poll::Pending => Poll::Pending, + } + } else if inner.flags.contains(Flags::SHUTDOWN) { if inner.flags.contains(Flags::WRITE_DISCONNECT) { Poll::Ready(Ok(())) } else { @@ -1118,6 +1303,7 @@ where let inner = inner.as_mut().project(); inner.flags.insert(Flags::READ_DISCONNECT); if let Some(mut payload) = inner.payload.take() { + payload.set_error(PayloadError::Incomplete(None)); payload.feed_eof(); } }; @@ -1181,8 +1367,16 @@ where let inner_p = inner.as_mut().project(); let state_is_none = inner_p.state.is_none(); - // read half is closed; we do not process any responses - if inner_p.flags.contains(Flags::READ_DISCONNECT) && state_is_none { + // If the read-half is closed, we start the shutdown procedure if either is + // true: + // + // - state is [`State::None`], which means that we're done with request + // processing, so if the client closed its writer-side it means that it won't + // send more requests. + // - The user requested to not allow half-closures + if inner_p.flags.contains(Flags::READ_DISCONNECT) + && (!inner_p.config.h1_allow_half_closed() || state_is_none) + { trace!("read half closed; start shutdown"); inner_p.flags.insert(Flags::SHUTDOWN); } @@ -1197,6 +1391,7 @@ where // disconnect if keep-alive is not enabled if inner_p.flags.contains(Flags::FINISHED) && !inner_p.flags.contains(Flags::KEEP_ALIVE) + && inner_p.payload.is_none() { inner_p.flags.remove(Flags::FINISHED); inner_p.flags.insert(Flags::SHUTDOWN); @@ -1216,6 +1411,9 @@ where inner_p.shutdown_timer, ); + if inner_p.flags.intersects(Flags::LINGER | Flags::SHUTDOWN) { + cx.waker().wake_by_ref(); + } Poll::Pending }; @@ -1227,6 +1425,13 @@ where } } +fn should_close_after_response(payload: Option<&PayloadSender>, payload_drainable: bool) -> bool { + let payload_unfinished = payload.is_some(); + let drain_payload = payload.is_some_and(|pl| pl.is_dropped()) && payload_drainable; + + payload_unfinished && !drain_payload +} + #[allow(dead_code)] fn trace_timer_states( label: &str, diff --git a/actix-http/src/h1/dispatcher_tests.rs b/actix-http/src/h1/dispatcher_tests.rs index 50259e6ce..05b2af394 100644 --- a/actix-http/src/h1/dispatcher_tests.rs +++ b/actix-http/src/h1/dispatcher_tests.rs @@ -1,7 +1,19 @@ -use std::{future::Future, str, task::Poll, time::Duration}; +use std::{ + cell::Cell, + future::Future, + io, + pin::Pin, + rc::Rc, + str, + task::{Context, Poll}, + time::Duration, +}; use actix_codec::Framed; -use actix_rt::{pin, time::sleep}; +use actix_rt::{ + pin, + time::{sleep, timeout}, +}; use actix_service::{fn_service, Service}; use actix_utils::future::{ready, Ready}; use bytes::{Buf, Bytes, BytesMut}; @@ -9,7 +21,7 @@ use futures_util::future::lazy; use super::dispatcher::{Dispatcher, DispatcherState, DispatcherStateProj, Flags}; use crate::{ - body::MessageBody, + body::{BoxBody, MessageBody}, config::ServiceConfig, h1::{Codec, ExpectHandler, UpgradeHandler}, service::HttpFlow, @@ -17,6 +29,131 @@ use crate::{ Error, HttpMessage, KeepAlive, Method, OnConnectData, Request, Response, StatusCode, }; +struct YieldService; + +impl Service 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()) + }) + } +} + +struct ReadyChunkBody { + chunk_polls: Rc>, + remaining: usize, + chunk_len: usize, +} + +impl ReadyChunkBody { + fn new(chunk_polls: Rc>, remaining: usize, chunk_len: usize) -> Self { + Self { + chunk_polls, + remaining, + chunk_len, + } + } +} + +impl MessageBody for ReadyChunkBody { + type Error = Error; + + fn size(&self) -> crate::body::BodySize { + crate::body::BodySize::Stream + } + + fn poll_next( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll>> { + if self.remaining == 0 { + return Poll::Ready(None); + } + + self.remaining -= 1; + self.chunk_polls.set(self.chunk_polls.get() + 1); + + Poll::Ready(Some(Ok(Bytes::from(vec![b'x'; self.chunk_len])))) + } +} + +struct PendingOnceWriteBuf { + io: TestBuffer, + block_next_write: bool, +} + +impl PendingOnceWriteBuf { + fn new(data: T) -> Self + where + T: Into, + { + Self { + io: TestBuffer::new(data), + block_next_write: true, + } + } +} + +impl io::Read for PendingOnceWriteBuf { + fn read(&mut self, dst: &mut [u8]) -> Result { + self.io.read(dst) + } +} + +impl io::Write for PendingOnceWriteBuf { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.io.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.io.flush() + } +} + +impl actix_codec::AsyncRead for PendingOnceWriteBuf { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut actix_codec::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.io).poll_read(cx, buf) + } +} + +impl actix_codec::AsyncWrite for PendingOnceWriteBuf { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.block_next_write { + self.block_next_write = false; + cx.waker().wake_by_ref(); + return Poll::Pending; + } + + Pin::new(&mut self.io).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_shutdown(cx) + } +} + fn find_slice(haystack: &[u8], needle: &[u8], from: usize) -> Option { memchr::memmem::find(&haystack[from..], needle) } @@ -58,6 +195,11 @@ fn drop_payload_service() -> impl Service impl Service, Error = Error> { + fn_service(|_req: Request| ready(Ok::<_, Error>(Response::with_body(StatusCode::OK, "ok")))) +} + fn echo_payload_service() -> impl Service, Error = Error> { fn_service(|mut req: Request| { Box::pin(async move { @@ -74,6 +216,18 @@ fn echo_payload_service() -> impl Service, E }) } +fn ready_chunk_body_service( + chunk_polls: Rc>, + chunk_count: usize, + chunk_len: usize, +) -> impl Service, Error = Error> { + fn_service(move |_req: Request| { + ready(Ok::<_, Error>(Response::ok().set_body( + ReadyChunkBody::new(chunk_polls.clone(), chunk_count, chunk_len), + ))) + }) +} + #[actix_rt::test] async fn late_request() { let mut buf = TestBuffer::empty(); @@ -509,6 +663,205 @@ async fn pipelining_ok_then_ok() { .await; } +#[actix_rt::test] +async fn early_response_with_payload_lingers_before_closing() { + lazy(|cx| { + let buf = TestSeqBuffer::new(http_msg( + r" + GET /unfinished HTTP/1.1 + Content-Length: 2 + ", + )); + + let cfg = ServiceConfig::new( + KeepAlive::Os, + Duration::from_millis(1), + Duration::from_millis(1), + false, + None, + ); + + let services = HttpFlow::new(echo_path_service(), ExpectHandler, None); + + let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new( + buf.clone(), + services, + cfg, + None, + OnConnectData::default(), + ); + + pin!(h1); + + assert!(matches!(&h1.inner, DispatcherState::Normal { .. })); + + match h1.as_mut().poll(cx) { + Poll::Pending => {} + Poll::Ready(res) => panic!("should still be lingering: {:?}", res), + } + + // polls: initial + assert_eq!(h1.poll_count, 1); + + let mut res = buf.take_write_buf().to_vec(); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = b"\ + HTTP/1.1 200 OK\r\n\ + content-length: 11\r\n\ + connection: close\r\n\ + date: Thu, 01 Jan 1970 12:34:56 UTC\r\n\r\n\ + /unfinished\ + "; + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(exp) + ); + + buf.close_read(); + + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(h1.as_mut().poll(cx).is_ready()); + }) + .await; +} + +#[actix_rt::test] +async fn buffered_upload_ignored_by_handler_should_not_shutdown_immediately() { + lazy(|cx| { + let buf = TestSeqBuffer::new(http_msg( + r" + POST / HTTP/1.1 + Content-Length: 8 + + ab + ", + )); + + let cfg = ServiceConfig::new( + KeepAlive::Os, + Duration::from_millis(1), + Duration::from_millis(1), + false, + None, + ); + + let services = HttpFlow::new(ignore_payload_service(), ExpectHandler, None); + + let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new( + buf.clone(), + services, + cfg, + None, + OnConnectData::default(), + ); + + pin!(h1); + + assert!(matches!(&h1.inner, DispatcherState::Normal { .. })); + + match h1.as_mut().poll(cx) { + Poll::Pending => {} + Poll::Ready(res) => panic!("closed connection early: {:?}", res), + } + + let mut res = BytesMut::from(buf.take_write_buf().as_ref()); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = http_msg( + r" + HTTP/1.1 200 OK + content-length: 2 + connection: close + date: Thu, 01 Jan 1970 12:34:56 UTC + + ok + ", + ); + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(&exp) + ); + + buf.close_read(); + + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(h1.as_mut().poll(cx).is_ready()); + }) + .await; +} + +#[actix_rt::test] +async fn lingering_timeout_uses_graceful_shutdown() { + let buf = TestSeqBuffer::new( + "\ + POST / HTTP/1.1\r\n\ + Content-Length: 8\r\n\ + \r\n\ + ab\ + ", + ); + + let cfg = ServiceConfig::new( + KeepAlive::Disabled, + Duration::ZERO, + Duration::from_millis(1), + false, + None, + ); + + let services = HttpFlow::new(ignore_payload_service(), ExpectHandler, None); + + let h1 = Dispatcher::<_, _, _, _, UpgradeHandler>::new( + buf.clone(), + services, + cfg, + None, + OnConnectData::default(), + ); + + assert!(matches!( + timeout(Duration::from_millis(100), h1).await, + Ok(Ok(())) + )); + + let mut res = buf.take_write_buf().to_vec(); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = b"\ + HTTP/1.1 200 OK\r\n\ + content-length: 2\r\n\ + connection: close\r\n\ + date: Thu, 01 Jan 1970 12:34:56 UTC\r\n\r\n\ + ok\ + "; + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(exp) + ); +} + #[actix_rt::test] async fn pipelining_ok_then_bad() { lazy(|cx| { @@ -791,7 +1144,7 @@ async fn handler_drop_payload() { r" POST /drop-payload HTTP/1.1 Content-Length: 3 - + abc ", )); @@ -924,6 +1277,265 @@ async fn handler_drop_payload() { .await; } +// Handler drops request payload without reading it. Server should keep reading and discarding the +// rest of the request body so clients that do not read the response until they've finished +// writing the request (like `requests` in Python) do not deadlock. +// ref. https://github.com/actix/actix-web/issues/2972 +#[actix_rt::test] +async fn handler_drop_payload_drains_body() { + let _ = env_logger::try_init(); + + let mut buf = TestSeqBuffer::new(http_msg( + r" + POST /drop-payload HTTP/1.1 + Transfer-Encoding: chunked + + ", + )); + + let services = HttpFlow::new( + drop_payload_service(), + ExpectHandler, + None::, + ); + + let h1 = Dispatcher::new( + buf.clone(), + services, + ServiceConfig::default(), + None, + OnConnectData::default(), + ); + pin!(h1); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + + let mut res = BytesMut::from(buf.take_write_buf().as_ref()); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = http_msg( + r" + HTTP/1.1 200 OK + content-length: 15 + date: Thu, 01 Jan 1970 12:34:56 UTC + + payload dropped + ", + ); + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(&exp) + ); + }) + .await; + + // stream a body larger than the dispatcher read buffer limit; it should still be drained + // (read + decoded + discarded) without stalling. + for _ in 0..32 { + let data = vec![b'a'; 8192]; + let mut chunk = BytesMut::new(); + chunk.extend_from_slice(format!("{:x}\r\n", data.len()).as_bytes()); + chunk.extend_from_slice(&data); + chunk.extend_from_slice(b"\r\n"); + + buf.extend_read_buf(chunk); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(buf.take_write_buf().is_empty()); + assert!(buf.read_buf().is_empty()); + }) + .await; + } + + // terminating chunk + buf.extend_read_buf(b"0\r\n\r\n"); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(buf.take_write_buf().is_empty()); + assert!(buf.read_buf().is_empty()); + }) + .await; + + // connection should be able to accept another request after draining the previous body + buf.extend_read_buf(http_msg("GET /drop-payload HTTP/1.1")); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + + let mut res = BytesMut::from(buf.take_write_buf().as_ref()); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = http_msg( + r" + HTTP/1.1 200 OK + content-length: 15 + date: Thu, 01 Jan 1970 12:34:56 UTC + + payload dropped + ", + ); + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(&exp) + ); + }) + .await; +} + +#[actix_rt::test] +async fn allow_half_closed() { + let buf = TestSeqBuffer::new(http_msg("GET / HTTP/1.1")); + buf.close_read(); + let services = HttpFlow::new(YieldService, ExpectHandler, None::); + + 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 { .. })) +} + +#[actix_rt::test] +async fn h1_write_buffer_size_limits_buffering() { + let request = "GET /stream HTTP/1.1\r\nConnection: close\r\n\r\n"; + + let default_polls = Rc::new(Cell::new(0)); + let default_services = HttpFlow::new( + ready_chunk_body_service(default_polls.clone(), 8, 1024), + ExpectHandler, + None::, + ); + let default_io = PendingOnceWriteBuf::new(request); + let default_dispatcher = Dispatcher::new( + default_io, + default_services, + ServiceConfig::default(), + None, + OnConnectData::default(), + ); + pin!(default_dispatcher); + + let mut cx = Context::from_waker(futures_util::task::noop_waker_ref()); + assert!(default_dispatcher.as_mut().poll(&mut cx).is_pending()); + assert_eq!(default_polls.get(), 8); + + let custom_polls = Rc::new(Cell::new(0)); + let custom_services = HttpFlow::new( + ready_chunk_body_service(custom_polls.clone(), 8, 1024), + ExpectHandler, + None::, + ); + let custom_io = PendingOnceWriteBuf::new(request); + let custom_dispatcher = Dispatcher::new( + custom_io, + custom_services, + crate::config::ServiceConfigBuilder::new() + .h1_write_buffer_size(1024) + .build(), + None, + OnConnectData::default(), + ); + pin!(custom_dispatcher); + + assert!(custom_dispatcher.as_mut().poll(&mut cx).is_pending()); + assert_eq!(custom_polls.get(), 1); +} + +#[actix_rt::test] +#[should_panic(expected = "HTTP/1 write buffer size must be greater than zero")] +async fn h1_write_buffer_size_rejects_zero() { + let _ = crate::config::ServiceConfigBuilder::new().h1_write_buffer_size(0); +} + fn http_msg(msg: impl AsRef) -> BytesMut { let mut msg = msg .as_ref() diff --git a/actix-http/src/h1/encoder.rs b/actix-http/src/h1/encoder.rs index 81af7868b..1853843c3 100644 --- a/actix-http/src/h1/encoder.rs +++ b/actix-http/src/h1/encoder.rs @@ -111,7 +111,13 @@ pub(crate) trait MessageType: Sized { // Connection match conn_type { - ConnectionType::Upgrade => dst.put_slice(b"connection: upgrade\r\n"), + ConnectionType::Upgrade => { + if camel_case { + dst.put_slice(b"Connection: Upgrade\r\n") + } else { + dst.put_slice(b"connection: upgrade\r\n") + } + } ConnectionType::KeepAlive if version < Version::HTTP_11 => { if camel_case { dst.put_slice(b"Connection: keep-alive\r\n") @@ -580,6 +586,16 @@ mod tests { assert!(data.contains("Date: date\r\n")); assert!(data.contains("Upgrade-Insecure-Requests: 1\r\n")); + let _ = head.encode_headers( + &mut bytes, + Version::HTTP_11, + BodySize::None, + ConnectionType::Upgrade, + &ServiceConfig::default(), + ); + let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap(); + assert!(data.contains("Connection: Upgrade\r\n")); + let _ = head.encode_headers( &mut bytes, Version::HTTP_11, diff --git a/actix-http/src/h1/payload.rs b/actix-http/src/h1/payload.rs index 2ad3a14a3..e12c87806 100644 --- a/actix-http/src/h1/payload.rs +++ b/actix-http/src/h1/payload.rs @@ -133,6 +133,11 @@ impl PayloadSender { PayloadStatus::Dropped } } + + #[inline] + pub fn is_dropped(&self) -> bool { + self.inner.strong_count() == 0 + } } #[derive(Debug)] @@ -176,11 +181,7 @@ impl Inner { /// Register future waiting data from payload. /// Waker would be used in `Inner::wake` fn register(&mut self, cx: &Context<'_>) { - if self - .task - .as_ref() - .map_or(true, |w| !cx.waker().will_wake(w)) - { + if self.task.as_ref().is_none_or(|w| !cx.waker().will_wake(w)) { self.task = Some(cx.waker().clone()); } } @@ -191,7 +192,7 @@ impl Inner { if self .io_task .as_ref() - .map_or(true, |w| !cx.waker().will_wake(w)) + .is_none_or(|w| !cx.waker().will_wake(w)) { self.io_task = Some(cx.waker().clone()); } @@ -200,11 +201,13 @@ impl Inner { #[inline] fn set_error(&mut self, err: PayloadError) { self.err = Some(err); + self.wake(); } #[inline] fn feed_eof(&mut self) { self.eof = true; + self.wake(); } #[inline] @@ -253,8 +256,13 @@ impl Inner { #[cfg(test)] mod tests { + use std::{task::Poll, time::Duration}; + + use actix_rt::time::timeout; use actix_utils::future::poll_fn; + use futures_util::{FutureExt, StreamExt}; use static_assertions::{assert_impl_all, assert_not_impl_any}; + use tokio::sync::oneshot; use super::*; @@ -263,6 +271,67 @@ mod tests { assert_impl_all!(Inner: Unpin, Send, Sync); + const WAKE_TIMEOUT: Duration = Duration::from_secs(2); + + fn prepare_waking_test( + mut payload: Payload, + expected: Option>, + ) -> (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/h2/mod.rs b/actix-http/src/h2/mod.rs index e47099cac..300af2ed3 100644 --- a/actix-http/src/h2/mod.rs +++ b/actix-http/src/h2/mod.rs @@ -11,7 +11,7 @@ use actix_rt::time::{sleep_until, Sleep}; use bytes::Bytes; use futures_core::{ready, Stream}; use h2::{ - server::{handshake, Connection, Handshake}, + server::{Builder, Connection, Handshake}, RecvStream, }; @@ -61,8 +61,13 @@ pub(crate) fn handshake_with_timeout(io: T, config: &ServiceConfig) -> Handsh where T: AsyncRead + AsyncWrite + Unpin, { + let mut builder = Builder::new(); + builder + .initial_window_size(config.h2_initial_window_size()) + .initial_connection_window_size(config.h2_initial_connection_window_size()); + HandshakeWithTimeout { - handshake: handshake(io), + handshake: builder.handshake(io), timer: config .client_request_deadline() .map(|deadline| Box::pin(sleep_until(deadline.into()))), diff --git a/actix-http/src/h2/service.rs b/actix-http/src/h2/service.rs index debc73e59..2ac9368fd 100644 --- a/actix-http/src/h2/service.rs +++ b/actix-http/src/h2/service.rs @@ -25,6 +25,16 @@ use crate::{ ConnectCallback, OnConnectData, Request, Response, }; +#[inline] +fn desired_nodelay(tcp_nodelay: Option) -> Option { + tcp_nodelay +} + +#[inline] +fn set_nodelay(stream: &TcpStream, nodelay: bool) { + let _ = stream.set_nodelay(nodelay); +} + /// `ServiceFactory` implementation for HTTP/2 transport pub struct H2Service { srv: S, @@ -82,8 +92,13 @@ where Error = DispatchError, InitError = S::InitError, > { - fn_factory(|| { - ready(Ok::<_, S::InitError>(fn_service(|io: TcpStream| { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); + + fn_factory(move || { + ready(Ok::<_, S::InitError>(fn_service(move |io: TcpStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(&io, nodelay); + } let peer_addr = io.peer_addr().ok(); ready(Ok::<_, DispatchError>((io, peer_addr))) }))) @@ -126,12 +141,17 @@ mod openssl { Error = TlsError, InitError = S::InitError, > { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); + Acceptor::new(acceptor) .map_init_err(|_| { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(io.get_ref(), nodelay); + } let peer_addr = io.get_ref().peer_addr().ok(); (io, peer_addr) }) @@ -173,6 +193,7 @@ mod rustls_0_20 { Error = TlsError, InitError = S::InitError, > { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); let mut protos = vec![b"h2".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -182,7 +203,10 @@ mod rustls_0_20 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(io.get_ref().0, nodelay); + } let peer_addr = io.get_ref().0.peer_addr().ok(); (io, peer_addr) }) @@ -224,6 +248,7 @@ mod rustls_0_21 { Error = TlsError, InitError = S::InitError, > { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); let mut protos = vec![b"h2".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -233,7 +258,10 @@ mod rustls_0_21 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(io.get_ref().0, nodelay); + } let peer_addr = io.get_ref().0.peer_addr().ok(); (io, peer_addr) }) @@ -275,6 +303,7 @@ mod rustls_0_22 { Error = TlsError, InitError = S::InitError, > { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); let mut protos = vec![b"h2".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -284,7 +313,10 @@ mod rustls_0_22 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(io.get_ref().0, nodelay); + } let peer_addr = io.get_ref().0.peer_addr().ok(); (io, peer_addr) }) @@ -326,6 +358,7 @@ mod rustls_0_23 { Error = TlsError, InitError = S::InitError, > { + let tcp_nodelay = desired_nodelay(self.cfg.tcp_nodelay()); let mut protos = vec![b"h2".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -335,7 +368,10 @@ mod rustls_0_23 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { + if let Some(nodelay) = tcp_nodelay { + set_nodelay(io.get_ref().0, nodelay); + } let peer_addr = io.get_ref().0.peer_addr().ok(); (io, peer_addr) }) diff --git a/actix-http/src/header/map.rs b/actix-http/src/header/map.rs index a9a201e1a..69f0aa2a7 100644 --- a/actix-http/src/header/map.rs +++ b/actix-http/src/header/map.rs @@ -537,7 +537,7 @@ impl HeaderMap { /// assert!(pairs.contains(&(&header::SET_COOKIE, &HeaderValue::from_static("two=2")))); /// ``` pub fn iter(&self) -> Iter<'_> { - Iter::new(self.inner.iter()) + Iter::new(self.inner.iter(), self.len()) } /// An iterator over all contained header names. @@ -626,7 +626,8 @@ impl HeaderMap { /// assert!(map.is_empty()); /// ``` pub fn drain(&mut self) -> Drain<'_> { - Drain::new(self.inner.drain()) + let len = self.len(); + Drain::new(self.inner.drain(), len) } } @@ -638,7 +639,8 @@ impl IntoIterator for HeaderMap { #[inline] fn into_iter(self) -> Self::IntoIter { - IntoIter::new(self.inner.into_iter()) + let len = self.len(); + IntoIter::new(self.inner.into_iter(), len) } } @@ -648,7 +650,7 @@ impl<'a> IntoIterator for &'a HeaderMap { #[inline] fn into_iter(self) -> Self::IntoIter { - Iter::new(self.inner.iter()) + Iter::new(self.inner.iter(), self.len()) } } @@ -760,14 +762,16 @@ pub struct Iter<'a> { inner: hash_map::Iter<'a, HeaderName, Value>, multi_inner: Option<(&'a HeaderName, &'a SmallVec<[HeaderValue; 4]>)>, multi_idx: usize, + remaining: usize, } impl<'a> Iter<'a> { - fn new(iter: hash_map::Iter<'a, HeaderName, Value>) -> Self { + fn new(iter: hash_map::Iter<'a, HeaderName, Value>, remaining: usize) -> Self { Self { inner: iter, multi_idx: 0, multi_inner: None, + remaining, } } } @@ -781,6 +785,7 @@ impl<'a> Iterator for Iter<'a> { match vals.get(self.multi_idx) { Some(val) => { self.multi_idx += 1; + self.remaining -= 1; return Some((name, val)); } None => { @@ -800,9 +805,7 @@ impl<'a> Iterator for Iter<'a> { #[inline] fn size_hint(&self) -> (usize, Option) { - // take inner lower bound - // make no attempt at an upper bound - (self.inner.size_hint().0, None) + (self.remaining, Some(self.remaining)) } } @@ -818,14 +821,16 @@ pub struct Drain<'a> { inner: hash_map::Drain<'a, HeaderName, Value>, multi_inner: Option<(Option, SmallVec<[HeaderValue; 4]>)>, multi_idx: usize, + remaining: usize, } impl<'a> Drain<'a> { - fn new(iter: hash_map::Drain<'a, HeaderName, Value>) -> Self { + fn new(iter: hash_map::Drain<'a, HeaderName, Value>, remaining: usize) -> Self { Self { inner: iter, multi_inner: None, multi_idx: 0, + remaining, } } } @@ -838,6 +843,7 @@ impl Iterator for Drain<'_> { if let Some((ref mut name, ref mut vals)) = self.multi_inner { if !vals.is_empty() { // OPTIMIZE: array removals + self.remaining -= 1; return Some((name.take(), vals.remove(0))); } else { // no more items in value iterator; reset state @@ -855,9 +861,7 @@ impl Iterator for Drain<'_> { #[inline] fn size_hint(&self) -> (usize, Option) { - // take inner lower bound - // make no attempt at an upper bound - (self.inner.size_hint().0, None) + (self.remaining, Some(self.remaining)) } } @@ -872,13 +876,15 @@ impl iter::FusedIterator for Drain<'_> {} pub struct IntoIter { inner: hash_map::IntoIter, multi_inner: Option<(HeaderName, smallvec::IntoIter<[HeaderValue; 4]>)>, + remaining: usize, } impl IntoIter { - fn new(inner: hash_map::IntoIter) -> Self { + fn new(inner: hash_map::IntoIter, remaining: usize) -> Self { Self { inner, multi_inner: None, + remaining, } } } @@ -891,6 +897,7 @@ impl Iterator for IntoIter { if let Some((ref name, ref mut vals)) = self.multi_inner { match vals.next() { Some(val) => { + self.remaining -= 1; return Some((name.clone(), val)); } None => { @@ -909,9 +916,7 @@ impl Iterator for IntoIter { #[inline] fn size_hint(&self) -> (usize, Option) { - // take inner lower bound - // make no attempt at an upper bound - (self.inner.size_hint().0, None) + (self.remaining, Some(self.remaining)) } } @@ -1160,6 +1165,40 @@ mod tests { assert!(vals.next().is_none()); } + #[test] + fn iter_len_counts_values() { + let mut map = HeaderMap::new(); + map.append(header::SET_COOKIE, HeaderValue::from_static("a=1")); + map.append(header::SET_COOKIE, HeaderValue::from_static("b=2")); + map.append(header::SET_COOKIE, HeaderValue::from_static("c=3")); + + assert_eq!(map.iter().count(), 3); + assert_eq!(map.iter().len(), 3); + } + + #[test] + fn into_iter_len_counts_values() { + let mut map = HeaderMap::new(); + map.append(header::SET_COOKIE, HeaderValue::from_static("a=1")); + map.append(header::SET_COOKIE, HeaderValue::from_static("b=2")); + map.append(header::SET_COOKIE, HeaderValue::from_static("c=3")); + + assert_eq!(map.clone().into_iter().count(), 3); + assert_eq!(map.into_iter().len(), 3); + } + + #[test] + fn drain_len_counts_values() { + let mut map = HeaderMap::new(); + map.append(header::SET_COOKIE, HeaderValue::from_static("a=1")); + map.append(header::SET_COOKIE, HeaderValue::from_static("b=2")); + map.append(header::SET_COOKIE, HeaderValue::from_static("c=3")); + + let mut drained = map.clone(); + assert_eq!(map.drain().count(), 3); + assert_eq!(drained.drain().len(), 3); + } + fn owned_pair<'a>((name, val): (&'a HeaderName, &'a HeaderValue)) -> (HeaderName, HeaderValue) { (name.clone(), val.clone()) } diff --git a/actix-http/src/lib.rs b/actix-http/src/lib.rs index a1b218f26..83fbaec1e 100644 --- a/actix-http/src/lib.rs +++ b/actix-http/src/lib.rs @@ -28,7 +28,7 @@ )] #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] pub use http::{uri, uri::Uri, Method, StatusCode, Version}; @@ -64,7 +64,7 @@ pub use self::payload::PayloadStream; pub use self::service::TlsAcceptorConfig; pub use self::{ builder::HttpServiceBuilder, - config::ServiceConfig, + config::{ServiceConfig, ServiceConfigBuilder}, error::Error, extensions::Extensions, header::ContentEncoding, diff --git a/actix-http/src/requests/head.rs b/actix-http/src/requests/head.rs index 9ceb2a20c..ddc9dd98f 100644 --- a/actix-http/src/requests/head.rs +++ b/actix-http/src/requests/head.rs @@ -61,14 +61,15 @@ impl RequestHead { &mut self.headers } - /// Is to uppercase headers with Camel-Case. - /// Default is `false` + /// Returns whether headers should be sent in Camel-Case. + /// + /// Default is `false`. #[inline] pub fn camel_case_headers(&self) -> bool { self.flags.contains(Flags::CAMEL_CASE) } - /// Set `true` to send headers which are formatted as Camel-Case. + /// Sets whether to send headers formatted as Camel-Case. #[inline] pub fn set_camel_case_headers(&mut self, val: bool) { if val { diff --git a/actix-http/src/service.rs b/actix-http/src/service.rs index 3be099d9f..1b6391740 100644 --- a/actix-http/src/service.rs +++ b/actix-http/src/service.rs @@ -24,6 +24,16 @@ use crate::{ h1, ConnectCallback, OnConnectData, Protocol, Request, Response, ServiceConfig, }; +#[inline] +fn desired_nodelay(tcp_nodelay: Option) -> Option { + tcp_nodelay +} + +#[inline] +fn set_nodelay(stream: &TcpStream, nodelay: bool) { + let _ = stream.set_nodelay(nodelay); +} + /// A [`ServiceFactory`] for HTTP/1.1 and HTTP/2 connections. /// /// Use [`build`](Self::build) to begin constructing service. Also see [`HttpServiceBuilder`]. @@ -202,7 +212,13 @@ where self, ) -> impl ServiceFactory { - fn_service(|io: TcpStream| async { + let tcp_nodelay = self.cfg.tcp_nodelay(); + + fn_service(move |io: TcpStream| async move { + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(&io, nodelay); + } + let peer_addr = io.peer_addr().ok(); Ok((io, Protocol::Http1, peer_addr)) }) @@ -216,6 +232,8 @@ where self, ) -> impl ServiceFactory { + let tcp_nodelay = self.cfg.tcp_nodelay(); + fn_service(move |io: TcpStream| async move { // subset of HTTP/2 preface defined by RFC 9113 §3.4 // this subset was chosen to maximize likelihood that peeking only once will allow us to @@ -233,6 +251,10 @@ where Protocol::Http1 }; + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(&io, nodelay); + } + let peer_addr = io.peer_addr().ok(); Ok((io, proto, peer_addr)) }) @@ -322,6 +344,7 @@ mod openssl { Error = TlsError, InitError = (), > { + let tcp_nodelay = self.cfg.tcp_nodelay(); let mut acceptor = Acceptor::new(acceptor); if let Some(handshake_timeout) = tls_acceptor_config.handshake_timeout { @@ -333,7 +356,7 @@ mod openssl { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .map(|io: TlsStream| { + .map(move |io: TlsStream| { let proto = if let Some(protos) = io.ssl().selected_alpn_protocol() { if protos.windows(2).any(|window| window == b"h2") { Protocol::Http2 @@ -344,6 +367,10 @@ mod openssl { Protocol::Http1 }; + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(io.get_ref(), nodelay); + } + let peer_addr = io.get_ref().peer_addr().ok(); (io, proto, peer_addr) }) @@ -415,6 +442,7 @@ mod rustls_0_20 { Error = TlsError, InitError = (), > { + let tcp_nodelay = self.cfg.tcp_nodelay(); let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -430,7 +458,7 @@ mod rustls_0_20 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .and_then(|io: TlsStream| async { + .and_then(move |io: TlsStream| async move { let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { if protos.windows(2).any(|window| window == b"h2") { Protocol::Http2 @@ -440,6 +468,11 @@ mod rustls_0_20 { } else { Protocol::Http1 }; + + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(io.get_ref().0, nodelay); + } + let peer_addr = io.get_ref().0.peer_addr().ok(); Ok((io, proto, peer_addr)) }) @@ -511,6 +544,7 @@ mod rustls_0_21 { Error = TlsError, InitError = (), > { + let tcp_nodelay = self.cfg.tcp_nodelay(); let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -526,7 +560,7 @@ mod rustls_0_21 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .and_then(|io: TlsStream| async { + .and_then(move |io: TlsStream| async move { let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { if protos.windows(2).any(|window| window == b"h2") { Protocol::Http2 @@ -536,6 +570,11 @@ mod rustls_0_21 { } else { Protocol::Http1 }; + + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(io.get_ref().0, nodelay); + } + let peer_addr = io.get_ref().0.peer_addr().ok(); Ok((io, proto, peer_addr)) }) @@ -607,6 +646,7 @@ mod rustls_0_22 { Error = TlsError, InitError = (), > { + let tcp_nodelay = self.cfg.tcp_nodelay(); let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -622,7 +662,7 @@ mod rustls_0_22 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .and_then(|io: TlsStream| async { + .and_then(move |io: TlsStream| async move { let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { if protos.windows(2).any(|window| window == b"h2") { Protocol::Http2 @@ -632,6 +672,11 @@ mod rustls_0_22 { } else { Protocol::Http1 }; + + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(io.get_ref().0, nodelay); + } + let peer_addr = io.get_ref().0.peer_addr().ok(); Ok((io, proto, peer_addr)) }) @@ -703,6 +748,7 @@ mod rustls_0_23 { Error = TlsError, InitError = (), > { + let tcp_nodelay = self.cfg.tcp_nodelay(); let mut protos = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; protos.extend_from_slice(&config.alpn_protocols); config.alpn_protocols = protos; @@ -718,7 +764,7 @@ mod rustls_0_23 { unreachable!("TLS acceptor service factory does not error on init") }) .map_err(TlsError::into_service_error) - .and_then(|io: TlsStream| async { + .and_then(move |io: TlsStream| async move { let proto = if let Some(protos) = io.get_ref().1.alpn_protocol() { if protos.windows(2).any(|window| window == b"h2") { Protocol::Http2 @@ -728,6 +774,11 @@ mod rustls_0_23 { } else { Protocol::Http1 }; + + if let Some(nodelay) = desired_nodelay(tcp_nodelay) { + set_nodelay(io.get_ref().0, nodelay); + } + let peer_addr = io.get_ref().0.peer_addr().ok(); Ok((io, proto, peer_addr)) }) diff --git a/actix-http/src/test.rs b/actix-http/src/test.rs index dfa9a86c9..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 } @@ -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/frame.rs b/actix-http/src/ws/frame.rs index 0bd64a465..1460955f5 100644 --- a/actix-http/src/ws/frame.rs +++ b/actix-http/src/ws/frame.rs @@ -98,11 +98,21 @@ impl Parser { Some(res) => res, }; + let frame_len = match idx.checked_add(length) { + Some(len) => len, + None => return Err(ProtocolError::Overflow), + }; + // not enough data - if src.len() < idx + length { + if src.len() < frame_len { let min_length = min(length, max_size); - if src.capacity() < idx + min_length { - src.reserve(idx + min_length - src.capacity()); + let required_cap = match idx.checked_add(min_length) { + Some(cap) => cap, + None => return Err(ProtocolError::Overflow), + }; + + if src.capacity() < required_cap { + src.reserve(required_cap - src.capacity()); } return Ok(None); } @@ -440,4 +450,14 @@ mod tests { Parser::write_close(&mut buf, None, RsvBits::empty(), false); assert_eq!(&buf[..], &vec![0x88, 0x00][..]); } + + #[test] + fn test_parse_length_overflow() { + let buf: [u8; 14] = [ + 0x0a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xeb, 0x0e, 0x8f, + ]; + let mut buf = BytesMut::from(&buf[..]); + let result = Parser::parse(&mut buf, true, 65536); + assert!(matches!(result, Err(ProtocolError::Overflow))); + } } diff --git a/actix-http/tests/test_openssl.rs b/actix-http/tests/test_openssl.rs index 83456b0cb..977e8b6cb 100644 --- a/actix-http/tests/test_openssl.rs +++ b/actix-http/tests/test_openssl.rs @@ -149,10 +149,16 @@ async fn h2_content_length() { { let req = srv.request(Method::HEAD, srv.surl("/0")).send(); - req.await.expect_err("should timeout on recv 1xx frame"); + actix_rt::time::timeout(Duration::from_secs(15), req) + .await + .expect("request future stalled on recv 1xx frame") + .expect_err("should timeout on recv 1xx frame"); let req = srv.request(Method::GET, srv.surl("/0")).send(); - req.await.expect_err("should timeout on recv 1xx frame"); + actix_rt::time::timeout(Duration::from_secs(15), req) + .await + .expect("request future stalled on recv 1xx frame") + .expect_err("should timeout on recv 1xx frame"); let req = srv.request(Method::GET, srv.surl("/1")).send(); let response = req.await.unwrap(); diff --git a/actix-http/tests/test_rustls.rs b/actix-http/tests/test_rustls.rs index 43e47c0a4..d29e0cbd0 100644 --- a/actix-http/tests/test_rustls.rs +++ b/actix-http/tests/test_rustls.rs @@ -1,10 +1,11 @@ #![cfg(feature = "rustls-0_23")] +extern crate tls_openssl as openssl; extern crate tls_rustls_023 as rustls; use std::{ convert::Infallible, - io::{self, BufReader, Write}, + io::{self, Write}, net::{SocketAddr, TcpStream as StdTcpStream}, sync::Arc, task::Poll, @@ -18,16 +19,17 @@ use actix_http::{ Error, HttpService, Method, Request, Response, StatusCode, TlsAcceptorConfig, Version, }; use actix_http_test::test_server; -use actix_rt::pin; +use actix_rt::{net::TcpStream as RtTcpStream, pin}; use actix_service::{fn_factory_with_config, fn_service}; -use actix_tls::connect::rustls_0_23::webpki_roots_cert_store; +use actix_tls::{accept::rustls_0_23::TlsStream, connect::rustls_0_23::webpki_roots_cert_store}; use actix_utils::future::{err, ok, poll_fn}; +use awc::{Client, Connector}; use bytes::{Bytes, BytesMut}; use derive_more::{Display, Error}; use futures_core::{ready, Stream}; use futures_util::stream::once; use rustls::{pki_types::ServerName, ServerConfig as RustlsServerConfig}; -use rustls_pemfile::{certs, pkcs8_private_keys}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; async fn load_body(stream: S) -> Result where @@ -51,34 +53,49 @@ where Ok(buf) } -fn tls_config() -> RustlsServerConfig { +fn tls_config_with_alpn(protocols: &[&[u8]]) -> RustlsServerConfig { let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); - let cert_file = cert.pem(); - let key_file = key_pair.serialize_pem(); - - let cert_file = &mut BufReader::new(cert_file.as_bytes()); - let key_file = &mut BufReader::new(key_file.as_bytes()); - - let cert_chain = certs(cert_file).collect::, _>>().unwrap(); - let mut keys = pkcs8_private_keys(key_file) - .collect::, _>>() - .unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); let mut config = RustlsServerConfig::builder() .with_no_client_auth() - .with_single_cert( - cert_chain, - rustls::pki_types::PrivateKeyDer::Pkcs8(keys.remove(0)), - ) + .with_single_cert(cert_chain, key_der) .unwrap(); - config.alpn_protocols.push(HTTP1_1_ALPN_PROTOCOL.to_vec()); - config.alpn_protocols.push(H2_ALPN_PROTOCOL.to_vec()); + config.alpn_protocols = protocols.iter().map(|proto| proto.to_vec()).collect(); config } +fn tls_config() -> RustlsServerConfig { + tls_config_with_alpn(&[HTTP1_1_ALPN_PROTOCOL, H2_ALPN_PROTOCOL]) +} + +fn tls_config_h1() -> RustlsServerConfig { + tls_config_with_alpn(&[HTTP1_1_ALPN_PROTOCOL]) +} + +fn tls_config_h2() -> RustlsServerConfig { + tls_config_with_alpn(&[H2_ALPN_PROTOCOL]) +} + +fn h1_client() -> Client { + use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; + + let mut builder = SslConnector::builder(SslMethod::tls()).unwrap(); + builder.set_verify(SslVerifyMode::NONE); + builder.set_alpn_protos(b"\x08http/1.1").unwrap(); + + let connector = Connector::new() + .conn_lifetime(Duration::from_secs(0)) + .timeout(Duration::from_millis(30_000)) + .openssl(builder.build()); + + Client::builder().connector(connector).finish() +} + pub fn get_negotiated_alpn_protocol( addr: SocketAddr, client_alpn_protocol: &[u8], @@ -106,53 +123,56 @@ pub fn get_negotiated_alpn_protocol( #[actix_rt::test] async fn h1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h1(|_| ok::<_, Error>(Response::ok())) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h1()) }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Error>(Response::ok())) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h1_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h1(|req: Request| { assert!(req.peer_addr().is_some()); assert_eq!(req.version(), Version::HTTP_11); ok::<_, Error>(Response::ok()) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h1()) }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .finish(|req: Request| { assert!(req.peer_addr().is_some()); @@ -160,7 +180,7 @@ async fn h2_1() -> io::Result<()> { ok::<_, Error>(Response::ok()) }) .rustls_0_23_with_config( - tls_config(), + tls_config_h2(), TlsAcceptorConfig::default().handshake_timeout(Duration::from_secs(5)), ) }) @@ -168,6 +188,51 @@ async fn h2_1() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; + Ok(()) +} + +#[actix_rt::test] +async fn h2_tcp_nodelay_override_true() -> io::Result<()> { + let mut srv = test_server(move || { + HttpService::build() + .tcp_nodelay(true) + .on_connect_ext(|io: &TlsStream, data| { + data.insert(io.get_ref().0.nodelay().unwrap()); + }) + .h2(|req: Request| { + assert_eq!(req.conn_data::(), Some(&true)); + ok::<_, Error>(Response::ok()) + }) + .rustls_0_23(tls_config_h2()) + }) + .await; + + let response = srv.sget("/").send().await.unwrap(); + assert!(response.status().is_success()); + srv.stop().await; + Ok(()) +} + +#[actix_rt::test] +async fn h2_tcp_nodelay_override_false() -> io::Result<()> { + let mut srv = test_server(move || { + HttpService::build() + .tcp_nodelay(false) + .on_connect_ext(|io: &TlsStream, data| { + data.insert(io.get_ref().0.nodelay().unwrap()); + }) + .h2(|req: Request| { + assert_eq!(req.conn_data::(), Some(&false)); + ok::<_, Error>(Response::ok()) + }) + .rustls_0_23(tls_config_h2()) + }) + .await; + + let response = srv.sget("/").send().await.unwrap(); + assert!(response.status().is_success()); + srv.stop().await; Ok(()) } @@ -180,7 +245,7 @@ async fn h2_body1() -> io::Result<()> { let body = load_body(req.take_payload()).await?; Ok::<_, Error>(Response::ok().set_body(body)) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -189,12 +254,13 @@ async fn h2_body1() -> io::Result<()> { let body = srv.load_body(response).await.unwrap(); assert_eq!(&body, data.as_bytes()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_content_length() { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|req: Request| { let indx: usize = req.uri().path()[1..].parse().unwrap(); @@ -206,7 +272,7 @@ async fn h2_content_length() { ]; ok::<_, Infallible>(Response::new(statuses[indx])) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -219,13 +285,19 @@ async fn h2_content_length() { let req = srv .request(Method::HEAD, srv.surl(&format!("/{}", i))) .send(); - let _response = req.await.expect_err("should timeout on recv 1xx frame"); + actix_rt::time::timeout(Duration::from_secs(15), req) + .await + .expect("request future stalled on recv 1xx frame") + .expect_err("should timeout on recv 1xx frame"); // assert_eq!(response.headers().get(&header), None); let req = srv .request(Method::GET, srv.surl(&format!("/{}", i))) .send(); - let _response = req.await.expect_err("should timeout on recv 1xx frame"); + actix_rt::time::timeout(Duration::from_secs(15), req) + .await + .expect("request future stalled on recv 1xx frame") + .expect_err("should timeout on recv 1xx frame"); // assert_eq!(response.headers().get(&header), None); } @@ -246,6 +318,8 @@ async fn h2_content_length() { assert_eq!(response.headers().get(&header), Some(&value)); } } + + srv.stop().await; } #[actix_rt::test] @@ -278,7 +352,7 @@ async fn h2_headers() { } ok::<_, Infallible>(config.body(data.clone())) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -288,6 +362,7 @@ async fn h2_headers() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from(data2)); + srv.stop().await; } const STR: &str = "Hello World Hello World Hello World Hello World Hello World \ @@ -317,7 +392,7 @@ async fn h2_body2() { let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -327,6 +402,7 @@ async fn h2_body2() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -334,7 +410,7 @@ async fn h2_head_empty() { let mut srv = test_server(move || { HttpService::build() .finish(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -353,6 +429,7 @@ async fn h2_head_empty() { // read response let bytes = srv.load_body(response).await.unwrap(); assert!(bytes.is_empty()); + srv.stop().await; } #[actix_rt::test] @@ -360,7 +437,7 @@ async fn h2_head_binary() { let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -378,14 +455,15 @@ async fn h2_head_binary() { // read response let bytes = srv.load_body(response).await.unwrap(); assert!(bytes.is_empty()); + srv.stop().await; } #[actix_rt::test] async fn h2_head_binary2() { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -399,6 +477,8 @@ async fn h2_head_binary2() { .unwrap(); assert_eq!(format!("{}", STR.len()), len.to_str().unwrap()); } + + srv.stop().await; } #[actix_rt::test] @@ -411,7 +491,7 @@ async fn h2_body_length() { Response::ok().set_body(SizedStream::new(STR.len() as u64, body)), ) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -421,6 +501,7 @@ async fn h2_body_length() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -435,7 +516,7 @@ async fn h2_body_chunked_explicit() { .body(BodyStream::new(body)), ) }) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -448,6 +529,7 @@ async fn h2_body_chunked_explicit() { // decode assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -464,7 +546,7 @@ async fn h2_response_http_error_handling() { ) })) })) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -477,6 +559,7 @@ async fn h2_response_http_error_handling() { bytes, Bytes::from_static(b"error processing HTTP: failed to parse header value") ); + srv.stop().await; } #[derive(Debug, Display, Error)] @@ -494,7 +577,7 @@ async fn h2_service_error() { let mut srv = test_server(move || { HttpService::build() .h2(|_| err::, _>(BadRequest)) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h2()) }) .await; @@ -504,6 +587,7 @@ async fn h2_service_error() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(b"error")); + srv.stop().await; } #[actix_rt::test] @@ -511,16 +595,17 @@ async fn h1_service_error() { let mut srv = test_server(move || { HttpService::build() .h1(|_| err::, _>(BadRequest)) - .rustls_0_23(tls_config()) + .rustls_0_23(tls_config_h1()) }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(b"error")); + srv.stop().await; } const H2_ALPN_PROTOCOL: &[u8] = b"h2"; @@ -529,8 +614,8 @@ const CUSTOM_ALPN_PROTOCOL: &[u8] = b"custom"; #[actix_rt::test] async fn alpn_h1() -> io::Result<()> { - let srv = test_server(move || { - let mut config = tls_config(); + let mut srv = test_server(move || { + let mut config = tls_config_h1(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() .h1(|_| ok::<_, Error>(Response::ok())) @@ -543,16 +628,17 @@ async fn alpn_h1() -> io::Result<()> { Some(CUSTOM_ALPN_PROTOCOL.to_vec()) ); - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn alpn_h2() -> io::Result<()> { - let srv = test_server(move || { - let mut config = tls_config(); + let mut srv = test_server(move || { + let mut config = tls_config_h2(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() .h2(|_| ok::<_, Error>(Response::ok())) @@ -572,12 +658,13 @@ async fn alpn_h2() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn alpn_h2_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { let mut config = tls_config(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() @@ -602,5 +689,6 @@ async fn alpn_h2_1() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } diff --git a/actix-http/tests/test_server.rs b/actix-http/tests/test_server.rs index aafcde19a..6cbb680d5 100644 --- a/actix-http/tests/test_server.rs +++ b/actix-http/tests/test_server.rs @@ -10,13 +10,16 @@ use actix_http::{ header, Error, HttpService, KeepAlive, Request, Response, StatusCode, Version, }; use actix_http_test::test_server; -use actix_rt::{net::TcpStream, time::sleep}; +use actix_rt::{ + net::TcpStream, + time::{sleep, timeout}, +}; use actix_service::fn_service; use actix_utils::future::{err, ok, ready}; use bytes::Bytes; use derive_more::{Display, Error}; use futures_util::{stream::once, FutureExt as _, StreamExt as _}; -use rand::Rng as _; +use rand::RngExt as _; use regex::Regex; #[actix_rt::test] @@ -443,6 +446,60 @@ async fn content_length() { srv.stop().await; } +#[actix_rt::test] +async fn content_length_truncated() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut srv = test_server(|| { + HttpService::build() + .h1(|mut req: Request| async move { + let expected_length: usize = req.uri().path()[1..].parse().unwrap(); + let mut payload = req.take_payload(); + + let mut length = 0; + let mut seen_error = false; + while let Some(chunk) = payload.next().await { + match chunk { + Ok(b) => length += b.len(), + Err(_) => { + seen_error = true; + break; + } + } + } + if seen_error { + return Result::<_, Infallible>::Ok(Response::bad_request()); + } + + assert_eq!(length, expected_length, "length must match when no error"); + Result::<_, Infallible>::Ok(Response::ok()) + }) + .tcp() + }) + .await; + + let addr = srv.addr(); + let mut buf = [0; 12]; + + let mut conn = TcpStream::connect(&addr).await.unwrap(); + conn.write_all(b"POST /10000 HTTP/1.1\r\nContent-Length: 10000\r\n\r\ndata_truncated") + .await + .unwrap(); + conn.shutdown().await.unwrap(); + conn.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"HTTP/1.1 400"); + + let mut conn = TcpStream::connect(&addr).await.unwrap(); + conn.write_all(b"POST /4 HTTP/1.1\r\nContent-Length: 4\r\n\r\ndata") + .await + .unwrap(); + conn.shutdown().await.unwrap(); + conn.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"HTTP/1.1 200"); + + srv.stop().await; +} + #[actix_rt::test] async fn h1_headers() { let data = STR.repeat(10); @@ -899,3 +956,68 @@ async fn h2c_auto() { srv.stop().await; } + +#[actix_rt::test] +async fn h2_flow_control_window_sizes() { + let mut srv = test_server(|| { + HttpService::build() + .keep_alive(KeepAlive::Disabled) + .finish(|mut req: Request| async move { + while let Some(item) = req.take_payload().next().await { + item?; + } + + Ok::<_, Error>(Response::ok()) + }) + .tcp_auto_h2c() + }) + .await; + + let tcp = TcpStream::connect(srv.addr()).await.unwrap(); + + let mut builder = h2::client::Builder::new(); + builder.max_send_buffer_size(4 * 1024 * 1024); + + let (h2, connection) = builder.handshake(tcp).await.unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + let mut h2 = h2.ready().await.unwrap(); + + let request = ::http::Request::builder() + .method("POST") + .uri("/") + .body(()) + .unwrap(); + + let (response, mut send) = h2.send_request(request, false).unwrap(); + + // request more than the default 64KiB. if server is advertising larger flow control windows, + // we should get at least 1MiB assigned. + send.reserve_capacity(2 * 1024 * 1024); + + let cap = timeout(Duration::from_secs(2), async { + loop { + let cap = std::future::poll_fn(|cx| send.poll_capacity(cx)) + .await + .expect("request stream closed before flow control capacity became available") + .expect("failed polling flow control capacity"); + + if cap >= 1024 * 1024 { + break cap; + } + } + }) + .await + .expect("timed out waiting for flow control capacity"); + + assert!( + cap >= 1024 * 1024, + "expected >= 1MiB send capacity, got {cap}" + ); + + send.send_data(Bytes::new(), true).unwrap(); + + let res = response.await.unwrap(); + assert!(res.status().is_success()); + + srv.stop().await; +} diff --git a/actix-multipart-derive/CHANGES.md b/actix-multipart-derive/CHANGES.md index d0c759297..a5f30757c 100644 --- a/actix-multipart-derive/CHANGES.md +++ b/actix-multipart-derive/CHANGES.md @@ -2,6 +2,10 @@ ## Unreleased +## 0.8.0 + +- Minimum supported Rust version (MSRV) is now 1.88. + ## 0.7.0 - Minimum supported Rust version (MSRV) is now 1.72. diff --git a/actix-multipart-derive/Cargo.toml b/actix-multipart-derive/Cargo.toml index d4b228020..aee19b388 100644 --- a/actix-multipart-derive/Cargo.toml +++ b/actix-multipart-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-multipart-derive" -version = "0.7.0" +version = "0.8.0" authors = ["Jacob Halsey "] description = "Multipart form derive macro for Actix Web" keywords = ["http", "web", "framework", "async", "futures"] @@ -11,7 +11,6 @@ edition.workspace = true rust-version.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] all-features = true [lib] @@ -25,7 +24,7 @@ quote = "1" syn = "2" [dev-dependencies] -actix-multipart = "0.7" +actix-multipart = "0.8" actix-web = "4" rustversion-msrv = "0.100" trybuild = "1" diff --git a/actix-multipart-derive/README.md b/actix-multipart-derive/README.md index bf75613ed..02a4f94e4 100644 --- a/actix-multipart-derive/README.md +++ b/actix-multipart-derive/README.md @@ -5,11 +5,11 @@ [![crates.io](https://img.shields.io/crates/v/actix-multipart-derive?label=latest)](https://crates.io/crates/actix-multipart-derive) -[![Documentation](https://docs.rs/actix-multipart-derive/badge.svg?version=0.7.0)](https://docs.rs/actix-multipart-derive/0.7.0) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +[![Documentation](https://docs.rs/actix-multipart-derive/badge.svg?version=0.8.0)](https://docs.rs/actix-multipart-derive/0.8.0) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart-derive.svg)
-[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.7.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.7.0) +[![dependency status](https://deps.rs/crate/actix-multipart-derive/0.8.0/status.svg)](https://deps.rs/crate/actix-multipart-derive/0.8.0) [![Download](https://img.shields.io/crates/d/actix-multipart-derive.svg)](https://crates.io/crates/actix-multipart-derive) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) diff --git a/actix-multipart-derive/src/lib.rs b/actix-multipart-derive/src/lib.rs index 4df9b78aa..161a9ba24 100644 --- a/actix-multipart-derive/src/lib.rs +++ b/actix-multipart-derive/src/lib.rs @@ -4,7 +4,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #![allow(clippy::disallowed_names)] // false positives in some macro expansions use std::collections::HashSet; @@ -16,19 +16,14 @@ use proc_macro2::Ident; use quote::quote; use syn::{parse_macro_input, Type}; -#[derive(FromMeta)] +#[derive(Default, FromMeta)] enum DuplicateField { + #[default] Ignore, Deny, Replace, } -impl Default for DuplicateField { - fn default() -> Self { - Self::Ignore - } -} - #[derive(FromDeriveInput, Default)] #[darling(attributes(multipart), default)] struct MultipartFormAttrs { @@ -232,7 +227,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS ::actix_multipart::MultipartError::UnknownField(field.name().unwrap().to_string()) )) } else { - quote!(::std::result::Result::Ok(())) + quote!(::actix_multipart::form::discard_field(field, limits).await) }; // Value for duplicate action @@ -294,7 +289,7 @@ pub fn impl_multipart_form(input: proc_macro::TokenStream) -> proc_macro::TokenS ) -> ::std::pin::Pin<::std::boxed::Box> + 't>> { match field.name().unwrap() { #handle_field_impl - _ => return ::std::boxed::Box::pin(::std::future::ready(#unknown_field_result)), + _ => return ::std::boxed::Box::pin(async move { #unknown_field_result }), } } 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 6633086c0..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,10 +1,10 @@ -error: Could not parse size limit `2 bytes`: couldn't parse "bytes" into a known SI unit, couldn't parse unit of "bytes" +error: Could not parse size limit `2 bytes`: couldn't parse "bytes" into a known SI unit, Failed to parse unit "byt..." --> tests/trybuild/size-limit-parse-fail.rs:6:5 | 6 | description: Text, | ^^^^^^^^^^^ -error: Could not parse size limit `2 megabytes`: couldn't parse "megabytes" into a known SI unit, couldn't parse unit of "megabytes" +error: Could not parse size limit `2 megabytes`: couldn't parse "megabytes" into a known SI unit, Failed to parse unit "meg..." --> tests/trybuild/size-limit-parse-fail.rs:12:5 | 12 | description: Text, diff --git a/actix-multipart/CHANGES.md b/actix-multipart/CHANGES.md index a030fac44..62a78d3d3 100644 --- a/actix-multipart/CHANGES.md +++ b/actix-multipart/CHANGES.md @@ -2,7 +2,18 @@ ## Unreleased -- Minimum supported Rust version (MSRV) is now 1.75. +## 0.8.0 + +- Add multi-field multipart payload builders to `actix_multipart::test`. [#3575] +- Add `MultipartForm` support for `Option>` fields. [#3577] +- Bound internal multipart parser buffering to prevent unbounded memory growth on malformed bodies. + - behavior change notice: There's now a cap for buffering (64KB). It can be changed with `MultipartConfig::buffer_limit`. +- Fix user-triggerable panic when parsing multipart boundaries. +- Minimum supported Rust version (MSRV) is now 1.88. +- Update `rand` dependency to `0.10`. + +[#3577]: https://github.com/actix/actix-web/pull/3577 +[#3575]: https://github.com/actix/actix-web/issues/3575 ## 0.7.2 diff --git a/actix-multipart/Cargo.toml b/actix-multipart/Cargo.toml index 7933e2a02..317abaca4 100644 --- a/actix-multipart/Cargo.toml +++ b/actix-multipart/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-multipart" -version = "0.7.2" +version = "0.8.0" authors = [ "Nikolay Kim ", "Jacob Halsey ", @@ -14,7 +14,6 @@ license.workspace = true edition.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] all-features = true [package.metadata.cargo_check_external_types] @@ -38,7 +37,7 @@ derive = ["actix-multipart-derive"] tempfile = ["dep:tempfile", "tokio/fs"] [dependencies] -actix-multipart-derive = { version = "=0.7.0", optional = true } +actix-multipart-derive = { version = "=0.8.0", optional = true } actix-utils = "3" actix-web = { version = "4", default-features = false } @@ -50,7 +49,7 @@ local-waker = "0.1" log = "0.4" memchr = "2.5" mime = "0.3" -rand = "0.9" +rand = "0.10.1" serde = "1" serde_json = "1" serde_plain = "1" diff --git a/actix-multipart/README.md b/actix-multipart/README.md index ec2e94bd8..faebee04c 100644 --- a/actix-multipart/README.md +++ b/actix-multipart/README.md @@ -3,11 +3,11 @@ [![crates.io](https://img.shields.io/crates/v/actix-multipart?label=latest)](https://crates.io/crates/actix-multipart) -[![Documentation](https://docs.rs/actix-multipart/badge.svg?version=0.7.2)](https://docs.rs/actix-multipart/0.7.2) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +[![Documentation](https://docs.rs/actix-multipart/badge.svg?version=0.8.0)](https://docs.rs/actix-multipart/0.8.0) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-multipart.svg)
-[![dependency status](https://deps.rs/crate/actix-multipart/0.7.2/status.svg)](https://deps.rs/crate/actix-multipart/0.7.2) +[![dependency status](https://deps.rs/crate/actix-multipart/0.8.0/status.svg)](https://deps.rs/crate/actix-multipart/0.8.0) [![Download](https://img.shields.io/crates/d/actix-multipart.svg)](https://crates.io/crates/actix-multipart) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) @@ -24,9 +24,10 @@ Due to additional requirements for `multipart/form-data` requests, the higher le ## Examples ```rust -use actix_web::{post, App, HttpServer, Responder}; - -use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm}; +use actix_multipart::form::{ + json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig, +}; +use actix_web::{middleware::Logger, post, App, HttpServer, Responder}; use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -36,25 +37,37 @@ struct Metadata { #[derive(Debug, MultipartForm)] struct UploadForm { + // Note: the form is also subject to the global limits configured using `MultipartFormConfig`. #[multipart(limit = "100MB")] file: TempFile, json: MpJson, } #[post("/videos")] -pub async fn post_video(MultipartForm(form): MultipartForm) -> impl Responder { +async fn post_video(MultipartForm(form): MultipartForm) -> impl Responder { format!( - "Uploaded file {}, with size: {}", - form.json.name, form.file.size + "Uploaded file {}, with size: {}\ntemporary file ({}) was deleted\n", + form.json.name, + form.file.size, + form.file.file.path().display(), ) } #[actix_web::main] async fn main() -> std::io::Result<()> { - HttpServer::new(move || App::new().service(post_video)) - .bind(("127.0.0.1", 8080))? - .run() - .await + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + HttpServer::new(move || { + App::new() + .service(post_video) + .wrap(Logger::default()) + // Also increase the global total limit to 100MiB. + .app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024)) + }) + .workers(2) + .bind(("127.0.0.1", 8080))? + .run() + .await } ``` @@ -71,4 +84,4 @@ curl -v --request POST \ -[More available in the examples repo →](https://github.com/actix/examples/tree/master/forms/multipart) +[More available in the examples repo →](https://github.com/actix/examples/tree/main/forms/multipart) diff --git a/actix-multipart/examples/form.rs b/actix-multipart/examples/form.rs index a90aeff96..e3fda9a23 100644 --- a/actix-multipart/examples/form.rs +++ b/actix-multipart/examples/form.rs @@ -1,4 +1,6 @@ -use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm}; +use actix_multipart::form::{ + json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig, +}; use actix_web::{middleware::Logger, post, App, HttpServer, Responder}; use serde::Deserialize; @@ -9,6 +11,7 @@ struct Metadata { #[derive(Debug, MultipartForm)] struct UploadForm { + // Note: the form is also subject to the global limits configured using `MultipartFormConfig`. #[multipart(limit = "100MB")] file: TempFile, json: MpJson, @@ -28,9 +31,15 @@ async fn post_video(MultipartForm(form): MultipartForm) -> impl Resp async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - HttpServer::new(move || App::new().service(post_video).wrap(Logger::default())) - .workers(2) - .bind(("127.0.0.1", 8080))? - .run() - .await + HttpServer::new(move || { + App::new() + .service(post_video) + .wrap(Logger::default()) + // Also increase the global total limit to 100MiB. + .app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024)) + }) + .workers(2) + .bind(("127.0.0.1", 8080))? + .run() + .await } diff --git a/actix-multipart/src/form/mod.rs b/actix-multipart/src/form/mod.rs index 693a45e8e..de0eeecaa 100644 --- a/actix-multipart/src/form/mod.rs +++ b/actix-multipart/src/form/mod.rs @@ -82,7 +82,9 @@ where ) -> Self::Future { if state.contains_key(&field.form_field_name) { match duplicate_field { - DuplicateField::Ignore => return Box::pin(ready(Ok(()))), + DuplicateField::Ignore => { + return Box::pin(async move { discard_field(field, limits).await }); + } DuplicateField::Deny => { return Box::pin(ready(Err(MultipartError::DuplicateField( @@ -159,7 +161,9 @@ where ) -> Self::Future { if state.contains_key(&field.form_field_name) { match duplicate_field { - DuplicateField::Ignore => return Box::pin(ready(Ok(()))), + DuplicateField::Ignore => { + return Box::pin(async move { discard_field(field, limits).await }); + } DuplicateField::Deny => { return Box::pin(ready(Err(MultipartError::DuplicateField( @@ -187,6 +191,45 @@ where } } +impl<'t, T> FieldGroupReader<'t> for Option> +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 +316,16 @@ impl Limits { } } +/// Drain a field that will not be retained while still accounting for form limits. +#[doc(hidden)] +pub async fn discard_field(mut field: Field, limits: &mut Limits) -> Result<(), MultipartError> { + while let Some(chunk) = field.try_next().await? { + limits.try_consume_limits(chunk.len(), false)?; + } + + Ok(()) +} + /// Typed `multipart/form-data` extractor. /// /// To extract typed data from a multipart stream, the inner type `T` must implement the @@ -506,6 +559,40 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + /// Test `Option` 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 { @@ -637,6 +724,32 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[actix_rt::test] + async fn test_discarded_fields_count_towards_total_limit() { + let srv = actix_test::start(|| { + App::new() + .route("/unknown", web::post().to(test_upload_limits_memory)) + .route("/duplicate", web::post().to(test_duplicate_ignore_route)) + .app_data( + MultipartFormConfig::default() + .memory_limit(usize::MAX) + .total_limit(20), + ) + }); + + let mut form = multipart::Form::default(); + form.add_text("field", "7 bytes"); + form.add_text("unknown", "this string is 28 bytes long"); + let response = send_form(&srv, form, "/unknown").await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let mut form = multipart::Form::default(); + form.add_text("field", "first_value"); + form.add_text("field", "this string is 28 bytes long"); + let response = send_form(&srv, form, "/duplicate").await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + /// Test the Limits. #[derive(MultipartForm)] struct TestMemoryUploadLimits { diff --git a/actix-multipart/src/lib.rs b/actix-multipart/src/lib.rs index 8eea35f2e..e7830b5e4 100644 --- a/actix-multipart/src/lib.rs +++ b/actix-multipart/src/lib.rs @@ -13,7 +13,7 @@ //! ```no_run //! use actix_web::{post, App, HttpServer, Responder}; //! -//! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm}; +//! use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm, MultipartFormConfig}; //! use serde::Deserialize; //! //! #[derive(Debug, Deserialize)] @@ -23,6 +23,7 @@ //! //! #[derive(Debug, MultipartForm)] //! struct UploadForm { +//! // Note: the form is also subject to the global limits configured using `MultipartFormConfig`. //! #[multipart(limit = "100MB")] //! file: TempFile, //! json: MpJson, @@ -38,10 +39,15 @@ //! //! #[actix_web::main] //! async fn main() -> std::io::Result<()> { -//! HttpServer::new(move || App::new().service(post_video)) -//! .bind(("127.0.0.1", 8080))? -//! .run() -//! .await +//! HttpServer::new(move || { +//! App::new() +//! .service(post_video) +//! // Also increase the global total limit to 100MiB. +//! .app_data(MultipartFormConfig::default().total_limit(100 * 1024 * 1024)) +//! }) +//! .bind(("127.0.0.1", 8080))? +//! .run() +//! .await //! } //! ``` //! @@ -58,7 +64,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] // This allows us to use the actix_multipart_derive within this crate's tests #[cfg(test)] @@ -76,5 +82,5 @@ pub mod test; pub use self::{ error::Error as MultipartError, field::{Field, LimitExceeded}, - multipart::Multipart, + multipart::{Multipart, MultipartConfig}, }; diff --git a/actix-multipart/src/multipart.rs b/actix-multipart/src/multipart.rs index e38fbde9e..bde7d122f 100644 --- a/actix-multipart/src/multipart.rs +++ b/actix-multipart/src/multipart.rs @@ -11,7 +11,7 @@ use actix_web::{ dev, error::{ParseError, PayloadError}, http::header::{self, ContentDisposition, HeaderMap, HeaderName, HeaderValue}, - web::Bytes, + web::{self, Bytes}, HttpRequest, }; use futures_core::stream::Stream; @@ -20,7 +20,7 @@ use mime::Mime; use crate::{ error::Error, field::InnerField, - payload::{PayloadBuffer, PayloadRef}, + payload::{PayloadBuffer, PayloadRef, DEFAULT_BUFFER_LIMIT}, safety::Safety, Field, }; @@ -44,6 +44,46 @@ enum Flow { Error(Option), } +/// [`Multipart`] extractor configuration. +/// +/// Add to your app data to have it picked up by [`Multipart`] extractors. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct MultipartConfig { + buffer_limit: usize, +} + +impl MultipartConfig { + /// Creates a default multipart extractor configuration. + pub fn new() -> Self { + DEFAULT_CONFIG + } + + /// Sets maximum internal parser buffer size. By default this limit is 64 KiB. + pub fn buffer_limit(mut self, buffer_limit: usize) -> Self { + self.buffer_limit = buffer_limit; + self + } + + /// Extracts multipart config from app data. Check both `T` and `Data`, in that order, and + /// fall back to the default multipart config. + fn from_req(req: &HttpRequest) -> &Self { + req.app_data::() + .or_else(|| req.app_data::>().map(|d| d.as_ref())) + .unwrap_or(&DEFAULT_CONFIG) + } +} + +static DEFAULT_CONFIG: MultipartConfig = MultipartConfig { + buffer_limit: DEFAULT_BUFFER_LIMIT, +}; + +impl Default for MultipartConfig { + fn default() -> Self { + Self::new() + } +} + impl Multipart { /// Creates multipart instance from parts. pub fn new(headers: &HeaderMap, stream: S) -> Self @@ -58,8 +98,15 @@ impl Multipart { /// Creates multipart instance from parts. pub(crate) fn from_req(req: &HttpRequest, payload: &mut dev::Payload) -> Self { + let config = MultipartConfig::from_req(req); + match Self::find_ct_and_boundary(req.headers()) { - Ok((ct, boundary)) => Self::from_ct_and_boundary(ct, boundary, payload.take()), + Ok((ct, boundary)) => Self::from_ct_and_boundary_with_buffer_limit( + ct, + boundary, + payload.take(), + config.buffer_limit, + ), Err(err) => Self::from_error(err), } } @@ -84,18 +131,39 @@ impl Multipart { .as_str() .to_owned(); + if boundary.is_empty() { + return Err(Error::BoundaryMissing); + } + Ok((content_type, boundary)) } /// Constructs a new multipart reader from given Content-Type, boundary, and stream. pub(crate) fn from_ct_and_boundary(ct: Mime, boundary: String, stream: S) -> Multipart + where + S: Stream> + 'static, + { + Self::from_ct_and_boundary_with_buffer_limit( + ct, + boundary, + stream, + DEFAULT_CONFIG.buffer_limit, + ) + } + + fn from_ct_and_boundary_with_buffer_limit( + ct: Mime, + boundary: String, + stream: S, + buffer_limit: usize, + ) -> Multipart where S: Stream> + 'static, { Multipart { safety: Safety::new(), flow: Flow::InFlight(Inner { - payload: PayloadRef::new(PayloadBuffer::new(stream)), + payload: PayloadRef::new(PayloadBuffer::new_with_limit(stream, buffer_limit)), content_type: ct, boundary, state: State::FirstBoundary, @@ -239,6 +307,10 @@ impl Inner { /// - `Ok(None)` - boundary not found, more data needs reading /// - `Err(BoundaryMissing)` - multipart boundary is missing fn read_boundary(payload: &mut PayloadBuffer, boundary: &str) -> Result, Error> { + if boundary.is_empty() { + return Err(Error::BoundaryMissing); + } + // TODO: need to read epilogue let chunk = match payload.readline_or_eof()? { // TODO: this might be okay as a let Some() else return Ok(None) @@ -249,34 +321,21 @@ impl Inner { const BOUNDARY_MARKER: &[u8] = b"--"; const LINE_BREAK: &[u8] = b"\r\n"; - let boundary_len = boundary.len(); - - if chunk.len() < boundary_len + 2 + 2 - || !chunk.starts_with(BOUNDARY_MARKER) - || &chunk[2..boundary_len + 2] != boundary.as_bytes() - { + let Some(chunk) = chunk.as_ref().strip_prefix(BOUNDARY_MARKER) else { return Err(Error::BoundaryMissing); - } + }; - // chunk facts: - // - long enough to contain boundary + 2 markers or 1 marker and line-break - // - starts with boundary marker - // - chunk contains correct boundary + let Some(chunk) = chunk.strip_prefix(boundary.as_bytes()) else { + return Err(Error::BoundaryMissing); + }; - if &chunk[boundary_len + 2..] == LINE_BREAK { + if chunk == LINE_BREAK { // boundary is followed by line-break, indicating more fields to come return Ok(Some(false)); } // boundary is followed by marker - if &chunk[boundary_len + 2..boundary_len + 4] == BOUNDARY_MARKER - && ( - // chunk is exactly boundary len + 2 markers - chunk.len() == boundary_len + 2 + 2 - // final boundary is allowed to end with a line-break - || &chunk[boundary_len + 4..] == LINE_BREAK - ) - { + if chunk == BOUNDARY_MARKER || chunk == b"--\r\n" { return Ok(Some(true)); } @@ -287,7 +346,12 @@ impl Inner { payload: &mut PayloadBuffer, boundary: &str, ) -> Result, Error> { + if boundary.is_empty() { + return Err(Error::BoundaryMissing); + } + let mut eof = false; + let boundary = boundary.as_bytes(); loop { match payload.readline()? { @@ -295,19 +359,17 @@ impl Inner { if chunk.is_empty() { return Err(Error::BoundaryMissing); } - if chunk.len() < boundary.len() { + + let Some(line) = chunk.as_ref().strip_suffix(b"\r\n") else { continue; - } - if &chunk[..2] == b"--" && &chunk[2..chunk.len() - 2] == boundary.as_bytes() { - break; - } else { - if chunk.len() < boundary.len() + 2 { - continue; + }; + + if let Some(line) = line.strip_prefix(b"--") { + if line == boundary { + break; } - let b: &[u8] = boundary.as_ref(); - if &chunk[..boundary.len()] == b - && &chunk[boundary.len()..boundary.len() + 2] == b"--" - { + + if line.strip_suffix(b"--") == Some(boundary) { eof = true; break; } @@ -589,11 +651,226 @@ mod tests { (bytes, headers) } + fn create_header(content_type: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static(content_type), + ); + headers + } + + fn create_multipart_with_buffer_limit( + body: impl Stream> + 'static, + buffer_limit: usize, + ) -> Multipart { + Multipart::from_ct_and_boundary_with_buffer_limit( + "multipart/mixed; boundary=\"a\"".parse().unwrap(), + "a".to_owned(), + body, + buffer_limit, + ) + } + + #[actix_rt::test] + async fn empty_boundary_does_not_panic() { + let payload = stream::once(async { Ok(Bytes::from_static(b"\n")) }); + let ct = "multipart/mixed; boundary=\"a\"".parse().unwrap(); + + let mut multipart = Multipart::from_ct_and_boundary(ct, String::new(), payload); + let res = multipart.next().await.unwrap(); + assert_matches!(res, Err(Error::BoundaryMissing)); + } + + #[actix_rt::test] + async fn short_line_with_one_byte_boundary_does_not_panic() { + let bytes = Bytes::from_static(b"\n"); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("multipart/mixed; boundary=\"a\""), + ); + let payload = stream::once(async { Ok(bytes) }); + + let mut multipart = Multipart::new(&headers, payload); + let res = multipart.next().await.unwrap(); + assert_matches!(res, Err(Error::Incomplete)); + } + + #[actix_rt::test] + async fn short_final_boundary_with_one_byte_boundary_does_not_panic() { + let bytes = Bytes::from_static(b"--\n"); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("multipart/mixed; boundary=\"a\""), + ); + let payload = stream::once(async { Ok(bytes) }); + + let mut multipart = Multipart::new(&headers, payload); + let res = multipart.next().await.unwrap(); + assert_matches!(res, Err(Error::Incomplete)); + } + + #[actix_rt::test] + async fn one_byte_boundary_parses_valid_body() { + let bytes = Bytes::from_static( + b"preamble\r\n\ + --a\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: 3\r\n\ + \r\n\ + one\r\n\ + --a\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: 3\r\n\ + \r\n\ + two\r\n\ + --a--\r\n", + ); + let headers = create_header("multipart/mixed; boundary=\"a\""); + let payload = stream::once(async { Ok(bytes) }); + + let mut multipart = Multipart::new(&headers, payload); + + let mut field = multipart.next().await.unwrap().unwrap(); + assert_eq!(get_whole_field(&mut field).await, "one"); + drop(field); + + let mut field = multipart.next().await.unwrap().unwrap(); + assert_eq!(get_whole_field(&mut field).await, "two"); + drop(field); + + assert!(multipart.next().await.is_none()); + } + + #[actix_rt::test] + async fn one_byte_boundary_parses_when_split_across_chunks() { + let bytes = Bytes::from_static( + b"x\r\n\ + --a\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: 4\r\n\ + \r\n\ + data\r\n\ + --a--\r\n", + ); + let headers = create_header("multipart/mixed; boundary=\"a\""); + let payload = stream::iter(bytes) + .map(|byte| Ok(Bytes::copy_from_slice(&[byte]))) + .interleave_pending(); + + let mut multipart = Multipart::new(&headers, payload); + + let mut field = multipart.next().await.unwrap().unwrap(); + assert_eq!(get_whole_field(&mut field).await, "data"); + drop(field); + + assert!(multipart.next().await.is_none()); + } + + #[actix_rt::test] + async fn short_preamble_lines_before_boundary_are_skipped() { + let bytes = Bytes::from_static( + b"\n\ + -\r\n\ + --a\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: 4\r\n\ + \r\n\ + data\r\n\ + --a--\r\n", + ); + let headers = create_header("multipart/mixed; boundary=\"a\""); + let payload = stream::once(async { Ok(bytes) }); + + let mut multipart = Multipart::new(&headers, payload); + + let mut field = multipart.next().await.unwrap().unwrap(); + assert_eq!(get_whole_field(&mut field).await, "data"); + drop(field); + + assert!(multipart.next().await.is_none()); + } + + #[actix_rt::test] + async fn first_boundary_can_be_final() { + let bytes = Bytes::from_static(b"--a--\r\n"); + let headers = create_header("multipart/mixed; boundary=\"a\""); + let payload = stream::once(async { Ok(bytes) }); + + let mut multipart = Multipart::new(&headers, payload); + assert!(multipart.next().await.is_none()); + } + + #[actix_rt::test] + async fn malformed_preamble_over_buffer_limit_errors() { + let body = stream::iter( + [b"aaaaaaaa", b"bbbbbbbb", b"cccccccc"].map(|chunk| Ok(Bytes::from_static(chunk))), + ); + + let mut multipart = create_multipart_with_buffer_limit(body, 16); + let res = multipart.next().await.unwrap(); + + assert_matches!(res, Err(Error::Payload(PayloadError::Overflow))); + } + + #[actix_rt::test] + async fn malformed_headers_over_buffer_limit_errors() { + let body = stream::iter( + [ + Bytes::from_static(b"--a\r\n"), + Bytes::from_static(b"X-Long: 12345678"), + Bytes::from_static(b"9012345678901234"), + Bytes::from_static(b"5678901234567890"), + ] + .map(Ok), + ); + + let mut multipart = create_multipart_with_buffer_limit(body, 24); + let res = multipart.next().await.unwrap(); + + assert_matches!(res, Err(Error::Payload(PayloadError::Overflow))); + } + + #[actix_rt::test] + async fn raw_extractor_uses_configured_buffer_limit() { + let (req, mut payload) = TestRequest::default() + .insert_header((header::CONTENT_TYPE, "multipart/mixed; boundary=\"a\"")) + .app_data(MultipartConfig::default().buffer_limit(16)) + .set_payload(Bytes::from_static(b"aaaaaaaabbbbbbbbcccccccc")) + .to_http_parts(); + + let mut multipart = Multipart::from_request(&req, &mut payload).await.unwrap(); + let res = multipart.next().await.unwrap(); + + assert_matches!(res, Err(Error::Payload(PayloadError::Overflow))); + } + + #[actix_rt::test] + async fn valid_large_field_streams_through_small_parser_buffer() { + let mut bytes = BytesMut::new(); + bytes.put(&b"--a\r\nContent-Length: 100\r\n\r\n"[..]); + bytes.put(&[b'x'; 100][..]); + bytes.put(&b"\r\n--a--\r\n"[..]); + let body = stream::once(async { Ok(bytes.freeze()) }); + + let mut multipart = create_multipart_with_buffer_limit(body, 32); + let mut field = multipart.next().await.unwrap().unwrap(); + + assert_eq!( + get_whole_field(&mut field).await, + Bytes::from(vec![b'x'; 100]) + ); + drop(field); + assert!(multipart.next().await.is_none()); + } + #[actix_rt::test] async fn test_multipart_no_end_crlf() { let (sender, payload) = create_stream(); let (mut bytes, headers) = create_double_request_with_header(); - let bytes_stripped = bytes.split_to(bytes.len()); // strip crlf + let bytes_stripped = bytes.split_to(bytes.len() - 2); // strip crlf sender.send(Ok(bytes_stripped)).unwrap(); drop(sender); // eof diff --git a/actix-multipart/src/payload.rs b/actix-multipart/src/payload.rs index 858634bc0..4c9929aed 100644 --- a/actix-multipart/src/payload.rs +++ b/actix-multipart/src/payload.rs @@ -14,6 +14,9 @@ use futures_core::stream::{LocalBoxStream, Stream}; use crate::{error::Error, safety::Safety}; +pub(crate) const DEFAULT_BUFFER_LIMIT: usize = 65_536; // 64 KiB +const MAX_READY_CHUNKS_PER_POLL: usize = 16; + pub(crate) struct PayloadRef { payload: Rc>, } @@ -45,31 +48,64 @@ impl Clone for PayloadRef { /// Payload buffer. pub(crate) struct PayloadBuffer { pub(crate) stream: LocalBoxStream<'static, Result>, + pending: Option, pub(crate) buf: BytesMut, + buffer_limit: usize, /// EOF flag. If true, no more payload reads will be attempted. pub(crate) eof: bool, } impl PayloadBuffer { /// Constructs new payload buffer. - pub(crate) fn new(stream: S) -> Self + pub(crate) fn new_with_limit(stream: S, buffer_limit: usize) -> Self where S: Stream> + 'static, { PayloadBuffer { stream: Box::pin(stream), + pending: None, buf: BytesMut::with_capacity(1_024), // pre-allocate 1KiB + buffer_limit, eof: false, } } + /// Polls a bounded amount of payload into the parser buffer. + /// + /// This does not drain the stream to EOF in one call. Callers must be prepared to poll again + /// after consuming buffered data. pub(crate) fn poll_stream(&mut self, cx: &mut Context<'_>) -> Result<(), PayloadError> { - loop { + if self.buffer_limit == 0 { + return Err(PayloadError::Overflow); + } + + let mut appended = false; + + for _ in 0..MAX_READY_CHUNKS_PER_POLL { + if self.pending.is_some() { + appended |= self.append_pending()?; + + if self.pending.is_some() || self.buf.len() >= self.buffer_limit { + if appended { + cx.waker().wake_by_ref(); + } + return Ok(()); + } + + continue; + } + match Pin::new(&mut self.stream).poll_next(cx) { Poll::Ready(Some(Ok(data))) => { - self.buf.extend_from_slice(&data); - // try to read more data - continue; + self.pending = Some(data); + appended |= self.append_pending()?; + + if self.pending.is_some() || self.buf.len() >= self.buffer_limit { + if appended { + cx.waker().wake_by_ref(); + } + return Ok(()); + } } Poll::Ready(Some(Err(err))) => return Err(err), Poll::Ready(None) => { @@ -79,6 +115,40 @@ impl PayloadBuffer { Poll::Pending => return Ok(()), } } + + if appended { + cx.waker().wake_by_ref(); + } + + Ok(()) + } + + fn append_pending(&mut self) -> Result { + let Some(mut data) = self.pending.take() else { + return Ok(false); + }; + + if data.is_empty() { + return Ok(false); + } + + if self.buf.len() >= self.buffer_limit { + self.pending = Some(data); + return Err(PayloadError::Overflow); + } + + let available = self.buffer_limit - self.buf.len(); + let len = cmp::min(data.len(), available); + + if len == data.len() { + self.buf.extend_from_slice(&data); + } else { + let chunk = data.split_to(len); + self.buf.extend_from_slice(&chunk); + self.pending = Some(data); + } + + Ok(len != 0) } /// Reads exact number of bytes. @@ -162,7 +232,7 @@ mod tests { #[actix_rt::test] async fn basic() { let (_, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); assert_eq!(payload.buf.len(), 0); lazy(|cx| payload.poll_stream(cx)).await.unwrap(); @@ -172,7 +242,7 @@ mod tests { #[actix_rt::test] async fn eof() { let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); assert_eq!(None, payload.read_max(4).unwrap()); sender.feed_data(Bytes::from("data")); @@ -181,6 +251,8 @@ mod tests { assert_eq!(Some(Bytes::from("data")), payload.read_max(4).unwrap()); assert_eq!(payload.buf.len(), 0); + + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); assert!(payload.read_max(1).is_err()); assert!(payload.eof); } @@ -188,7 +260,7 @@ mod tests { #[actix_rt::test] async fn err() { let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); assert_eq!(None, payload.read_max(1).unwrap()); sender.set_error(PayloadError::Incomplete(None)); lazy(|cx| payload.poll_stream(cx)).await.err().unwrap(); @@ -197,11 +269,12 @@ mod tests { #[actix_rt::test] async fn read_max() { let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); sender.feed_data(Bytes::from("line1")); sender.feed_data(Bytes::from("line2")); lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); assert_eq!(payload.buf.len(), 10); assert_eq!(Some(Bytes::from("line1")), payload.read_max(5).unwrap()); @@ -214,13 +287,14 @@ mod tests { #[actix_rt::test] async fn read_exactly() { let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); assert_eq!(None, payload.read_exact(2)); sender.feed_data(Bytes::from("line1")); sender.feed_data(Bytes::from("line2")); lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); assert_eq!(Some(Bytes::from_static(b"li")), payload.read_exact(2)); assert_eq!(payload.buf.len(), 8); @@ -232,13 +306,14 @@ mod tests { #[actix_rt::test] async fn read_until() { let (mut sender, payload) = h1::Payload::create(false); - let mut payload = PayloadBuffer::new(payload); + let mut payload = PayloadBuffer::new_with_limit(payload, DEFAULT_BUFFER_LIMIT); assert_eq!(None, payload.read_until(b"ne").unwrap()); sender.feed_data(Bytes::from("line1")); sender.feed_data(Bytes::from("line2")); lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); assert_eq!( Some(Bytes::from("line")), @@ -252,4 +327,38 @@ mod tests { ); assert_eq!(payload.buf.len(), 0); } + + #[actix_rt::test] + async fn poll_stream_does_not_exceed_buffer_limit() { + let stream = futures_util::stream::iter([ + Ok(Bytes::from_static(b"12345678")), + Ok(Bytes::from_static(b"abcdefgh")), + Ok(Bytes::from_static(b"overflow")), + ]); + let mut payload = PayloadBuffer::new_with_limit(stream, 16); + + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + assert_eq!(payload.buf.len(), 16); + + let err = lazy(|cx| payload.poll_stream(cx)).await.unwrap_err(); + assert!(matches!(err, PayloadError::Overflow)); + assert_eq!(payload.buf.len(), 16); + } + + #[actix_rt::test] + async fn oversized_chunk_can_be_consumed_incrementally() { + let stream = futures_util::stream::once(async { Ok(Bytes::from_static(b"12345678")) }); + let mut payload = PayloadBuffer::new_with_limit(stream, 4); + + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + assert_eq!(payload.buf, Bytes::from_static(b"1234")); + assert_eq!(payload.read_max(4).unwrap().unwrap(), "1234"); + + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + assert_eq!(payload.buf, Bytes::from_static(b"5678")); + assert_eq!(payload.read_max(4).unwrap().unwrap(), "5678"); + + lazy(|cx| payload.poll_stream(cx)).await.unwrap(); + assert!(payload.eof); + } } diff --git a/actix-multipart/src/test.rs b/actix-multipart/src/test.rs index b0e907266..56d62ac6e 100644 --- a/actix-multipart/src/test.rs +++ b/actix-multipart/src/test.rs @@ -1,5 +1,7 @@ //! Multipart testing utilities. +use std::borrow::Cow; + use actix_web::{ http::header::{self, HeaderMap}, web::{BufMut as _, Bytes, BytesMut}, @@ -12,6 +14,38 @@ const CRLF_CRLF: &[u8] = b"\r\n\r\n"; const HYPHENS: &[u8] = b"--"; const BOUNDARY_PREFIX: &str = "------------------------"; +/// Multipart form field for test payload generation. +pub struct TestFormField<'a> { + name: Cow<'a, str>, + filename: Option>, + content_type: Option, + data: Bytes, +} + +impl<'a> TestFormField<'a> { + /// Creates a multipart form field from bytes. + pub fn new(name: impl Into>, data: impl Into) -> Self { + Self { + name: name.into(), + filename: None, + content_type: None, + data: data.into(), + } + } + + /// Sets the field's file name metadata. + pub fn filename(mut self, filename: impl Into>) -> Self { + self.filename = Some(filename.into()); + self + } + + /// Sets the field's content type metadata. + pub fn content_type(mut self, content_type: Mime) -> Self { + self.content_type = Some(content_type); + self + } +} + /// Constructs a `multipart/form-data` payload from bytes and metadata. /// /// Returned header map can be extended or merged with existing headers. @@ -61,15 +95,17 @@ pub fn create_form_data_payload_and_headers( content_type: Option, file: Bytes, ) -> (Bytes, HeaderMap) { - let boundary = Alphanumeric.sample_string(&mut rand::rng(), 32); + let mut field = TestFormField::new(name, file); - create_form_data_payload_and_headers_with_boundary( - &boundary, - name, - filename, - content_type, - file, - ) + if let Some(filename) = filename { + field = field.filename(filename); + } + + if let Some(content_type) = content_type { + field = field.content_type(content_type); + } + + create_form_data_payload_and_headers_from_fields([field]) } /// Constructs a `multipart/form-data` payload from bytes and metadata with a fixed boundary. @@ -82,32 +118,101 @@ pub fn create_form_data_payload_and_headers_with_boundary( content_type: Option, file: Bytes, ) -> (Bytes, HeaderMap) { - let mut buf = BytesMut::with_capacity(file.len() + 128); + let mut field = TestFormField::new(name, file); + + if let Some(filename) = filename { + field = field.filename(filename); + } + + if let Some(content_type) = content_type { + field = field.content_type(content_type); + } + + create_form_data_payload_and_headers_from_fields_with_boundary(boundary, [field]) +} + +/// Constructs a `multipart/form-data` payload from multiple fields. +/// +/// Returned header map can be extended or merged with existing headers. +/// +/// Multipart boundary used is a random alphanumeric string. +/// +/// # Examples +/// +/// ``` +/// use actix_multipart::test::{ +/// create_form_data_payload_and_headers_from_fields, TestFormField, +/// }; +/// use actix_web::{test::TestRequest, web::Bytes}; +/// use memchr::memmem::find_iter; +/// +/// let (body, headers) = create_form_data_payload_and_headers_from_fields([ +/// TestFormField::new("title", Bytes::from_static(b"Multipart support")), +/// TestFormField::new("tags", Bytes::from_static(b"tests")), +/// TestFormField::new("tags", Bytes::from_static(b"actix")), +/// ]); +/// +/// assert_eq!(find_iter(&body, b"name=\"tags\"").count(), 2); +/// +/// let req = headers +/// .into_iter() +/// .fold(TestRequest::post(), |req, hdr| req.insert_header(hdr)) +/// .set_payload(body) +/// .to_http_request(); +/// +/// assert!(req.headers().contains_key("content-type")); +/// ``` +pub fn create_form_data_payload_and_headers_from_fields<'a>( + fields: impl IntoIterator>, +) -> (Bytes, HeaderMap) { + let boundary = Alphanumeric.sample_string(&mut rand::rng(), 32); + + create_form_data_payload_and_headers_from_fields_with_boundary(&boundary, fields) +} + +/// Constructs a `multipart/form-data` payload from multiple fields with a fixed boundary. +// FIXME: terrible naming, but this is needed for compat with the current naming. +// Maybe we can rename the func here in a next major version. +pub fn create_form_data_payload_and_headers_from_fields_with_boundary<'a>( + boundary: &str, + fields: impl IntoIterator>, +) -> (Bytes, HeaderMap) { + let fields = fields.into_iter().collect::>(); + let mut buf = BytesMut::with_capacity(fields.iter().map(|field| field.data.len() + 128).sum()); let boundary_str = [BOUNDARY_PREFIX, boundary].concat(); let boundary = boundary_str.as_bytes(); - buf.put(HYPHENS); - buf.put(boundary); - buf.put(CRLF); + for field in fields { + let TestFormField { + name, + filename, + content_type, + data, + } = field; - buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes()); - if let Some(filename) = filename { - buf.put(format!("; filename=\"{filename}\"").as_bytes()); - } - buf.put(CRLF); + buf.put(HYPHENS); + buf.put(boundary); + buf.put(CRLF); - if let Some(ct) = content_type { - buf.put(format!("Content-Type: {ct}").as_bytes()); + buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes()); + if let Some(filename) = filename { + buf.put(format!("; filename=\"{filename}\"").as_bytes()); + } + buf.put(CRLF); + + if let Some(ct) = content_type { + buf.put(format!("Content-Type: {ct}").as_bytes()); + buf.put(CRLF); + } + + buf.put(format!("Content-Length: {}", data.len()).as_bytes()); + buf.put(CRLF_CRLF); + + buf.put(data); buf.put(CRLF); } - buf.put(format!("Content-Length: {}", file.len()).as_bytes()); - buf.put(CRLF_CRLF); - - buf.put(file); - buf.put(CRLF); - buf.put(HYPHENS); buf.put(boundary); buf.put(HYPHENS); @@ -128,9 +233,15 @@ pub fn create_form_data_payload_and_headers_with_boundary( mod tests { use std::convert::Infallible; + use actix_web::{ + http::StatusCode, + test::{call_service, init_service, TestRequest}, + web, App, HttpResponse, Responder, + }; use futures_util::stream; use super::*; + use crate::form::{text::Text, MultipartForm}; fn find_boundary(headers: &HeaderMap) -> String { headers @@ -191,6 +302,30 @@ mod tests { sit ame.\r\n\ --------------------------qWeRtYuIoP--\r\n", ); + + let (pl, _headers) = create_form_data_payload_and_headers_from_fields_with_boundary( + "qWeRtYuIoP", + [ + TestFormField::new("foo", Bytes::from_static(b"Lorem ipsum dolor\nsit ame.")), + TestFormField::new("bar", Bytes::from_static(b"dolor sit")), + ], + ); + + assert_eq!( + std::str::from_utf8(&pl).unwrap(), + "--------------------------qWeRtYuIoP\r\n\ + Content-Disposition: form-data; name=\"foo\"\r\n\ + Content-Length: 26\r\n\ + \r\n\ + Lorem ipsum dolor\n\ + sit ame.\r\n\ + --------------------------qWeRtYuIoP\r\n\ + Content-Disposition: form-data; name=\"bar\"\r\n\ + Content-Length: 9\r\n\ + \r\n\ + dolor sit\r\n\ + --------------------------qWeRtYuIoP--\r\n", + ); } /// Test using an external library to prevent the two-wrongs-make-a-right class of errors. @@ -214,4 +349,50 @@ mod tests { assert_eq!(field.content_type(), None); assert!(field.bytes().await.unwrap().starts_with(b"Lorem")); } + + #[derive(MultipartForm)] + struct TestMultipartRequestForm { + title: Text, + tags: Vec>, + } + + async fn multipart_test_request_route( + form: MultipartForm, + ) -> impl Responder { + let form = form.into_inner(); + + assert_eq!(form.title.into_inner(), "Multipart support"); + assert_eq!( + form.tags + .into_iter() + .map(Text::into_inner) + .collect::>(), + vec!["tests", "actix"], + ); + + HttpResponse::Ok().finish() + } + + #[actix_web::test] + async fn test_request_compat() { + let app = + init_service(App::new().route("/", web::post().to(multipart_test_request_route))).await; + + let (body, headers) = create_form_data_payload_and_headers_from_fields([ + TestFormField::new("title", Bytes::from_static(b"Multipart support")), + TestFormField::new("tags", Bytes::from_static(b"tests")), + TestFormField::new("tags", Bytes::from_static(b"actix")), + ]); + + let req = headers + .into_iter() + .fold(TestRequest::post().uri("/"), |req, header| { + req.insert_header(header) + }) + .set_payload(body) + .to_request(); + + let res = call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + } } diff --git a/actix-router/CHANGES.md b/actix-router/CHANGES.md index 6305b45c3..355f13fc8 100644 --- a/actix-router/CHANGES.md +++ b/actix-router/CHANGES.md @@ -2,6 +2,19 @@ ## Unreleased +- Add support for extracting multi-component path params into a sequence (Vec, tuple, ...). [#3432] + +[#3432]: https://github.com/actix/actix-web/pull/3432 + +## 0.5.4 + +- Minimum supported Rust version (MSRV) is now 1.88. +- Support `deserialize_any` in `PathDeserializer` (enables derived `#[serde(untagged)]` enums in path segments). [#2881] +- Fix stale path segment indices after path rewrites, preventing out-of-bounds access during extraction. [#3562] + +[#2881]: https://github.com/actix/actix-web/pull/2881 +[#3562]: https://github.com/actix/actix-web/issues/3562 + ## 0.5.3 - Add `unicode` crate feature (on-by-default) to switch between `regex` and `regex-lite` as a trade-off between full unicode support and binary size. diff --git a/actix-router/Cargo.toml b/actix-router/Cargo.toml index ba801188a..38be79944 100644 --- a/actix-router/Cargo.toml +++ b/actix-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-router" -version = "0.5.3" +version = "0.5.4" authors = [ "Nikolay Kim ", "Ali MJ Al-Nasrawy ", diff --git a/actix-router/README.md b/actix-router/README.md index 12d1b0146..20444904c 100644 --- a/actix-router/README.md +++ b/actix-router/README.md @@ -4,7 +4,7 @@ [![crates.io](https://img.shields.io/crates/v/actix-router?label=latest)](https://crates.io/crates/actix-router) [![Documentation](https://docs.rs/actix-router/badge.svg?version=0.5.3)](https://docs.rs/actix-router/0.5.3) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-router.svg)
[![dependency status](https://deps.rs/crate/actix-router/0.5.3/status.svg)](https://deps.rs/crate/actix-router/0.5.3) 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 2f50619f8..d255704fe 100644 --- a/actix-router/src/de.rs +++ b/actix-router/src/de.rs @@ -27,6 +27,9 @@ macro_rules! unsupported_type { macro_rules! parse_single_value { ($trait_fn:ident) => { + parse_single_value!($trait_fn, $trait_fn); + }; + ($trait_fn:ident, $visit_fn:ident) => { fn $trait_fn(self, visitor: V) -> Result where V: Visitor<'de>, @@ -43,7 +46,7 @@ macro_rules! parse_single_value { Value { value: &self.path[0], } - .$trait_fn(visitor) + .$visit_fn(visitor) } } }; @@ -205,11 +208,11 @@ impl<'de, T: ResourcePath + 'de> Deserializer<'de> for PathDeserializer<'de, T> }) } - unsupported_type!(deserialize_any, "'any'"); unsupported_type!(deserialize_option, "Option"); unsupported_type!(deserialize_identifier, "identifier"); unsupported_type!(deserialize_ignored_any, "ignored_any"); + parse_single_value!(deserialize_any); parse_single_value!(deserialize_bool); parse_single_value!(deserialize_i8); parse_single_value!(deserialize_i16); @@ -396,11 +399,25 @@ impl<'de> Deserializer<'de> for Value<'de> { visitor.visit_newtype_struct(self) } - fn deserialize_tuple(self, _: usize, _: V) -> Result + fn deserialize_tuple(self, len: usize, visitor: V) -> Result where V: Visitor<'de>, { - Err(de::value::Error::custom("unsupported type: tuple")) + let value_seq = ValueSeq::new(self.value); + if len == value_seq.len() { + visitor.visit_seq(value_seq) + } else { + Err(de::value::Error::custom( + "path and tuple lengths don't match", + )) + } + } + + fn deserialize_seq(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + visitor.visit_seq(ValueSeq::new(self.value)) } fn deserialize_struct( @@ -418,17 +435,48 @@ impl<'de> Deserializer<'de> for Value<'de> { fn deserialize_tuple_struct( self, _: &'static str, - _: usize, - _: V, + len: usize, + visitor: V, ) -> Result where V: Visitor<'de>, { - Err(de::value::Error::custom("unsupported type: tuple struct")) + self.deserialize_tuple(len, visitor) + } + + fn deserialize_any(self, visitor: V) -> Result + where + V: Visitor<'de>, + { + let decoded = FULL_QUOTER + .with(|q| q.requote_str_lossy(self.value)) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(self.value)); + + let s = decoded.as_ref(); + // We have to do it manually here on behalf of serde. + if let Ok(v) = s.parse::() { + if let Ok(v) = u32::try_from(v) { + return visitor.visit_u32(v); + } + + return visitor.visit_u64(v); + } + + if let Ok(v) = s.parse::() { + if let Ok(v) = i32::try_from(v) { + return visitor.visit_i32(v); + } + + return visitor.visit_i64(v); + } + + match decoded { + Cow::Borrowed(value) => visitor.visit_borrowed_str(value), + Cow::Owned(value) => visitor.visit_string(value), + } } - unsupported_type!(deserialize_any, "any"); - unsupported_type!(deserialize_seq, "seq"); unsupported_type!(deserialize_map, "map"); unsupported_type!(deserialize_identifier, "identifier"); } @@ -498,6 +546,43 @@ impl<'de> de::VariantAccess<'de> for UnitVariant { } } +struct ValueSeq<'de> { + elems: std::str::Split<'de, char>, +} + +impl<'de> ValueSeq<'de> { + fn new(value: &'de str) -> Self { + Self { + elems: value.split('/'), + } + } + + fn len(&self) -> usize { + self.elems.clone().filter(|s| !s.is_empty()).count() + } +} + +impl<'de> de::SeqAccess<'de> for ValueSeq<'de> { + type Error = de::value::Error; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: de::DeserializeSeed<'de>, + { + for elem in &mut self.elems { + if !elem.is_empty() { + return seed.deserialize(Value { value: elem }).map(Some); + } + } + + Ok(None) + } + + fn size_hint(&self) -> Option { + Some(self.len()) + } +} + #[cfg(test)] mod tests { use serde::Deserialize; @@ -532,6 +617,24 @@ mod tests { val: TestEnum, } + #[derive(Debug, Deserialize)] + struct TestSeq1 { + tail: Vec, + } + + #[derive(Debug, Deserialize)] + struct TestSeq2 { + tail: (String, String, String), + } + + #[derive(Debug, Deserialize)] + struct TestSeq3 { + tail: TestTupleStruct, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestTupleStruct(String, String, String); + #[test] fn test_request_extract() { let mut router = Router::<()>::build(); @@ -627,6 +730,62 @@ mod tests { assert!(format!("{:?}", i).contains("unknown variant")); } + #[test] + fn test_extract_seq() { + let mut router = Router::<()>::build(); + router.path("/path/to/{tail}*", ()); + let router = router.finish(); + + let mut path = Path::new("/path/to/tail/with/slash%2fes"); + assert!(router.recognize(&mut path).is_some()); + + let i: (String,) = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap(); + assert_eq!(i.0, String::from("tail/with/slash/es")); + + let i: TestSeq1 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap(); + assert_eq!( + i.tail, + vec![ + String::from("tail"), + String::from("with"), + String::from("slash/es") + ] + ); + + let i: TestSeq2 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap(); + assert_eq!( + i.tail, + ( + String::from("tail"), + String::from("with"), + String::from("slash/es") + ) + ); + + let i: TestSeq3 = de::Deserialize::deserialize(PathDeserializer::new(&path)).unwrap(); + assert_eq!( + i.tail, + TestTupleStruct( + String::from("tail"), + String::from("with"), + String::from("slash/es") + ) + ); + } + + #[test] + fn test_value_seq_size_hint_counts_remaining_elements() { + use serde::de::SeqAccess as _; + + let mut seq = ValueSeq::new("tail/with/slash"); + + assert_eq!(seq.size_hint(), Some(3)); + + let elem = seq.next_element::().unwrap(); + assert_eq!(elem.as_deref(), Some("tail")); + assert_eq!(seq.size_hint(), Some(2)); + } + #[test] fn test_extract_errors() { let mut router = Router::<()>::build(); @@ -704,6 +863,119 @@ mod tests { assert_eq!(vals.value, "/"); } + #[test] + fn deserialize_path_decode_any() { + #[derive(Debug, PartialEq)] + pub enum AnyEnumCustom { + String(String), + Int(u32), + Other, + } + + impl<'de> Deserialize<'de> for AnyEnumCustom { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Vis; + impl<'de> Visitor<'de> for Vis { + type Value = AnyEnumCustom; + + fn expecting<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result { + write!(f, "my thing") + } + + fn visit_u32(self, v: u32) -> Result + where + E: serde::de::Error, + { + Ok(AnyEnumCustom::Int(v)) + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + match u32::try_from(v) { + Ok(v) => Ok(AnyEnumCustom::Int(v)), + Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))), + } + } + + fn visit_i64(self, v: i64) -> Result + where + E: serde::de::Error, + { + match u32::try_from(v) { + Ok(v) => Ok(AnyEnumCustom::Int(v)), + Err(_) => Ok(AnyEnumCustom::String(format!("some str: {v}"))), + } + } + + fn visit_str(self, v: &str) -> Result { + v.parse().map(AnyEnumCustom::Int).or_else(|_| { + Ok(match v { + "other" => AnyEnumCustom::Other, + _ => AnyEnumCustom::String(format!("some str: {v}")), + }) + }) + } + } + + deserializer.deserialize_any(Vis) + } + } + + #[derive(Debug, Deserialize, PartialEq)] + #[serde(untagged)] + pub enum AnyEnumDerive { + String(String), + Int(u32), + Other, + } + + // single + let rdef = ResourceDef::new("/{key}"); + + let mut path = Path::new("/%25"); + rdef.capture_match_info(&mut path); + let de = PathDeserializer::new(&path); + let segment: AnyEnumCustom = serde::Deserialize::deserialize(de).unwrap(); + assert_eq!(segment, AnyEnumCustom::String("some str: %".to_string())); + + let mut path = Path::new("/%25"); + rdef.capture_match_info(&mut path); + let de = PathDeserializer::new(&path); + let segment: AnyEnumDerive = serde::Deserialize::deserialize(de).unwrap(); + assert_eq!(segment, AnyEnumDerive::String("%".to_string())); + + // seq + let rdef = ResourceDef::new("/{key}/{value}"); + + let mut path = Path::new("/other/123"); + rdef.capture_match_info(&mut path); + let de = PathDeserializer::new(&path); + let segment: (AnyEnumCustom, AnyEnumDerive) = serde::Deserialize::deserialize(de).unwrap(); + assert_eq!(segment.0, AnyEnumCustom::Other); + assert_eq!(segment.1, AnyEnumDerive::Int(123)); + + // map + #[derive(Deserialize)] + struct Vals { + key: AnyEnumCustom, + value: AnyEnumDerive, + } + + let rdef = ResourceDef::new("/{key}/{value}"); + + let mut path = Path::new("/123/%2F"); + rdef.capture_match_info(&mut path); + let de = PathDeserializer::new(&path); + let vals: Vals = serde::Deserialize::deserialize(de).unwrap(); + assert_eq!(vals.key, AnyEnumCustom::Int(123)); + assert_eq!(vals.value, AnyEnumDerive::String("/".to_string())); + } + #[test] fn deserialize_borrowed() { #[derive(Debug, Deserialize)] diff --git a/actix-router/src/lib.rs b/actix-router/src/lib.rs index 3f5e969e7..cc59a9f58 100644 --- a/actix-router/src/lib.rs +++ b/actix-router/src/lib.rs @@ -2,7 +2,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] mod de; mod path; diff --git a/actix-router/src/path.rs b/actix-router/src/path.rs index ab4a943fe..3d1bb27bc 100644 --- a/actix-router/src/path.rs +++ b/actix-router/src/path.rs @@ -93,6 +93,45 @@ impl Path { self.segments.clear(); } + /// Set new path while preserving and remapping existing captured segment indices. + /// + /// The `reindex` closure maps byte indices from the previous path to byte indices in the new + /// path. + #[doc(hidden)] + pub fn update_with_reindex(&mut self, path: T, mut reindex: F) + where + F: FnMut(u16) -> u16, + { + self.skip = reindex(self.skip); + + for (_, item) in &mut self.segments { + if let PathItem::Segment(start, end) = item { + *start = reindex(*start); + *end = reindex(*end); + + if *start > *end { + *start = *end; + } + } + } + + self.path = path; + let path = self.path.path(); + + self.skip = clamp_to_char_boundary(path, self.skip); + + for (_, item) in &mut self.segments { + if let PathItem::Segment(start, end) = item { + *start = clamp_to_char_boundary(path, *start); + *end = clamp_to_char_boundary(path, *end); + + if *start > *end { + *start = *end; + } + } + } + } + /// Reset state. #[inline] pub fn reset(&mut self) { @@ -179,6 +218,16 @@ impl Path { } } +fn clamp_to_char_boundary(path: &str, idx: u16) -> u16 { + let mut idx = usize::from(idx).min(path.len()); + + while idx > 0 && !path.is_char_boundary(idx) { + idx -= 1; + } + + idx as u16 +} + #[derive(Debug)] pub struct PathIter<'a, T> { idx: usize, diff --git a/actix-router/src/resource_path.rs b/actix-router/src/resource_path.rs index 610dc344d..8823108ae 100644 --- a/actix-router/src/resource_path.rs +++ b/actix-router/src/resource_path.rs @@ -1,11 +1,16 @@ use crate::Path; -// TODO: this trait is necessary, document it -// see impl Resource for ServiceRequest +/// Abstraction over types that can provide a mutable [`Path`] for routing. +/// +/// This trait is used by the router to extract the request path in a uniform way across different +/// request types (e.g., Actix Web's `ServiceRequest`). Implementors return a mutable [`Path`] +/// wrapper so routing can read and potentially normalize/parse the path without requiring the +/// original request type. pub trait Resource { /// Type of resource's path returned in `resource_path`. type Path: ResourcePath; + /// Returns a mutable reference to the path wrapper used by the router. fn resource_path(&mut self) -> &mut Path; } diff --git a/actix-test/CHANGES.md b/actix-test/CHANGES.md index ec2dd6776..fc64cb7a3 100644 --- a/actix-test/CHANGES.md +++ b/actix-test/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Minimum supported Rust version (MSRV) is now 1.88. + ## 0.1.5 - Add `TestServerConfig::listen_address()` method. diff --git a/actix-test/README.md b/actix-test/README.md index 1a9b6f22a..3a0caf2ef 100644 --- a/actix-test/README.md +++ b/actix-test/README.md @@ -4,7 +4,7 @@ [![crates.io](https://img.shields.io/crates/v/actix-test?label=latest)](https://crates.io/crates/actix-test) [![Documentation](https://docs.rs/actix-test/badge.svg?version=0.1.5)](https://docs.rs/actix-test/0.1.5) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-test.svg)
[![dependency status](https://deps.rs/crate/actix-test/0.1.5/status.svg)](https://deps.rs/crate/actix-test/0.1.5) diff --git a/actix-test/src/lib.rs b/actix-test/src/lib.rs index f0da2c20d..84adacbce 100644 --- a/actix-test/src/lib.rs +++ b/actix-test/src/lib.rs @@ -29,7 +29,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[cfg(feature = "openssl")] extern crate tls_openssl as openssl; diff --git a/actix-web-actors/CHANGES.md b/actix-web-actors/CHANGES.md index 3f214274d..79fecaadf 100644 --- a/actix-web-actors/CHANGES.md +++ b/actix-web-actors/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Minimum supported Rust version (MSRV) is now 1.88. + ## 4.3.1 - Reduce memory usage by `take`-ing (rather than `split`-ing) the encoded buffer when yielding bytes in the response stream. diff --git a/actix-web-actors/Cargo.toml b/actix-web-actors/Cargo.toml index 61d454b55..c8ff628de 100644 --- a/actix-web-actors/Cargo.toml +++ b/actix-web-actors/Cargo.toml @@ -24,7 +24,7 @@ allowed_external_types = [ actix = { version = ">=0.12, <0.14", default-features = false } actix-codec = "0.5" actix-http = "3" -actix-web = { version = "4", default-features = false } +actix-web = { version = "4", default-features = false, features = ["ws"] } bytes = "1" bytestring = "1" diff --git a/actix-web-actors/README.md b/actix-web-actors/README.md index 0ec91a224..6a2e8f1a8 100644 --- a/actix-web-actors/README.md +++ b/actix-web-actors/README.md @@ -8,7 +8,7 @@ [![crates.io](https://img.shields.io/crates/v/actix-web-actors?label=latest)](https://crates.io/crates/actix-web-actors) [![Documentation](https://docs.rs/actix-web-actors/badge.svg?version=4.3.1)](https://docs.rs/actix-web-actors/4.3.1) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-web-actors.svg)
![maintenance-status](https://img.shields.io/badge/maintenance-deprecated-red.svg) diff --git a/actix-web-actors/src/lib.rs b/actix-web-actors/src/lib.rs index 4831d2637..619a2204f 100644 --- a/actix-web-actors/src/lib.rs +++ b/actix-web-actors/src/lib.rs @@ -59,7 +59,7 @@ #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] mod context; pub mod ws; diff --git a/actix-web-codegen/CHANGES.md b/actix-web-codegen/CHANGES.md index d143723f4..6138892a1 100644 --- a/actix-web-codegen/CHANGES.md +++ b/actix-web-codegen/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Minimum supported Rust version (MSRV) is now 1.88. + ## 4.3.0 - Add `#[scope]` macro. diff --git a/actix-web-codegen/README.md b/actix-web-codegen/README.md index e61bf5c74..52d522b0a 100644 --- a/actix-web-codegen/README.md +++ b/actix-web-codegen/README.md @@ -6,7 +6,7 @@ [![crates.io](https://img.shields.io/crates/v/actix-web-codegen?label=latest)](https://crates.io/crates/actix-web-codegen) [![Documentation](https://docs.rs/actix-web-codegen/badge.svg?version=4.3.0)](https://docs.rs/actix-web-codegen/4.3.0) -![Version](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![License](https://img.shields.io/crates/l/actix-web-codegen.svg)
[![dependency status](https://deps.rs/crate/actix-web-codegen/4.3.0/status.svg)](https://deps.rs/crate/actix-web-codegen/4.3.0) diff --git a/actix-web-codegen/src/lib.rs b/actix-web-codegen/src/lib.rs index e22bff8cd..f6ca56aa0 100644 --- a/actix-web-codegen/src/lib.rs +++ b/actix-web-codegen/src/lib.rs @@ -75,7 +75,7 @@ #![recursion_limit = "512"] #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] use proc_macro::TokenStream; use quote::quote; diff --git a/actix-web-codegen/src/route.rs b/actix-web-codegen/src/route.rs index e24903e3a..cd1ad4c66 100644 --- a/actix-web-codegen/src/route.rs +++ b/actix-web-codegen/src/route.rs @@ -59,6 +59,7 @@ macro_rules! standard_method_type { ( $($variant:ident, $upper:ident, $lower:ident,)+ ) => { + #[doc(hidden)] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MethodType { $( @@ -466,7 +467,7 @@ impl ToTokens for Route { let stream = quote! { #(#doc_attributes)* - #[allow(non_camel_case_types, missing_docs)] + #[allow(non_camel_case_types)] #vis struct #name; impl ::actix_web::dev::HttpServiceFactory for #name { diff --git a/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr b/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr index c2a51d005..34c79efea 100644 --- a/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr +++ b/actix-web-codegen/tests/trybuild/route-custom-lowercase.stderr @@ -13,14 +13,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future | required by a bound introduced by this call | = help: the following other types implement trait `HttpServiceFactory`: - Resource - actix_web::Scope - Vec - Redirect - (A,) (A, B) (A, B, C) (A, B, C, D) + (A, B, C, D, E) + (A, B, C, D, E, F) + (A, B, C, D, E, F, G) + (A, B, C, D, E, F, G, H) + (A, B, C, D, E, F, G, H, I) and $N others note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs 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 ae18f347f..7b176e5b8 100644 --- a/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/route-duplicate-method-fail.stderr @@ -13,14 +13,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future | required by a bound introduced by this call | = help: the following other types implement trait `HttpServiceFactory`: - Resource - actix_web::Scope - Vec - Redirect - (A,) (A, B) (A, B, C) (A, B, C, D) + (A, B, C, D, E) + (A, B, C, D, E, F) + (A, B, C, D, E, F, G) + (A, B, C, D, E, F, G, H) + (A, B, C, D, E, F, G, H, I) and $N others note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs 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 37d8354c9..e8814b9b1 100644 --- a/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/route-missing-method-fail.stderr @@ -15,14 +15,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future | required by a bound introduced by this call | = help: the following other types implement trait `HttpServiceFactory`: - Resource - actix_web::Scope - Vec - Redirect - (A,) (A, B) (A, B, C) (A, B, C, D) + (A, B, C, D, E) + (A, B, C, D, E, F) + (A, B, C, D, E, F, G) + (A, B, C, D, E, F, G, H) + (A, B, C, D, E, F, G, H, I) and $N others note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs 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 40b19fc77..77d53fa90 100644 --- a/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr +++ b/actix-web-codegen/tests/trybuild/routes-missing-args-fail.stderr @@ -29,14 +29,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future | required by a bound introduced by this call | = help: the following other types implement trait `HttpServiceFactory`: - Resource - actix_web::Scope - Vec - Redirect - (A,) (A, B) (A, B, C) (A, B, C, D) + (A, B, C, D, E) + (A, B, C, D, E, F) + (A, B, C, D, E, F, G) + (A, B, C, D, E, F, G, H) + (A, B, C, D, E, F, G, H, I) and $N others note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs 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 ff7f00b3b..9c1c3e23b 100644 --- a/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr +++ b/actix-web-codegen/tests/trybuild/routes-missing-method-fail.stderr @@ -15,14 +15,14 @@ error[E0277]: the trait bound `fn() -> impl std::future::Future | required by a bound introduced by this call | = help: the following other types implement trait `HttpServiceFactory`: - Resource - actix_web::Scope - Vec - Redirect - (A,) (A, B) (A, B, C) (A, B, C, D) + (A, B, C, D, E) + (A, B, C, D, E, F) + (A, B, C, D, E, F, G) + (A, B, C, D, E, F, G, H) + (A, B, C, D, E, F, G, H, I) and $N others note: required by a bound in `App::::service` --> $WORKSPACE/actix-web/src/app.rs diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index ab40eea98..e0dffae8e 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,49 @@ ## Unreleased +- Add `HttpRequest::{cookies_raw,cookie_raw}` and `ServiceRequest::{cookies_raw,cookie_raw}` for reading request cookies without percent-decoding names and values. [#3542] +- Enable dual-stack IPv6 sockets on Windows when possible so that Actix-created listeners bound to `[::]` also accept IPv4 connections. +- Panic when calling `Route::to()` or `Route::service()` after `Route::wrap()` to prevent silently dropping route middleware. [#3944] +- Fix `HttpRequest::{match_pattern,match_name}` reporting path-only matches when route guards disambiguate overlapping resources. [#3346] +- Fix `Readlines` handling of lines split across payload chunks so combined line limits are enforced and complete lines are yielded. +- Update `foldhash` dependency to `0.2`. +- Update `rand` dependency to `0.10`. +- Add `HttpServer::h1_write_buffer_size()`. + +[#3944]: https://github.com/actix/actix-web/pull/3944 +[#3346]: https://github.com/actix/actix-web/issues/3346 +[#3542]: https://github.com/actix/actix-web/issues/3542 + +## 4.13.0 + +- Minimum supported Rust version (MSRV) is now 1.88. +- Improve HTTP/2 upload throughput by increasing default flow control window sizes. [#3638] +- Add `HttpServer::{h2_initial_window_size, h2_initial_connection_window_size}` methods for tuning. [#3638] +- Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895] +- Ignore unparsable cookies in `Cookie` request header. +- Add `experimental-introspection` feature to report configured routes [#3594] +- Add config/method for `TCP_NODELAY`. [#3918] +- Fix panic when `NormalizePath` rewrites a scoped dynamic path before extraction (e.g., `scope("{tail:.*}")` + `Path`). [#3562] +- Do not compress 206 Partial Content responses. [#3191] + +[#3895]: https://github.com/actix/actix-web/pull/3895 +[#3594]: https://github.com/actix/actix-web/pull/3594 +[#3918]: https://github.com/actix/actix-web/pull/3918 +[#3638]: https://github.com/actix/actix-web/issues/3638 +[#3562]: https://github.com/actix/actix-web/issues/3562 +[#3191]: https://github.com/actix/actix-web/issues/3191 + +## 4.12.1 + +- Correct `actix-http` dependency requirement. + +## 4.12.0 + +- `actix_web::response::builder::HttpResponseBuilder::streaming()` now sets `Content-Type` to `application/octet-stream` if `Content-Type` does not exist. +- `actix_web::response::builder::HttpResponseBuilder::streaming()` now calls `actix_web::response::builder::HttpResponseBuilder::no_chunking()` and returns `SizedStream` if `Content-Length` is set by user. +- Add `ws` crate feature (on-by-default) which forwards to `actix-http` and guards some of its `ResponseError` impls. +- Add public export for `EitherExtractError` in `error` module. + ## 4.11.0 - Add `Logger::log_level()` method. diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 39ffe3341..f6e05d325 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-web" -version = "4.11.0" +version = "4.13.0" description = "Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust" authors = ["Nikolay Kim ", "Rob Ede "] keywords = ["actix", "http", "web", "framework", "async"] @@ -17,7 +17,6 @@ edition.workspace = true rust-version.workspace = true [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] features = [ "macros", "openssl", @@ -53,7 +52,6 @@ allowed_external_types = [ "serde_json::*", "serde_urlencoded::*", "serde::*", - "serde::*", "tokio::*", "url::*", ] @@ -68,6 +66,7 @@ default = [ "http2", "unicode", "compat", + "ws", ] # Brotli algorithm content-encoding support @@ -86,9 +85,12 @@ cookies = ["dep:cookie"] # Secure & signed cookies secure-cookies = ["cookies", "cookie/secure"] -# HTTP/2 support (including h2c). +# HTTP/2 support (including h2c) http2 = ["actix-http/http2"] +# WebSocket support +ws = ["actix-http/ws"] + # TLS via OpenSSL openssl = ["__tls", "http2", "actix-http/openssl", "actix-tls/accept", "actix-tls/openssl"] @@ -123,6 +125,9 @@ compat = ["compat-routing-macros-force-pub"] # Opt-out forwards-compatibility for handler visibility inheritance fix. compat-routing-macros-force-pub = ["actix-web-codegen?/compat-routing-macros-force-pub"] +# Enabling the retrieval of metadata for initialized resources, including path and HTTP method. +experimental-introspection = ["serde/derive"] + [dependencies] actix-codec = "0.5" actix-macros = { version = "0.2.3", optional = true } @@ -132,8 +137,8 @@ actix-service = "2" actix-tls = { version = "3.4", default-features = false, optional = true } actix-utils = "3" -actix-http = { version = "3.11", features = ["ws"] } -actix-router = { version = "0.5.3", default-features = false, features = ["http"] } +actix-http = "3.12.1" +actix-router = { version = "0.5.4", default-features = false, features = ["http"] } actix-web-codegen = { version = "4.3", optional = true, default-features = false } bytes = "1" @@ -142,7 +147,7 @@ cfg-if = "1" cookie = { version = "0.16", features = ["percent-encode"], optional = true } derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "display", "error", "from"] } encoding_rs = "0.8" -foldhash = "0.1" +foldhash = "0.2" futures-core = { version = "0.3.17", default-features = false } futures-util = { version = "0.3.17", default-features = false } impl-more = "0.1.4" @@ -158,7 +163,7 @@ serde = "1.0" serde_json = "1.0" serde_urlencoded = "0.7" smallvec = "1.6.1" -socket2 = "0.5" +socket2 = "0.6" time = { version = "0.3", default-features = false, features = ["formatting"] } tracing = "0.1.30" url = "2.5.4" @@ -169,15 +174,15 @@ actix-test = { version = "0.1", features = ["openssl", "rustls-0_23"] } awc = { version = "3", features = ["openssl"] } brotli = "8" -const-str = "0.5" # TODO(MSRV 1.77): update to 0.6 +const-str = "1.1" core_affinity = "0.8" criterion = { version = "0.5", features = ["html_reports"] } env_logger = "0.11" flate2 = "1.0.13" futures-util = { version = "0.3.17", default-features = false, features = ["std"] } -rand = "0.9" +rand = "0.10.1" rcgen = "0.13" -rustls-pemfile = "2" +rustls-pki-types = "1.13.1" serde = { version = "1", features = ["derive"] } static_assertions = "1" tls-openssl = { package = "openssl", version = "0.10.55" } 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 08c89635a..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 diff --git a/actix-web/README.md b/actix-web/README.md index 033de02ed..eab4587b0 100644 --- a/actix-web/README.md +++ b/actix-web/README.md @@ -8,10 +8,10 @@ [![crates.io](https://img.shields.io/crates/v/actix-web?label=latest)](https://crates.io/crates/actix-web) -[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.11.0)](https://docs.rs/actix-web/4.11.0) -![MSRV](https://img.shields.io/badge/rustc-1.72+-ab6000.svg) +[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.13.0)](https://docs.rs/actix-web/4.13.0) +![MSRV](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-web.svg) -[![Dependency Status](https://deps.rs/crate/actix-web/4.11.0/status.svg)](https://deps.rs/crate/actix-web/4.11.0) +[![Dependency Status](https://deps.rs/crate/actix-web/4.13.0/status.svg)](https://deps.rs/crate/actix-web/4.13.0)
[![CI](https://github.com/actix/actix-web/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-web/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/actix/actix-web/graph/badge.svg?token=dSwOnp9QCv)](https://codecov.io/gh/actix/actix-web) @@ -37,14 +37,24 @@ - SSL support using OpenSSL or Rustls - Middlewares ([Logger, Session, CORS, etc](https://actix.rs/docs/middleware/)) - Integrates with the [`awc` HTTP client](https://docs.rs/awc/) -- Runs on stable Rust 1.72+ +- Runs on stable Rust 1.88+ + +### Experimental features + +To enable faster release iterations, we mark some features as experimental. +These features are prefixed with `experimental` and a breaking change may happen at any release. +Please use them in a production environment at your own risk. + +- `experimental-introspection`: exposes route and method reporting helpers for local diagnostics + and tooling. See [`examples/introspection.rs`](examples/introspection.rs) and + [`examples/introspection_multi_servers.rs`](examples/introspection_multi_servers.rs). ## Documentation - [Website & User Guide](https://actix.rs) - [Examples Repository](https://github.com/actix/examples) - [API Documentation](https://docs.rs/actix-web) -- [API Documentation (master branch)](https://actix.rs/actix-web/actix_web) +- [API Documentation (mainranch)](https://actix.rs/actix-web/actix_web) ## Example @@ -78,23 +88,23 @@ async fn main() -> std::io::Result<()> { ### More Examples -- [Hello World](https://github.com/actix/examples/tree/master/basics/hello-world) -- [Basic Setup](https://github.com/actix/examples/tree/master/basics/basics) -- [Application State](https://github.com/actix/examples/tree/master/basics/state) -- [JSON Handling](https://github.com/actix/examples/tree/master/json/json) -- [Multipart Streams](https://github.com/actix/examples/tree/master/forms/multipart) -- [MongoDB Integration](https://github.com/actix/examples/tree/master/databases/mongodb) -- [Diesel Integration](https://github.com/actix/examples/tree/master/databases/diesel) -- [SQLite Integration](https://github.com/actix/examples/tree/master/databases/sqlite) -- [Postgres Integration](https://github.com/actix/examples/tree/master/databases/postgres) -- [Tera Templates](https://github.com/actix/examples/tree/master/templating/tera) -- [Askama Templates](https://github.com/actix/examples/tree/master/templating/askama) -- [HTTPS using Rustls](https://github.com/actix/examples/tree/master/https-tls/rustls) -- [HTTPS using OpenSSL](https://github.com/actix/examples/tree/master/https-tls/openssl) -- [Simple WebSocket](https://github.com/actix/examples/tree/master/websockets) -- [WebSocket Chat](https://github.com/actix/examples/tree/master/websockets/chat) +- [Hello World](https://github.com/actix/examples/tree/main/basics/hello-world) +- [Basic Setup](https://github.com/actix/examples/tree/main/basics/basics) +- [Application State](https://github.com/actix/examples/tree/main/basics/state) +- [JSON Handling](https://github.com/actix/examples/tree/main/json/json) +- [Multipart Streams](https://github.com/actix/examples/tree/main/forms/multipart) +- [MongoDB Integration](https://github.com/actix/examples/tree/main/databases/mongodb) +- [Diesel Integration](https://github.com/actix/examples/tree/main/databases/diesel) +- [SQLite Integration](https://github.com/actix/examples/tree/main/databases/sqlite) +- [Postgres Integration](https://github.com/actix/examples/tree/main/databases/postgres) +- [Tera Templates](https://github.com/actix/examples/tree/main/templating/tera) +- [Askama Templates](https://github.com/actix/examples/tree/main/templating/askama) +- [HTTPS using Rustls](https://github.com/actix/examples/tree/main/https-tls/rustls) +- [HTTPS using OpenSSL](https://github.com/actix/examples/tree/main/https-tls/openssl) +- [Simple WebSocket](https://github.com/actix/examples/tree/main/websockets) +- [WebSocket Chat](https://github.com/actix/examples/tree/main/websockets/chat) -You may consider checking out [this directory](https://github.com/actix/examples/tree/master) for more examples. +You may consider checking out [this directory](https://github.com/actix/examples/tree/main) for more examples. ## Benchmarks diff --git a/actix-web/examples/introspection.rs b/actix-web/examples/introspection.rs new file mode 100644 index 000000000..04e62daeb --- /dev/null +++ b/actix-web/examples/introspection.rs @@ -0,0 +1,304 @@ +// Example showcasing the experimental introspection feature. +// Run with: `cargo run --features experimental-introspection --example introspection` + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + #[cfg(feature = "experimental-introspection")] + { + use actix_web::{dev::Service, guard, web, App, HttpResponse, HttpServer, Responder}; + use serde::Deserialize; + + // Initialize logging + env_logger::Builder::new() + .filter_level(log::LevelFilter::Debug) + .init(); + + // Custom guard to check if the Content-Type header is present. + struct ContentTypeGuard; + + impl guard::Guard for ContentTypeGuard { + fn check(&self, req: &guard::GuardContext<'_>) -> bool { + req.head() + .headers() + .contains_key(actix_web::http::header::CONTENT_TYPE) + } + } + + // Data structure for endpoints that receive JSON. + #[derive(Deserialize)] + struct UserInfo { + username: String, + age: u8, + } + + // GET /introspection for JSON response + async fn introspection_handler_json( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_as_json(); + HttpResponse::Ok() + .content_type("application/json") + .body(report) + } + + // GET /introspection/externals for external resources report + async fn introspection_handler_externals( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_externals_as_json(); + HttpResponse::Ok() + .content_type("application/json") + .body(report) + } + + // GET /introspection for plain text response + async fn introspection_handler_text( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_as_text(); + HttpResponse::Ok().content_type("text/plain").body(report) + } + + // GET /api/v1/item/{id} and GET /v1/item/{id} + #[actix_web::get("/item/{id}")] + async fn get_item(path: web::Path) -> impl Responder { + let id = path.into_inner(); + HttpResponse::Ok().body(format!("Requested item with id: {}", id)) + } + + // POST /api/v1/info + #[actix_web::post("/info")] + async fn post_user_info(info: web::Json) -> impl Responder { + HttpResponse::Ok().json(format!( + "User {} with age {} received", + info.username, info.age + )) + } + + // /api/v1/guarded + async fn guarded_handler() -> impl Responder { + HttpResponse::Ok().body("Passed the Content-Type guard!") + } + + // GET /api/v2/hello + async fn hello_v2() -> impl Responder { + HttpResponse::Ok().body("Hello from API v2!") + } + + // GET /admin/dashboard + async fn admin_dashboard() -> impl Responder { + HttpResponse::Ok().body("Welcome to the Admin Dashboard!") + } + + // GET /admin/settings + async fn get_settings() -> impl Responder { + HttpResponse::Ok().body("Current settings: ...") + } + + // POST /admin/settings + async fn update_settings() -> impl Responder { + HttpResponse::Ok().body("Settings have been updated!") + } + + // GET and POST on / + async fn root_index() -> impl Responder { + HttpResponse::Ok().body("Welcome to the Root Endpoint!") + } + + // GET /alpha and /beta (named multi-pattern resource) + async fn multi_pattern() -> impl Responder { + HttpResponse::Ok().body("Hello from multi-pattern resource!") + } + + // GET /acceptable (Acceptable guard) + async fn acceptable_guarded() -> impl Responder { + HttpResponse::Ok().body("Acceptable guard matched!") + } + + // GET /hosted (Host guard) + async fn host_guarded() -> impl Responder { + HttpResponse::Ok().body("Host guard matched!") + } + + // Additional endpoints for /extra + fn extra_endpoints(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/extra") + .route( + "/ping", + web::get().to(|| async { HttpResponse::Ok().body("pong") }), // GET /extra/ping + ) + .service( + web::resource("/multi") + .route(web::get().to(|| async { + HttpResponse::Ok().body("GET response from /extra/multi") + })) // GET /extra/multi + .route(web::post().to(|| async { + HttpResponse::Ok().body("POST response from /extra/multi") + })), // POST /extra/multi + ) + .service( + web::scope("{entities_id:\\d+}") + .service( + web::scope("/secure") + .route( + "", + web::get().to(|| async { + HttpResponse::Ok() + .body("GET response from /extra/secure") + }), + ) // GET /extra/{entities_id}/secure/ + .route( + "/post", + web::post().to(|| async { + HttpResponse::Ok() + .body("POST response from /extra/secure") + }), + ), // POST /extra/{entities_id}/secure/post + ) + .wrap_fn(|req, srv| { + println!( + "Request to /extra/secure with id: {}", + req.match_info().get("entities_id").unwrap() + ); + let fut = srv.call(req); + async move { + let res = fut.await?; + Ok(res) + } + }), + ), + ); + } + + // Additional endpoints for /foo + fn other_endpoints(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/extra") + .route( + "/ping", + web::post() + .to(|| async { HttpResponse::Ok().body("post from /extra/ping") }), // POST /foo/extra/ping + ) + .route( + "/ping", + web::delete() + .to(|| async { HttpResponse::Ok().body("delete from /extra/ping") }), // DELETE /foo/extra/ping + ), + ); + } + + // Create the HTTP server with all the routes and handlers + let server = HttpServer::new(|| { + App::new() + // Get introspection report + // curl --location '127.0.0.1:8080/introspection' --header 'Accept: application/json' + // curl --location '127.0.0.1:8080/introspection' --header 'Accept: text/plain' + // curl --location '127.0.0.1:8080/introspection/externals' + .external_resource("app-external", "https://example.com/{id}") + .service( + web::resource("/introspection") + .route( + web::get() + .guard(guard::Header("accept", "application/json")) + .to(introspection_handler_json), + ) + .route( + web::get() + .guard(guard::Header("accept", "text/plain")) + .to(introspection_handler_text), + ), + ) + .service( + web::resource("/introspection/externals") + .route(web::get().to(introspection_handler_externals)), + ) + .service( + web::resource(["/alpha", "/beta"]) + .name("multi") + .route(web::get().to(multi_pattern)), + ) + .route( + "/acceptable", + web::get() + .guard(guard::Acceptable::new(mime::APPLICATION_JSON).match_star_star()) + .to(acceptable_guarded), + ) + .route( + "/hosted", + web::get().guard(guard::Host("127.0.0.1")).to(host_guarded), + ) + // API endpoints under /api + .service( + web::scope("/api") + .configure(|cfg| { + cfg.external_resource("api-external", "https://api.example/{id}"); + }) + // Endpoints under /api/v1 + .service( + web::scope("/v1") + .service(get_item) // GET /api/v1/item/{id} + .service(post_user_info) // POST /api/v1/info + .route( + "/guarded", + web::route().guard(ContentTypeGuard).to(guarded_handler), // /api/v1/guarded + ), + ) + // Endpoints under /api/v2 + .service(web::scope("/v2").route("/hello", web::get().to(hello_v2))), // GET /api/v2/hello + ) + // Endpoints under /v1 (outside /api) + .service(web::scope("/v1").service(get_item)) // GET /v1/item/{id} + // Admin endpoints under /admin + .service( + web::scope("/admin") + .route("/dashboard", web::get().to(admin_dashboard)) // GET /admin/dashboard + .service( + web::resource("/settings") + .route(web::get().to(get_settings)) // GET /admin/settings + .route(web::post().to(update_settings)), // POST /admin/settings + ), + ) + // Root endpoints + .service( + web::resource("/") + .route(web::get().to(root_index)) // GET / + .route(web::post().to(root_index)), // POST / + ) + // Endpoints under /bar + .service(web::scope("/bar").configure(extra_endpoints)) // /bar/extra/ping, /bar/extra/multi, etc. + // Endpoints under /foo + .service(web::scope("/foo").configure(other_endpoints)) // /foo/extra/ping with POST and DELETE + // Additional endpoints under /extra + .configure(extra_endpoints) // /extra/ping, /extra/multi, etc. + .configure(other_endpoints) + // Endpoint that rejects GET on /not_guard (allows other methods) + .route( + "/not_guard", + web::route() + .guard(guard::Not(guard::Get())) + .to(HttpResponse::MethodNotAllowed), + ) + // Endpoint that requires GET with header or POST on /all_guard + .route( + "/all_guard", + web::route() + .guard( + guard::All(guard::Get()) + .and(guard::Header("content-type", "plain/text")) + .and(guard::Any(guard::Post())), + ) + .to(HttpResponse::MethodNotAllowed), + ) + }) + .workers(5) + .bind("127.0.0.1:8080")?; + + server.run().await + } + #[cfg(not(feature = "experimental-introspection"))] + { + eprintln!("This example requires the 'experimental-introspection' feature to be enabled."); + std::process::exit(1); + } +} diff --git a/actix-web/examples/introspection_multi_servers.rs b/actix-web/examples/introspection_multi_servers.rs new file mode 100644 index 000000000..7ed2224e3 --- /dev/null +++ b/actix-web/examples/introspection_multi_servers.rs @@ -0,0 +1,52 @@ +// Example showcasing the experimental introspection feature with multiple App instances. +// Run with: `cargo run --features experimental-introspection --example introspection_multi_servers` + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + #[cfg(feature = "experimental-introspection")] + { + use actix_web::{web, App, HttpResponse, HttpServer, Responder}; + use futures_util::future; + + async fn introspection_handler( + tree: web::Data, + ) -> impl Responder { + HttpResponse::Ok() + .content_type("text/plain") + .body(tree.report_as_text()) + } + + async fn index() -> impl Responder { + HttpResponse::Ok().body("Hello from app") + } + + let srv1 = HttpServer::new(|| { + App::new() + .service(web::resource("/a").route(web::get().to(index))) + .service( + web::resource("/introspection").route(web::get().to(introspection_handler)), + ) + }) + .workers(8) + .bind("127.0.0.1:8081")? + .run(); + + let srv2 = HttpServer::new(|| { + App::new() + .service(web::resource("/b").route(web::get().to(index))) + .service( + web::resource("/introspection").route(web::get().to(introspection_handler)), + ) + }) + .workers(3) + .bind("127.0.0.1:8082")? + .run(); + + future::try_join(srv1, srv2).await?; + } + #[cfg(not(feature = "experimental-introspection"))] + { + eprintln!("This example requires the 'experimental-introspection' feature to be enabled."); + } + Ok(()) +} 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/src/app.rs b/actix-web/src/app.rs index f12d39979..1099731f3 100644 --- a/actix-web/src/app.rs +++ b/actix-web/src/app.rs @@ -30,6 +30,8 @@ pub struct App { data_factories: Vec, external: Vec, extensions: Extensions, + #[cfg(feature = "experimental-introspection")] + introspector: Rc>, } impl App { @@ -46,6 +48,10 @@ impl App { factory_ref, external: Vec::new(), extensions: Extensions::new(), + #[cfg(feature = "experimental-introspection")] + introspector: Rc::new(RefCell::new( + crate::introspection::IntrospectionCollector::new(), + )), } } } @@ -366,6 +372,8 @@ where factory_ref: self.factory_ref, external: self.external, extensions: self.extensions, + #[cfg(feature = "experimental-introspection")] + introspector: self.introspector, } } @@ -429,6 +437,8 @@ where factory_ref: self.factory_ref, external: self.external, extensions: self.extensions, + #[cfg(feature = "experimental-introspection")] + introspector: self.introspector, } } } @@ -453,6 +463,8 @@ where default: self.default, factory_ref: self.factory_ref, extensions: RefCell::new(Some(self.extensions)), + #[cfg(feature = "experimental-introspection")] + introspector: Rc::clone(&self.introspector), } } } diff --git a/actix-web/src/app_service.rs b/actix-web/src/app_service.rs index 7aa16b790..fadcf825b 100644 --- a/actix-web/src/app_service.rs +++ b/actix-web/src/app_service.rs @@ -41,6 +41,8 @@ where pub(crate) default: Option>, pub(crate) factory_ref: Rc>>, pub(crate) external: RefCell>, + #[cfg(feature = "experimental-introspection")] + pub(crate) introspector: Rc>, } impl ServiceFactory for AppInit @@ -72,6 +74,10 @@ where // create App config to pass to child services let mut config = AppService::new(config, Rc::clone(&default)); + #[cfg(feature = "experimental-introspection")] + { + config.introspector = Rc::clone(&self.introspector); + } // register services mem::take(&mut *self.services.borrow_mut()) @@ -80,6 +86,9 @@ where let mut rmap = ResourceMap::new(ResourceDef::prefix("")); + #[cfg(feature = "experimental-introspection")] + let (config, services, _) = config.into_services(); + #[cfg(not(feature = "experimental-introspection"))] let (config, services) = config.into_services(); // complete pipeline creation. @@ -98,6 +107,10 @@ where // external resources for mut rdef in mem::take(&mut *self.external.borrow_mut()) { + #[cfg(feature = "experimental-introspection")] + { + self.introspector.borrow_mut().register_external(&rdef, "/"); + } rmap.add(&mut rdef, None); } @@ -110,6 +123,8 @@ where // construct app service and middleware service factory future. let endpoint_fut = self.endpoint.new_service(()); + #[cfg(feature = "experimental-introspection")] + let introspector = Rc::clone(&self.introspector); // take extensions or create new one as app data container. let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default(); @@ -130,6 +145,12 @@ where factory.create(&mut app_data); } + #[cfg(feature = "experimental-introspection")] + { + let tree = introspector.borrow_mut().finalize(); + app_data.insert(crate::web::Data::new(tree)); + } + Ok(AppInitService { service, app_data: Rc::new(app_data), @@ -207,6 +228,8 @@ where let inner = Rc::get_mut(&mut req.inner).unwrap(); inner.path.get_mut().update(&head.uri); inner.path.reset(); + inner.resource_path.clear(); + inner.resource_path_matched = false; inner.head = head; inner.conn_data = conn_data; inner.extensions = extensions; @@ -311,7 +334,15 @@ impl Service for AppRouting { guards.iter().all(|guard| guard.check(&guard_ctx)) }); - if let Some((srv, _info)) = res { + if let Some((srv, info)) = res { + req.push_resource_id(info.0); + + let matched = req + .resource_map() + .is_resource_path_match(req.resource_id_path()); + + req.mark_resource_path(matched); + srv.call(req) } else { self.default.call(req) diff --git a/actix-web/src/config.rs b/actix-web/src/config.rs index 0e856f574..a7611da4f 100644 --- a/actix-web/src/config.rs +++ b/actix-web/src/config.rs @@ -30,6 +30,15 @@ pub struct AppService { Option, Option>, )>, + #[cfg(feature = "experimental-introspection")] + pub current_prefix: String, + #[cfg(feature = "experimental-introspection")] + pub(crate) introspector: + std::rc::Rc>, + #[cfg(feature = "experimental-introspection")] + pub(crate) scope_id_stack: Vec, + #[cfg(feature = "experimental-introspection")] + pending_scope_id: Option, } impl AppService { @@ -40,6 +49,16 @@ impl AppService { default, root: true, services: Vec::new(), + #[cfg(feature = "experimental-introspection")] + current_prefix: "".to_string(), + #[cfg(feature = "experimental-introspection")] + introspector: std::rc::Rc::new(std::cell::RefCell::new( + crate::introspection::IntrospectionCollector::new(), + )), + #[cfg(feature = "experimental-introspection")] + scope_id_stack: Vec::new(), + #[cfg(feature = "experimental-introspection")] + pending_scope_id: None, } } @@ -49,6 +68,24 @@ impl AppService { } #[allow(clippy::type_complexity)] + #[cfg(feature = "experimental-introspection")] + pub(crate) fn into_services( + self, + ) -> ( + AppConfig, + Vec<( + ResourceDef, + BoxedHttpServiceFactory, + Option, + Option>, + )>, + std::rc::Rc>, + ) { + (self.config, self.services, self.introspector) + } + + #[allow(clippy::type_complexity)] + #[cfg(not(feature = "experimental-introspection"))] pub(crate) fn into_services( self, ) -> ( @@ -71,6 +108,14 @@ impl AppService { default: Rc::clone(&self.default), services: Vec::new(), root: false, + #[cfg(feature = "experimental-introspection")] + current_prefix: self.current_prefix.clone(), + #[cfg(feature = "experimental-introspection")] + introspector: std::rc::Rc::clone(&self.introspector), + #[cfg(feature = "experimental-introspection")] + scope_id_stack: self.scope_id_stack.clone(), + #[cfg(feature = "experimental-introspection")] + pending_scope_id: None, } } @@ -101,9 +146,87 @@ impl AppService { InitError = (), > + 'static, { + #[cfg(feature = "experimental-introspection")] + { + use std::borrow::Borrow; + + // Extract methods and guards for introspection + let guard_list: &[Box] = guards.borrow().as_ref().map_or(&[], |v| &v[..]); + let methods = guard_list + .iter() + .flat_map(|g| g.details().unwrap_or_default()) + .flat_map(|d| { + if let crate::guard::GuardDetail::HttpMethods(v) = d { + v.into_iter() + .filter_map(|s| s.parse().ok()) + .collect::>() + } else { + Vec::new() + } + }) + .collect::>(); + let guard_names = guard_list.iter().map(|g| g.name()).collect::>(); + let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter()); + + let is_resource = nested.is_none(); + let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef); + let patterns = rdef + .pattern_iter() + .map(|pattern| pattern.to_string()) + .collect::>(); + let resource_name = rdef.name().map(|name| name.to_string()); + let is_prefix = rdef.is_prefix(); + let scope_id = if nested.is_some() { + self.pending_scope_id.take() + } else { + None + }; + let parent_scope_id = self.scope_id_stack.last().copied(); + + for full_path in full_paths { + let info = crate::introspection::RouteInfo::new( + full_path, + methods.clone(), + guard_names.clone(), + guard_details.clone(), + patterns.clone(), + resource_name.clone(), + ); + self.introspector.borrow_mut().register_service( + info, + is_resource, + is_prefix, + scope_id, + parent_scope_id, + ); + } + } + self.services .push((rdef, boxed::factory(factory.into_factory()), guards, nested)); } + + /// Update the current path prefix. + #[cfg(feature = "experimental-introspection")] + pub(crate) fn update_prefix(&mut self, prefix: &str) { + let next = ResourceDef::root_prefix(prefix); + + if self.current_prefix.is_empty() { + self.current_prefix = next.pattern().unwrap_or("").to_string(); + return; + } + + let current = ResourceDef::root_prefix(&self.current_prefix); + let joined = current.join(&next); + self.current_prefix = joined.pattern().unwrap_or("").to_string(); + } + + #[cfg(feature = "experimental-introspection")] + pub(crate) fn prepare_scope_id(&mut self) -> usize { + let scope_id = self.introspector.borrow_mut().next_scope_id(); + self.pending_scope_id = Some(scope_id); + scope_id + } } /// Application connection config. diff --git a/actix-web/src/error/mod.rs b/actix-web/src/error/mod.rs index b2f672720..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. /// 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/guard/acceptable.rs b/actix-web/src/guard/acceptable.rs index 8fa7165c8..dd7b6f105 100644 --- a/actix-web/src/guard/acceptable.rs +++ b/actix-web/src/guard/acceptable.rs @@ -63,6 +63,27 @@ impl Guard for Acceptable { false } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + if self.match_star_star { + format!("Acceptable({}, match_star_star=true)", self.mime) + } else { + format!("Acceptable({})", self.mime) + } + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + let mut details = Vec::new(); + details.push(super::GuardDetail::Generic(format!("mime={}", self.mime))); + if self.match_star_star { + details.push(super::GuardDetail::Generic( + "match_star_star=true".to_string(), + )); + } + Some(details) + } } #[cfg(test)] @@ -96,4 +117,28 @@ mod tests { .match_star_star() .check(&req.guard_ctx())); } + + #[cfg(feature = "experimental-introspection")] + #[test] + fn acceptable_guard_details_include_mime() { + let guard = Acceptable::new(mime::APPLICATION_JSON).match_star_star(); + let details = guard.details().expect("missing guard details"); + + assert!(details.iter().any(|detail| match detail { + crate::guard::GuardDetail::Generic(value) => value == "match_star_star=true", + _ => false, + })); + let expected = format!("mime={}", mime::APPLICATION_JSON); + assert!(details.iter().any(|detail| match detail { + crate::guard::GuardDetail::Generic(value) => value == &expected, + _ => false, + })); + assert_eq!( + guard.name(), + format!( + "Acceptable({}, match_star_star=true)", + mime::APPLICATION_JSON + ) + ); + } } diff --git a/actix-web/src/guard/host.rs b/actix-web/src/guard/host.rs index 835662346..6867c51f1 100644 --- a/actix-web/src/guard/host.rs +++ b/actix-web/src/guard/host.rs @@ -117,6 +117,29 @@ impl Guard for HostGuard { // all conditions passed true } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + if let Some(ref scheme) = self.scheme { + format!("Host({}, scheme={})", self.host, scheme) + } else { + format!("Host({})", self.host) + } + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + let mut details = vec![super::GuardDetail::Headers(vec![( + "host".to_string(), + self.host.clone(), + )])]; + + if let Some(ref scheme) = self.scheme { + details.push(super::GuardDetail::Generic(format!("scheme={scheme}"))); + } + + Some(details) + } } #[cfg(test)] @@ -239,4 +262,23 @@ mod tests { let host = Host("localhost"); assert!(!host.check(&req.guard_ctx())); } + + #[cfg(feature = "experimental-introspection")] + #[test] + fn host_guard_details_include_host_and_scheme() { + let host = Host("example.com").scheme("https"); + let details = host.details().expect("missing guard details"); + + assert!(details.iter().any(|detail| match detail { + crate::guard::GuardDetail::Headers(headers) => headers + .iter() + .any(|(name, value)| name == "host" && value == "example.com"), + _ => false, + })); + assert!(details.iter().any(|detail| match detail { + crate::guard::GuardDetail::Generic(value) => value == "scheme=https", + _ => false, + })); + assert_eq!(host.name(), "Host(example.com, scheme=https)"); + } } diff --git a/actix-web/src/guard/mod.rs b/actix-web/src/guard/mod.rs index 41609953a..60bb116bf 100644 --- a/actix-web/src/guard/mod.rs +++ b/actix-web/src/guard/mod.rs @@ -11,7 +11,7 @@ //! or handler. This interface is defined by the [`Guard`] trait. //! //! Commonly-used guards are provided in this module as well as a way of creating a guard from a -//! closure ([`fn_guard`]). The [`Not`], [`Any`], and [`All`] guards are noteworthy, as they can be +//! closure ([`fn_guard`]). The [`Not`], [`Any()`], and [`All()`] guards are noteworthy, as they can be //! used to compose other guards in a more flexible and semantic way than calling `.guard(...)` on //! services multiple times (which might have different combining behavior than you want). //! @@ -66,6 +66,19 @@ pub use self::{ host::{Host, HostGuard}, }; +/// Enum to encapsulate various introspection details of a guard. +#[cfg(feature = "experimental-introspection")] +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum GuardDetail { + /// Detail associated with explicit HTTP method guards. + HttpMethods(Vec), + /// Detail associated with headers (header, value). + Headers(Vec<(String, String)>), + /// Generic detail, typically used for compound guard representations. + Generic(String), +} + /// Provides access to request parts that are useful during routing. #[derive(Debug)] pub struct GuardContext<'a> { @@ -124,12 +137,36 @@ impl<'a> GuardContext<'a> { pub trait Guard { /// Returns true if predicate condition is met for a given request. fn check(&self, ctx: &GuardContext<'_>) -> bool; + + /// Returns a nominal representation of the guard. + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + std::any::type_name::().to_string() + } + + /// Returns detailed introspection information, when available. + /// + /// This is best-effort and may omit complex guard logic. + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + None + } } impl Guard for Rc { fn check(&self, ctx: &GuardContext<'_>) -> bool { (**self).check(ctx) } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + (**self).name() + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + (**self).details() + } } /// Creates a guard using the given function. @@ -195,7 +232,7 @@ pub fn Any(guard: F) -> AnyGuard { /// /// That is, only one contained guard needs to match in order for the aggregate guard to match. /// -/// Construct an `AnyGuard` using [`Any`]. +/// Construct an `AnyGuard` using [`Any()`]. pub struct AnyGuard { guards: Vec>, } @@ -219,6 +256,28 @@ impl Guard for AnyGuard { false } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + format!( + "AnyGuard({})", + self.guards + .iter() + .map(|g| g.name()) + .collect::>() + .join(", ") + ) + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + Some( + self.guards + .iter() + .flat_map(|g| g.details().unwrap_or_default()) + .collect(), + ) + } } /// Creates a guard that matches if all added guards match. @@ -247,7 +306,7 @@ pub fn All(guard: F) -> AllGuard { /// /// That is, **all** contained guard needs to match in order for the aggregate guard to match. /// -/// Construct an `AllGuard` using [`All`]. +/// Construct an `AllGuard` using [`All()`]. pub struct AllGuard { guards: Vec>, } @@ -271,6 +330,28 @@ impl Guard for AllGuard { true } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + format!( + "AllGuard({})", + self.guards + .iter() + .map(|g| g.name()) + .collect::>() + .join(", ") + ) + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + Some( + self.guards + .iter() + .flat_map(|g| g.details().unwrap_or_default()) + .collect(), + ) + } } /// Wraps a guard and inverts the outcome of its `Guard` implementation. @@ -291,6 +372,16 @@ impl Guard for Not { fn check(&self, ctx: &GuardContext<'_>) -> bool { !self.0.check(ctx) } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + format!("Not({})", self.0.name()) + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + Some(vec![GuardDetail::Generic(self.name())]) + } } /// Creates a guard that matches a specified HTTP method. @@ -320,6 +411,16 @@ impl Guard for MethodGuard { ctx.head().method == self.0 } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + self.0.to_string() + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + Some(vec![GuardDetail::HttpMethods(vec![self.0.to_string()])]) + } } macro_rules! method_guard { @@ -382,6 +483,19 @@ impl Guard for HeaderGuard { false } + + #[cfg(feature = "experimental-introspection")] + fn name(&self) -> String { + format!("Header({}, {})", self.0, self.1.to_str().unwrap_or("")) + } + + #[cfg(feature = "experimental-introspection")] + fn details(&self) -> Option> { + Some(vec![GuardDetail::Headers(vec![( + self.0.to_string(), + self.1.to_str().unwrap_or("").to_string(), + )])]) + } } #[cfg(test)] @@ -522,7 +636,7 @@ mod tests { #[test] fn app_data() { const TEST_VALUE: u32 = 42; - let guard = fn_guard(|ctx| dbg!(ctx.app_data::()) == Some(&TEST_VALUE)); + let guard = fn_guard(|ctx| ctx.app_data::() == Some(&TEST_VALUE)); let req = TestRequest::default().app_data(TEST_VALUE).to_srv_request(); assert!(guard.check(&req.guard_ctx())); diff --git a/actix-web/src/http/header/accept.rs b/actix-web/src/http/header/accept.rs index 99c95175f..db2938437 100644 --- a/actix-web/src/http/header/accept.rs +++ b/actix-web/src/http/header/accept.rs @@ -26,49 +26,49 @@ common_header! { /// accept-ext = OWS ";" OWS token [ "=" ( token / quoted-string ) ] /// ``` /// + /// # Note + /// This is a request header. Servers should not send `Accept` in responses; to describe the + /// response body media type, use [`ContentType`](super::ContentType) / the `Content-Type` + /// header instead. + /// /// # Example Values /// * `audio/*; q=0.2, audio/basic` /// * `text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c` /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{Accept, QualityItem}; + /// use actix_web::{http::header::{Accept, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// Accept(vec![ - /// QualityItem::max(mime::TEXT_HTML), - /// ]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(Accept(vec![QualityItem::max(mime::TEXT_HTML)])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{Accept, QualityItem}; + /// use actix_web::{http::header::{Accept, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// Accept(vec![ - /// QualityItem::max(mime::APPLICATION_JSON), - /// ]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(Accept(vec![QualityItem::max(mime::APPLICATION_JSON)])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{Accept, QualityItem, q}; + /// use actix_web::{http::header::{Accept, Header as _, QualityItem, q}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// Accept(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(Accept(vec![ /// QualityItem::max(mime::TEXT_HTML), /// QualityItem::max("application/xhtml+xml".parse().unwrap()), /// QualityItem::new(mime::TEXT_XML, q(0.9)), /// QualityItem::max("image/webp".parse().unwrap()), /// QualityItem::new(mime::STAR_STAR, q(0.8)), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// + /// let accept = Accept::parse(&req).unwrap(); + /// assert_eq!(accept.preference(), mime::TEXT_HTML); /// ``` /// /// [RFC 7231 §5.3.2]: https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 diff --git a/actix-web/src/http/header/accept_charset.rs b/actix-web/src/http/header/accept_charset.rs index 43a7861fe..04216f69a 100644 --- a/actix-web/src/http/header/accept_charset.rs +++ b/actix-web/src/http/header/accept_charset.rs @@ -10,6 +10,10 @@ common_header! { /// to an origin server that is capable of representing information in /// those charsets. /// + /// # Note + /// This is a request header. Servers should not send `Accept-Charset` in responses; to + /// describe the response body's charset, set an appropriate `Content-Type` header instead. + /// /// # ABNF /// ```plain /// Accept-Charset = 1#( ( charset / "*" ) [ weight ] ) @@ -20,36 +24,33 @@ common_header! { /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptCharset, Charset, QualityItem}; + /// use actix_web::{http::header::{AcceptCharset, Charset, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptCharset(vec![QualityItem::max(Charset::Us_Ascii)]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(AcceptCharset(vec![QualityItem::max(Charset::Us_Ascii)])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptCharset, Charset, q, QualityItem}; + /// use actix_web::{http::header::{AcceptCharset, Charset, q, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptCharset(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(AcceptCharset(vec![ /// QualityItem::new(Charset::Us_Ascii, q(0.9)), /// QualityItem::new(Charset::Iso_8859_10, q(0.2)), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptCharset, Charset, QualityItem}; + /// use actix_web::{http::header::{AcceptCharset, Charset, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptCharset(vec![QualityItem::max(Charset::Ext("utf-8".to_owned()))]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(AcceptCharset(vec![QualityItem::max(Charset::Ext("utf-8".to_owned()))])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// [RFC 7231 §5.3.3]: https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.3 diff --git a/actix-web/src/http/header/accept_encoding.rs b/actix-web/src/http/header/accept_encoding.rs index 19d649926..ac85ec9e1 100644 --- a/actix-web/src/http/header/accept_encoding.rs +++ b/actix-web/src/http/header/accept_encoding.rs @@ -11,6 +11,11 @@ common_header! { /// content-codings are acceptable in the response. An `identity` token is used as a synonym /// for "no encoding" in order to communicate when no encoding is preferred. /// + /// # Note + /// This is a request header. Servers should not send `Accept-Encoding` in responses; use the + /// `Content-Encoding` header (or middleware like compression) to describe any content-coding + /// applied to the response body. + /// /// # ABNF /// ```plain /// Accept-Encoding = #( codings [ weight ] ) @@ -26,26 +31,26 @@ common_header! { /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptEncoding, Encoding, Preference, QualityItem}; + /// use actix_web::{http::header::{AcceptEncoding, Encoding, Preference, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptEncoding(vec![QualityItem::max(Preference::Specific(Encoding::gzip()))]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(AcceptEncoding(vec![ + /// QualityItem::max(Preference::Specific(Encoding::gzip())), + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptEncoding, Encoding, QualityItem}; + /// use actix_web::{http::header::{AcceptEncoding, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptEncoding(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(AcceptEncoding(vec![ /// "gzip".parse().unwrap(), /// "br".parse().unwrap(), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` (AcceptEncoding, header::ACCEPT_ENCODING) => (QualityItem>)* diff --git a/actix-web/src/http/header/accept_language.rs b/actix-web/src/http/header/accept_language.rs index b1d588f8d..0f7800d16 100644 --- a/actix-web/src/http/header/accept_language.rs +++ b/actix-web/src/http/header/accept_language.rs @@ -14,6 +14,10 @@ common_header! { /// [RFC 7231 §5.3.5](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5) using language /// ranges defined in [RFC 4647 §2.1](https://datatracker.ietf.org/doc/html/rfc4647#section-2.1). /// + /// # Note + /// This is a request header. Servers should not send `Accept-Language` in responses; use + /// `Content-Language` to describe the language of the response body. + /// /// # ABNF /// ```plain /// Accept-Language = 1#( language-range [ weight ] ) @@ -31,29 +35,25 @@ common_header! { /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptLanguage, QualityItem}; + /// use actix_web::{http::header::{AcceptLanguage, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptLanguage(vec![ - /// "en-US".parse().unwrap(), - /// ]) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(AcceptLanguage(vec!["en-US".parse().unwrap()])) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{AcceptLanguage, QualityItem, q}; + /// use actix_web::{http::header::{AcceptLanguage, q, QualityItem}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// AcceptLanguage(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(AcceptLanguage(vec![ /// "da".parse().unwrap(), /// "en-GB;q=0.8".parse().unwrap(), /// "en;q=0.7".parse().unwrap(), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` (AcceptLanguage, header::ACCEPT_LANGUAGE) => (QualityItem>)* diff --git a/actix-web/src/http/header/content_length.rs b/actix-web/src/http/header/content_length.rs index 557c7c9f5..1bb5545ae 100644 --- a/actix-web/src/http/header/content_length.rs +++ b/actix-web/src/http/header/content_length.rs @@ -214,7 +214,11 @@ mod tests { assert_parse_eq::([" 0"], ContentLength(0)); assert_parse_eq::(["0 "], ContentLength(0)); assert_parse_eq::([" 0 "], ContentLength(0)); + } + #[cfg(target_pointer_width = "64")] + #[test] + fn good_header_max_usize() { // large value (2^64 - 1) assert_parse_eq::( ["18446744073709551615"], diff --git a/actix-web/src/http/header/if_match.rs b/actix-web/src/http/header/if_match.rs index e0b46a6c3..d6e1e2c4b 100644 --- a/actix-web/src/http/header/if_match.rs +++ b/actix-web/src/http/header/if_match.rs @@ -16,6 +16,11 @@ common_header! { /// intends this precondition to prevent the method from being applied if /// there have been any changes to the representation data. /// + /// # Note + /// This is a request header used for conditional requests (typically to avoid lost updates). + /// Servers should not send `If-Match` in responses; use [`ETag`](super::ETag) to describe the + /// current representation instead. + /// /// # ABNF /// ```plain /// If-Match = "*" / 1#entity-tag @@ -27,25 +32,25 @@ common_header! { /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::IfMatch; + /// use actix_web::{http::header::IfMatch, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header(IfMatch::Any); + /// let req = test::TestRequest::default() + /// .insert_header(IfMatch::Any) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{IfMatch, EntityTag}; + /// use actix_web::{http::header::{EntityTag, IfMatch}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// IfMatch::Items(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(IfMatch::Items(vec![ /// EntityTag::new(false, "xyzzy".to_owned()), /// EntityTag::new(false, "foobar".to_owned()), /// EntityTag::new(false, "bazquux".to_owned()), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` (IfMatch, IF_MATCH) => {Any / (EntityTag)+} diff --git a/actix-web/src/http/header/if_modified_since.rs b/actix-web/src/http/header/if_modified_since.rs index 8547ff490..9367db62d 100644 --- a/actix-web/src/http/header/if_modified_since.rs +++ b/actix-web/src/http/header/if_modified_since.rs @@ -10,9 +10,14 @@ crate::http::header::common_header! { /// Transfer of the selected representation's data is avoided if that /// data has not changed. /// + /// # Note + /// This is a request header used for cache validation. Servers should not send + /// `If-Modified-Since` in responses; use [`LastModified`](super::LastModified) / the + /// `Last-Modified` header instead. + /// /// # ABNF /// ```plain - /// If-Unmodified-Since = HTTP-date + /// If-Modified-Since = HTTP-date /// ``` /// /// # Example Values @@ -22,14 +27,13 @@ crate::http::header::common_header! { /// /// ``` /// use std::time::{SystemTime, Duration}; - /// use actix_web::HttpResponse; - /// use actix_web::http::header::IfModifiedSince; + /// use actix_web::{http::header::IfModifiedSince, test}; /// - /// let mut builder = HttpResponse::Ok(); /// let modified = SystemTime::now() - Duration::from_secs(60 * 60 * 24); - /// builder.insert_header( - /// IfModifiedSince(modified.into()) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(IfModifiedSince(modified.into())) + /// .to_http_request(); + /// # let _ = req; /// ``` (IfModifiedSince, IF_MODIFIED_SINCE) => [HttpDate] diff --git a/actix-web/src/http/header/if_none_match.rs b/actix-web/src/http/header/if_none_match.rs index 1a424df96..ed8bbde81 100644 --- a/actix-web/src/http/header/if_none_match.rs +++ b/actix-web/src/http/header/if_none_match.rs @@ -15,6 +15,11 @@ crate::http::header::common_header! { /// can be used for cache validation even if there have been changes to /// the representation data. /// + /// # Note + /// This is a request header used for cache validation (and conditional requests). Servers + /// should not send `If-None-Match` in responses; use [`ETag`](super::ETag) to describe the + /// current representation instead. + /// /// # ABNF /// ```plain /// If-None-Match = "*" / 1#entity-tag @@ -29,25 +34,25 @@ crate::http::header::common_header! { /// /// # Examples /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::IfNoneMatch; + /// use actix_web::{http::header::IfNoneMatch, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header(IfNoneMatch::Any); + /// let req = test::TestRequest::default() + /// .insert_header(IfNoneMatch::Any) + /// .to_http_request(); + /// # let _ = req; /// ``` /// /// ``` - /// use actix_web::HttpResponse; - /// use actix_web::http::header::{IfNoneMatch, EntityTag}; + /// use actix_web::{http::header::{EntityTag, IfNoneMatch}, test}; /// - /// let mut builder = HttpResponse::Ok(); - /// builder.insert_header( - /// IfNoneMatch::Items(vec![ + /// let req = test::TestRequest::default() + /// .insert_header(IfNoneMatch::Items(vec![ /// EntityTag::new(false, "xyzzy".to_owned()), /// EntityTag::new(false, "foobar".to_owned()), /// EntityTag::new(false, "bazquux".to_owned()), - /// ]) - /// ); + /// ])) + /// .to_http_request(); + /// # let _ = req; /// ``` (IfNoneMatch, IF_NONE_MATCH) => {Any / (EntityTag)+} diff --git a/actix-web/src/http/header/if_range.rs b/actix-web/src/http/header/if_range.rs index 3e8727ab0..d611a5897 100644 --- a/actix-web/src/http/header/if_range.rs +++ b/actix-web/src/http/header/if_range.rs @@ -22,6 +22,9 @@ use crate::{error::ParseError, http::header, HttpMessage}; /// representation is unchanged, send me the part(s) that I am requesting /// in Range; otherwise, send me the entire representation. /// +/// # Note +/// This is a request header. Servers should not send `If-Range` in responses. +/// /// # ABNF /// ```plain /// If-Range = entity-tag / HTTP-date @@ -34,26 +37,23 @@ use crate::{error::ParseError, http::header, HttpMessage}; /// /// # Examples /// ``` -/// use actix_web::HttpResponse; -/// use actix_web::http::header::{EntityTag, IfRange}; +/// use actix_web::{http::header::{EntityTag, IfRange}, test}; /// -/// let mut builder = HttpResponse::Ok(); -/// builder.insert_header( -/// IfRange::EntityTag( -/// EntityTag::new(false, "abc".to_owned()) -/// ) -/// ); +/// let req = test::TestRequest::default() +/// .insert_header(IfRange::EntityTag(EntityTag::new(false, "abc".to_owned()))) +/// .to_http_request(); +/// # let _ = req; /// ``` /// /// ``` /// use std::time::{Duration, SystemTime}; -/// use actix_web::{http::header::IfRange, HttpResponse}; +/// use actix_web::{http::header::IfRange, test}; /// -/// let mut builder = HttpResponse::Ok(); /// let fetched = SystemTime::now() - Duration::from_secs(60 * 60 * 24); -/// builder.insert_header( -/// IfRange::Date(fetched.into()) -/// ); +/// let req = test::TestRequest::default() +/// .insert_header(IfRange::Date(fetched.into())) +/// .to_http_request(); +/// # let _ = req; /// ``` #[derive(Clone, Debug, PartialEq, Eq)] pub enum IfRange { diff --git a/actix-web/src/http/header/if_unmodified_since.rs b/actix-web/src/http/header/if_unmodified_since.rs index afa4eb8e5..af32d7e46 100644 --- a/actix-web/src/http/header/if_unmodified_since.rs +++ b/actix-web/src/http/header/if_unmodified_since.rs @@ -10,6 +10,11 @@ crate::http::header::common_header! { /// This field accomplishes the same purpose as If-Match for cases where /// the user agent does not have an entity-tag for the representation. /// + /// # Note + /// This is a request header used for conditional requests. Servers should not send + /// `If-Unmodified-Since` in responses; use [`LastModified`](super::LastModified) / the + /// `Last-Modified` header instead. + /// /// # ABNF /// ```plain /// If-Unmodified-Since = HTTP-date @@ -22,14 +27,13 @@ crate::http::header::common_header! { /// /// ``` /// use std::time::{SystemTime, Duration}; - /// use actix_web::HttpResponse; - /// use actix_web::http::header::IfUnmodifiedSince; + /// use actix_web::{http::header::IfUnmodifiedSince, test}; /// - /// let mut builder = HttpResponse::Ok(); /// let modified = SystemTime::now() - Duration::from_secs(60 * 60 * 24); - /// builder.insert_header( - /// IfUnmodifiedSince(modified.into()) - /// ); + /// let req = test::TestRequest::default() + /// .insert_header(IfUnmodifiedSince(modified.into())) + /// .to_http_request(); + /// # let _ = req; /// ``` (IfUnmodifiedSince, IF_UNMODIFIED_SINCE) => [HttpDate] diff --git a/actix-web/src/http/header/range.rs b/actix-web/src/http/header/range.rs index 4a5d95d93..0f0958e5c 100644 --- a/actix-web/src/http/header/range.rs +++ b/actix-web/src/http/header/range.rs @@ -15,6 +15,10 @@ use super::{Header, HeaderName, HeaderValue, InvalidHeaderValue, TryIntoHeaderVa /// only one or more sub-ranges of the selected representation data, rather than the entire selected /// representation data. /// +/// # Note +/// This is a request header. Servers should not send `Range` in responses; use +/// [`ContentRange`](super::ContentRange) / the `Content-Range` header for partial responses. +/// /// # ABNF /// ```plain /// Range = byte-ranges-specifier / other-ranges-specifier @@ -42,16 +46,18 @@ use super::{Header, HeaderName, HeaderValue, InvalidHeaderValue, TryIntoHeaderVa /// /// # Examples /// ``` -/// use actix_web::http::header::{Range, ByteRangeSpec}; -/// use actix_web::HttpResponse; +/// use actix_web::{http::header::{ByteRangeSpec, Range}, test}; /// -/// let mut builder = HttpResponse::Ok(); -/// builder.insert_header(Range::Bytes( -/// vec![ByteRangeSpec::FromTo(1, 100), ByteRangeSpec::From(200)] -/// )); -/// builder.insert_header(Range::Unregistered("letters".to_owned(), "a-f".to_owned())); -/// builder.insert_header(Range::bytes(1, 100)); -/// builder.insert_header(Range::bytes_multi(vec![(1, 100), (200, 300)])); +/// let req = test::TestRequest::default() +/// .insert_header(Range::Bytes(vec![ +/// ByteRangeSpec::FromTo(1, 100), +/// ByteRangeSpec::From(200), +/// ])) +/// .insert_header(Range::Unregistered("letters".to_owned(), "a-f".to_owned())) +/// .insert_header(Range::bytes(1, 100)) +/// .insert_header(Range::bytes_multi(vec![(1, 100), (200, 300)])) +/// .to_http_request(); +/// # let _ = req; /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum Range { diff --git a/actix-web/src/introspection.rs b/actix-web/src/introspection.rs new file mode 100644 index 000000000..32a55143a --- /dev/null +++ b/actix-web/src/introspection.rs @@ -0,0 +1,1337 @@ +//! Experimental route introspection helpers. +//! +//! Enabled with the `experimental-introspection` feature. +//! +//! What it reports: +//! - Configured routes with their patterns, method guards, guard details, and resource metadata +//! (`resource_name`, `resource_type`, `scope_depth`). +//! - Reachability hints for routes that may be shadowed by registration order or conflicting +//! method guards. +//! - External resources (used only for URL generation) in a separate report, including the scope +//! path where they were registered. External resources never participate in request routing. +//! +//! Notes: +//! - Method lists are best-effort and derived only from explicit method guards; an empty list means +//! no explicit method guards were observed for the node. +//! - Guard and method lists are aggregated per `full_path` and do not preserve per-route +//! correlations when multiple routes/services share the same path. +//! - Reachability hints are best-effort and should be treated as diagnostics, not a hard guarantee. +//! +//! This feature is intended for local/non-production use. Avoid exposing introspection endpoints +//! in production, since reports can include sensitive configuration details. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as FmtWrite, +}; + +use serde::Serialize; + +use crate::{ + dev::ResourceDef, + guard::{Guard, GuardDetail}, + http::Method, +}; + +#[derive(Clone)] +struct RouteDetail { + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + is_resource: bool, +} + +/// Input data for registering routes with the introspector. +#[derive(Clone)] +pub(crate) struct RouteInfo { + full_path: String, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, +} + +impl RouteInfo { + pub(crate) fn new( + full_path: String, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + ) -> Self { + Self { + full_path, + methods, + guards, + guard_details, + patterns, + resource_name, + } + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GuardReport { + pub name: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub details: Vec, +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GuardDetailReport { + HttpMethods { methods: Vec }, + Headers { headers: Vec }, + Generic { value: String }, +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HeaderReport { + pub name: String, + pub value: String, +} + +/// A report item for an external resource configured for URL generation. +/// +/// `origin_scope` is the scope path where the external resource was registered. It is informational +/// only and does not affect URL generation or routing; external resources are always global. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExternalResourceReportItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub patterns: Vec, + pub origin_scope: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegistrationKind { + Service, + Route, +} + +#[derive(Clone)] +struct Registration { + order: usize, + kind: RegistrationKind, + scope_id: Option, + parent_scope_id: Option, + full_path: String, + is_prefix: bool, + methods: Vec, + guards: Vec, +} + +#[derive(Clone)] +struct ShadowingContext { + path: String, + order: usize, +} + +/// Node type within an introspection tree. +#[non_exhaustive] +#[derive(Debug, Clone, Copy)] +pub enum ResourceType { + /// The application root. + App, + /// A scope/prefix path. + Scope, + /// A resource (route) path. + Resource, +} + +fn resource_type_label(kind: ResourceType) -> &'static str { + match kind { + ResourceType::App => "app", + ResourceType::Scope => "scope", + ResourceType::Resource => "resource", + } +} + +/// A node in the introspection tree. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct IntrospectionNode { + /// The node's classification. + pub kind: ResourceType, + /// The path segment used for this node. + pub pattern: String, + /// The full path for this node. + pub full_path: String, + /// HTTP methods derived from explicit method guards. + pub methods: Vec, + /// Guard names attached to this node. + pub guards: Vec, + /// Structured guard details, when available. + pub guard_details: Vec, + /// Resource name, when configured. + pub resource_name: Option, + /// Original patterns used for this resource. + pub patterns: Vec, + /// Child nodes under this prefix. + pub children: Vec, + /// True if the node might be unreachable at runtime. + pub potentially_unreachable: bool, + /// Reasons for potential unreachability. + pub reachability_notes: Vec, +} + +/// A flattened report item for a route. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize)] +pub struct IntrospectionReportItem { + /// Full path for the route. + pub full_path: String, + /// Methods derived from explicit method guards. + /// + /// An empty list indicates no explicit method guards were observed for the node. + pub methods: Vec, + /// Guard names attached to the route. + /// + /// This is aggregated per `full_path` and does not necessarily represent a single matching + /// condition when multiple routes/services share the same path. + pub guards: Vec, + /// Structured guard details, when available. + /// + /// Includes method guards even if `guards` filters them out for readability. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guards_detail: Vec, + /// Resource name, when configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_name: Option, + /// Original patterns used for this resource. + /// + /// These are raw ResourceDef patterns (may be relative to a scope), not expanded full paths. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub patterns: Vec, + /// The type of node represented by the report item. + pub resource_type: String, + /// Depth within this report tree (root = 0). + /// + /// This currently corresponds to the number of path segments (for example, `/foo` has depth 1 + /// and `/foo/bar` has depth 2). + pub scope_depth: usize, + /// True if the route might be unreachable at runtime. + #[serde(skip_serializing_if = "is_false")] + pub potentially_unreachable: bool, + /// Reasons for potential unreachability. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub reachability_notes: Vec, +} + +impl IntrospectionNode { + pub fn new(kind: ResourceType, pattern: String, full_path: String) -> Self { + IntrospectionNode { + kind, + pattern, + full_path, + methods: Vec::new(), + guards: Vec::new(), + guard_details: Vec::new(), + resource_name: None, + patterns: Vec::new(), + children: Vec::new(), + potentially_unreachable: false, + reachability_notes: Vec::new(), + } + } +} + +impl From<&IntrospectionNode> for Vec { + fn from(node: &IntrospectionNode) -> Self { + fn collect_report_items( + node: &IntrospectionNode, + report_items: &mut Vec, + depth: usize, + ) { + let include_node = matches!(node.kind, ResourceType::Resource) + || !node.methods.is_empty() + || !node.guards.is_empty() + || node.potentially_unreachable; + + if include_node { + let method_names = node + .methods + .iter() + .map(|m| m.to_string()) + .collect::>(); + let filtered_guards = filter_guard_names(&node.guards, &node.methods); + + report_items.push(IntrospectionReportItem { + full_path: node.full_path.clone(), + methods: method_names, + guards: filtered_guards, + guards_detail: node.guard_details.clone(), + resource_name: node.resource_name.clone(), + patterns: node.patterns.clone(), + resource_type: resource_type_label(node.kind).to_string(), + scope_depth: depth, + potentially_unreachable: node.potentially_unreachable, + reachability_notes: node.reachability_notes.clone(), + }); + } + + for child in &node.children { + collect_report_items(child, report_items, depth + 1); + } + } + + let mut report_items = Vec::new(); + collect_report_items(node, &mut report_items, 0); + report_items + } +} + +/// Collects route details during app configuration. +#[derive(Clone, Default)] +pub(crate) struct IntrospectionCollector { + details: BTreeMap, + registrations: Vec, + externals: Vec, + next_registration_order: usize, + next_scope_id: usize, +} + +impl IntrospectionCollector { + /// Creates a new, empty collector. + pub(crate) fn new() -> Self { + Self { + details: BTreeMap::new(), + registrations: Vec::new(), + externals: Vec::new(), + next_registration_order: 0, + next_scope_id: 0, + } + } + + pub(crate) fn next_scope_id(&mut self) -> usize { + let scope_id = self.next_scope_id; + self.next_scope_id += 1; + scope_id + } + + pub(crate) fn register_service( + &mut self, + info: RouteInfo, + is_resource: bool, + is_prefix: bool, + scope_id: Option, + parent_scope_id: Option, + ) { + let full_path = normalize_path(&info.full_path); + + self.register_pattern_detail(&full_path, &info, is_resource); + + self.registrations.push(Registration { + order: self.next_registration_order, + kind: RegistrationKind::Service, + scope_id, + parent_scope_id, + full_path, + is_prefix, + methods: info.methods, + guards: info.guards, + }); + self.next_registration_order += 1; + } + + pub(crate) fn register_route(&mut self, info: RouteInfo, scope_id: Option) { + let full_path = normalize_path(&info.full_path); + + self.register_pattern_detail(&full_path, &info, true); + + self.registrations.push(Registration { + order: self.next_registration_order, + kind: RegistrationKind::Route, + scope_id, + parent_scope_id: None, + full_path, + is_prefix: false, + methods: info.methods, + guards: info.guards, + }); + self.next_registration_order += 1; + } + + pub(crate) fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) { + let report = external_report_from_rdef(rdef, origin_scope); + + if let Some(name) = report.name.as_deref() { + if let Some(existing) = self + .externals + .iter_mut() + .find(|item| item.name.as_deref() == Some(name)) + { + *existing = report; + return; + } + } + + if !self.externals.contains(&report) { + self.externals.push(report); + } + } + + /// Registers details for a route pattern. + fn register_pattern_detail(&mut self, full_path: &str, info: &RouteInfo, is_resource: bool) { + let full_path = normalize_path(full_path); + + self.details + .entry(full_path) + .and_modify(|d| { + update_unique(&mut d.methods, &info.methods); + update_unique(&mut d.guards, &info.guards); + merge_guard_reports(&mut d.guard_details, &info.guard_details); + update_unique(&mut d.patterns, &info.patterns); + if d.resource_name.is_none() { + d.resource_name = info.resource_name.clone(); + } + if !d.is_resource && is_resource { + d.is_resource = true; + } + }) + .or_insert(RouteDetail { + methods: info.methods.clone(), + guards: info.guards.clone(), + guard_details: info.guard_details.clone(), + patterns: info.patterns.clone(), + resource_name: info.resource_name.clone(), + is_resource, + }); + } + + /// Produces the finalized introspection tree. + pub(crate) fn finalize(&mut self) -> IntrospectionTree { + let detail_registry = std::mem::take(&mut self.details); + let registrations = std::mem::take(&mut self.registrations); + let externals = std::mem::take(&mut self.externals); + let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into()); + + for (full_path, _) in detail_registry.iter() { + let parts = split_path_segments(full_path); + let mut current_node = &mut root; + let mut assembled = String::new(); + + for part in parts.iter() { + assembled.push('/'); + assembled.push_str(part); + + let child_full_path = assembled.clone(); + let existing_child_index = current_node + .children + .iter() + .position(|n| n.pattern == *part); + + let child_index = if let Some(idx) = existing_child_index { + idx + } else { + let kind = if detail_registry + .get(&child_full_path) + .is_some_and(|d| d.is_resource) + { + ResourceType::Resource + } else { + ResourceType::Scope + }; + let new_node = IntrospectionNode::new(kind, part.to_string(), child_full_path); + current_node.children.push(new_node); + current_node.children.len() - 1 + }; + + current_node = &mut current_node.children[child_index]; + + if let Some(detail) = detail_registry.get(¤t_node.full_path) { + update_unique(&mut current_node.methods, &detail.methods); + update_unique(&mut current_node.guards, &detail.guards); + merge_guard_reports(&mut current_node.guard_details, &detail.guard_details); + update_unique(&mut current_node.patterns, &detail.patterns); + if current_node.resource_name.is_none() { + current_node.resource_name = detail.resource_name.clone(); + } + } + } + } + + let reachability = analyze_reachability(®istrations); + apply_reachability(&mut root, &reachability); + + IntrospectionTree { root, externals } + } +} + +/// The finalized introspection tree. +#[non_exhaustive] +#[derive(Clone)] +pub struct IntrospectionTree { + /// Root node of the tree. + pub root: IntrospectionNode, + /// External resources configured for URL generation. + pub externals: Vec, +} + +impl IntrospectionTree { + /// Returns a formatted, human-readable report. + pub fn report_as_text(&self) -> String { + warn_release_mode_once(); + let report_items: Vec = (&self.root).into(); + + let mut buf = String::new(); + for item in report_items { + let full_path = sanitize_text(&item.full_path); + let methods = item + .methods + .iter() + .map(|method| sanitize_text(method)) + .collect::>(); + let guards = item + .guards + .iter() + .map(|guard| sanitize_text(guard)) + .collect::>(); + writeln!( + buf, + "{} => Methods: {:?} | Guards: {:?}{}", + full_path, + methods, + guards, + format_reachability(&item) + ) + .unwrap(); + } + + buf + } + + /// Returns a JSON report of configured routes. + pub fn report_as_json(&self) -> String { + warn_release_mode_once(); + let report_items: Vec = (&self.root).into(); + serde_json::to_string_pretty(&report_items).unwrap() + } + + /// Returns a JSON report of external resources. + pub fn report_externals_as_json(&self) -> String { + warn_release_mode_once(); + serde_json::to_string_pretty(&self.externals).unwrap() + } +} + +pub(crate) fn guard_reports_from_iter<'a, I>(guards: I) -> Vec +where + I: IntoIterator>, +{ + guards + .into_iter() + .map(|guard| { + let mut details = Vec::new(); + if let Some(guard_details) = guard.details() { + for detail in guard_details { + merge_guard_detail_reports(&mut details, detail.into()); + } + } + GuardReport { + name: guard.name(), + details, + } + }) + .collect() +} + +impl From for GuardDetailReport { + fn from(detail: GuardDetail) -> Self { + match detail { + GuardDetail::HttpMethods(methods) => GuardDetailReport::HttpMethods { methods }, + GuardDetail::Headers(headers) => GuardDetailReport::Headers { + headers: headers + .into_iter() + .map(|(name, value)| HeaderReport { name, value }) + .collect(), + }, + GuardDetail::Generic(value) => GuardDetailReport::Generic { value }, + } + } +} + +pub(crate) fn external_report_from_rdef( + rdef: &ResourceDef, + origin_scope: &str, +) -> ExternalResourceReportItem { + ExternalResourceReportItem { + name: rdef.name().map(|name| name.to_string()), + patterns: rdef + .pattern_iter() + .map(|pattern| pattern.to_string()) + .collect(), + origin_scope: normalize_path(origin_scope), + } +} + +pub(crate) fn expand_patterns(prefix: &str, rdef: &ResourceDef) -> Vec { + let mut full_paths = Vec::new(); + + if prefix.is_empty() { + for pat in rdef.pattern_iter() { + full_paths.push(normalize_path(pat)); + } + + return full_paths; + } + + let joined = ResourceDef::root_prefix(prefix).join(rdef); + + for pat in joined.pattern_iter() { + full_paths.push(normalize_path(pat)); + } + + full_paths +} + +fn analyze_reachability(registrations: &[Registration]) -> BTreeMap> { + let shadowed_scopes = shadowed_scope_context(registrations); + let shadowed_routes = shadowed_route_context(registrations); + + let mut notes_by_path: BTreeMap> = BTreeMap::new(); + + for reg in registrations { + let mut notes = Vec::new(); + + if let Some(scope_id) = reg.scope_id { + if let Some(context) = shadowed_scopes.get(&scope_id) { + notes.push("shadowed_by_scope".to_string()); + notes.push(format!("shadowed_by_path:{}", context.path)); + notes.push(format!("shadowed_by_order:{}", context.order)); + } + } + + if reg.kind == RegistrationKind::Route { + if let Some(context) = shadowed_routes.get(&(reg.scope_id, reg.full_path.clone())) { + notes.push("shadowed_by_route".to_string()); + notes.push(format!("shadowed_by_path:{}", context.path)); + notes.push(format!("shadowed_by_order:{}", context.order)); + } + + if has_conflicting_methods(®.methods, ®.guards) { + notes.push("conflicting_method_guards".to_string()); + } + } + + if !notes.is_empty() { + let entry = notes_by_path.entry(reg.full_path.clone()).or_default(); + for note in notes { + entry.insert(note); + } + } + } + + notes_by_path + .into_iter() + .map(|(path, notes)| (path, notes.into_iter().collect())) + .collect() +} + +fn shadowed_scope_context(registrations: &[Registration]) -> BTreeMap { + let mut groups: BTreeMap<(Option, String), Vec<&Registration>> = BTreeMap::new(); + + for reg in registrations { + if reg.kind != RegistrationKind::Service || !reg.is_prefix { + continue; + } + + if reg.scope_id.is_none() { + continue; + } + + groups + .entry((reg.parent_scope_id, reg.full_path.clone())) + .or_default() + .push(reg); + } + + let mut shadowed = BTreeMap::new(); + + for regs in groups.values_mut() { + regs.sort_by_key(|reg| reg.order); + + let mut shadowing_reg = None; + + for reg in regs.iter() { + if matches_all(®.methods, ®.guards) { + shadowing_reg = Some(*reg); + break; + } + } + + if let Some(shadowing) = shadowing_reg { + for reg in regs.iter() { + if reg.order > shadowing.order { + let scope_id = reg.scope_id.expect("scope_id must exist"); + shadowed.insert( + scope_id, + ShadowingContext { + path: shadowing.full_path.clone(), + order: shadowing.order, + }, + ); + } + } + } + } + + shadowed +} + +fn shadowed_route_context( + registrations: &[Registration], +) -> BTreeMap<(Option, String), ShadowingContext> { + let mut groups: BTreeMap<(Option, String), Vec<&Registration>> = BTreeMap::new(); + + for reg in registrations { + if reg.kind != RegistrationKind::Route { + continue; + } + + groups + .entry((reg.scope_id, reg.full_path.clone())) + .or_default() + .push(reg); + } + + let mut shadowed = BTreeMap::new(); + + for (key, regs) in groups { + let mut regs = regs; + regs.sort_by_key(|reg| reg.order); + + for idx in 1..regs.len() { + let current = regs[idx]; + let current_methods = method_set(¤t.methods); + + if !guards_only_methods(¤t.guards, ¤t.methods) { + continue; + } + + let mut shadowing_reg = None; + + for earlier in ®s[..idx] { + if !guards_only_methods(&earlier.guards, &earlier.methods) { + continue; + } + + if earlier.methods.is_empty() { + shadowing_reg = Some(*earlier); + break; + } + + let earlier_methods = method_set(&earlier.methods); + if !current_methods.is_empty() && current_methods.is_subset(&earlier_methods) { + shadowing_reg = Some(*earlier); + break; + } + } + + if let Some(reg) = shadowing_reg { + shadowed.insert( + key.clone(), + ShadowingContext { + path: reg.full_path.clone(), + order: reg.order, + }, + ); + break; + } + } + } + + shadowed +} + +fn apply_reachability(root: &mut IntrospectionNode, notes: &BTreeMap>) { + fn apply(node: &mut IntrospectionNode, notes: &BTreeMap>) { + if let Some(node_notes) = notes.get(&node.full_path) { + node.potentially_unreachable = true; + node.reachability_notes = node_notes.clone(); + } + + for child in &mut node.children { + apply(child, notes); + } + } + + apply(root, notes); +} + +fn normalize_path(path: &str) -> String { + if path.is_empty() { + return "/".to_string(); + } + + if path.starts_with('/') { + path.to_string() + } else { + let mut buf = String::with_capacity(path.len() + 1); + buf.push('/'); + buf.push_str(path); + buf + } +} + +fn split_path_segments(path: &str) -> Vec<&str> { + let trimmed = path.strip_prefix('/').unwrap_or(path); + + if trimmed.is_empty() { + return vec![""]; + } + + trimmed.split('/').collect() +} + +fn matches_all(methods: &[Method], guards: &[String]) -> bool { + methods.is_empty() && filter_guard_names(guards, methods).is_empty() +} + +fn guards_only_methods(guards: &[String], methods: &[Method]) -> bool { + filter_guard_names(guards, methods).is_empty() +} + +fn has_conflicting_methods(methods: &[Method], guards: &[String]) -> bool { + // This check is best-effort: it tries to determine if the conjunction of method guards can + // match any single HTTP method. It relies on guard names since introspection details flatten + // guard structure. + if method_set(methods).len() <= 1 { + return false; + } + + fn split_top_level_args(mut args: &str) -> Vec<&str> { + args = args.trim(); + if args.is_empty() { + return Vec::new(); + } + + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + + for (idx, ch) in args.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(args[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + + parts.push(args[start..].trim()); + parts.into_iter().filter(|s| !s.is_empty()).collect() + } + + fn parse_method(name: &str) -> Option> { + name.trim().parse::().ok().map(|method| { + let mut set = BTreeSet::new(); + set.insert(method.to_string()); + set + }) + } + + fn union_methods( + left: Option>, + right: Option>, + ) -> Option> { + match (left, right) { + // If any branch doesn't constrain methods, the disjunction doesn't either. + (None, _) | (_, None) => None, + (Some(mut a), Some(b)) => { + a.extend(b); + Some(a) + } + } + } + + fn intersect_methods( + left: Option>, + right: Option>, + ) -> Option> { + match (left, right) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) => Some(a.intersection(&b).cloned().collect()), + } + } + + fn guard_possible_methods(name: &str) -> Option> { + let name = name.trim(); + if name.is_empty() { + return None; + } + + if let Some(set) = parse_method(name) { + return Some(set); + } + + if let Some(inner) = name + .strip_prefix("AnyGuard(") + .and_then(|s| s.strip_suffix(')')) + { + let mut acc = Some(BTreeSet::new()); + for arg in split_top_level_args(inner) { + acc = union_methods(acc, guard_possible_methods(arg)); + if acc.is_none() { + break; + } + } + return acc; + } + + if let Some(inner) = name + .strip_prefix("AllGuard(") + .and_then(|s| s.strip_suffix(')')) + { + let mut acc = None; + for arg in split_top_level_args(inner) { + acc = intersect_methods(acc, guard_possible_methods(arg)); + if matches!(acc, Some(ref set) if set.is_empty()) { + break; + } + } + return acc; + } + + // `Not(...)` (and unknown/custom guard names) are treated as not restricting methods. + None + } + + let mut possible = None; + for guard in guards { + possible = intersect_methods(possible, guard_possible_methods(guard)); + if matches!(possible, Some(ref set) if set.is_empty()) { + return true; + } + } + + false +} + +fn method_set(methods: &[Method]) -> BTreeSet { + methods.iter().map(|m| m.to_string()).collect() +} + +fn filter_guard_names(guards: &[String], methods: &[Method]) -> Vec { + let method_names = method_set(methods); + guards + .iter() + .filter(|guard| !method_names.iter().any(|method| method == *guard)) + .cloned() + .collect() +} + +fn merge_guard_reports(existing: &mut Vec, incoming: &[GuardReport]) { + for report in incoming { + if let Some(existing_report) = existing.iter_mut().find(|r| r.name == report.name) { + for detail in &report.details { + merge_guard_detail_reports(&mut existing_report.details, detail.clone()); + } + } else { + existing.push(report.clone()); + } + } +} + +fn merge_guard_detail_reports(existing: &mut Vec, incoming: GuardDetailReport) { + match incoming { + GuardDetailReport::HttpMethods { methods } => { + if let Some(existing_methods) = existing.iter_mut().find_map(|detail| { + if let GuardDetailReport::HttpMethods { methods } = detail { + Some(methods) + } else { + None + } + }) { + update_unique(existing_methods, &methods); + } else { + existing.push(GuardDetailReport::HttpMethods { methods }); + } + } + GuardDetailReport::Headers { headers } => { + if let Some(existing_headers) = existing.iter_mut().find_map(|detail| { + if let GuardDetailReport::Headers { headers } = detail { + Some(headers) + } else { + None + } + }) { + update_unique(existing_headers, &headers); + } else { + existing.push(GuardDetailReport::Headers { headers }); + } + } + GuardDetailReport::Generic { value } => { + let detail = GuardDetailReport::Generic { value }; + if !existing.contains(&detail) { + existing.push(detail); + } + } + } +} + +fn update_unique(existing: &mut Vec, new_items: &[T]) { + for item in new_items { + if !existing.contains(item) { + existing.push(item.clone()); + } + } +} + +fn is_false(value: &bool) -> bool { + !*value +} + +fn format_reachability(item: &IntrospectionReportItem) -> String { + if !item.potentially_unreachable { + return String::new(); + } + + if item.reachability_notes.is_empty() { + " | PotentiallyUnreachable".to_string() + } else { + let notes = item + .reachability_notes + .iter() + .map(|note| sanitize_text(note)) + .collect::>(); + format!(" | PotentiallyUnreachable | Notes: {:?}", notes) + } +} + +fn sanitize_text(value: &str) -> String { + // Escape control characters to keep the text report format stable in logs/terminals. + let mut buf = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_control() { + let code = ch as u32; + if code <= 0xFF { + write!(buf, "\\x{:02x}", code).unwrap(); + } else { + write!(buf, "\\u{{{:x}}}", code).unwrap(); + } + } else { + buf.push(ch); + } + } + buf +} + +fn warn_release_mode_once() { + #[cfg(not(debug_assertions))] + { + use std::sync::Once; + + static WARN_ONCE: Once = Once::new(); + WARN_ONCE.call_once(|| { + log::warn!( + "experimental-introspection is intended for local/non-production use; \ +avoid exposing introspection endpoints in production" + ); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route_info( + full_path: &str, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + ) -> RouteInfo { + RouteInfo::new( + full_path.to_string(), + methods, + guards, + guard_details, + patterns, + resource_name, + ) + } + + #[test] + fn report_includes_resources_without_methods() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/no-guards", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/no-guards") + .expect("missing resource without guards"); + + assert!(item.methods.is_empty()); + assert!(item.guards.is_empty()); + assert_eq!(item.resource_type, "resource"); + assert!(!item.potentially_unreachable); + assert!(item.reachability_notes.is_empty()); + } + + #[test] + fn report_includes_guard_details_and_metadata() { + let mut collector = IntrospectionCollector::new(); + let guard_details = vec![GuardReport { + name: "Header(accept, text/plain)".to_string(), + details: vec![GuardDetailReport::Headers { + headers: vec![HeaderReport { + name: "accept".to_string(), + value: "text/plain".to_string(), + }], + }], + }]; + + let info = route_info( + "/meta", + vec![Method::GET], + vec!["Header(accept, text/plain)".to_string()], + guard_details, + vec!["/meta".to_string()], + Some("meta-resource".to_string()), + ); + collector.register_route(info, None); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/meta") + .expect("missing metadata route"); + + assert_eq!(item.resource_name.as_deref(), Some("meta-resource")); + assert!(item.patterns.contains(&"/meta".to_string())); + assert_eq!(item.resource_type, "resource"); + assert_eq!(item.scope_depth, 1); + assert_eq!(item.guards_detail.len(), 1); + } + + #[test] + fn expand_patterns_handles_scope_paths() { + let empty = ResourceDef::new(""); + let slash = ResourceDef::new("/"); + + assert_eq!(expand_patterns("/app", &empty), vec!["/app"]); + assert_eq!(expand_patterns("/app", &slash), vec!["/app/"]); + assert_eq!(expand_patterns("/app/", &empty), vec!["/app/"]); + assert_eq!(expand_patterns("/app/", &slash), vec!["/app//"]); + } + + #[test] + fn expand_patterns_handles_multi_patterns() { + let rdef = ResourceDef::new(["/a", "/b"]); + assert_eq!(expand_patterns("/api", &rdef), vec!["/api/a", "/api/b"]); + } + + #[test] + fn conflicting_method_guards_mark_unreachable() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/all-guard", + vec![Method::GET, Method::POST], + vec!["AllGuard(GET, POST)".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/all-guard") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"conflicting_method_guards".to_string())); + } + + #[test] + fn allguard_anyguard_does_not_mark_conflict_when_methods_are_feasible() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/feasible", + vec![Method::GET, Method::POST], + vec![ + "AllGuard(AnyGuard(GET, POST), Header(x, y))".to_string(), + "Header(x, y)".to_string(), + ], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/feasible") + .expect("missing route"); + + assert!(!item.potentially_unreachable); + assert!(!item + .reachability_notes + .contains(&"conflicting_method_guards".to_string())); + } + + #[test] + fn allguard_anyguard_marks_conflict_when_methods_are_impossible() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/impossible", + vec![Method::GET, Method::POST], + vec!["AllGuard(GET, AnyGuard(POST))".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/impossible") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"conflicting_method_guards".to_string())); + } + + #[test] + fn shadowed_scopes_mark_routes() { + let mut collector = IntrospectionCollector::new(); + + let scope_a = collector.next_scope_id(); + let info = route_info( + "/extra", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_service(info, true, true, Some(scope_a), None); + let info = route_info( + "/extra/ping", + vec![Method::GET], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, Some(scope_a)); + + let scope_b = collector.next_scope_id(); + let info = route_info( + "/extra", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_service(info, true, true, Some(scope_b), None); + let info = route_info( + "/extra/ping", + vec![Method::POST], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, Some(scope_b)); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/extra/ping") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"shadowed_by_scope".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_path:/extra".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_order:0".to_string())); + } + + #[test] + fn shadowed_routes_include_context() { + let mut collector = IntrospectionCollector::new(); + + let info = route_info( + "/shadow", + vec![Method::GET], + vec!["GET".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let info = route_info( + "/shadow", + vec![Method::GET], + vec!["GET".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/shadow") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"shadowed_by_route".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_path:/shadow".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_order:0".to_string())); + } +} diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index d490706ff..53ee93c83 100644 --- a/actix-web/src/lib.rs +++ b/actix-web/src/lib.rs @@ -55,7 +55,7 @@ //! - SSL support using OpenSSL or Rustls //! - Middlewares ([Logger, Session, CORS, etc](middleware)) //! - Integrates with the [`awc` HTTP client](https://docs.rs/awc/) -//! - Runs on stable Rust 1.54+ +//! - Runs on stable Rust 1.88+ //! //! # Crate Features //! - `cookies` - cookies support (enabled by default) @@ -69,10 +69,18 @@ //! - `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 +//! +//! ## Experimental features +//! To enable faster release iterations, we mark some features as experimental. +//! These features are prefixed with `experimental` and a breaking change may happen at any release. +//! Please use them in a production environment at your own risk. +//! +//! - `experimental-introspection` - route and method reporting utilities for local diagnostics +//! and tooling. See `examples/introspection.rs` and `examples/introspection_multi_servers.rs`. #![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")] @@ -108,6 +116,9 @@ mod thin_data; pub(crate) mod types; pub mod web; +#[cfg(feature = "experimental-introspection")] +pub mod introspection; + #[doc(inline)] pub use crate::error::Result; pub use crate::{ 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/compress.rs b/actix-web/src/middleware/compress.rs index 7f0d8a4fb..38bb909fd 100644 --- a/actix-web/src/middleware/compress.rs +++ b/actix-web/src/middleware/compress.rs @@ -449,6 +449,29 @@ mod tests { assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); assert!(test::read_body(res).await.is_empty()); } + + #[actix_rt::test] + async fn skips_compression_partial_content() { + let app = test::init_service({ + App::new() + .wrap(Compress::default()) + .default_service(web::to(|| { + HttpResponse::PartialContent() + .insert_header((header::CONTENT_TYPE, "text/plain")) + .insert_header((header::CONTENT_RANGE, "bytes 0-10/100")) + .body(TEXT_DATA) + })) + }) + .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::PARTIAL_CONTENT); + assert!(!res.headers().contains_key(header::CONTENT_ENCODING)); + assert_eq!(test::read_body(res).await, TEXT_DATA.as_bytes()); + } } #[cfg(feature = "compress-brotli")] diff --git a/actix-web/src/middleware/normalize.rs b/actix-web/src/middleware/normalize.rs index 482107ecb..e98fc4e7a 100644 --- a/actix-web/src/middleware/normalize.rs +++ b/actix-web/src/middleware/normalize.rs @@ -1,6 +1,7 @@ //! For middleware documentation, see [`NormalizePath`]. use actix_http::uri::{PathAndQuery, Uri}; +use actix_router::Url; use actix_service::{Service, Transform}; use actix_utils::future::{ready, Ready}; use bytes::Bytes; @@ -14,6 +15,28 @@ use crate::{ Error, }; +fn build_byte_index_map(old_path: &str, new_path: &str) -> Vec { + let old_path = old_path.as_bytes(); + let new_path = new_path.as_bytes(); + + let mut map = Vec::with_capacity(old_path.len() + 1); + map.push(0); + + let mut old_idx = 0usize; + let mut new_idx = 0usize; + + while old_idx < old_path.len() { + if new_idx < new_path.len() && old_path[old_idx] == new_path[new_idx] { + new_idx += 1; + } + + old_idx += 1; + map.push(new_idx.min(u16::MAX as usize) as u16); + } + + map +} + /// Determines the behavior of the [`NormalizePath`] middleware. /// /// The default is `TrailingSlash::Trim`. @@ -183,6 +206,7 @@ where // Both of the paths have the same length, // so the change can not be deduced from the length comparison if path != original_path { + let reindex = build_byte_index_map(original_path, path); let mut parts = head.uri.clone().into_parts(); let query = parts.path_and_query.as_ref().and_then(|pq| pq.query()); @@ -193,7 +217,11 @@ where parts.path_and_query = Some(PathAndQuery::from_maybe_shared(path).unwrap()); let uri = Uri::from_parts(parts).unwrap(); - req.match_info_mut().get_mut().update(&uri); + req.match_info_mut() + .update_with_reindex(Url::new(uri.clone()), |idx| { + let idx = usize::from(idx).min(reindex.len() - 1); + reindex[idx] + }); req.head_mut().uri = uri; } } @@ -209,7 +237,7 @@ mod tests { use super::*; use crate::{ guard::fn_guard, - test::{call_service, init_service, TestRequest}, + test::{call_service, init_service, read_body, TestRequest}, web, App, HttpResponse, }; @@ -406,6 +434,45 @@ mod tests { } } + #[actix_rt::test] + async fn scope_dynamic_tail_path_is_reindexed() { + async fn handler(path: web::Path) -> HttpResponse { + HttpResponse::Ok().body(path.into_inner()) + } + + let app = init_service( + App::new().service( + web::scope("{tail:.*}") + .wrap(NormalizePath::trim()) + .default_service(web::to(handler)), + ), + ) + .await; + + let req = TestRequest::with_uri("/uaie//iuaei").to_request(); + let res = call_service(&app, req).await; + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(read_body(res).await, Bytes::from_static(b"uaie/iuaei")); + } + + #[actix_rt::test] + async fn scope_static_prefix_skip_is_reindexed() { + let app = init_service( + App::new().service( + web::scope("/api") + .wrap(NormalizePath::trim()) + .service(web::resource("/v1").to(HttpResponse::Ok)), + ), + ) + .await; + + let req = TestRequest::with_uri("/api//v1").to_request(); + let res = call_service(&app, req).await; + + assert_eq!(res.status(), StatusCode::OK); + } + #[actix_rt::test] async fn no_path() { let app = init_service( diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index a49a55bd0..08be38ca6 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, }; @@ -26,6 +29,9 @@ use crate::{ #[cfg(feature = "cookies")] struct Cookies(Vec>); +#[cfg(feature = "cookies")] +struct RawCookies(Vec>); + /// An incoming request. #[derive(Clone)] pub struct HttpRequest { @@ -39,6 +45,8 @@ pub struct HttpRequest { pub(crate) struct HttpRequestInner { pub(crate) head: Message, pub(crate) path: Path, + pub(crate) resource_path: SmallVec<[u16; 4]>, + pub(crate) resource_path_matched: bool, pub(crate) app_data: SmallVec<[Rc; 4]>, pub(crate) conn_data: Option>, pub(crate) extensions: Rc>, @@ -62,6 +70,8 @@ impl HttpRequest { inner: Rc::new(HttpRequestInner { head, path, + resource_path: SmallVec::new(), + resource_path_matched: false, app_state, app_data: data, conn_data, @@ -79,7 +89,10 @@ impl HttpRequest { } /// This method returns mutable reference to the request head. - /// panics if multiple references of HTTP request exists. + /// + /// # Panics + /// + /// Panics if multiple references of HTTP request exists. #[inline] pub(crate) fn head_mut(&mut self) -> &mut RequestHead { &mut Rc::get_mut(&mut self.inner).unwrap().head @@ -96,6 +109,12 @@ impl HttpRequest { /// Reconstructed URL is best-effort, using [`connection_info`](HttpRequest::connection_info()) /// to get forwarded scheme & host. /// + /// # Panics + /// + /// Panics when the reconstructed URL cannot be parsed, such as when the host is malformed. + /// + /// # Examples + /// /// ``` /// use actix_web::test::TestRequest; /// let req = TestRequest::with_uri("http://10.1.2.3:8443/api?id=4&name=foo") @@ -177,6 +196,26 @@ impl HttpRequest { &mut Rc::get_mut(&mut self.inner).unwrap().path } + #[inline] + pub(crate) fn push_resource_id(&mut self, id: u16) { + Rc::get_mut(&mut self.inner).unwrap().resource_path.push(id); + } + + #[inline] + pub(crate) fn mark_resource_path(&mut self, is_matched: bool) { + Rc::get_mut(&mut self.inner).unwrap().resource_path_matched = is_matched; + } + + #[inline] + pub(crate) fn resource_path(&self) -> &[u16] { + &self.inner.resource_path + } + + #[inline] + pub(crate) fn is_resource_path_matched(&self) -> bool { + self.inner.resource_path_matched + } + /// The resource definition pattern that matched the path. Useful for logging and metrics. /// /// For example, when a resource with pattern `/user/{id}/profile` is defined and a call is made @@ -185,6 +224,15 @@ impl HttpRequest { /// Returns a None when no resource is fully matched, including default services. #[inline] pub fn match_pattern(&self) -> Option { + if self.is_resource_path_matched() { + if let Some(pattern) = self + .resource_map() + .match_pattern_by_resource_path(self.resource_path()) + { + return Some(pattern); + } + } + self.resource_map().match_pattern(self.path()) } @@ -193,6 +241,15 @@ impl HttpRequest { /// Returns a None when no resource is fully matched, including default services. #[inline] pub fn match_name(&self) -> Option<&str> { + if self.is_resource_path_matched() { + if let Some(name) = self + .resource_map() + .match_name_by_resource_path(self.resource_path()) + { + return Some(name); + } + } + self.resource_map().match_name(self.path()) } @@ -242,6 +299,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 @@ -341,6 +468,11 @@ impl HttpRequest { } /// Load request cookies. + /// + /// The names and values of cookies are percent-decoded. + /// + /// Any cookie that cannot be parsed is omitted from the result. + /// This includes cookies with an empty name (e.g. `document.cookie = "=value"`). #[cfg(feature = "cookies")] pub fn cookies(&self) -> Result>>, CookieParseError> { use actix_http::header::COOKIE; @@ -349,9 +481,9 @@ impl HttpRequest { let mut cookies = Vec::new(); for hdr in self.headers().get_all(COOKIE) { let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?; - for cookie_str in s.split(';').map(|s| s.trim()) { - if !cookie_str.is_empty() { - cookies.push(Cookie::parse_encoded(cookie_str)?.into_owned()); + for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Ok(cookie) = Cookie::parse_encoded(cookie_str) { + cookies.push(cookie.into_owned()); } } } @@ -363,16 +495,49 @@ impl HttpRequest { })) } + /// Load request cookies **without** percent-decoding their names and values. + /// + /// Any cookie that cannot be parsed is omitted from the result. + /// This includes cookies with an empty name (e.g. `document.cookie = "=value"`). + #[cfg(feature = "cookies")] + pub fn cookies_raw(&self) -> Result>>, CookieParseError> { + use actix_http::header::COOKIE; + + if self.extensions().get::().is_none() { + let mut cookies = Vec::new(); + for hdr in self.headers().get_all(COOKIE) { + let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?; + for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Ok(cookie) = Cookie::parse(cookie_str) { + cookies.push(cookie.into_owned()); + } + } + } + self.extensions_mut().insert(RawCookies(cookies)); + } + + Ok(Ref::map(self.extensions(), |ext| { + &ext.get::().unwrap().0 + })) + } + /// Return request cookie. #[cfg(feature = "cookies")] pub fn cookie(&self, name: &str) -> Option> { if let Ok(cookies) = self.cookies() { - for cookie in cookies.iter() { - if cookie.name() == name { - return Some(cookie.to_owned()); - } - } + return cookies.iter().find(|cookie| cookie.name() == name).cloned(); } + + None + } + + /// Return request cookie **without** percent-decoding its name and value. + #[cfg(feature = "cookies")] + pub fn cookie_raw(&self, name: &str) -> Option> { + if let Ok(cookies) = self.cookies_raw() { + return cookies.iter().find(|cookie| cookie.name() == name).cloned(); + } + None } } @@ -550,11 +715,14 @@ impl HttpRequestPool { #[cfg(test)] mod tests { + use std::collections::HashMap; + use bytes::Bytes; use super::*; use crate::{ dev::{ResourceDef, Service}, + guard, http::{header, StatusCode}, test::{self, call_service, init_service, read_body, TestRequest}, web, App, HttpResponse, @@ -602,6 +770,65 @@ mod tests { assert!(cookie.is_none()); } + #[test] + #[cfg(feature = "cookies")] + fn test_request_cookies_raw() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie1=hello%20world")) + .append_header((header::COOKIE, "cookie2=%db")) + .to_http_request(); + { + let cookies = req.cookies_raw().unwrap(); + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].name(), "cookie1"); + assert_eq!(cookies[0].value(), "hello%20world"); + assert_eq!(cookies[1].name(), "cookie2"); + assert_eq!(cookies[1].value(), "%db"); + } + + let cookie = req.cookie_raw("cookie1"); + assert!(cookie.is_some()); + let cookie = cookie.unwrap(); + assert_eq!(cookie.name(), "cookie1"); + assert_eq!(cookie.value(), "hello%20world"); + + let cookie = req.cookie_raw("cookie2"); + assert!(cookie.is_some()); + let cookie = cookie.unwrap(); + assert_eq!(cookie.name(), "cookie2"); + assert_eq!(cookie.value(), "%db"); + } + + #[test] + #[cfg(feature = "cookies")] + fn test_request_cookies_raw_is_independent_from_encoded_cookies() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie=%20")) + .to_http_request(); + + let cookie = req.cookie("cookie").unwrap(); + assert_eq!(cookie.value(), " "); + + let raw_cookie = req.cookie_raw("cookie").unwrap(); + assert_eq!(raw_cookie.value(), "%20"); + } + + #[test] + #[cfg(feature = "cookies")] + fn test_empty_key() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie1=value1; value2; cookie3=value3")) + .to_http_request(); + { + let cookies = req.cookies().unwrap(); + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].name(), "cookie1"); + assert_eq!(cookies[0].value(), "value1"); + assert_eq!(cookies[1].name(), "cookie3"); + assert_eq!(cookies[1].value(), "value3"); + } + } + #[test] fn test_request_query() { let req = TestRequest::with_uri("/?id=test").to_http_request(); @@ -638,6 +865,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"); @@ -872,6 +1152,44 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); } + #[actix_rt::test] + async fn extract_path_pattern_with_guards() { + let srv = init_service( + App::new().service( + web::scope("/widgets") + .service( + web::resource("/{id}") + .name("get_widget") + .guard(guard::Get()) + .to(|req: HttpRequest| { + assert_eq!(req.match_pattern(), Some("/widgets/{id}".to_owned())); + assert_eq!(req.match_name(), Some("get_widget")); + HttpResponse::Ok().finish() + }), + ) + .service( + web::resource("/action") + .name("widget_action") + .guard(guard::Post()) + .to(|req: HttpRequest| { + assert_eq!(req.match_pattern(), Some("/widgets/action".to_owned())); + assert_eq!(req.match_name(), Some("widget_action")); + HttpResponse::Ok().finish() + }), + ), + ), + ) + .await; + + let req = TestRequest::get().uri("/widgets/42").to_request(); + let res = call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + + let req = TestRequest::post().uri("/widgets/action").to_request(); + let res = call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + } + #[actix_rt::test] async fn extract_path_pattern_complex() { let srv = init_service( diff --git a/actix-web/src/resource.rs b/actix-web/src/resource.rs index aee0dff93..441b05bae 100644 --- a/actix-web/src/resource.rs +++ b/actix-web/src/resource.rs @@ -417,6 +417,8 @@ where B: MessageBody + 'static, { fn register(mut self, config: &mut AppService) { + let routes = std::mem::take(&mut self.routes); + let guards = if self.guards.is_empty() { None } else { @@ -428,13 +430,71 @@ where } else { ResourceDef::new(self.rdef.clone()) }; + #[cfg(feature = "experimental-introspection")] + { + use crate::http::Method; + + let full_paths = crate::introspection::expand_patterns(&config.current_prefix, &rdef); + let patterns = rdef + .pattern_iter() + .map(|pattern| pattern.to_string()) + .collect::>(); + let guards_routes = routes.iter().map(|r| r.guards()).collect::>(); + let scope_id = config.scope_id_stack.last().copied(); + let resource_guards: &[Box] = guards.as_deref().unwrap_or(&[]); + let resource_name = self.name.clone(); + + for route_guards in guards_routes { + // Log the guards and methods for introspection + let mut guard_names = Vec::new(); + let mut methods = Vec::new(); + + for guard in resource_guards.iter().chain(route_guards.iter()) { + guard_names.push(guard.name()); + methods.extend( + guard + .details() + .unwrap_or_default() + .into_iter() + .flat_map(|d| { + if let crate::guard::GuardDetail::HttpMethods(v) = d { + v.into_iter() + .filter_map(|s| s.parse::().ok()) + .collect::>() + } else { + Vec::new() + } + }), + ); + } + + let guard_details = crate::introspection::guard_reports_from_iter( + resource_guards.iter().chain(route_guards.iter()), + ); + + for full_path in &full_paths { + let info = crate::introspection::RouteInfo::new( + full_path.clone(), + methods.clone(), + guard_names.clone(), + guard_details.clone(), + patterns.clone(), + resource_name.clone(), + ); + config + .introspector + .borrow_mut() + .register_route(info, scope_id); + } + } + } if let Some(ref name) = self.name { rdef.set_name(name); } *self.factory_ref.borrow_mut() = Some(ResourceFactory { - routes: self.routes, + routes, default: self.default, }); diff --git a/actix-web/src/response/builder.rs b/actix-web/src/response/builder.rs index c23de8e36..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::{ @@ -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`. 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/rmap.rs b/actix-web/src/rmap.rs index b445687ac..d3e7c3917 100644 --- a/actix-web/src/rmap.rs +++ b/actix-web/src/rmap.rs @@ -1,7 +1,9 @@ use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, cell::RefCell, + collections::HashMap, fmt::Write as _, + hash::{BuildHasher, Hash}, rc::{Rc, Weak}, }; @@ -140,6 +142,56 @@ impl ResourceMap { }) .ok_or(UrlGenerationError::NotEnoughElements)?; + self.url_from_path(req, path) + } + + /// Generate URL for named resource using map of dynamic segment values. + /// + /// Check [`HttpRequest::url_for_map`] for detailed information. + pub fn url_for_map( + &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(); @@ -188,10 +240,40 @@ impl ResourceMap { ) } + pub(crate) fn is_resource_path_match(&self, resource_path: &[u16]) -> bool { + self.find_node_by_resource_path(resource_path) + .is_some_and(|node| node.nodes.is_none()) + } + + pub(crate) fn match_name_by_resource_path(&self, resource_path: &[u16]) -> Option<&str> { + self.find_node_by_resource_path(resource_path)? + .pattern + .name() + } + + pub(crate) fn match_pattern_by_resource_path(&self, resource_path: &[u16]) -> Option { + self.find_node_by_resource_path(resource_path)? + .root_rmap_fn(String::with_capacity(AVG_PATH_LEN), |mut acc, node| { + let pattern = node.pattern.pattern()?; + acc.push_str(pattern); + Some(acc) + }) + } + fn find_matching_node(&self, path: &str) -> Option<&ResourceMap> { self._find_matching_node(path).flatten() } + fn find_node_by_resource_path(&self, resource_path: &[u16]) -> Option<&ResourceMap> { + let mut node = self; + + for id in resource_path { + node = node.nodes.as_ref()?.get(*id as usize)?; + } + + Some(node) + } + /// Returns `None` if root pattern doesn't match; /// `Some(None)` if root pattern matches but there is no matching child pattern. /// Don't search sideways when `Some(none)` is returned. diff --git a/actix-web/src/route.rs b/actix-web/src/route.rs index e05e6be52..feb94338e 100644 --- a/actix-web/src/route.rs +++ b/actix-web/src/route.rs @@ -23,6 +23,7 @@ use crate::{ pub struct Route { service: BoxedHttpServiceFactory, guards: Rc>>, + wrapped: bool, } impl Route { @@ -34,6 +35,7 @@ impl Route { Ok(req.into_response(HttpResponse::NotFound())) })), guards: Rc::new(Vec::new()), + wrapped: false, } } @@ -42,6 +44,17 @@ impl Route { /// `mw` is a middleware component (type), that can modify the requests and responses handled by /// this `Route`. /// + /// This middleware wraps the currently configured route service. Call this method after + /// [`Route::to`] or [`Route::service`] so the middleware is applied to the final handler. + /// + /// # Examples + /// ``` + /// # use actix_web::{web, HttpResponse, middleware}; + /// web::get() + /// .to(|| async { HttpResponse::Ok() }) + /// .wrap(middleware::Logger::default()); + /// ``` + /// /// See [`App::wrap`](crate::App::wrap) for more details. #[doc(alias = "middleware")] #[doc(alias = "use")] // nodejs terminology @@ -59,12 +72,24 @@ impl Route { Route { service: boxed::factory(apply(Compat::new(mw), self.service)), guards: self.guards, + wrapped: true, } } pub(crate) fn take_guards(&mut self) -> Vec> { mem::take(Rc::get_mut(&mut self.guards).unwrap()) } + + #[cold] + #[inline(never)] + #[track_caller] + fn panic_after_wrap(replaced: &str, example: &str) -> ! { + panic!( + "Route middleware was already registered with `.wrap()`. \ + Calling `.{replaced}()` now would replace the wrapped service and silently drop middleware. \ + Call `.{replaced}()` before `.wrap()` (for example: `{example}`)." + ); + } } impl ServiceFactory for Route { @@ -159,6 +184,11 @@ impl Route { self } + #[cfg(feature = "experimental-introspection")] + pub(crate) fn guards(&self) -> &Vec> { + &self.guards + } + /// Set handler function, use request extractors for parameters. /// /// # Examples @@ -207,12 +237,21 @@ impl Route { /// .route(web::get().to(index)) /// ); /// ``` + /// + /// # Panics + /// Panics if called after [`Route::wrap`], since this would replace the wrapped service and + /// silently discard middleware. + #[track_caller] pub fn to(mut self, handler: F) -> Self where F: Handler, Args: FromRequest + 'static, F::Output: Responder + 'static, { + if self.wrapped { + Self::panic_after_wrap("to", "web::get().to(handler).wrap(mw)"); + } + self.service = handler_service(handler); self } @@ -249,6 +288,11 @@ impl Route { /// web::get().service(fn_factory(|| async { Ok(HelloWorld) })), /// ); /// ``` + /// + /// # Panics + /// Panics if called after [`Route::wrap`], since this would replace the wrapped service and + /// silently discard middleware. + #[track_caller] pub fn service(mut self, service_factory: S) -> Self where S: ServiceFactory< @@ -260,6 +304,10 @@ impl Route { > + 'static, E: Into + 'static, { + if self.wrapped { + Self::panic_after_wrap("service", "web::get().service(factory).wrap(mw)"); + } + self.service = boxed::factory(service_factory.map_err(Into::into)); self } @@ -454,4 +502,25 @@ mod tests { Bytes::from_static(b"Goodbye, and thanks for all the fish!") ); } + + #[test] + #[should_panic(expected = "Route middleware was already registered with `.wrap()`")] + fn wrap_before_to_panics() { + web::get() + .wrap(DefaultHeaders::new().add(("x-test", "x-value"))) + .to(HttpResponse::Ok); + } + + #[test] + #[should_panic(expected = "Route middleware was already registered with `.wrap()`")] + fn wrap_before_service_panics() { + web::get() + .wrap(DefaultHeaders::new().add(("x-test", "x-value"))) + .service(fn_factory(|| async { + Ok::<_, ()>(fn_service(|req: ServiceRequest| async { + let (req, _) = req.into_parts(); + Ok::<_, Infallible>(ServiceResponse::new(req, HttpResponse::Ok().finish())) + })) + })); + } } diff --git a/actix-web/src/scope.rs b/actix-web/src/scope.rs index e317349da..560a66b8e 100644 --- a/actix-web/src/scope.rs +++ b/actix-web/src/scope.rs @@ -384,14 +384,32 @@ where // register nested services let mut cfg = config.clone_config(); + + // Update the prefix for the nested scope + #[cfg(feature = "experimental-introspection")] + { + let scope_id = config.prepare_scope_id(); + cfg.scope_id_stack.push(scope_id); + cfg.update_prefix(&self.rdef); + } + self.services .into_iter() .for_each(|mut srv| srv.register(&mut cfg)); let mut rmap = ResourceMap::new(ResourceDef::root_prefix(&self.rdef)); + #[cfg(feature = "experimental-introspection")] + let origin_scope = cfg.current_prefix.clone(); + // external resources for mut rdef in mem::take(&mut self.external) { + #[cfg(feature = "experimental-introspection")] + { + cfg.introspector + .borrow_mut() + .register_external(&rdef, &origin_scope); + } rmap.add(&mut rdef, None); } @@ -515,7 +533,15 @@ impl Service for ScopeService { guards.iter().all(|guard| guard.check(&guard_ctx)) }); - if let Some((srv, _info)) = res { + if let Some((srv, info)) = res { + req.push_resource_id(info.0); + + let matched = req + .resource_map() + .is_resource_path_match(req.resource_id_path()); + + req.mark_resource_path(matched); + srv.call(req) } else { self.default.call(req) diff --git a/actix-web/src/server.rs b/actix-web/src/server.rs index 0717f5bc6..3f5899b50 100644 --- a/actix-web/src/server.rs +++ b/actix-web/src/server.rs @@ -29,8 +29,13 @@ struct Socket { struct Config { host: Option, keep_alive: KeepAlive, + tcp_nodelay: Option, client_request_timeout: Duration, client_disconnect_timeout: Duration, + h1_allow_half_closed: bool, + h1_write_buffer_size: Option, + h2_initial_window_size: Option, + h2_initial_connection_window_size: Option, #[allow(dead_code)] // only dead when no TLS features are enabled tls_handshake_timeout: Option, } @@ -114,8 +119,13 @@ where config: Arc::new(Mutex::new(Config { host: None, keep_alive: KeepAlive::default(), + tcp_nodelay: None, client_request_timeout: Duration::from_secs(5), client_disconnect_timeout: Duration::from_secs(1), + h1_allow_half_closed: true, + h1_write_buffer_size: None, + h2_initial_window_size: None, + h2_initial_connection_window_size: None, tls_handshake_timeout: None, })), backlog: 1024, @@ -153,13 +163,22 @@ where self } + /// Sets `TCP_NODELAY` value on accepted TCP connections. + /// + /// By default, accepted TCP connections keep the OS default. + /// This method overrides that behavior for all accepted TCP connections. + pub fn tcp_nodelay(self, enabled: bool) -> Self { + self.config.lock().unwrap().tcp_nodelay = Some(enabled); + self + } + /// Sets the maximum number of pending connections. /// /// This refers to the number of clients that can be waiting to be served. Exceeding this number /// results in the client getting an error when attempting to connect. It should only affect /// servers under significant load. /// - /// Generally set in the 64–2048 range. Default value is 2048. + /// Generally set in the 64–2048 range. Default value is 1024. /// /// This method will have no effect if called after a `bind()`. pub fn backlog(mut self, backlog: u32) -> Self { @@ -228,7 +247,7 @@ where /// /// To disable timeout set value to 0. /// - /// By default client timeout is set to 5000 milliseconds. + /// By default client timeout is set to 1000 milliseconds. pub fn client_disconnect_timeout(self, dur: Duration) -> Self { self.config.lock().unwrap().client_disconnect_timeout = dur; self @@ -257,6 +276,64 @@ 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 the maximum response write buffer size for HTTP/1 connections. + /// + /// Once the response buffer reaches this size, the dispatcher flushes it to the I/O stream. + /// + /// The default value is 32 KiB. + /// + /// # Panics + /// + /// Panics if `size` is 0. + pub fn h1_write_buffer_size(self, size: usize) -> Self { + assert!( + size > 0, + "HTTP/1 write buffer size must be greater than zero" + ); + + self.config.lock().unwrap().h1_write_buffer_size = Some(size); + self + } + + /// Sets initial stream-level flow control window size for HTTP/2 connections. + /// + /// Higher values can improve upload performance on high-latency links at the cost of higher + /// worst-case memory usage per connection. + /// + /// The default value is 1MiB. + #[cfg(feature = "http2")] + pub fn h2_initial_window_size(self, size: u32) -> Self { + self.config.lock().unwrap().h2_initial_window_size = Some(size); + self + } + + /// Sets initial connection-level flow control window size for HTTP/2 connections. + /// + /// Higher values can improve upload performance on high-latency links at the cost of higher + /// worst-case memory usage per connection. + /// + /// The default value is 2MiB. + #[cfg(feature = "http2")] + pub fn h2_initial_connection_window_size(self, size: u32) -> Self { + self.config + .lock() + .unwrap() + .h2_initial_connection_window_size = Some(size); + 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 @@ -389,6 +466,14 @@ where /// 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. /// + /// # Dual-Stack IPv6 + /// + /// On Windows, when this method creates an IPv6 listener (e.g., for `[::]:8080`), this + /// attempts to enable dual-stack mode so the socket can accept both IPv4 and IPv6 + /// connections. On Linux and macOS, dual-stack is typically already the OS default. If you + /// need IPv6-only behavior on Windows, create the listener manually and pass it to + /// [`listen()`](Self::listen()). + /// /// # Typical Usage /// /// In general, use `127.0.0.1:` when testing locally and `0.0.0.0:` when deploying @@ -558,8 +643,25 @@ 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(enabled) = cfg.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = cfg.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = cfg.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = cfg.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + if let Some(handler) = on_connect_fn.clone() { svc = svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) @@ -602,8 +704,25 @@ 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(enabled) = cfg.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = cfg.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = cfg.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = cfg.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + if let Some(handler) = on_connect_fn.clone() { svc = svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) @@ -674,15 +793,31 @@ where let c = cfg.lock().unwrap(); let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); - let svc = HttpService::build() + 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); - 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 + if let Some(enabled) = c.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = c.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = c.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + + if let Some(handler) = on_connect_fn.clone() { + svc = svc + .on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); }; let fac = factory() @@ -725,15 +860,31 @@ where let c = cfg.lock().unwrap(); let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); - let svc = HttpService::build() + 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); - 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 + if let Some(enabled) = c.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = c.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = c.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + + if let Some(handler) = on_connect_fn.clone() { + svc = svc + .on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); }; let fac = factory() @@ -791,15 +942,31 @@ where let c = cfg.lock().unwrap(); let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); - let svc = HttpService::build() + 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); - 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 + if let Some(enabled) = c.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = c.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = c.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + + if let Some(handler) = on_connect_fn.clone() { + svc = svc + .on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); }; let fac = factory() @@ -857,15 +1024,31 @@ where let c = cfg.lock().unwrap(); let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); - let svc = HttpService::build() + 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); - 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 + if let Some(enabled) = c.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = c.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = c.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + + if let Some(handler) = on_connect_fn.clone() { + svc = svc + .on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); }; let fac = factory() @@ -923,16 +1106,32 @@ where let c = cfg.lock().unwrap(); let host = c.host.clone().unwrap_or_else(|| format!("{}", addr)); - let svc = HttpService::build() + let mut svc = HttpService::build() .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() { - svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)) - } else { - svc + if let Some(enabled) = c.tcp_nodelay { + svc = svc.tcp_nodelay(enabled); + } + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + if let Some(val) = c.h2_initial_window_size { + svc = svc.h2_initial_window_size(val); + } + + if let Some(val) = c.h2_initial_connection_window_size { + svc = svc.h2_initial_connection_window_size(val); + } + + if let Some(handler) = on_connect_fn.clone() { + svc = svc + .on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); }; let fac = factory() @@ -990,13 +1189,19 @@ where .into_factory() .map_err(|err| err.into().error_response()); - fn_service(|io: UnixStream| async { Ok((io, Protocol::Http1, None)) }).and_then( - HttpService::build() + fn_service(|io: UnixStream| async { Ok((io, Protocol::Http1, None)) }).and_then({ + let mut svc = HttpService::build() .keep_alive(c.keep_alive) .client_request_timeout(c.client_request_timeout) .client_disconnect_timeout(c.client_disconnect_timeout) - .finish(map_config(fac, move |_| config.clone())), - ) + .h1_allow_half_closed(c.h1_allow_half_closed); + + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + + svc.finish(map_config(fac, move |_| config.clone())) + }) }, )?; @@ -1036,12 +1241,17 @@ 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() { svc = svc.on_connect_ext(move |io: &_, ext: _| (handler)(io as &dyn Any, ext)); } + if let Some(size) = c.h1_write_buffer_size { + svc = svc.h1_write_buffer_size(size); + } + let fac = factory() .into_factory() .map_err(|err| err.into().error_response()); @@ -1115,6 +1325,14 @@ fn create_tcp_listener(addr: net::SocketAddr, backlog: u32) -> io::Result Result>>, CookieParseError> { + self.req.cookies_raw() + } + /// Return request cookie. #[cfg(feature = "cookies")] #[inline] @@ -304,6 +311,13 @@ impl ServiceRequest { self.req.cookie(name) } + /// Return request cookie **without** percent-decoding its name and value. + #[cfg(feature = "cookies")] + #[inline] + pub fn cookie_raw(&self, name: &str) -> Option> { + self.req.cookie_raw(name) + } + /// Set request payload. #[inline] pub fn set_payload(&mut self, payload: Payload) { @@ -321,6 +335,21 @@ impl ServiceRequest { .push(extensions); } + #[inline] + pub(crate) fn push_resource_id(&mut self, id: u16) { + self.req.push_resource_id(id); + } + + #[inline] + pub(crate) fn mark_resource_path(&mut self, is_matched: bool) { + self.req.mark_resource_path(is_matched); + } + + #[inline] + pub(crate) fn resource_id_path(&self) -> &[u16] { + self.req.resource_path() + } + /// Creates a context object for use with a routing [guard](crate::guard). #[inline] pub fn guard_ctx(&self) -> GuardContext<'_> { diff --git a/actix-web/src/types/json.rs b/actix-web/src/types/json.rs index 22ed624c3..58464f360 100644 --- a/actix-web/src/types/json.rs +++ b/actix-web/src/types/json.rs @@ -616,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"), @@ -624,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; diff --git a/actix-web/src/types/mod.rs b/actix-web/src/types/mod.rs index cabe53d6a..5334c46af 100644 --- a/actix-web/src/types/mod.rs +++ b/actix-web/src/types/mod.rs @@ -11,7 +11,7 @@ mod query; mod readlines; pub use self::{ - either::Either, + either::{Either, EitherExtractError}, form::{Form, FormConfig, UrlEncoded}, header::Header, html::Html, diff --git a/actix-web/src/types/path.rs b/actix-web/src/types/path.rs index 5f22568cc..8bae70755 100644 --- a/actix-web/src/types/path.rs +++ b/actix-web/src/types/path.rs @@ -53,6 +53,26 @@ use crate::{ /// format!("Welcome {}!", info.name) /// } /// ``` +/// +/// Segments matching multiple path components can be deserialized +/// into a `Vec<_>` to percent-decode the components individually. Empty +/// path components are ignored. +/// +/// ``` +/// use actix_web::{get, web}; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct Tail { +/// tail: Vec, +/// } +/// +/// // extract `Tail` from a path using serde +/// #[get("/path/to/{tail}*")] +/// async fn index(info: web::Path) -> String { +/// format!("Navigating to {}!", info.tail.join(" :: ")) +/// } +/// ``` #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deref, DerefMut, AsRef, Display, From)] pub struct Path(T); diff --git a/actix-web/src/types/readlines.rs b/actix-web/src/types/readlines.rs index e75239968..17998a1a3 100644 --- a/actix-web/src/types/readlines.rs +++ b/actix-web/src/types/readlines.rs @@ -65,6 +65,23 @@ where err: Some(err), } } + + /// Decodes one complete logical line using the request's configured encoding. + /// + /// Callers are expected to pass only the bytes that belong to the line being yielded, + /// whether they came from the internal buffer, the current payload chunk, or both. + fn decode(encoding: &'static Encoding, bytes: &[u8]) -> Result { + if encoding == UTF_8 { + str::from_utf8(bytes) + .map_err(|_| ReadlinesError::EncodingError) + .map(str::to_owned) + } else { + encoding + .decode_without_bom_handling_and_without_replacement(bytes) + .map(Cow::into_owned) + .ok_or(ReadlinesError::EncodingError) + } + } } impl Stream for Readlines @@ -95,18 +112,7 @@ where if ind + 1 > this.limit { return Poll::Ready(Some(Err(ReadlinesError::LimitOverflow))); } - let line = if this.encoding == UTF_8 { - str::from_utf8(&this.buf.split_to(ind + 1)) - .map_err(|_| ReadlinesError::EncodingError)? - .to_owned() - } else { - this.encoding - .decode_without_bom_handling_and_without_replacement( - &this.buf.split_to(ind + 1), - ) - .map(Cow::into_owned) - .ok_or(ReadlinesError::EncodingError)? - }; + let line = Self::decode(this.encoding, &this.buf.split_to(ind + 1))?; return Poll::Ready(Some(Ok(line))); } this.checked_buff = true; @@ -125,24 +131,17 @@ where } if let Some(ind) = found { // check if line is longer than limit - if ind + 1 > this.limit { + if this.buf.len() + ind + 1 > this.limit { return Poll::Ready(Some(Err(ReadlinesError::LimitOverflow))); } - let line = if this.encoding == UTF_8 { - str::from_utf8(&bytes.split_to(ind + 1)) - .map_err(|_| ReadlinesError::EncodingError)? - .to_owned() - } else { - this.encoding - .decode_without_bom_handling_and_without_replacement( - &bytes.split_to(ind + 1), - ) - .map(Cow::into_owned) - .ok_or(ReadlinesError::EncodingError)? - }; - // extend buffer with rest of the bytes; + + this.buf.extend_from_slice(&bytes.split_to(ind + 1)); + let line = Self::decode(this.encoding, &this.buf)?; + this.buf.clear(); + + // buffer bytes following the returned line this.buf.extend_from_slice(&bytes); - this.checked_buff = false; + this.checked_buff = this.buf.is_empty(); return Poll::Ready(Some(Ok(line))); } this.buf.extend_from_slice(&bytes); @@ -156,16 +155,7 @@ where if this.buf.len() > this.limit { return Poll::Ready(Some(Err(ReadlinesError::LimitOverflow))); } - let line = if this.encoding == UTF_8 { - str::from_utf8(&this.buf) - .map_err(|_| ReadlinesError::EncodingError)? - .to_owned() - } else { - this.encoding - .decode_without_bom_handling_and_without_replacement(&this.buf) - .map(Cow::into_owned) - .ok_or(ReadlinesError::EncodingError)? - }; + let line = Self::decode(this.encoding, &this.buf)?; this.buf.clear(); Poll::Ready(Some(Ok(line))) } @@ -177,10 +167,16 @@ where #[cfg(test)] mod tests { - use futures_util::StreamExt as _; + use std::{ + pin::Pin, + task::{Context, Poll}, + }; + + use actix_http::{h1, Request}; + use futures_util::{task::noop_waker_ref, StreamExt as _}; use super::*; - use crate::test::TestRequest; + use crate::{error::ReadlinesError, test::TestRequest}; #[actix_rt::test] async fn test_readlines() { @@ -208,4 +204,46 @@ mod tests { "Contrary to popular belief, Lorem Ipsum is not simply random text." ); } + + #[test] + fn test_readlines_limit_across_chunks() { + let (mut sender, payload) = h1::Payload::create(false); + let payload: actix_http::Payload = payload.into(); + let mut req = Request::with_payload(payload); + let mut stream = Readlines::new(&mut req).limit(10); + let mut cx = Context::from_waker(noop_waker_ref()); + + sender.feed_data(Bytes::from_static(b"AAAAAAAAAA")); + assert!(matches!( + Pin::new(&mut stream).poll_next(&mut cx), + Poll::Pending + )); + + sender.feed_data(Bytes::from_static(b"A\n")); + assert!(matches!( + Pin::new(&mut stream).poll_next(&mut cx), + Poll::Ready(Some(Err(ReadlinesError::LimitOverflow))) + )); + } + + #[test] + fn test_readlines_returns_full_line_across_chunks() { + let (mut sender, payload) = h1::Payload::create(false); + let payload: actix_http::Payload = payload.into(); + let mut req = Request::with_payload(payload); + let mut stream = Readlines::new(&mut req); + let mut cx = Context::from_waker(noop_waker_ref()); + + sender.feed_data(Bytes::from_static(b"hello ")); + assert!(matches!( + Pin::new(&mut stream).poll_next(&mut cx), + Poll::Pending + )); + + sender.feed_data(Bytes::from_static(b"world\nnext")); + assert!(matches!( + Pin::new(&mut stream).poll_next(&mut cx), + Poll::Ready(Some(Ok(ref line))) if line == "hello world\n" + )); + } } diff --git a/actix-web/src/web.rs b/actix-web/src/web.rs index 3a4c46730..92d05eb74 100644 --- a/actix-web/src/web.rs +++ b/actix-web/src/web.rs @@ -38,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/introspection.rs b/actix-web/tests/introspection.rs new file mode 100644 index 000000000..d56cc4e7b --- /dev/null +++ b/actix-web/tests/introspection.rs @@ -0,0 +1,167 @@ +#![cfg(feature = "experimental-introspection")] + +use actix_web::{guard, test, web, App, HttpResponse}; + +async fn introspection_handler( + tree: web::Data, +) -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(tree.report_as_json()) +} + +async fn externals_handler( + tree: web::Data, +) -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(tree.report_externals_as_json()) +} + +fn find_item<'a>(items: &'a [serde_json::Value], path: &str) -> &'a serde_json::Value { + items + .iter() + .find(|item| item.get("full_path").and_then(|v| v.as_str()) == Some(path)) + .unwrap_or_else(|| panic!("missing route for {path}")) +} + +fn find_external<'a>(items: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { + items + .iter() + .find(|item| item.get("name").and_then(|v| v.as_str()) == Some(name)) + .unwrap_or_else(|| panic!("missing external resource for {name}")) +} + +#[actix_rt::test] +async fn introspection_report_includes_details_and_metadata() { + let app = test::init_service( + App::new() + .external_resource("app-external", "https://example.com/{id}") + .service( + web::resource(["/alpha", "/beta"]) + .name("multi") + .route(web::get().to(HttpResponse::Ok)), + ) + .service( + web::resource("/guarded") + .guard(guard::Header("accept", "text/plain")) + .route(web::get().to(HttpResponse::Ok)), + ) + .service( + web::scope("/scoped") + .guard(guard::Header("x-scope", "1")) + .configure(|cfg| { + cfg.external_resource("scope-external", "https://scope.example/{id}"); + }) + .service(web::resource("/item").route(web::get().to(HttpResponse::Ok))), + ) + .service(web::resource("/introspection").route(web::get().to(introspection_handler))) + .service( + web::resource("/introspection/externals").route(web::get().to(externals_handler)), + ), + ) + .await; + + let req = test::TestRequest::get().uri("/introspection").to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let items: Vec = + serde_json::from_slice(&body).expect("invalid introspection json"); + + let alpha = find_item(&items, "/alpha"); + let patterns = alpha + .get("patterns") + .and_then(|v| v.as_array()) + .expect("patterns missing"); + let patterns = patterns + .iter() + .filter_map(|v| v.as_str()) + .collect::>(); + assert!(patterns.contains(&"/alpha")); + assert!(patterns.contains(&"/beta")); + assert_eq!( + alpha.get("resource_name").and_then(|v| v.as_str()), + Some("multi") + ); + assert_eq!( + alpha.get("resource_type").and_then(|v| v.as_str()), + Some("resource") + ); + + let guarded = find_item(&items, "/guarded"); + let guards = guarded + .get("guards") + .and_then(|v| v.as_array()) + .expect("guards missing"); + assert!(guards + .iter() + .any(|v| v.as_str() == Some("Header(accept, text/plain)"))); + + let guard_details = guarded + .get("guards_detail") + .and_then(|v| v.as_array()) + .expect("guards_detail missing"); + assert!(!guard_details.is_empty()); + + let alpha_guards = alpha + .get("guards") + .and_then(|v| v.as_array()) + .expect("alpha guards missing"); + let alpha_guard_details = alpha + .get("guards_detail") + .and_then(|v| v.as_array()) + .expect("alpha guards_detail missing"); + assert!(alpha_guards.is_empty()); + assert!(!alpha_guard_details.is_empty()); + + let scoped = find_item(&items, "/scoped"); + assert_eq!( + scoped.get("resource_type").and_then(|v| v.as_str()), + Some("scope") + ); + let scoped_guards = scoped + .get("guards") + .and_then(|v| v.as_array()) + .expect("scoped guards missing"); + assert!(scoped_guards + .iter() + .any(|v| v.as_str() == Some("Header(x-scope, 1)"))); + + let req = test::TestRequest::get() + .uri("/introspection/externals") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = test::read_body(resp).await; + let externals: Vec = + serde_json::from_slice(&body).expect("invalid externals json"); + + let app_external = find_external(&externals, "app-external"); + let app_patterns = app_external + .get("patterns") + .and_then(|v| v.as_array()) + .expect("app external patterns missing"); + assert!(app_patterns + .iter() + .any(|v| v.as_str() == Some("https://example.com/{id}"))); + assert_eq!( + app_external.get("origin_scope").and_then(|v| v.as_str()), + Some("/") + ); + + let scope_external = find_external(&externals, "scope-external"); + let scope_patterns = scope_external + .get("patterns") + .and_then(|v| v.as_array()) + .expect("scope external patterns missing"); + assert!(scope_patterns + .iter() + .any(|v| v.as_str() == Some("https://scope.example/{id}"))); + assert_eq!( + scope_external.get("origin_scope").and_then(|v| v.as_str()), + Some("/scoped") + ); +} diff --git a/actix-web/tests/test_httpserver.rs b/actix-web/tests/test_httpserver.rs index 5fd7d7190..44283ebd4 100644 --- a/actix-web/tests/test_httpserver.rs +++ b/actix-web/tests/test_httpserver.rs @@ -3,7 +3,7 @@ extern crate tls_openssl as openssl; use std::{sync::mpsc, thread, time::Duration}; -use actix_web::{web, App, HttpResponse, HttpServer}; +use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer}; #[actix_rt::test] async fn test_start() { @@ -153,3 +153,112 @@ async fn test_start_ssl() { srv.stop(false).await; } + +async fn assert_tcp_nodelay_config(nodelay: bool) { + let addr = actix_test::unused_addr(); + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || { + actix_rt::System::new() + .block_on(async move { + let srv = HttpServer::new(move || { + let expected = nodelay; + + App::new().service(web::resource("/").route(web::to( + move |req: HttpRequest| { + let expected = expected; + + async move { + let actual = req.conn_data::().copied().unwrap_or(!expected); + if actual == expected { + HttpResponse::Ok().finish() + } else { + HttpResponse::InternalServerError().finish() + } + } + }, + ))) + }) + .workers(1) + .tcp_nodelay(nodelay) + .on_connect(move |io, ext| { + if let Some(io) = io.downcast_ref::() { + ext.insert(io.nodelay().unwrap()); + } + }) + .bind(format!("{}", addr)) + .unwrap() + .run(); + + tx.send(srv.handle()).unwrap(); + srv.await + }) + .unwrap() + }); + + let srv = rx.recv().unwrap(); + + let client = awc::Client::builder() + .connector(awc::Connector::new().timeout(Duration::from_millis(100))) + .finish(); + + let response = client.get(format!("http://{}", addr)).send().await.unwrap(); + assert!(response.status().is_success()); + + srv.stop(false).await; +} + +#[actix_rt::test] +async fn test_tcp_nodelay_enabled() { + assert_tcp_nodelay_config(true).await; +} + +#[actix_rt::test] +async fn test_tcp_nodelay_disabled() { + assert_tcp_nodelay_config(false).await; +} + +#[actix_rt::test] +#[cfg(windows)] +async fn test_dual_stack_ipv6_on_windows() { + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || { + actix_rt::System::new() + .block_on(async { + let srv = HttpServer::new(|| { + App::new().service( + web::resource("/") + .route(web::to(|| async { HttpResponse::Ok().body("test") })), + ) + }) + .workers(1) + .disable_signals() + .bind("[::]:0") + .unwrap(); + + let port = srv.addrs()[0].port(); + let srv = srv.run(); + + tx.send((srv.handle(), port)).unwrap(); + srv.await + }) + .unwrap(); + }); + + let (srv, port) = rx.recv().unwrap(); + + let client = awc::Client::builder() + .connector(awc::Connector::new().timeout(Duration::from_secs(1))) + .finish(); + + let response = client + .get(format!("http://127.0.0.1:{port}")) + .send() + .await + .unwrap(); + + assert!(response.status().is_success()); + + srv.stop(false).await; +} diff --git a/actix-web/tests/test_server.rs b/actix-web/tests/test_server.rs index f13aa3cfd..343b7f104 100644 --- a/actix-web/tests/test_server.rs +++ b/actix-web/tests/test_server.rs @@ -688,30 +688,20 @@ async fn test_brotli_encoding_large_openssl() { #[cfg(feature = "rustls-0_23")] mod plus_rustls { - use std::io::BufReader; - use rustls::{pki_types::PrivateKeyDer, ServerConfig as RustlsServerConfig}; - use rustls_pemfile::{certs, pkcs8_private_keys}; + use rustls_pki_types::PrivatePkcs8KeyDer; use super::*; fn tls_config() -> RustlsServerConfig { let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); - let cert_file = cert.pem(); - let key_file = key_pair.serialize_pem(); - - let cert_file = &mut BufReader::new(cert_file.as_bytes()); - let key_file = &mut BufReader::new(key_file.as_bytes()); - - let cert_chain = certs(cert_file).collect::, _>>().unwrap(); - let mut keys = pkcs8_private_keys(key_file) - .collect::, _>>() - .unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); RustlsServerConfig::builder() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKeyDer::Pkcs8(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap() } 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 092239719..e523c0fc7 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -2,6 +2,25 @@ ## Unreleased +- Add camel-case header controls to `WebsocketsRequest` via `camel_case_headers()` and `set_camel_case_headers()`. [#3953] +- Update `rand` dependency to `0.10`. + +[#3953]: https://github.com/actix/actix-web/pull/3953 + +## 3.8.2 + +- Minimum supported Rust version (MSRV) is now 1.88. +- Fix empty streaming request bodies being sent with chunked transfer encoding. + +## 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`. diff --git a/awc/Cargo.toml b/awc/Cargo.toml index 3acaf0208..bc3b6b403 100644 --- a/awc/Cargo.toml +++ b/awc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "awc" -version = "3.7.0" +version = "3.8.2" authors = ["Nikolay Kim "] description = "Async HTTP and WebSocket client library" keywords = ["actix", "http", "framework", "async", "web"] @@ -16,7 +16,6 @@ license = "MIT OR Apache-2.0" edition = "2021" [package.metadata.docs.rs] -rustdoc-args = ["--cfg", "docsrs"] features = [ "cookies", "openssl", @@ -85,8 +84,10 @@ compress-ws-deflate = ["actix-http/compress-ws-deflate"] # Cookie parsing and cookie jar cookies = ["dep:cookie"] -# Use `trust-dns-resolver` crate 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 checking feature status. # Don't rely on these whatsoever. They may disappear at anytime. @@ -99,7 +100,7 @@ dangerous-h2c = [] [dependencies] actix-codec = "0.5" -actix-http = { version = "3.10", features = ["http2", "ws"] } +actix-http = { version = "3.12.1", features = ["http2", "ws"] } actix-rt = { version = "2.1", default-features = false } actix-service = "2" actix-tls = { version = "3.4", features = ["connect", "uri"] } @@ -111,14 +112,14 @@ cfg-if = "1" 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.26" +h2 = "0.3.27" http = "0.2.7" itoa = "1" log = "0.4" mime = "0.3" percent-encoding = "2.1" pin-project-lite = "0.2" -rand = "0.9" +rand = "0.10.1" serde = "1.0" serde_json = "1.0" serde_urlencoded = "0.7" @@ -132,25 +133,25 @@ tls-rustls-0_21 = { package = "rustls", version = "0.21", optional = true, featu 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.23", optional = true } +hickory-resolver = { version = "0.25", optional = true, features = ["system-config", "tokio"] } [dev-dependencies] -actix-http = { version = "3.7", features = ["openssl"] } +actix-http = { version = "3.12", features = ["openssl"] } actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" 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"] } +actix-web = { version = "4.13", features = ["openssl"] } brotli = "8" -const-str = "0.5" # TODO(MSRV 1.77): update to 0.6 +const-str = "1.1" env_logger = "0.11" flate2 = "1.0.13" futures-util = { version = "0.3.17", default-features = false } static_assertions = "1.1" rcgen = "0.13" -rustls-pemfile = "2" +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 diff --git a/awc/README.md b/awc/README.md index ab0ffa00f..4c74931e6 100644 --- a/awc/README.md +++ b/awc/README.md @@ -5,16 +5,16 @@ [![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.7.0)](https://docs.rs/awc/3.7.0) +[![Documentation](https://docs.rs/awc/badge.svg?version=3.8.2)](https://docs.rs/awc/3.8.2) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/awc) -[![Dependency Status](https://deps.rs/crate/awc/3.7.0/status.svg)](https://deps.rs/crate/awc/3.7.0) +[![Dependency Status](https://deps.rs/crate/awc/3.8.2/status.svg)](https://deps.rs/crate/awc/3.8.2) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) ## Examples -[Example project using TLS-enabled client →](https://github.com/actix/examples/tree/master/https-tls/awc-https) +[Example project using TLS-enabled client →](https://github.com/actix/examples/tree/main/https-tls/awc-https) Basic usage: diff --git a/awc/src/client/connector.rs b/awc/src/client/connector.rs index a7c606826..0ce542365 100644 --- a/awc/src/client/connector.rs +++ b/awc/src/client/connector.rs @@ -1037,7 +1037,7 @@ where } } -#[cfg(not(feature = "trust-dns"))] +#[cfg(not(feature = "hickory-dns"))] mod resolver { use super::*; @@ -1046,24 +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 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, @@ -1085,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> = const { 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(err) => { - log::error!("Trust-DNS can not load system config: {err}"); + log::error!("Hickory DNS can not load system config: {err}"); (ResolverConfig::default(), ResolverOpts::default()) } }; - let resolver = TokioAsyncResolver::tokio(cfg, opts); + 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/h1proto.rs b/awc/src/client/h1proto.rs index 3f4c9f979..3d8d8db08 100644 --- a/awc/src/client/h1proto.rs +++ b/awc/src/client/h1proto.rs @@ -34,6 +34,35 @@ where B: MessageBody, B::Error: Into, { + actix_rt::pin!(body); + + let orig_length = body.size(); + let mut length = orig_length; + let mut first_chunk = None; + + // This avoids sending `Transfer-Encoding: chunked` for requests with an empty body stream. + // https://github.com/actix/actix-web/issues/2320 + if matches!(orig_length, BodySize::Stream) { + enum Peek { + Pending, + Item(Result), + Eof, + } + + match poll_fn(|cx| match body.as_mut().poll_next(cx) { + Poll::Pending => Poll::Ready(Peek::Pending), + Poll::Ready(Some(res)) => Poll::Ready(Peek::Item(res)), + Poll::Ready(None) => Poll::Ready(Peek::Eof), + }) + .await + { + Peek::Pending => {} + Peek::Eof => length = BodySize::Sized(0), + Peek::Item(Ok(chunk)) => first_chunk = Some(chunk), + Peek::Item(Err(err)) => return Err(SendRequestError::Body(err.into())), + } + } + // set request host header if !head.as_ref().headers.contains_key(HOST) && !head.extra_headers().iter().any(|h| h.contains_key(HOST)) @@ -67,7 +96,7 @@ where // Check EXPECT header and enable expect handle flag accordingly. // See https://datatracker.ietf.org/doc/html/rfc7231#section-5.1.1 let is_expect = if head.as_ref().headers.contains_key(EXPECT) { - match body.size() { + match orig_length { BodySize::None | BodySize::Sized(0) => { let keep_alive = framed.codec_ref().keep_alive(); framed.io_mut().on_release(keep_alive); @@ -86,7 +115,7 @@ where // special handle for EXPECT request. let (do_send, mut res_head) = if is_expect { - pin_framed.send((head, body.size()).into()).await?; + pin_framed.send((head, length).into()).await?; let head = poll_fn(|cx| pin_framed.as_mut().poll_next(cx)) .await @@ -96,18 +125,18 @@ where // and current head would be used as final response head. (head.status == StatusCode::CONTINUE, Some(head)) } else { - pin_framed.feed((head, body.size()).into()).await?; + pin_framed.feed((head, length).into()).await?; (true, None) }; if do_send { // send request body - match body.size() { + match length { BodySize::None | BodySize::Sized(0) => { poll_fn(|cx| pin_framed.as_mut().flush(cx)).await?; } - _ => send_body(body, pin_framed.as_mut()).await?, + _ => send_body(body.as_mut(), pin_framed.as_mut(), first_chunk).await?, }; // read response and init read body @@ -157,15 +186,18 @@ where /// send request body to the peer pub(crate) async fn send_body( - body: B, + mut body: Pin<&mut B>, mut framed: Pin<&mut Framed>, + first_chunk: Option, ) -> Result<(), SendRequestError> where Io: ConnectionIo, B: MessageBody, B::Error: Into, { - actix_rt::pin!(body); + if let Some(chunk) = first_chunk { + framed.as_mut().write(h1::Message::Chunk(Some(chunk)))?; + } let mut eof = false; while !eof { diff --git a/awc/src/client/h2proto.rs b/awc/src/client/h2proto.rs index f12ac3b43..2e0da2e4f 100644 --- a/awc/src/client/h2proto.rs +++ b/awc/src/client/h2proto.rs @@ -107,7 +107,7 @@ where let res = poll_fn(|cx| io.poll_ready(cx)).await; if let Err(err) = res { - io.on_release(err.is_io()); + io.on_release(err.is_io() || err.is_go_away()); return Err(SendRequestError::from(err)); } @@ -121,7 +121,7 @@ where fut.await.map_err(SendRequestError::from)? } Err(err) => { - io.on_release(err.is_io()); + io.on_release(err.is_io() || err.is_go_away()); return Err(err.into()); } }; diff --git a/awc/src/lib.rs b/awc/src/lib.rs index b582d51e4..360b3db0e 100644 --- a/awc/src/lib.rs +++ b/awc/src/lib.rs @@ -108,7 +108,7 @@ )] #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] pub use actix_http::body; #[cfg(feature = "cookies")] diff --git a/awc/src/request.rs b/awc/src/request.rs index 5f42f67ec..02ff4ef57 100644 --- a/awc/src/request.rs +++ b/awc/src/request.rs @@ -263,13 +263,9 @@ impl ClientRequest { /// ``` #[cfg(feature = "cookies")] pub fn cookie(mut self, cookie: Cookie<'_>) -> Self { - if self.cookies.is_none() { - let mut jar = CookieJar::new(); - jar.add(cookie.into_owned()); - self.cookies = Some(jar) - } else { - self.cookies.as_mut().unwrap().add(cookie.into_owned()); - } + self.cookies + .get_or_insert_with(CookieJar::new) + .add(cookie.into_owned()); self } @@ -309,10 +305,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(err) => return Err(err.into()), - }; + let slf = self.prep_for_sending()?; let request = FrozenClientRequest { head: Rc::new(slf.head), diff --git a/awc/src/ws.rs b/awc/src/ws.rs index f11542375..7d0672a36 100644 --- a/awc/src/ws.rs +++ b/awc/src/ws.rs @@ -132,13 +132,9 @@ impl WebsocketsRequest { /// Set a cookie #[cfg(feature = "cookies")] pub fn cookie(mut self, cookie: Cookie<'_>) -> Self { - if self.cookies.is_none() { - let mut jar = CookieJar::new(); - jar.add(cookie.into_owned()); - self.cookies = Some(jar) - } else { - self.cookies.as_mut().unwrap().add(cookie.into_owned()); - } + self.cookies + .get_or_insert_with(CookieJar::new) + .add(cookie.into_owned()); self } @@ -272,6 +268,21 @@ impl WebsocketsRequest { self.header(key, value) } + /// Returns whether headers should be sent in Camel-Case. + /// + /// Default is `false`. + #[inline] + pub fn camel_case_headers(&self) -> bool { + self.head.camel_case_headers() + } + + /// Sets whether to send headers formatted as Camel-Case. + #[inline] + pub fn set_camel_case_headers(mut self, val: bool) -> Self { + self.head.set_camel_case_headers(val); + self + } + /// Complete request construction and connect to a WebSocket server. pub async fn connect( mut self, @@ -591,6 +602,12 @@ mod tests { let _ = req.connect(); } + #[actix_rt::test] + async fn camel_case_headers() { + let req = Client::new().ws("/").set_camel_case_headers(true); + assert!(req.camel_case_headers()); + } + #[actix_rt::test] async fn basics() { let req = Client::new() diff --git a/awc/tests/test_empty_stream.rs b/awc/tests/test_empty_stream.rs new file mode 100644 index 000000000..76f6337ca --- /dev/null +++ b/awc/tests/test_empty_stream.rs @@ -0,0 +1,91 @@ +use std::{convert::Infallible, time::Duration}; + +use actix_rt::net::TcpListener; +use awc::Client; +use bytes::Bytes; +use futures_util::stream; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + time::timeout, +}; + +#[actix_rt::test] +async fn empty_body_stream_does_not_use_chunked_encoding() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Minimal HTTP/1.1 server that rejects chunked requests. + let srv = actix_rt::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + let mut buf = Vec::with_capacity(1024); + let mut tmp = [0u8; 1024]; + + let header_end = loop { + let n = timeout(Duration::from_secs(2), sock.read(&mut tmp)) + .await + .unwrap() + .unwrap(); + if n == 0 { + break None; + } + + buf.extend_from_slice(&tmp[..n]); + + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break Some(pos + 4); + } + + if buf.len() > 16 * 1024 { + break None; + } + } + .expect("did not receive complete request headers"); + + let headers_lower = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase(); + let has_chunked = headers_lower.contains("\r\ntransfer-encoding: chunked\r\n"); + + if has_chunked { + // Drain terminating chunk so client doesn't error on write before response is read. + let terminator = b"0\r\n\r\n"; + while !buf[header_end..] + .windows(terminator.len()) + .any(|w| w == terminator) + { + let n = match timeout(Duration::from_secs(2), sock.read(&mut tmp)).await { + Ok(Ok(n)) => n, + _ => break, + }; + + if n == 0 { + break; + } + + buf.extend_from_slice(&tmp[..n]); + + if buf.len() > 32 * 1024 { + break; + } + } + } + + let status = if has_chunked { + "400 Bad Request" + } else { + "200 OK" + }; + let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + sock.write_all(resp.as_bytes()).await.unwrap(); + }); + + let url = format!("http://{addr}/"); + let res = Client::default() + .get(url) + .send_stream(stream::empty::>()) + .await + .unwrap(); + + assert!(res.status().is_success()); + + srv.await.unwrap(); +} diff --git a/awc/tests/test_rustls_client.rs b/awc/tests/test_rustls_client.rs index 7e832f67d..9ce6bd31f 100644 --- a/awc/tests/test_rustls_client.rs +++ b/awc/tests/test_rustls_client.rs @@ -2,12 +2,9 @@ extern crate tls_rustls_0_23 as rustls; -use std::{ - io::BufReader, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, }; use actix_http::HttpService; @@ -16,29 +13,18 @@ use actix_service::{fn_service, map_config, ServiceFactoryExt}; 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::{ - pki_types::{CertificateDer, PrivateKeyDer, ServerName}, - ClientConfig, ServerConfig, -}; -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 rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); - let cert_file = cert.pem(); - let key_file = key_pair.serialize_pem(); - - let cert_file = &mut BufReader::new(cert_file.as_bytes()); - let key_file = &mut BufReader::new(key_file.as_bytes()); - - let cert_chain = certs(cert_file).collect::, _>>().unwrap(); - let mut keys = pkcs8_private_keys(key_file) - .collect::, _>>() - .unwrap(); + let cert_chain = vec![cert.der().clone()]; + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); ServerConfig::builder() .with_no_client_auth() - .with_single_cert(cert_chain, PrivateKeyDer::Pkcs8(keys.remove(0))) + .with_single_cert(cert_chain, key_der) .unwrap() } diff --git a/deny.toml b/deny.toml new file mode 100644 index 000000000..c0dd7ad78 --- /dev/null +++ b/deny.toml @@ -0,0 +1,48 @@ +[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", + "RUSTSEC-2026-0049", + "RUSTSEC-2026-0098", + "RUSTSEC-2026-0099", +] diff --git a/justfile b/justfile index e808a53ae..ba8014f43 100644 --- a/justfile +++ b/justfile @@ -12,11 +12,7 @@ fmt: # Downgrade dependencies necessary to run MSRV checks/tests. [private] downgrade-for-msrv: - cargo {{ toolchain }} update -p=divan --precise=0.1.15 # next ver: 1.80.0 - cargo {{ toolchain }} update -p=half --precise=2.4.1 # next ver: 1.81.0 - cargo {{ toolchain }} update -p=idna_adapter --precise=1.2.0 # next ver: 1.82.0 - cargo {{ toolchain }} update -p=litemap --precise=0.7.4 # next ver: 1.81.0 - cargo {{ toolchain }} update -p=zerofrom --precise=0.1.5 # next ver: 1.81.0 + # no downgrades currently needed msrv := ``` cargo metadata --format-version=1 \ @@ -50,8 +46,7 @@ clippy: cargo {{ toolchain }} clippy --workspace --all-targets {{ all_crate_features }} # Run Clippy over workspace using MSRV. -clippy-msrv: - @just toolchain={{ msrv_rustup }} downgrade-for-msrv +clippy-msrv: downgrade-for-msrv @just toolchain={{ msrv_rustup }} clippy # Test workspace code. @@ -62,8 +57,7 @@ test: 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: - @just toolchain={{ msrv_rustup }} downgrade-for-msrv +test-msrv: downgrade-for-msrv @just toolchain={{ msrv_rustup }} test # Test workspace docs. @@ -90,7 +84,7 @@ test-coverage-lcov: test-coverage # 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 --workspace {{ all_crate_features }} {{ args }} + RUSTDOCFLAGS="--cfg=docsrs -Dwarnings" cargo +nightly doc --no-deps --workspace {{ all_crate_features }} {{ args }} [private] doc-set-workspace-crates: diff --git a/scripts/bump b/scripts/bump index 7a57e6ed0..0b16466b4 100755 --- a/scripts/bump +++ b/scripts/bump @@ -1,4 +1,4 @@ -#!/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 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/publish b/scripts/publish new file mode 100755 index 000000000..7d521a20d --- /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