diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index b01b907..4286319 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -18,8 +18,35 @@ jobs: sudo apt-get update sudo apt-get install --assume-yes --no-install-recommends libpam0g-dev - uses: taiki-e/install-action@cargo-llvm-cov - # Limit test parallelism to 1 thread to avoid resource contention - - run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info -- --test-threads=1 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Cover Rust and language SDK integration tests + shell: bash + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/sdk-venv" + source "$RUNNER_TEMP/sdk-venv/bin/activate" + python -m pip install 'maturin>=1.8,<2' + eval "$(cargo llvm-cov show-env --export-prefix)" + cargo llvm-cov clean --workspace + cargo test --locked --all-features --workspace -- --test-threads=1 + export DHTTP_TEST_OPENSSL="$(command -v openssl)" + cargo test --locked -p dhttp-api --test quic_roundtrip -- --include-ignored --test-threads=1 + export DHTTP_TEST_PROFILE_ROOT="$RUNNER_TEMP/sdk-fixtures" + cargo run --locked -p dhttp-api --example sdk_credentials -- "$DHTTP_TEST_PROFILE_ROOT" + npm --prefix api run build + node --test api/tests/node.test.cjs + # Preserve the N-API coverage object before the PyO3 build replaces it. + native_target="$(cargo metadata --locked --no-deps --format-version 1 | python -c 'import json, sys; print(json.load(sys.stdin)["target_directory"])')" + native_hash="$(sha256sum "$native_target/debug/libdhttp_api.so" | cut -d ' ' -f1)" + cp "$native_target/debug/libdhttp_api.so" "$native_target/debug/deps/libdhttp_api-$native_hash.so" + maturin develop --locked --manifest-path api/Cargo.toml + python -m unittest discover -s api/tests -p test_python_sdk.py -v + cargo llvm-cov report --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/network-platforms.yml b/.github/workflows/network-platforms.yml new file mode 100644 index 0000000..ae1a977 --- /dev/null +++ b/.github/workflows/network-platforms.yml @@ -0,0 +1,93 @@ +name: Network platforms + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + # The Rust workflow runs the full suite on Ubuntu, macOS and Windows. + # Exercise another GNU userspace and the static musl build used on Alpine. + debian: + runs-on: ubuntu-latest + container: rust:bookworm + steps: + - uses: actions/checkout@v4 + - name: Test interface bindings and network lifecycle + run: cargo test --locked -p dhttp --lib --test network_lifecycle -- --test-threads=1 + + musl: + runs-on: ubuntu-latest + env: + CC_x86_64_unknown_linux_musl: musl-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: x86_64-unknown-linux-musl + - name: Install musl compiler + run: | + sudo apt-get update + sudo apt-get install --assume-yes --no-install-recommends musl-tools + - name: Test interface bindings and network lifecycle with musl + run: cargo test --locked -p dhttp --target x86_64-unknown-linux-musl --lib --test network_lifecycle -- --test-threads=1 + + ios: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + target: [aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios] + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + - name: Check iOS device and simulator builds + run: cargo check --locked -p dhttp --lib --target ${{ matrix.target }} + + android: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-linux-android + clang: aarch64-linux-android21 + - target: armv7-linux-androideabi + clang: armv7a-linux-androideabi21 + - target: x86_64-linux-android + clang: x86_64-linux-android21 + - target: i686-linux-android + clang: i686-linux-android21 + env: + ANDROID_PLATFORM: android-34 + ANDROID_BUILD_TOOLS_VERSION: 34.0.0 + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Set up Android SDK tools + uses: android-actions/setup-android@v4 + - name: Install Android Java build dependencies + run: sdkmanager "platforms;android-34" "build-tools;34.0.0" + - uses: nttld/setup-ndk@v1 + id: ndk + with: + ndk-version: r27d + - name: Check Android builds + env: + TARGET_CC: ${{ steps.ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/${{ matrix.clang }}-clang + TARGET_AR: ${{ steps.ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar + run: cargo check --locked -p dhttp --lib --target ${{ matrix.target }} diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 38c9852..63ac9ea 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -22,38 +22,43 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Validate release tag version + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + run: | + python3 - "$GITHUB_REF_NAME" <<'PY' + import sys + import tomllib + from pathlib import Path + + manifest = tomllib.loads(Path("Cargo.toml").read_text()) + package = manifest.get("package", manifest.get("workspace", {}).get("package", {})) + expected = f"v{package['version']}" + if sys.argv[1] != expected: + raise SystemExit(f"release tag {sys.argv[1]} must match {expected}") + PY + - name: Install Rust stable toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Check formatting run: cargo fmt -- --check - # The identity-only tag bootstraps the stable dependency graph. Other - # workspace members intentionally reference crates published later. - name: Run clippy - if: ${{ !endsWith(github.head_ref, '-dhttp-identity') && !endsWith(github.ref_name, '-dhttp-identity') }} run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Test workspace - if: ${{ !endsWith(github.head_ref, '-dhttp-identity') && !endsWith(github.ref_name, '-dhttp-identity') }} - run: cargo test --workspace --all-features --all-targets - - - name: Check dhttp-identity - if: ${{ endsWith(github.head_ref, '-dhttp-identity') || endsWith(github.ref_name, '-dhttp-identity') }} - run: | - cargo clippy --package dhttp-identity --all-targets --all-features -- -D warnings - cargo test --package dhttp-identity --all-features --all-targets + run: cargo test --workspace --all-features --all-targets -- --test-threads=1 - name: Authenticate to crates.io if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') uses: rust-lang/crates-io-auth-action@v1 id: auth - - name: Release publishable Rust crates + - name: Release publishable workspace crates shell: bash env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - IDENTITY_ONLY: ${{ endsWith(github.head_ref, '-dhttp-identity') || endsWith(github.ref_name, '-dhttp-identity') }} + FIRST_PUBLISH_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | set -euo pipefail @@ -63,23 +68,20 @@ jobs: mode=dry-run fi - if [[ "${IDENTITY_ONLY}" == "true" ]]; then - packages=(dhttp-identity) - else - packages=( - dhttp-identity - dhttp-home - dhttp-access - dhttp-log - dhttp - ) - fi + # Dependencies must precede dependents for per-crate publication. + packages=( + dhttp-home + dhttp-access + dhttp-log + dhttp + ) - cargo metadata --no-deps --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" + cargo metadata --format-version 1 > "$RUNNER_TEMP/workspace-metadata.json" package_versions="$( PACKAGES="$(printf '%s\n' "${packages[@]}")" RUNNER_TEMP="$RUNNER_TEMP" python3 - <<'PY' import json import os + import sys from pathlib import Path metadata = json.loads((Path(os.environ["RUNNER_TEMP"]) / "workspace-metadata.json").read_text()) @@ -89,11 +91,15 @@ jobs: package = by_name.get(name) if package is None: raise SystemExit(f"failed to resolve workspace package metadata for {name}") + if package.get("publish") == []: + print(f"skip {name} (publish = false)", file=sys.stderr) + continue print(f"{name}\t{package['version']}") PY )" packages_to_publish=() + first_releases=() while IFS=$'\t' read -r crate_name crate_version; do [ -n "$crate_name" ] || continue crate_state="$( @@ -138,28 +144,41 @@ jobs: echo "publish $crate_name $crate_version" packages_to_publish+=("$crate_name") else - echo "skip $crate_name $crate_version (crate not yet initialized on crates.io)" + if [[ "$mode" == "publish" && -z "$FIRST_PUBLISH_TOKEN" ]]; then + echo "::error::$crate_name needs its first publication. Configure the CARGO_REGISTRY_TOKEN repository secret for this CI release." + exit 1 + fi + echo "publish $crate_name $crate_version (first release)" + packages_to_publish+=("$crate_name") + first_releases+=("$crate_name") fi done <<< "$package_versions" if [[ "${#packages_to_publish[@]}" -eq 0 ]]; then - echo "no already-initialized crates need a new crates.io release" + echo "no workspace crates need a new crates.io release" exit 0 fi if [[ "$mode" == "dry-run" ]]; then - publish_args=(cargo publish --dry-run) + publish_args=(cargo publish --dry-run --locked) echo "dry-run publish packages: ${packages_to_publish[*]}" + for package in "${packages_to_publish[@]}"; do + publish_args+=(-p "$package") + done + "${publish_args[@]}" else - publish_args=(cargo publish) echo "publish packages: ${packages_to_publish[*]}" + for package in "${packages_to_publish[@]}"; do + if [[ " ${first_releases[*]} " == *" $package "* ]]; then + echo "publish $package using first-release API token" + CARGO_REGISTRY_TOKEN="$FIRST_PUBLISH_TOKEN" cargo publish --locked -p "$package" + else + echo "publish $package using OIDC" + cargo publish --locked -p "$package" + fi + done fi - for package in "${packages_to_publish[@]}"; do - publish_args+=(-p "$package") - done - "${publish_args[@]}" - - name: Create GitHub Release if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') env: @@ -168,12 +187,27 @@ jobs: set -euo pipefail tag_ref="refs/tags/$GITHUB_REF_NAME" git fetch --force origin "$tag_ref:$tag_ref" + tag_object="$(git rev-parse "$tag_ref^{tag}" 2>/dev/null || true)" target_commit="$(git rev-parse "$tag_ref^{commit}")" notes_file="$RUNNER_TEMP/release-notes.md" git for-each-ref "$tag_ref" --format='%(contents)' > "$notes_file" if [ ! -s "$notes_file" ]; then git log -1 --format=%B "$target_commit" > "$notes_file" fi + { + printf '\n## Authentication and provenance\n\n' + printf -- '- Tag: `%s`\n' "$GITHUB_REF_NAME" + if [ -n "$tag_object" ]; then + printf -- '- Annotated tag object: `%s`\n' "$tag_object" + else + printf -- '- Tag object: lightweight tag\n' + fi + printf -- '- Target commit: `%s`\n' "$target_commit" + printf -- '- Workflow run: %s/%s/actions/runs/%s\n' \ + "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" + printf -- '- Workflow attempt: `%s`\n' "$GITHUB_RUN_ATTEMPT" + printf -- '- Published by: GitHub Actions `%s` workflow\n' "$GITHUB_WORKFLOW" + } >> "$notes_file" if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "github release $GITHUB_REF_NAME already exists" diff --git a/.gitignore b/.gitignore index adc8861..032b6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,10 @@ refs/ doc tmp/ + +# Local implementation notes and agent working documents +/docs/design/ +/docs/api/sdk-interfaces.md +/docs/api/top-level-review.md +/docs/api/pishoo-service-boundary.md +/docs/reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 048337c..f9f8eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.7.0] - 2026-10-12 + +### Changed + +- Migrate to independent named Endpoints, anonymous requests and shared QUIC networking. +- Use native HTTP/3 streams, standard bodies, streaming uploads and request trailers. +- Maintain interface bindings and NAT mappings, and evict unusable H3 connections. +- Restore Node.js and Python SDKs; retire the dhttp-identity workspace package. +- Update identity-home, access and log crates for the new API. + +### Release versions + +| Package | Previous published version | Prepared version | +| --- | --- | --- | +| `dhttp` | 0.6.2 | 0.7.0 | +| `dhttp-home` | 0.5.0 | 0.6.0 | +| `dhttp-access` | 0.4.2 | 0.5.0 | +| `dhttp-log` | 0.1.0 | 0.2.0 | +| `@genmeta/dhttp / dhttpy` | 0.6.2 | 0.7.0 | + + ## [0.6.2] - 2026-08-19 ### Fixed diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c731be --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5807 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ce58e76c04e8162c2e4bc3fe53fa7c2418ef5fba7bb2838b8531b7d4fbf14" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2303ebedb1071f4f72d71171e383859a9a285c94d99e4892685a96fca9890720" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dff6e4b9f158864a0aeb6a131ad53858ecbae7a2fc8989307b9b3c6b2f122e" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7235b863533e9db3ab86905b4521251de11226275b436ff3cb2d0010558d1c5" +dependencies = [ + "bytes", + "half", + "num-bigint 0.5.1", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cf840f0c34aeeb7e8b05aba72d66878e6c001827f423331f0b260209e7b7e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi 3.1.0", + "base64 0.23.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-cmp" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73a0e9adb28d95739fabcdae637261a2c14e618caf58ba1b88857120f9ee2687" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", +] + +[[package]] +name = "arrow-data" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa06d5e22786c4966ffc4d2f171d0cb65d95f6bdea108a14e8c335e9faa437f" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7ac2c6a9cdc782fa9548d3763461eaec8c93347e297fddbaa44807e70a3698" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cmp", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758b87e9648d80001e0916c083334ed93074c677a81a96144d5c84bd2d8eab2a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85a9a58cd03560526b0066487574aacb9008e413504fa0ee23d61141e96c338" + +[[package]] +name = "arrow-select" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3482b7d03bf4a3bf0b195f08a7f2b071595ccd54897e35b975ac63d3104977" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-cmp", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300b4400b1e7cd58f7cbbd5dd2a0072cc59e73bfaee2a152b3ac660e46d9dd1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atoi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint 0.4.8", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[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 = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.8", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive 0.7.3", + "flagset", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "der_derive 0.8.0", + "flagset", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint 0.4.8", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "dhttp" +version = "0.7.0" +dependencies = [ + "async-stream", + "bytes", + "dhttp-access", + "dhttp-home", + "dhttp-log", + "futures", + "h3x", + "http", + "http-body", + "http-body-util", + "netwatcher", + "qbase", + "qconnection", + "qprotocol", + "qrecovery", + "qresolve", + "qrustls", + "qtls", + "qtransport", + "qudp", + "rcgen", + "rustls-pemfile", + "scopeguard", + "tokio", + "tower", + "tower-service", + "tracing", + "url", + "x509-parser", +] + +[[package]] +name = "dhttp-access" +version = "0.5.0" +dependencies = [ + "chrono", + "clap", + "derive_more", + "dhttp-home", + "globset", + "http", + "peg", + "regex", + "regex-automata", + "sea-orm", + "sea-orm-migration", + "serde", + "serde_json", + "snafu", + "tokio", + "tracing", +] + +[[package]] +name = "dhttp-api" +version = "0.7.0" +dependencies = [ + "async-stream", + "bytes", + "dhttp", + "dhttp-home", + "futures", + "h3x", + "http", + "http-body", + "http-body-util", + "napi", + "napi-derive", + "pyo3", + "pyo3-async-runtimes", + "qprotocol", + "qtls", + "rustls-pemfile", + "scopeguard", + "serde_json", + "tokio", + "tokio-util", + "tower-service", + "tracing", +] + +[[package]] +name = "dhttp-home" +version = "0.6.0" +dependencies = [ + "der 0.7.10", + "dhttp-identity", + "dirs", + "futures", + "qrustls", + "qtls", + "reqwest", + "ring", + "serde", + "sha1 0.10.7", + "snafu", + "tempfile", + "tokio", + "toml", + "x509-cert 0.2.5", + "x509-ocsp", + "x509-parser", +] + +[[package]] +name = "dhttp-identity" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb46f8d8f2dcd5d9657bbf37a48b9d3bec45b6dc6f5a673801019294da1d1934" +dependencies = [ + "bytes", + "futures", + "http", + "ring", + "rustls", + "serde", + "snafu", + "x509-parser", +] + +[[package]] +name = "dhttp-log" +version = "0.2.0" +dependencies = [ + "chrono", + "dhttp-home", + "http", + "sha2 0.11.0", + "snafu", + "x509-parser", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dns-lookup" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263c0ccfc6b3b6a93e959edad4f985779e76c0b36dbb2e13e3d27c690974adf1" +dependencies = [ + "cfg-if", + "libc", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h3x" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d6a73037e5c3066129d142450eba18b16404693d696c11fb1d1b3fc637fc67" +dependencies = [ + "async-stream", + "async-trait", + "bytes", + "dashmap", + "futures", + "httlib-huffman", + "http", + "http-body", + "http-body-util", + "qbase", + "qrecovery", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "napi" +version = "3.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" +dependencies = [ + "bitflags 2.13.2", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" +dependencies = [ + "convert_case 0.12.0", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" +dependencies = [ + "convert_case 0.12.0", + "proc-macro2", + "quote", + "semver", + "syn 3.0.8", +] + +[[package]] +name = "napi-sys" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" +dependencies = [ + "libloading", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c52c2584961c68f5a6e3356797344061f818c93b8acc9cd6d7e284795e563e" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags 2.13.2", + "libc", + "log", + "netlink-packet-core", + "zerocopy", +] + +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "netwatcher" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407d9dd4b11dd93484ef769ca060f8e42c135b1a3931592daec5522e08302e55" +dependencies = [ + "aligned-vec", + "android-build", + "jni 0.22.4", + "ndk-context", + "nix", + "tokio", + "windows", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "peg" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pkix-path" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325809ccf6e48c67f1e1e7a91187566372346797737ee00d1381a5881f76aecf" +dependencies = [ + "der 0.7.10", + "p256", + "rsa", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "x509-cert 0.2.5", +] + +[[package]] +name = "pkix-revocation" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3b442616fbb7b036180da1830f4877bfa09e7cbe38d06aa78c64d4dadbeafc" +dependencies = [ + "der 0.7.10", + "pkix-path", + "sha1 0.10.7", + "sha2 0.10.9", + "spki 0.7.3", + "x509-cert 0.2.5", + "x509-ocsp", +] + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "qbase" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f8b7a61a7395f50d7e445be878b2b1bfd0724659283c67a58d777230ec66e2" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "derive_more", + "enum_dispatch", + "futures", + "getset", + "nom 8.0.0", + "qmacro", + "qtls", + "rand 0.10.3", + "rustls", + "serde", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "qcongestion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95adba81e48aa5347f5594401359f891c0d1e4c3228f6e5d2742d6fbc4ee738" +dependencies = [ + "qbase", + "qevent", + "rand 0.10.3", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "qconnection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab6cf9495670da09b9d610eecb66ea10b1dde3f519ad5975fd1bb1d02b3bfbf" +dependencies = [ + "bytes", + "futures", + "qbase", + "qcongestion", + "qprotocol", + "qrecovery", + "qresolve", + "qtls", + "qtransport", + "qtraversal", + "tokio", + "tracing", +] + +[[package]] +name = "qevent" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7c9bde6b05cb097c970655848bfcc20c1f1458c0272c0ff7bda7d38a2023ff" +dependencies = [ + "bytes", + "derive_builder", + "derive_more", + "enum_dispatch", + "pin-project-lite", + "qbase", + "serde", + "serde_json", + "serde_with", + "tokio", + "tracing", +] + +[[package]] +name = "qmacro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed07a010b67663afaa9e8e43069f27f1f8c68c62b65b9d0089b022f2ae1f2dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "qprotocol" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8548c54dbd080cef9ed3a6e79819da3e178ad15c010f73bf2346594bac5a152f" +dependencies = [ + "bytes", + "dashmap", + "derive_more", + "futures", + "http", + "netdev", + "nom 8.0.0", + "qbase", + "qresolve", + "qudp", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "qrecovery" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51eac3d871931d94a867e50c54d58fabbfb1c8d893bd48e6a95f12ebaf87bf7a" +dependencies = [ + "bytes", + "derive_more", + "futures", + "qbase", + "qevent", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "qresolve" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "304f2a3ffacb1a2516044b13b3a8e95a7e91fd5b87dafb4a37a7cb23086a7cbd" +dependencies = [ + "dns-lookup", + "futures", + "qbase", + "tokio", +] + +[[package]] +name = "qrustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8050bd9948ac6868ddadcb6bf58e5da7a46ad922acd595db4a9ba04578139c2f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "qtls" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427f5e986d4ce4a397f8712265e1b3dfaf043d5c2d300e0f836ecc3a9f601b06" +dependencies = [ + "bytes", + "der 0.8.2", + "pkix-path", + "pkix-revocation", + "qrustls", + "ring", + "signature 2.2.0", + "signature 3.0.0", + "spki 0.8.1", + "thiserror 2.0.21", + "x509-cert 0.2.5", + "x509-cert 0.3.0", + "x509-parser", + "zeroize", +] + +[[package]] +name = "qtransport" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fff22111657b3a094a75403ec051c0dcd1ee83b3c7fe28983469a2617c5f439" +dependencies = [ + "bytes", + "dashmap", + "derive_more", + "futures", + "qbase", + "qcongestion", + "qevent", + "qprotocol", + "qrecovery", + "qtls", + "smallvec", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "qtraversal" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e573c395a0b45f64659f0f6d4707a8bdd463a6195426d58e83c8e1ce955da6" +dependencies = [ + "bytes", + "dashmap", + "qbase", + "qprotocol", + "qtransport", + "rand 0.10.3", + "tokio", + "tracing", +] + +[[package]] +name = "qudp" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f814f5448612fe66c307b3da58284c3ee8839138ae395e72d6dd1bd65e7cc2" +dependencies = [ + "bytes", + "cfg-if", + "libc", + "nix", + "qbase", + "socket2", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sea-orm" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40bc08e0aa1963295a30473f98d9b4c9c2b0b3a028381e0143cb2647d0a59a32" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive-where", + "derive_more", + "futures-util", + "itertools", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-arrow", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "sqlx-core", + "strum", + "thiserror 2.0.21", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2edbdc3c8e00e855d7911b6c6c4048f61c28eba40a1d40456f363d7b4969a4" +dependencies = [ + "arrow", + "sea-query", + "thiserror 2.0.21", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04469c4a54e7271fe3fe2b8140102a2cd2eb7e48129a3afcd013b9909ddebc3c" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "indoc", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fa8c81c20fc8cfe78d22d9a2721ca5ef151dc4a8a7b372de218aaba33733fa" +dependencies = [ + "heck 0.5.0", + "itertools", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07970137dbbed6daf56a2429db349abfb68ed1c79206957c30d4a0e9ea808c08" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.21", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.2", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.21", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.21", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi 2.0.0", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi 2.0.0", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.21", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.8", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.1", + "tls_codec", +] + +[[package]] +name = "x509-ocsp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e54e695a31f0fecb826cf59ae2093c941d7ef932a1f8508185dd23b29ce2e2e" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "x509-cert 0.2.5", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index d4e8830..1fb5286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "3" -members = ["dhttp", "home", "access", "log"] +members = ["dhttp", "home", "access", "log", "api"] [workspace.package] -version = "0.6.2" +version = "0.7.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -19,7 +19,7 @@ serde = { version = "1", features = ["derive"] } futures = "0.3" netwatcher = { version = "0.8", features = ["tokio"] } matchit = "0.9" -rustls = { version = "0.23", default-features = false } +rustls = { package = "qrustls", version = "0.23.45", default-features = false } tokio = { version = "1" } toml = "1" tower-service = "0.3" @@ -41,22 +41,17 @@ clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } -# Pin transport sources so a standalone Git checkout needs no adjacent repositories. -qbase = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qconn = { package = "qconnection", git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qtransport = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qprotocol = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qresolve = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qudp = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qrecovery = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qtls = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -h3x = { git = "https://github.com/genmeta/h3x.git", rev = "90ab46c8df5332139d6ffd9eab30daa61b05bbca" } -dhttp-home = { path = "home", version = "0.5.0", default-features = false } -dhttp = { path = "dhttp", version = "0.6.2" } -dhttp-access = { path = "access", version = "0.4.2" } -dhttp-log = { path = "log", version = "0.1.0" } - -# h3x uses these crate names from the registry; share the pinned QUIC types. -[patch.crates-io] -qbase = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } -qrecovery = { git = "https://github.com/genmeta/dquic.git", rev = "c64cf762cbaab9f0b848cf744d4f7352b2514864" } +# Release dependencies use compatible crates.io versions. +qbase = { version = "0.7.0" } +qconn = { version = "0.9.0", package = "qconnection" } +qtransport = { version = "0.1.0" } +qprotocol = { version = "0.7.0" } +qresolve = { version = "0.9.0" } +qudp = { version = "0.8.1" } +qrecovery = { version = "0.7.0" } +qtls = { version = "0.1.0" } +h3x = { version = "0.7.0" } +dhttp-home = { path = "home", version = "0.6.0", default-features = false } +dhttp = { path = "dhttp", version = "0.7.0" } +dhttp-access = { path = "access", version = "0.5.0" } +dhttp-log = { path = "log", version = "0.2.0" } diff --git a/README.md b/README.md index 2c1acec..6284212 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,222 @@ -# DHTTP - -带身份的 HTTP/3 通信库。按 Pishoo 的[三仓第一版冻结清单](../pishoo/design/README.md)实现独立 Endpoint、全局 Network、标准 HTTP Body 和 Tower Service 接入。 - -```rust,ignore -dhttp::resolve::Resolver::add(my_resolver); // Arc -dhttp::DhttpNetwork::init().await?; -let endpoint = dhttp::Endpoint::load("alice").await?; -let uri: http::Uri = "https://bob~/profile".parse()?; -let response = endpoint.get(uri) - .header(http::header::ACCEPT, http::HeaderValue::from_static("application/json")) - .await?; -``` +

+ + DHttp + +

+

Clients can be servers; all endpoints are created equal.

-匿名请求使用 `Anonymous.get(uri)` 等方法,与 Endpoint 的请求方法对称,无需本地身份材料;远端身份仍会验证。与具名请求一样,发送前需要注册解析器并初始化 Network: +

+ Crates.io + License + Documentation + Rust +

-```rust,ignore -dhttp::resolve::Resolver::add(my_resolver); -dhttp::DhttpNetwork::init().await?; -let response = dhttp::Anonymous.get("https://bob~/profile".parse()?).await?; -``` +HTTP has been the Internet's most widely used protocol for decades, powering the Web and reaching into nearly every corner of the digital world. Yet beneath that prosperity lies a deep structural flaw: HTTP created a hierarchy between clients and servers. Servers became the aristocracy—named, served by DNS, discoverable, and globally callable. Clients remained nameless subjects, continuously sending their data upward for servers to analyze and monetize. This architectural inequality has become the machinery that entrenches **technological feudalism** and perpetuates **data colonialism**. -`Endpoint` 持有 `Arc`;`Endpoint::load` 读取名称对应的证书链、私钥和 OCSP,加载失败立即返回错误,clone 共享已加载的端点。监听和新建连接直接使用这些凭据,池仍按双方名称复用连接;匿名与具名请求使用不同池条目。凭据文件更新后需要重新 load,已有 Endpoint 保留加载时的材料。 +DHttp is **Decentralized HTTP on QUIC and HTTP/3**. It extends HTTP: each endpoint is both a client and a server, and domain names extend beyond servers to every endpoint. **A name for every endpoint** seems so basic that we overlook its profound significance: it is the foundation of **Omniconnectivity**, enabling identification, discovery, invocation, authorization, and authentication—all through a simple name. **This is DHttp**—opening a new era of Omniconnectivity for the Internet of Agents. -`Endpoint::new(identity)` 必须接收已准备好的 `Arc`,与底层 QUIC 的构造参数一致。`name()` 返回 `&str`;Endpoint 始终有身份,可以发起请求和监听。`Anonymous` 是无状态的出站入口,提供相同的 get/head/post/put/patch/delete/options/request/from_request 方法,不提供 listen。`Anonymous.from_request(http_request)` 和 `Request::new(http_request)` 都可接入已有的标准 HTTP 请求。 +## DHttp vs HTTP -Endpoint 加载和请求构造不访问网络;await 请求时才取得共享连接。`Request` 组合可选的具名 Endpoint 和 `http::Request`;`B` 为 `Empty` 或 `WndBuf`。`dhttp::Empty` 是标准 `http_body_util::Empty` 的别名,其 `Body::Data` 为 Bytes,本身不保存数据。GET/HEAD(以及默认的 DELETE/OPTIONS)返回 `Request`,await 时直接发送空 body、结束请求方向并等待响应。POST/PUT/PATCH 和通用 `request(method, uri)` 返回 `Request`,构造时就持有 h3x 的可写缓冲;首次 await 消费构造器、建连、开流并启动上传,返回 `(RequestWriter, RequestFuture)`。同一个 WndBuf 直接交给 h3x,与 RequestWriter 共享。通过 RequestWriter 继续写入,响应 future 可独立等待响应头。两种请求分别实现 IntoFuture,共用 URI 处理和连接获取逻辑。 +| "Interconnected"? | HTTP | DHttp | +| --- | :---: | :---: | +| Client to server | ✓ | ✓ | +| Server to server | ✓ | ✓ | +| Client to client | ✗ | ✓ | +| Server to client | ✗ | ✓ | -```rust,ignore -use tokio::io::AsyncWriteExt; -use bytes::Bytes; -let response = endpoint.get(uri.clone()).await?; +> In HTTP, network listening is confined to the server side: servers can accept incoming connections, whereas clients lack this capability. Thus, HTTP provides only partial connectivity, leaving interconnection across the World Wide Web dependent on cloud-hosted infrastructure. DHttp, conversely, establishes client-server equivalence, treats all endpoints with endpoint equality, and achieves **Omniconnectivity** without depending on privileged endpoints such as traditional servers. -let (mut writer, response) = endpoint.post(uri.clone()).await?; -writer.write_all(b"content").await?; -writer.shutdown().await?; -let response = response.await?; +## How DHttp Extends HTTP -let (mut writer, response) = endpoint.post(uri.clone()).write(b"initial-").await?; -writer.write_all(b"more").await?; -writer.shutdown().await?; -let response = response.await?; +- **Client and Server in One** — Every client can also listen for incoming connections, and serve HTTP APIs. Each endpoint is both client and server; all endpoints are created equal. +- **Names extend to Clients** — DHttp makes domain names and PKI certificates available to every endpoint, not just servers. Every endpoint deserves a name. +- **EndpointAddress Record** — Every endpoint's network address can be resolved by name, including in private networks; the result is a set of EndpointAddress Records, not just an IP address. +- **Peer-to-Peer Communication** — Using DQuic to traverse NATs, striving to establish a direct peer-to-peer path—including across IPv4 and IPv6 networks—for every connection. +- **Volunteer Relay Network** — Any qualifying endpoint can voluntarily provide relay service for endpoints in private networks. -let tag = dhttp::HeaderName::from_static("x-tag"); -let (mut writer, response) = endpoint - .post(uri.clone()) - .trailer(tag.clone(), dhttp::HeaderValue::from_static("initial")) - .await?; -writer.write_all(b"content").await?; -writer.append_trailer(tag, dhttp::HeaderValue::from_static("computed"))?; -writer.shutdown().await?; // 自动发送剩余 DATA、trailers,再结束上传 -let response = response.await?; +## DHttp Request Flow -let body = dhttp::WndBuf::new(64 * 1024); -body.write_bytes(Bytes::from_static(b"content")).await?; -let (mut writer, response) = endpoint.post(uri).body(body).await?; -writer.shutdown().await?; -let response = response.await?; -``` +

+ A named DHttp request from bob.lee to alice.smith +

-`.body(window)` 设置请求使用的 WndBuf;`.write(data)` 创建容量至少为数据长度的 WndBuf,再写入初始字节(默认容量为 64 KiB)。后续可用 `write_all` 继续写入,调用 `AsyncWriteExt::shutdown()` 发送 EOF 并等待上传完成。空的 WndBuf 也需要 shutdown;显式 `.body(dhttp::Empty::new())` 则按空请求处理。`from_request` 和 `Request::new` 同样接受以 `Empty` 或 `WndBuf` 为 body 的标准请求。流式请求首次 await 不等待响应头,因此服务端可以等待完整请求后再响应。请求头和初始 body 在发送前配置;发送后的 RequestWriter 实现 AsyncWrite 和 dquic 的 CancelStream(由 dhttp 重导出)。在它上面调用 header、append_header、body 或再次 await 会在编译期报错。`cancel(error_code)` 按指定 HTTP/3 错误码 reset 请求方向;丢弃句柄会取消未完成的上传。响应 Body 的读取或丢弃不控制上传任务。 +> Bob.Lee makes an DHttp request to Alice.Smith. DDns resolves `alice.smith` to the EndpointAddress record, DQuic establishes the connection, and DHttp carries the request and response. +> Alice.Smith's endpoint resides on a private network and would otherwise be unreachable. With assistance from `3.66.134.55:20002`, it discovers its public address, `208.90.123.177:61901`, establishes a listener, and traverses NAT to form a direct peer-to-peer connection. -Request 构造阶段支持 `.trailer(name, value)` 替换同名字段、`.append_trailer(name, value)` 追加同名值;替换 body 时保留这些字段。发送后,RequestWriter 提供同名方法并返回 `io::Result<()>`,可继续补充或修改 trailers;登记 trailers 后仍可写 DATA。首次轮询 `shutdown()` 时冻结字段,后台按剩余 DATA、trailers HEADERS、FIN 的顺序发送,并等待上传完成。开始 shutdown(包括等待期间)或取消后,再写 DATA 或修改 trailers 返回 `BrokenPipe`;重复成功的 shutdown 幂等。空请求预设的 trailers 在 await 时自动发送。 +## Quick start -服务请求携带实际 `qtls::HandshakeSummary`;对外不暴露 QUIC/H3 连接。 +### Rust SDK -`DhttpNetwork::init()` 为所有可用活动网卡准备 UDP 绑定;重复或并发调用返回同一个 Network。netwatcher 先交付当前快照,再持续监听系统网卡事件。每次扫描保留匹配 socket 的实际端口,添加新绑定并撤销失效绑定;IPv6 link-local 地址保留网卡 scope ID。没有可用网卡或部分绑定失败时仍可初始化,后续网卡变化会重新扫描。 +Add the library and the dependencies used by these examples: -`DhttpNetwork` 只保存 HTTP/3 连接池和服务表。池键分为 Incoming 和 Outgoing,分别要求本端或远端身份;双方身份齐全时按同一名称对匹配,出站请求可以复用入站连接。socket、收包任务、直接 QUIC 地址登记及清理统一交给 dquic 的 `Dock`,地址发布到配套 `AddressBook`;纯客户端也可直接建连。收包失败由 Dock 清理,Network 等待下一次网卡事件补齐绑定。Tokio runtime 必须持续存活。 +```toml +[dependencies] +dhttp = "0.7.0" +ddns = { package = "dyns", version = "0.8.0", features = ["h3"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +http-body-util = "0.1" +axum = "0.8" +``` -初始化不等待 STUN/NAT 探测;NAT 分类、公网映射和 relay 别名由 dquic 的独立协议能力提供。当前 Network 只发布直接绑定地址,不自动测量或发布 NAT 映射地址。所有连接使用通过 `dhttp::resolve::Resolver::add` 注入的解析器。Pishoo 等上层负责按域名分流:DHTTP 名称交给 DDNS/mDNS,普通 DNS 域名交给 `SystemResolver`。全局 Resolver 并发合并已注册来源,本身不决定域名分流规则。 +Prepare the named identities before running the examples. `Endpoint::load` +reads `ssl/fullchain.crt`, `ssl/privkey.pem`, and `ssl/ocsp.der` from the identity's +profile under `DHTTP_HOME` or the default user home. For example, +`alice.smith~` uses the `alice.smith` profile and identifies +`alice.smith.dhttp.net`. Named peers need valid OCSP staples; anonymous clients +still verify the server's certificate and OCSP response. + +Register discovery sources explicitly before initializing the shared network. +System DNS resolves ordinary hostnames; the H3 resolver queries DHTTP endpoint +records. The examples assume the destination has published reachable records. + +### Client Mode + +```rust,no_run +use std::sync::Arc; + +use dhttp::{DhttpNetwork, Endpoint, HeaderName, HeaderValue, Uri}; +use dhttp::resolve::{Resolver, SystemResolver}; +use http_body_util::BodyExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + Resolver::add(Arc::new(SystemResolver)); + Resolver::add(Arc::new(ddns::H3Resolver::anonymous( + "https://ddns.genmeta.net".parse()?, + )?)); + DhttpNetwork::init().await?; + + let endpoint = Endpoint::load("alice.smith~").await?; + let uri: Uri = "https://bob.lee~/welcome".parse()?; + let response = endpoint + .get(uri) + .header( + HeaderName::from_static("accept"), + HeaderValue::from_static("text/plain"), + ) + .await?; + + let bytes = response.into_body().collect().await?.to_bytes(); + println!("{}", String::from_utf8_lossy(&bytes)); + Ok(()) +} +``` -请求 URI 中只有 `bob~`、`~` 等显式简写展开为 DHTTP 名称。普通 DNS 名称保持原域名,显式端口传到连接层,并参与连接池匹配;`https://ddns.genmeta.net:4433` 不会变成 `.dhttp.net` 名称或丢失端口。通过 HTTP/3 查询 DDNS 时,其服务 origin 也走全局 Resolver,必须由上层的域名分流交给系统解析,避免递归进入 DDNS 查询。DHTTP authority 的 `:序号` 仍只保留在请求 URI 中,传输地址和端口来自解析结果。TLS 仍使用配置的信任根并验证名称和 OCSP。 +Use `dhttp::Anonymous.get(uri).await` for a request without local credentials. +Request URIs and headers use validated HTTP types. `bob.lee~` is explicit name +shorthand; ordinary DNS names retain their original namespace. + +For streaming uploads, `endpoint.post(uri).await` returns a request writer and +an independent response future. Write the content, call `shutdown()` on the +writer, and await the response. Responses use standard HTTP bodies, with data, +trailers and stream errors available through normal body operations. + +### Server Mode + +```rust,no_run +use std::sync::Arc; + +use axum::{Router, routing::get}; +use dhttp::{DhttpNetwork, Endpoint, Scope}; +use dhttp::resolve::{Resolver, SystemResolver}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + Resolver::add(Arc::new(SystemResolver)); + DhttpNetwork::init().await?; + let endpoint = Endpoint::load("bob.lee~").await?; + + let service = Router::new().route( + "/welcome", + get(|| async { "hello from DHttp" }), + ); + let listener = endpoint + .listen(Scope::Internal | Scope::External, service) + .await?; + + listener.await; + Ok(()) +} +``` -```rust,ignore -dhttp::DhttpNetwork::init().await?; -let addresses = dhttp::AddressBook::global(); -let public_addresses = addresses.ddns_endpoints(); -let updates = addresses.subscribe_ddns(); // 名称发布服务直接订阅地址变化 -// 针对某个本地绑定,可使用 addresses.subscribe_mdns(bound)。 +The listener accepts a standard Tower service, including an Axum router. Keep +its returned future alive while serving; dropping it unregisters the listener +without closing shared connections. Each request carries its verified +`dhttp::HandshakeSummary` in the request extensions. + +Listening and name publication are separate. A discovery publisher must keep +the endpoint records synchronized with `dhttp::AddressBook::global()`; `dyns` +provides H3 and mDNS discovery. The network maintains local bindings and NAT +mappings, while the application owns name publication. + +## Node.js and Python SDK + +The native bindings follow the same endpoint model using each language's +standard request, response and asynchronous streaming conventions. The 0.7.0 +bindings can be built from [api/](api/README.md); multi-platform npm/PyPI release +packaging remains pending. + +These examples use an explicit peer address returned by the remote SDK's +`addresses()` function. Replace `192.168.1.10:51234` with that endpoint's actual +reachable address. Native SDK discovery currently supports system DNS and +explicit peers; default H3 DNS/mDNS and name publication remain pending. + +Node.js (22.17 or later): + +```js +import { Endpoint, init } from "@genmeta/dhttp"; + +await init({ peers: { "alice.smith.dhttp.net": "192.168.1.10:51234" } }); +const endpoint = await Endpoint.load("bob.lee~"); +try { + const response = await endpoint.fetch("https://alice.smith~/welcome"); + console.log(await response.text()); +} finally { + await endpoint.close(); +} ``` -`endpoint.listen(scopes, service).await` 接受标准 Tower Service,登记成功后返回 `ListenFuture`;调用方 spawn 或持有它以维持监听。`scopes` 原样交给 qconn,按名称限制来源范围。接入回调直接装配 H3 并启动请求驱动。丢弃 `ListenFuture`(包括未 poll 的 future)撤销名称和 Service,socket 和已有连接保留;重新监听同名服务后,旧连接上的新请求交付给新 Service。解析器与名称发布服务需配套:system DNS 默认解析到 443,而 Network 绑定动态端口,部署时需发布实际地址。 +Python (3.10 or later): -h3x 内部保留有界收发缓冲,接收消息和 Service 响应使用标准 HTTP Body;dhttp 的空请求以 `http::Request` 直接交给 h3x 的泛型 `WriteRequest` 实现,流式请求走 `WriteRequest`。泛型发送路径直接读取 body 帧,空请求无需装箱或转换为另一种 body。`WndBuf::new` 创建空窗口,`write_bytes` 让 Bytes 无拷贝进入窗口;所有写入都遵守容量限制。发送前预填数据时应保证容量足够;超过容量的数据应在开始发送后通过 RequestWriter 写入,让消费和写入并发进行。DATA、多值 trailers、EOF、错误、提前丢弃和 HEAD/204/304 语义由 h3x 处理。客户端直接返回 h3x 的响应 Body;流式上传任务由返回的 `RequestWriter` 独立持有。 +```python +import asyncio +import dhttpy -等待取得连接的期限为 30 秒;业务请求和终端会话期限由调用方决定。远端停止在后续流 I/O 中观察,不提供独立终态订阅。 +async def main(): + await dhttpy.init(peers={"alice.smith.dhttp.net": "192.168.1.10:51234"}) + async with await dhttpy.Endpoint.load("bob.lee~") as endpoint: + async with endpoint.get("https://alice.smith~/welcome") as response: + print(await response.text()) -`dhttp-home` 负责目录定位,`Endpoint::load` 从 `DHTTP_HOME//ssl` 或用户默认 home 读取身份材料并装配 QUIC 端点。TLS 身份和握手类型直接复用 qtls。`dhttp-home` 同时承载 DHTTP 名称、证书链标识、SKI 解析和规范签名验证;需要这些规则的应用直接调用其证书接口,签名使用 qtls 的本端身份能力。入站请求的握手信息作为 `qtls::HandshakeSummary` 放在 request extensions 中。 +asyncio.run(main()) +``` -当前依赖相邻 `../dquic` 和 `../h3x`,传输仅使用 QUIC。具名请求通过 QuicEndpoint 建连,匿名请求调用 qconnection 的 connect_anonymously,两者共用底层流程。QuicEndpoint 始终持有身份;匿名只表示不提交客户端凭据,仍验证服务器身份。出站路径发现由 qconnection 消费全局 Resolver 和 AddressBook;双方显式采用 dquic 的 client/server transport parameters,开放 HTTP/3 所需的流和流控额度。dhttp 显式配置两端的 `h3` ALPN,在装配 H3 前校验实际协商值,握手摘要保留实际结果。未交付建连的取消及 QUIC 清理由 dquic 管理。WASM、授权、应用路由和终端执行由 Pishoo 负责。 +See the [SDK guide](api/README.md) for streaming uploads, handler services, +verified peer identities, cancellation and local build instructions. -```sh -cargo test -p dhttp --lib --tests -cargo check -p dhttp --no-default-features --all-targets -# 需要 OpenSSL 和本地 UDP socket 权限;生成临时证书及有效 OCSP,验证真实双向 TLS 与 HTTP 请求 -cargo test -p dhttp --test quic_roundtrip -- --ignored -cargo test -p dhttp --test dns_bootstrap -- --ignored -``` +## Platforms and verification -真实网络测试使用自建 CA、实际生成的证书/私钥和有效 OCSP。生成器明确指定 named_curve 与 SHA-256,并核对证书/私钥公钥一致、证书链和域名、OCSP 签名。需要支持 `pkey -check` 与 `ocsp -rmd` 的 OpenSSL;若子进程命中系统自带的兼容实现,可通过 `DHTTP_TEST_OPENSSL` 指定可执行文件。例如 macOS: +Rust CI checks Linux GNU/musl, macOS and Windows, plus iOS device/simulator and +four Android ABIs. Cross-compilation checks do not replace mobile device +validation. Android applications need `INTERNET` and `ACCESS_NETWORK_STATE`; +embedders that do not initialize `ndk-context` must call +`dhttp::set_android_context` before network initialization. Call +`DhttpNetwork::global()?.resume().await` when a mobile app resumes to refresh +its interface bindings and STUN maintenance. ```sh -DHTTP_TEST_OPENSSL=/opt/homebrew/bin/openssl cargo test -p dhttp --lib --tests -- --include-ignored +cargo test --locked --workspace --all-features -- --test-threads=1 +DHTTP_TEST_OPENSSL=/path/to/openssl cargo test --locked -p dhttp-api \ + --test quic_roundtrip -- --include-ignored --test-threads=1 ``` -旧 Node.js/Python 包装已移除;语言绑定和发布版本固定待 Rust 接口及真实联网验证完成后处理。 +The real QUIC tests generate temporary CA, identity and OCSP fixtures. CI also +runs the existing Node.js and Python integration tests and includes their +native execution in coverage reports. + +See [CHANGELOG](CHANGELOG.md) for changes and [RELEASING](RELEASING.md) for crate +versions and tag-triggered publication. Applications upgrading from 0.6.x +should replace the old response builder and custom Service API with the request +and Tower service examples above. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..fb9de87 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,47 @@ +# Release preparation for v0.7.0 + +| Package | Previous published version | Prepared version | +| --- | --- | --- | +| `dhttp` | 0.6.2 | 0.7.0 | +| `dhttp-home` | 0.5.0 | 0.6.0 | +| `dhttp-access` | 0.4.2 | 0.5.0 | +| `dhttp-log` | 0.1.0 | 0.2.0 | +| `@genmeta/dhttp / dhttpy` | 0.6.2 | 0.7.0 | + +## Dependency order + +dquic → h3x + +Manifest versions use Cargo's compatible-version requirements, without `=`. +They allow patch updates within the selected 0.x series. External Genmeta +dependencies come from crates.io; no sibling checkout or Git patch is required. +The dquic TLS backend is the published qrustls 0.23.45 package, used as `rustls` +where the application exchanges TLS types with qtls. + +## Validation and lockfiles + +This PR is release preparation. Unpublished upstream versions can make CI fail; +such failures do not confirm compilation, tests or package verification. +For tracked lockfiles, run `cargo generate-lockfile` after upstream publication, +review and commit the resulting registry lockfile before tagging. +Run Cargo from a checkout outside directories containing ancestor path patches. +Never commit a lockfile generated with local transport path overrides as the +registry release lockfile. Re-run the normal CI checks and publish dry run after +upstream publication and lockfile refresh; merge and tag only that checked commit. + +## Publication + +Planned tag: `v0.7.0`. Release through the repository's GitHub Actions workflow. +Use an annotated tag with the changelog entry as its release notes. Actual +publication and production deployment remain separate from this preparation PR. + +Publish crates in order: dhttp-home, dhttp-access, dhttp-log, dhttp. +The dhttp-api bridge is not a crates.io package; Node.js and Python bindings +use version 0.7.0. The removed dhttp-identity package is not republished. + +The workspace Cargo.lock is tracked for locked platform checks and publication. +Refresh it from the published registry graph before each release. + +Android builds require qudp 0.8.1 from the dquic v0.8.1 patch release. +The registry lockfile retains libc 0.2.189 while nix is incompatible with the +Darwin constants removed from iOS in libc 0.2.190. diff --git a/access/Cargo.toml b/access/Cargo.toml index e4d9d8a..9f04d58 100644 --- a/access/Cargo.toml +++ b/access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-access" description = "Identity-aware access control primitives for DHttp" -version = "0.4.2" +version = "0.5.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..2200ef1 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,8 @@ +*.node +*.so +*.dylib +*.dll +__pycache__/ +*.pyc +.venv/ +dist/ diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..c26e796 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "dhttp-api" +version = "0.7.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared asynchronous SDK bridge for DHTTP language bindings" +publish = false + +[lib] +crate-type = ["rlib", "cdylib"] + +[features] +default = [] +napi = ["dep:napi", "dep:napi-derive"] +pyo3 = ["dep:pyo3", "dep:pyo3-async-runtimes"] + +[dependencies] +bytes.workspace = true +dhttp.workspace = true +dhttp-home = { workspace = true, features = ["ssl"] } +futures.workspace = true +http.workspace = true +http-body.workspace = true +http-body-util.workspace = true +h3x.workspace = true +tokio = { workspace = true, features = ["sync", "rt-multi-thread", "macros", "time", "io-util"] } +tokio-util = { version = "0.7", features = ["rt"] } +tower-service.workspace = true +tracing.workspace = true +serde_json.workspace = true +qtls.workspace = true +rustls-pemfile = "2" +napi = { version = "3.9.0", default-features = false, features = ["napi4", "async", "dyn-symbols", "tokio_rt"], optional = true } +napi-derive = { version = "3.5.6", optional = true } +pyo3 = { version = "0.29.0", optional = true } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"], optional = true } + +[dev-dependencies] +async-stream = "0.3" +qtls.workspace = true +qprotocol.workspace = true +rustls-pemfile = "2" +scopeguard = "1" +tokio = { workspace = true, features = ["test-util"] } diff --git a/api/README.md b/api/README.md index 282291f..a72e471 100644 --- a/api/README.md +++ b/api/README.md @@ -1,5 +1,206 @@ -# DHTTP SDK +# DHTTP SDK 公共绑定层 -旧 Node.js / Python 实现已在下一版重构中删除。此目录保留给架构设计的 D4 阶段:待 Rust Endpoint、请求、响应、Body 和生命周期接口稳定后重建语言绑定。 +2026-10-08 开始恢复 Node.js / Python SDK。`api/` 现在是可构建的 `dhttp-api` +crate,两种语言将共用这里的流桥接、错误和生命周期实现。 +`api/` / `dhttp-api` 沿用 v0.6.2 的语言绑定包名称,职责限定为绑定适配。 -当前没有可构建的 SDK crate、npm 包或 Python 包。 +当前已接入 N-API / PyO3,恢复 Node `@genmeta/dhttp` 和 Python `dhttpy` 的高层客户端与 +handler 服务端。两种包装复用公共层的有界流、取消与任务所有权,不恢复旧 raw connection API。 + +## Node.js + +```sh +cd api +npm run build +``` + +```js +import {Endpoint, init} from '@genmeta/dhttp'; + +// peers 的地址来自远端服务进程的 addresses();当前需要显式配置 DHTTP 名称解析。 +await init({peers: {'bob.dhttp.net': '192.168.1.10:51234'}}); +const endpoint = await Endpoint.load('alice'); +try { + const response = await endpoint.fetch('https://bob~/hello'); + console.log(await response.text(), response.remoteAuthority?.ownerHash); + console.log(await response.trailers); +} finally { + await endpoint.close(); +} +``` + +Node 22.17+ 支持 ESM/CommonJS 和 TypeScript 声明。消息直接使用 Web Request、Response、 +Headers 和 ReadableStream;响应附带 remoteAuthority、rawHeaders、trailers Promise 与 release()。 +fetch 支持 AbortSignal、毫秒总期限 timeout、ownerHash,以及静态/Promise/函数形式的 trailers。 +标准 Headers 会合并可合并的多值字段,rawHeaders 保留收到的原始字段对;服务响应可附加 rawHeaders。 +`listen(scopes, handler)` 接收 Web Request 并要求返回 Web Response。 +服务请求的 signal 在监听关闭时中止,SDK 不等待无法取消的用户 Promise。 +缓存、cookie jar 和浏览器 CORS 策略仍由调用方负责;流式请求体无法在 307/308 跳转时重放。 + +## Python + +```sh +cd api +uv run --with maturin maturin build --interpreter python3 --out dist +# 将 dist 中当前平台的 wheel 安装到项目虚拟环境。 +``` + +```python +import dhttpy + +await dhttpy.init(peers={'bob.dhttp.net': '192.168.1.10:51234'}) +async with await dhttpy.Endpoint.load('alice') as endpoint: + async with endpoint.get('https://bob~/hello') as response: + print(await response.text(), response.remote_authority.owner_hash) + print((await response.trailers()).items()) +``` + +Python 3.10+ 的包装支持 await / async with、异步字节迭代器、JSON/text、重复 Headers、 +秒单位总期限 timeout、owner_hash 和 asyncio 任务取消。handler 接收 ServerRequest,返回 Response; +Response 的 body 可为文本、字节或异步字节流,并可提供静态或异步 trailers。 +包提供 py.typed 和类型声明。安装验收目前覆盖本机 CPython 3.14 / macOS arm64。 + +具名端点可以 load_from/loadFrom 指定 profile;local_authority/localAuthority 提供证书链、公钥、 +SKI、owner hash 和签名能力,remote authority 只来自已验证握手。verify 使用现有证书签名规则。 +签名入口不导出私钥。 + +## 初始化与发现范围 + +Native SDK 按进程安装一次 System DNS 和显式 peers 解析器。init 可显式调用,首次 request/listen +也会幂等初始化;load 本身不发布名称。root_certificates/rootCertificates 接受 PEM 信任根, +显式配置会替换进程默认信任根,首次配置后只允许相同值重复初始化。 +peers 可增补或更新;addresses() 返回当前本地绑定,用于显式寻址和跨进程验收。 +默认 H3 DNS、mDNS 和名称发布维护仍待接入,因此当前不是完整的开箱即用发现版本。 + +Native Node build 产物是本机构建的 dhttp.node;npm pack 可用于本机安装验证。 +多平台 npm 二进制包、其他 Python wheel 平台和发布 CI 尚未恢复。当前绑定版本为 0.7.0;多平台 npm/PyPI 发布仍待完成。 + +## 流式请求 + +使用公共 Rust 层时,嵌入方需要先按 Rust 核心接口注册解析器。`init()` 或首次 request/listen 会幂等 +初始化共享 Network;Tokio runtime 必须保持存活。加载凭据本身不初始化网络或发布名称。 + +```rust,no_run +use dhttp_api::{Client, RequestOptions, upload_channel}; +use http::{HeaderMap, Method}; + +# async fn example() -> Result<(), Box> { +let client = Client::load_from("/path/to/home/alice").await?; +let (upload, body) = upload_channel(); +let request = http::Request::builder() + .method(Method::POST).uri("https://bob~/echo").body(Some(body))?; +let options = RequestOptions { + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() +}; +// request() 启动交换后返回等待响应头的 future,不预填整个 native 窗口。 +let response = client.request(request, options).await?; +let cancellation = response.cancellation(); + +let sending = async { + upload.send(vec![0x5a; 512 * 1024]).await?; + upload.finish(HeaderMap::new()).await +}; +let receiving = async { + let response = response.await?; + while let Some(bytes) = response.body().next().await? { + // 在这里将 bytes 交付给语言侧消费者。 + let _ = bytes; + } + let trailers = response.body().trailers().await?; + let _ = trailers; + Ok::<_, dhttp_api::Error>(()) +}; +futures::try_join!(sending, receiving)?; +let _ = cancellation; // AbortSignal / asyncio 取消可以调用 cancellation.cancel()。 +client.close().await; +# Ok(()) +# } +``` + +上传队列最多保留 4 个 16 KiB DATA 块,native WndBuf 容量为 64 KiB。 +`send()` 将大块拆分后按消费速度入队;调用方提供的源数据不计入桥接队列容量。 +服务响应也可以使用 `upload_channel()` 返回的 Body,通过标准 HTTP Body 路径发送。 +`finish(trailers)` 冻结上传并提交结束,直接丢弃未完成 writer 会传播 producer 错误。 +取消部分完成的 `send()` 会失败整个上传,避免静默丢失剩余字节。 + +`BodyReader::next()` 按需读取 native DATA。同一个 body 只允许一个活跃读取; +并发读取返回 `ERR_BODY_IN_USE`。trailers 仅在成功 EOF 后可用, +`trailers()` 本身不消费 DATA;失败或取消返回错误。丢弃/取消未完成 reader +会释放 native Body;取消正在等待的 next future 也会取消本次接收。 +正常接收 EOF 不自动取消仍在完成的上传。 + +## Handler 服务 + +```rust,no_run +use bytes::Bytes; +use dhttp_api::{Client, ServerRequest}; +use http_body_util::{BodyExt, Full}; + +# async fn example() -> Result<(), Box> { +let endpoint = Client::load_from("/path/to/home/alice").await?; +let listener = endpoint.listen(dhttp::Scope::Loopback.into(), |request: ServerRequest| async move { + let verified = request.extensions().get::().and_then(|summary| summary.remote.as_ref()); + let _ = verified; + let body = Full::new(Bytes::from_static(b"hello")) + .map_err(|never| match never {}) + .boxed_unsync(); + Ok(http::Response::new(body)) +}).await?; +listener.close().await; +endpoint.close().await; +# Ok(()) +# } +``` + +handler 返回前发生异常或 panic 时生成空的 500,异常详情只记录到诊断日志。 +返回响应后的 Body 错误终止该流,不重新发送 500。可信身份保留在标准请求/响应 +extensions 中的核心 HandshakeSummary / RemoteAuthority;请求 header 和 URI 不参与身份构造。 +公共层不定义第二份 HTTP 消息结构,也不新增 handler trait;listen 接收闭包。 + +## 已确定的生命周期 + +- 默认没有 SDK 总期限;调用方可以指定 `RequestOptions.timeout`,覆盖连接、上传和响应消费。 +- close 默认立即取消,不隐式排空。listener 先撤销登记,再取消其 handler、请求接收和响应 Body。 +- client close 拒绝新操作,取消并等待自有交换和 listener 清理,保留共享 Network/连接池。 +- close 幂等。Client 克隆共享同一份 SDK 所有权;独立加载的句柄各自管理任务,即使名称相同。 + 同名服务登记沿用核心规则,只允许一个 listener。 +- Drop 只触发补充清理;适配器必须将语言侧显式 close/cancel 转换为这些原生操作。 +- reload 成功才替换当前 Client 及其克隆的 Endpoint;失败保留原凭据。 + 显式 profile 使用绝对路径重读,不修改 DHTTP_HOME。reload 不自动获取 OCSP; + 证书链或私钥轮换需重启,既有连接继续沿用其握手身份。 +- 此阶段仍沿用核心按名称更新 TLS 登记的 reload 语义;语言适配需要保留同名登记的唯一所有权。 + +## 验证 + +```sh +cargo test --offline -p dhttp-api +DHTTP_TEST_OPENSSL=/path/to/openssl cargo test --offline -p dhttp-api \ + --test quic_roundtrip -- --include-ignored +cargo clippy --offline -p dhttp-api --all-targets --no-deps -- -D warnings +``` + +普通测试覆盖有界背压、生产者失败、接收错误/EOF/trailers、并发读取、取消、 +handler 异常与 Body 取消。真实 QUIC 测试使用临时 CA、证书、匹配私钥和 OCSP, +覆盖具名/匿名请求、512 KiB 双向流式收发、早响应、多值 headers/trailers、 +可信身份和错误 ownerHash、双向请求、各阶段取消/期限、同名句柄与两身份隔离、 +重复监听/关闭、显式 profile reload 与失败保留凭据。 + +## 语言验收 + +```sh +DHTTP_TEST_OPENSSL=/path/to/openssl cargo run -p dhttp-api --example sdk_credentials -- target/sdk-fixtures +DHTTP_TEST_PROFILE_ROOT="$PWD/target/sdk-fixtures" node --test api/tests/node.test.cjs +# 在已安装本次 wheel 的虚拟环境中运行: +DHTTP_TEST_PROFILE_ROOT="$PWD/target/sdk-fixtures" python -m unittest discover -s api/tests -p test_python_sdk.py -v +``` + +两种测试都启动独立语言服务端进程,使用临时 CA、规范 DHTTP SKI、匹配私钥及 OCSP。 +覆盖具名/匿名请求、512 KiB 流、早响应、重复字段和 trailers、签名验证、错误 owner pin、 +producer/handler 异常、取消、期限、端点隔离、重载和重复关闭;Node 还覆盖反向请求。 +Python 检查事件循环关闭前无遗留 asyncio 任务,两种服务端都验证正常退出。 + +## 当前限制 + +默认 H3 DNS/mDNS、名称发布与多平台发行尚不提供;当前使用 System DNS +和显式 peers。跨设备 NAT/relay 与其他平台的运行行为需要在目标环境验证。 diff --git a/api/examples/sdk_credentials.rs b/api/examples/sdk_credentials.rs new file mode 100644 index 0000000..30f52bf --- /dev/null +++ b/api/examples/sdk_credentials.rs @@ -0,0 +1,14 @@ +//! Temporary CA/profiles for SDK acceptance tests; never uses the user's home. +#[path = "../../dhttp/tests/support/credentials.rs"] +mod credentials; +fn main() { + let root = std::path::PathBuf::from(std::env::args_os().nth(1).expect("profile root argument")); + credentials::generate( + &root, + &[ + ("server", "server.dhttp.net"), + ("alice", "alice.dhttp.net"), + ("bob", "bob.dhttp.net"), + ], + ); +} diff --git a/api/js/index.d.ts b/api/js/index.d.ts new file mode 100644 index 0000000..755277f --- /dev/null +++ b/api/js/index.d.ts @@ -0,0 +1,53 @@ +export interface Authority { + readonly name: string; + readonly certificates: Uint8Array[]; + readonly publicKey: Uint8Array; + readonly ownerHash: string; + readonly subjectKeyIdentifier: Uint8Array; + verify(data: Uint8Array, signature: Uint8Array): boolean; +} +export interface LocalAuthority extends Authority { sign(data: Uint8Array): Promise; } +export type Trailers = HeadersInit | Promise | (() => HeadersInit | Promise); +export interface FetchOptions extends RequestInit { + trailers?: Trailers; + ownerHash?: string; + /** Total exchange deadline, in milliseconds. */ + timeout?: number; +} +export interface DhttpResponse extends Response { + readonly remoteAuthority: Authority | null; + readonly rawHeaders: [string, string][]; + readonly trailers: Promise; + authority(): Authority | null; + release(): Promise; +} +export interface ServerRequest extends Request { + readonly localAuthority: LocalAuthority; + readonly remoteAuthority: Authority | null; + readonly rawHeaders: [string, string][]; + readonly trailers: Promise; + authority(): Authority | null; + release(): Promise; +} +export interface ServerResponse extends Response { trailers?: Trailers; rawHeaders?: [string, string][]; } +export type Scope = 'loopback' | 'internal' | 'external'; +export interface Listener { close(): Promise; } +export class Endpoint { + private constructor(); + readonly name: string | null; + static load(name: string): Promise; + static loadFrom(path: string): Promise; + localAuthority(): Promise; + reload(): Promise; + fetch(input: RequestInfo | URL, options?: FetchOptions): Promise; + listen(scopes: Iterable, handler: (request: ServerRequest) => ServerResponse | Promise): Promise; + close(): Promise; +} +export const Anonymous: Pick; +export function init(options?: {peers?: Record | Map | [string, string][]; rootCertificates?: Uint8Array}): Promise; +export function addresses(): string[]; +export class DhttpError extends Error { readonly code: string; readonly protocolCode?: number; } +export const Request: typeof globalThis.Request; +export const Response: typeof globalThis.Response; +export const Headers: typeof globalThis.Headers; +export const ReadableStream: typeof globalThis.ReadableStream; diff --git a/api/js/index.js b/api/js/index.js new file mode 100644 index 0000000..b1279f5 --- /dev/null +++ b/api/js/index.js @@ -0,0 +1,282 @@ +'use strict'; +const native = require('../dhttp.node'); +const HEADER_ENCODING = 'latin1'; +const EMPTY_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); +const CACHE_MODES = new Set(['default', 'no-store', 'reload', 'no-cache', 'force-cache', 'only-if-cached']); +const CREDENTIALS_MODES = new Set(['omit', 'same-origin', 'include']); +const REQUEST_MODES = new Set(['cors', 'no-cors', 'same-origin']); +const REDIRECT_MODES = new Set(['follow', 'manual', 'error']); +const MAX_REDIRECTS = 20; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const REFERRER_POLICIES = new Set([ + '', + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url', +]); +function rejectPseudoHeaders(headers, kind) { + for (const [name] of headers) { + if (String(name).startsWith(':')) { + throw new TypeError(`${kind} headers must not contain pseudo-header ${name}`); + } + } +} + +function validateEnum(name, value, allowed) { + if (value != null && !allowed.has(value)) { + throw new TypeError(`${name} has unsupported value ${value}`); + } +} + +function validateRequestInit(init) { + if (init == null) { + return; + } + if (init.duplex != null && init.duplex !== 'half') { + throw new TypeError('duplex must be "half" when provided'); + } + validateEnum('cache', init.cache, CACHE_MODES); + validateEnum('credentials', init.credentials, CREDENTIALS_MODES); + validateEnum('mode', init.mode, REQUEST_MODES); + validateEnum('redirect', init.redirect, REDIRECT_MODES); + validateEnum('referrerPolicy', init.referrerPolicy, REFERRER_POLICIES); + if (init.integrity != null && init.integrity !== '') { + throw new TypeError('unsupported integrity; only empty string is currently supported'); + } + if (init.window !== undefined && init.window !== null) { + throw new TypeError('window must be null'); + } +} + +function abortError(reason) { + if (reason instanceof Error) { + return reason; + } + const message = reason == null ? 'operation aborted' : String(reason); + return new DOMException(message, 'AbortError'); +} + +function throwIfAborted(signal) { + if (signal?.aborted) { + throw abortError(signal.reason); + } +} + +function toRequest(input, init) { + validateRequestInit(init); + if (init && init.body != null && init.duplex == null) { + return new Request(input, { ...init, duplex: 'half' }); + } + return new Request(input, init); +} + + +class DhttpError extends Error { + constructor(message, code, {cause, protocolCode} = {}) { + super(message, {cause}); this.name = 'DhttpError'; this.code = code; this.protocolCode = protocolCode ?? undefined; + } +} +function errorFromNative(error) { + try { + const value = JSON.parse(error.message); + if (value.dhttp === true) return new DhttpError(value.message, value.code, {cause: error, protocolCode: value.protocolCode}); + } catch {} + return error; +} +function syncCall(operation) { try { return operation(); } catch (error) { throw errorFromNative(error); } } +async function call(operation) { try { return await operation(); } catch (error) { throw errorFromNative(error); } } +function pairs(value) { + if (value == null) return []; + if (typeof value[Symbol.iterator] === 'function') return Array.from(value, ([name, value]) => [String(name), String(value)]); + return Object.entries(value).map(([name, value]) => [name, String(value)]); +} +function wire(value) { + const fields = pairs(value); rejectPseudoHeaders(fields, 'HTTP'); + return fields.map(([name, value]) => ({name, value: Buffer.from(value, HEADER_ENCODING)})); +} +function fromWire(fields) { return fields.map(({name, value}) => [name, Buffer.from(value).toString(HEADER_ENCODING)]); } +function authority(value, client = null) { + if (value == null) return null; + const result = {...value, verify: (data, signature) => syncCall(() => native.verifySignature(value.publicKey, Buffer.from(data), Buffer.from(signature)))}; + if (client) result.sign = data => call(() => client.sign(Buffer.from(data))); + return Object.freeze(result); +} + +async function pump(body, writer, trailers) { + const reader = body?.getReader(); + const stopped = writer.closed().then(() => ({closed: true})); + try { + if (reader) { + while (true) { + const part = await Promise.race([reader.read(), stopped]); + if (part.closed) throw new DhttpError('native body closed', 'ERR_CLOSED'); + if (part.done) break; + await call(() => writer.write(Buffer.from(part.value))); + } + } + const end = typeof trailers === 'function' ? await trailers() : await trailers; + await call(() => writer.finish(wire(end))); + } catch (error) { + writer.fail(error.message ?? String(error)); + if (reader) reader.cancel(error).catch(() => {}); + throw error; + } finally { + try { reader?.releaseLock(); } catch {} + } +} + +function streamFromNative(reader, finished = () => {}, signal = null) { + return new ReadableStream({ + async pull(controller) { + try { + const chunk = await call(() => reader.read()); + if (chunk == null) { controller.close(); finished(); } + else controller.enqueue(new Uint8Array(chunk)); + } catch (error) { controller.error(signal?.aborted ? abortError(signal.reason) : error); reader.cancel(); finished(); } + }, + cancel() { reader.cancel(); finished(); }, + }, {highWaterMark: 0}); +} +function attach(message, reader, remoteAuthority, rawHeaders, release) { + let trailers; + Object.defineProperties(message, { + remoteAuthority: {value: remoteAuthority}, + authority: {value: () => remoteAuthority}, + rawHeaders: {value: rawHeaders}, + trailers: {get() { return trailers ??= call(async () => new Headers(fromWire(await reader.trailers()))); }}, + release: {value: async () => { reader.cancel(); await release?.(); }}, + }); + return message; +} + +async function init(options = {}) { + const peers = options.peers instanceof Map ? [...options.peers] : pairs(options.peers); + return call(() => native.init(peers, options.rootCertificates == null ? undefined : Buffer.from(options.rootCertificates))); +} +function addresses() { return native.addresses(); } + +class Endpoint { + constructor(inner, name = null) { this._native = inner; this.name = name; this._listeners = new Set(); this._pumps = new Set(); this._closed = false; } + static async load(name) { const inner = await call(() => native.NativeClient.load(name)); return new Endpoint(inner, await inner.name()); } + static async loadFrom(path) { const inner = await call(() => native.NativeClient.loadFrom(String(path))); return new Endpoint(inner, await inner.name()); } + async localAuthority() { return authority(await call(() => this._native.localAuthority()), this._native); } + async reload() { return call(() => this._native.reload()); } + async close() { + this._closed = true; + await this._native.close(); + await Promise.all([...this._listeners].map(listener => listener.close())); + await Promise.allSettled([...this._pumps]); + } + async fetch(input, options = {}) { + let request = toRequest(input, options); + let currentOptions = options; + for (let count = 0; ; count++) { + const response = await this._fetch(request, currentOptions); + const location = response.headers.get('location'); + if (!REDIRECT_STATUSES.has(response.status) || location == null || request.redirect === 'manual') return response; + await response.release(); + if (request.redirect === 'error') throw new TypeError('redirect encountered with redirect: "error"'); + if (count >= MAX_REDIRECTS) throw new TypeError('maximum redirect count exceeded'); + const url = new URL(location, request.url); + const headers = new Headers(request.headers); + if (url.origin !== new URL(request.url).origin) { + for (const name of ['authorization', 'proxy-authorization', 'cookie']) headers.delete(name); + } + let method = request.method; + if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) { + method = 'GET'; headers.delete('content-length'); headers.delete('content-type'); + } else if (request.body != null) { + throw new TypeError('cannot replay streaming request body after redirect'); + } + request = new Request(url, {method, headers, signal: request.signal, redirect: request.redirect}); + currentOptions = {timeout: options.timeout, ownerHash: options.ownerHash}; + } + } + async _fetch(input, options = {}) { + if (this._closed) throw new DhttpError('endpoint closed', 'ERR_CLOSED'); + const request = input instanceof Request ? input : toRequest(input, options); + throwIfAborted(request.signal); + const raw = options.headers != null ? pairs(options.headers) : input?.rawHeaders ?? pairs(request.headers); + for (const [name, value] of request.headers) { + if (!raw.some(([key]) => key.toLowerCase() === name.toLowerCase())) raw.push([name, value]); + } + const initialTrailers = request.body ? [] : wire(typeof options.trailers === 'function' ? await options.trailers() : await options.trailers); + const exchange = await call(() => this._native.start(request.method, request.url, wire(raw), request.body != null, + options.timeout == null ? undefined : options.timeout, options.ownerHash, initialTrailers)); + const abort = () => { exchange.cancel(); request.body?.cancel(request.signal.reason).catch(() => {}); }; + request.signal.addEventListener('abort', abort, {once: true}); + if (request.signal.aborted) { abort(); throw abortError(request.signal.reason); } + let bodyDone = false, uploadDone = request.body == null; + const cleanup = () => { if (bodyDone && uploadDone) request.signal.removeEventListener('abort', abort); }; + let uploading = Promise.resolve(); + if (request.body) { + uploading = pump(request.body, exchange, options.trailers).finally(() => { uploadDone = true; cleanup(); }); + this._pumps.add(uploading); + uploading.then(() => this._pumps.delete(uploading), () => this._pumps.delete(uploading)); + } + try { + const incoming = await call(() => exchange.response()); + const noBody = request.method === 'HEAD' || EMPTY_BODY_STATUSES.has(incoming.status); + const response = new Response(noBody ? null : streamFromNative(incoming, () => { bodyDone = true; cleanup(); }, request.signal), { + status: incoming.status, headers: fromWire(incoming.headers), + }); + Object.defineProperty(response, 'url', {value: request.url}); + if (noBody) { + // Drive native EOF even when the Web Response has no consumable body. + const finishing = (async () => { while (await call(() => incoming.read()) != null) {} })().finally(() => { bodyDone = true; cleanup(); }); + finishing.catch(() => {}); + } + return attach(response, incoming, authority(syncCall(() => incoming.authority())), fromWire(incoming.headers), async () => { + exchange.cancel(); bodyDone = true; await uploading.catch(() => {}); cleanup(); + }); + } catch (error) { + exchange.cancel(); request.signal.removeEventListener('abort', abort); + await uploading.catch(() => {}); + if (request.signal.aborted) throw abortError(request.signal.reason); + throw error; + } + } + async listen(scopes, handler) { + if (this._closed) throw new DhttpError('endpoint closed', 'ERR_CLOSED'); + const pumps = new Set(); + const callback = new native.NativeHandler(async incoming => { + try { + const request = new Request(incoming.url, {method: incoming.method, headers: fromWire(incoming.headers), + ...(incoming.method === 'GET' || incoming.method === 'HEAD' ? {} : {body: streamFromNative(incoming), duplex: 'half'})}); + const controller = new AbortController(); + Object.defineProperty(request, 'signal', {value: controller.signal}); + attach(request, incoming, authority(syncCall(() => incoming.authority())), fromWire(incoming.headers), null); + Object.defineProperty(request, 'localAuthority', {value: authority(syncCall(() => incoming.localAuthority()), incoming)}); + if (incoming.method === 'GET' || incoming.method === 'HEAD') { + (async () => { while (await incoming.read() != null) {} })().catch(() => {}); + } + const stopped = incoming.closed().then(() => { + controller.abort(new DhttpError('request cancelled', 'ERR_CANCELLED')); + return {closed: true}; + }); + const response = await Promise.race([Promise.resolve().then(() => handler(request)), stopped]); + if (response?.closed) throw controller.signal.reason; + if (!(response instanceof Response)) throw new TypeError('handler must return a Response'); + const writer = incoming.respond(response.status, wire(response.rawHeaders ?? response.headers)); + const uploading = pump(response.body, writer, response.trailers).finally(() => incoming.cancel()); + pumps.add(uploading); + uploading.then(() => pumps.delete(uploading), () => pumps.delete(uploading)); + } catch (error) { incoming.cancel(); throw error; } + }); + const inner = await call(() => this._native.listen(Array.from(scopes), callback)); + const keepAlive = setInterval(() => {}, 2 ** 30); + const listener = { + close: async () => { clearInterval(keepAlive); await inner.close(); await Promise.allSettled([...pumps]); this._listeners.delete(listener); }, + [Symbol.asyncDispose]: async () => listener.close(), + }; + this._listeners.add(listener); return listener; + } + async [Symbol.asyncDispose]() { await this.close(); } +} +const Anonymous = new Endpoint(native.NativeClient.anonymous()); +module.exports = {Endpoint, Anonymous, init, addresses, DhttpError, Request, Response, Headers, ReadableStream}; diff --git a/api/js/index.mjs b/api/js/index.mjs new file mode 100644 index 0000000..3b781f1 --- /dev/null +++ b/api/js/index.mjs @@ -0,0 +1,2 @@ +import sdk from './index.js'; +export const {Endpoint, Anonymous, init, addresses, DhttpError, Request, Response, Headers, ReadableStream} = sdk; diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..d484625 --- /dev/null +++ b/api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@genmeta/dhttp", + "version": "0.7.0", + "description": "DHTTP HTTP/3 SDK", + "license": "Apache-2.0", + "main": "js/index.js", + "types": "js/index.d.ts", + "engines": { + "node": ">=22.17.0" + }, + "exports": { + ".": { + "types": "./js/index.d.ts", + "import": "./js/index.mjs", + "require": "./js/index.js" + } + }, + "files": [ + "js", + "dhttp.node", + "README.md" + ], + "scripts": { + "build": "node scripts/build.cjs", + "test": "node --test tests/node.test.cjs" + }, + "repository": { + "type": "git", + "url": "https://github.com/genmeta/dhttp.git", + "directory": "api" + } +} diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..1987346 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.8,<2"] +build-backend = "maturin" + +[project] +name = "dhttpy" +dynamic = ["version"] +description = "DHTTP HTTP/3 SDK" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" + +[tool.maturin] +python-source = "python" +module-name = "dhttpy._native" +features = ["pyo3", "pyo3/extension-module"] diff --git a/api/python/dhttpy/__init__.py b/api/python/dhttpy/__init__.py new file mode 100644 index 0000000..74aee04 --- /dev/null +++ b/api/python/dhttpy/__init__.py @@ -0,0 +1,5 @@ +from ._native import Authority +from .authority import LocalAuthority +from .endpoint import Anonymous, Endpoint, Listener, ServerRequest, addresses, init +from .response import ClientResponse, DhttpError, Headers, Response, json_response +__all__ = ['Authority', 'LocalAuthority', 'Anonymous', 'Endpoint', 'Listener', 'ServerRequest', 'ClientResponse', 'DhttpError', 'Headers', 'Response', 'json_response', 'addresses', 'init'] diff --git a/api/python/dhttpy/__init__.pyi b/api/python/dhttpy/__init__.pyi new file mode 100644 index 0000000..f0646c7 --- /dev/null +++ b/api/python/dhttpy/__init__.pyi @@ -0,0 +1,85 @@ +from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping +from typing import Any, AsyncContextManager, Literal + +HeaderInput = Mapping[str, str] | Iterable[tuple[str, str]] | None +BodyInput = str | bytes | bytearray | memoryview | Iterable[bytes] | AsyncIterable[bytes] | None +Scope = Literal['loopback', 'internal', 'external'] +class DhttpError(RuntimeError): + code: str + protocol_code: int | None +class Headers: + def __init__(self, pairs: HeaderInput = ...) -> None: ... + def items(self) -> list[tuple[str, str]]: ... + def get(self, name: str, default: str | None = ...) -> str | None: ... + def getall(self, name: str) -> list[str]: ... +class Authority: + name: str + certificates: list[bytes] + public_key: bytes + owner_hash: str + subject_key_identifier: bytes + def verify(self, data: bytes, signature: bytes) -> bool: ... +class LocalAuthority(Authority): + async def sign(self, data: bytes) -> bytes: ... +class ClientResponse: + status: int + headers: Headers + remote_authority: Authority | None + content: AsyncIterable[bytes] + async def read(self) -> bytes: ... + async def text(self, encoding: str = ...) -> str: ... + async def json(self) -> Any: ... + async def trailers(self) -> Headers: ... + async def release(self) -> None: ... + async def close(self) -> None: ... + async def __aenter__(self) -> ClientResponse: ... + async def __aexit__(self, *args: Any) -> None: ... +class ServerRequest: + local_authority: LocalAuthority + method: str + url: str + headers: Headers + remote_authority: Authority | None + content: AsyncIterable[bytes] + async def read(self) -> bytes: ... + async def text(self, encoding: str = ...) -> str: ... + async def json(self) -> Any: ... + async def trailers(self) -> Headers: ... +class Response: + body: BodyInput + status: int + headers: Headers + def __init__(self, body: BodyInput = ..., *, status: int = ..., headers: HeaderInput = ..., trailers: Any = ...) -> None: ... + @classmethod + def text(cls, text: str, *, status: int = ..., headers: HeaderInput = ..., encoding: str = ...) -> Response: ... + @classmethod + def json(cls, data: Any, *, status: int = ..., headers: HeaderInput = ...) -> Response: ... +class _RequestContext(Awaitable[ClientResponse], AsyncContextManager[ClientResponse]): ... +class Listener: + async def close(self) -> None: ... + async def __aenter__(self) -> Listener: ... + async def __aexit__(self, *args: Any) -> None: ... +class Endpoint: + name: str | None + @classmethod + async def load(cls, name: str) -> Endpoint: ... + @classmethod + async def load_from(cls, path: str) -> Endpoint: ... + async def local_authority(self) -> LocalAuthority | None: ... + async def reload(self) -> None: ... + async def close(self) -> None: ... + async def __aenter__(self) -> Endpoint: ... + async def __aexit__(self, *args: Any) -> None: ... + def request(self, method: str, url: str, *, headers: HeaderInput = ..., data: BodyInput = ..., json: Any = ..., trailers: Any = ..., timeout: float | None = ..., owner_hash: str | None = ...) -> _RequestContext: ... + def get(self, url: str, **options: Any) -> _RequestContext: ... + def head(self, url: str, **options: Any) -> _RequestContext: ... + def post(self, url: str, **options: Any) -> _RequestContext: ... + def put(self, url: str, **options: Any) -> _RequestContext: ... + def patch(self, url: str, **options: Any) -> _RequestContext: ... + def delete(self, url: str, **options: Any) -> _RequestContext: ... + def options(self, url: str, **options: Any) -> _RequestContext: ... + async def listen(self, scopes: Iterable[Scope], handler: Callable[[ServerRequest], Response | Awaitable[Response]]) -> Listener: ... +Anonymous: Endpoint +async def init(*, peers: Mapping[str, str] | Iterable[tuple[str, str]] | None = ..., root_certificates: bytes | None = ...) -> None: ... +def addresses() -> list[str]: ... +def json_response(data: Any, *, status: int = ..., headers: HeaderInput = ...) -> Response: ... diff --git a/api/python/dhttpy/authority.py b/api/python/dhttpy/authority.py new file mode 100644 index 0000000..078e8fc --- /dev/null +++ b/api/python/dhttpy/authority.py @@ -0,0 +1,10 @@ +from . import _native +from .response import native_call, native_sync + +class LocalAuthority: + def __init__(self, native, client): + self._native, self._client = native, client + def __getattr__(self, name): return getattr(self._native, name) + async def sign(self, data: bytes) -> bytes: return await native_call(self._client.sign(bytes(data))) + def verify(self, data: bytes, signature: bytes) -> bool: + return native_sync(lambda: _native.verify_signature(self.public_key, bytes(data), bytes(signature))) diff --git a/api/python/dhttpy/endpoint.py b/api/python/dhttpy/endpoint.py new file mode 100644 index 0000000..ea30bf2 --- /dev/null +++ b/api/python/dhttpy/endpoint.py @@ -0,0 +1,191 @@ +"""High-level asyncio SDK, retaining the previous dhttpy wrapper conventions.""" +from __future__ import annotations +import asyncio +import inspect +import json as _json +from typing import Any +from . import _native +from .authority import LocalAuthority +from .response import ClientResponse, DhttpError, Headers, Response, StreamContent, header_fields, native_call, native_sync, normalize_headers + +async def init(*, peers=None, root_certificates=None): + pairs = list(peers.items()) if hasattr(peers, 'items') else list(peers or ()) + await native_call(_native.init(pairs, root_certificates)) + +def addresses(): + return _native.addresses() + +async def _body_chunks(body): + # Reused from the previous wrapper: accept finite content or byte iterators. + if body is None: + return + if isinstance(body, str): + yield body.encode() + elif isinstance(body, (bytes, bytearray, memoryview)): + yield bytes(body) + elif hasattr(body, '__aiter__'): + async for chunk in body: + yield bytes(chunk) + else: + for chunk in body: + yield bytes(chunk) + +async def _pump(body, writer, trailers): + async def writing(): + try: + async for chunk in _body_chunks(body): + await native_call(writer.write(chunk)) + end = trailers() if callable(trailers) else trailers + if inspect.isawaitable(end): + end = await end + await native_call(writer.finish([(name.decode('ascii'), value) for name, value in header_fields(end)])) + except BaseException as error: + writer.fail(str(error)) + raise + task = asyncio.create_task(writing()) + closed = asyncio.ensure_future(writer.closed()) + try: + done, _ = await asyncio.wait((task, closed), return_when=asyncio.FIRST_COMPLETED) + if task not in done: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + return + await task + finally: + task.cancel() + closed.cancel() + await asyncio.gather(task, closed, return_exceptions=True) + if hasattr(body, 'aclose'): + await body.aclose() + +class _RequestContext: + def __init__(self, awaitable): + self._awaitable = awaitable + self._response = None + def __await__(self): + return self._awaitable.__await__() + async def __aenter__(self): + self._response = await self._awaitable + return self._response + async def __aexit__(self, *_): + await self._response.release() + +class ServerRequest: + def __init__(self, native): + self._native = native + self.method = native.method + self.url = native.url + self.headers = Headers((name, value.decode('latin-1')) for name, value in native.headers) + self.remote_authority = native_sync(native.authority) + self.local_authority = LocalAuthority(native_sync(native.local_authority), native) + self.content = StreamContent(native) + def authority(self): return self.remote_authority + async def read(self): return await self.content.read() + async def text(self, encoding='utf-8'): return (await self.read()).decode(encoding) + async def json(self): return _json.loads(await self.text()) + async def trailers(self): return Headers((name, value.decode('latin-1')) for name, value in await native_call(self._native.trailers())) + async def release(self): self._native.cancel() + +class Listener: + def __init__(self, native, tasks, handlers, owner): + self._native, self._tasks, self._handlers, self._owner = native, tasks, handlers, owner + async def close(self): + await native_call(self._native.close()) + tasks = [task for task in self._tasks | self._handlers if task is not asyncio.current_task()] + for task in tasks: task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + self._owner._listeners.discard(self) + async def __aenter__(self): return self + async def __aexit__(self, *_): await self.close() + +class Endpoint: + def __init__(self, native, name=None): + self._native, self.name = native, name + self._pumps, self._listeners = set(), set() + self._closed = False + @classmethod + async def load(cls, name): + native = await native_call(_native.NativeClient.load(name)) + return cls(native, await native.name()) + @classmethod + async def load_from(cls, path): + native = await native_call(_native.NativeClient.load_from(str(path))) + return cls(native, await native.name()) + async def local_authority(self): + authority = await native_call(self._native.local_authority()) + return None if authority is None else LocalAuthority(authority, self._native) + async def reload(self): await native_call(self._native.reload()) + async def close(self): + self._closed = True + await native_call(self._native.close()) + for listener in tuple(self._listeners): await listener.close() + for task in self._pumps: task.cancel() + await asyncio.gather(*self._pumps, return_exceptions=True) + async def __aenter__(self): return self + async def __aexit__(self, *_): await self.close() + def request(self, method, url, **options): return _RequestContext(self._request(method.upper(), url, **options)) + async def _request(self, method, url, *, headers=None, data=None, json=None, trailers=None, timeout=None, owner_hash=None): + if self._closed: raise RuntimeError('endpoint closed') + if data is not None and json is not None: raise ValueError('only one of data or json may be provided') + headers = normalize_headers(headers) + if json is not None: + data = _json.dumps(json).encode() + if not any(name.lower() == 'content-type' for name, _ in headers): headers.append(('content-type', 'application/json')) + fields = [(name.decode('ascii'), value) for name, value in header_fields(headers)] + end = [] if data is not None else [(name.decode('ascii'), value) for name, value in header_fields(trailers)] + exchange = await native_call(self._native.start(method, url, fields, data is not None, None if timeout is None else round(timeout * 1000), owner_hash, end)) + uploading = None + if data is not None: + uploading = asyncio.create_task(_pump(data, exchange, trailers)) + self._pumps.add(uploading) + def complete(task): + self._pumps.discard(task) + if not task.cancelled(): task.exception() + uploading.add_done_callback(complete) + async def release(): + exchange.cancel() + if uploading: + uploading.cancel() + await asyncio.gather(uploading, return_exceptions=True) + try: + native = await native_call(exchange.response()) + return ClientResponse(native, method=method, url=url, release=release) + except BaseException: + await release() + raise + async def listen(self, scopes, handler): + tasks, handlers = set(), set() + async def callback(native): + current = asyncio.current_task() + handlers.add(current) + try: + request = ServerRequest(native) + response = handler(request) + if inspect.isawaitable(response): response = await response + if not isinstance(response, Response): raise TypeError('handler must return a Response') + fields = [(name.decode('ascii'), value) for name, value in header_fields(response.headers)] + writer = native.respond(response.status, fields) + async def sending(): + try: await _pump(response.body, writer, response.trailers) + finally: native.cancel() + task = asyncio.create_task(sending()) + tasks.add(task) + def complete(task): + tasks.discard(task) + if not task.cancelled(): task.exception() + task.add_done_callback(complete) + finally: + handlers.discard(current) + native = await native_call(self._native.listen(list(scopes), callback)) + listener = Listener(native, tasks, handlers, self) + self._listeners.add(listener) + return listener + def get(self, url, **options): return self.request('GET', url, **options) + def head(self, url, **options): return self.request('HEAD', url, **options) + def post(self, url, **options): return self.request('POST', url, **options) + def put(self, url, **options): return self.request('PUT', url, **options) + def patch(self, url, **options): return self.request('PATCH', url, **options) + def delete(self, url, **options): return self.request('DELETE', url, **options) + def options(self, url, **options): return self.request('OPTIONS', url, **options) + +Anonymous = Endpoint(_native.NativeClient.anonymous()) diff --git a/api/python/dhttpy/py.typed b/api/python/dhttpy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/api/python/dhttpy/response.py b/api/python/dhttpy/response.py new file mode 100644 index 0000000..124f503 --- /dev/null +++ b/api/python/dhttpy/response.py @@ -0,0 +1,268 @@ +"""Response helpers for the dhttp Python wrapper.""" + +from __future__ import annotations + +import asyncio +import json as _json +from collections.abc import AsyncIterator, Iterable, Mapping +from typing import Any + +HEADER_ENCODING = "latin-1" +EMPTY_BODY_STATUSES = {204, 205, 304} + +HeaderInput = Mapping[str, str] | Iterable[tuple[str, str]] | None + + +def _header_bytes(value: Any) -> bytes: + if isinstance(value, bytes): + return value + if isinstance(value, bytearray | memoryview): + return bytes(value) + return str(value).encode(HEADER_ENCODING) + + +def header_field(name: Any, value: Any) -> tuple[bytes, bytes]: + return (_header_bytes(name), _header_bytes(value)) + + +def header_text(value: bytes) -> str: + return bytes(value).decode(HEADER_ENCODING) + + +def normalize_headers(headers: HeaderInput) -> list[tuple[str, str]]: + if headers is None: + return [] + items = headers.items() if isinstance(headers, Mapping) else headers + return [(str(name), str(value)) for name, value in items] + + +def _outbound_header_name(name: str) -> str: + if not name: + raise ValueError("header name must not be empty") + if name.startswith(":"): + raise ValueError("header name must not be a pseudo-header") + if any(ord(char) <= 32 or ord(char) == 127 for char in name): + raise ValueError("header name must not contain control characters") + return name.lower() + + +def header_fields(headers: HeaderInput) -> list[tuple[bytes, bytes]]: + return [ + header_field(_outbound_header_name(name), value) + for name, value in normalize_headers(headers) + ] + + +def has_body(method: str, status: int) -> bool: + return ( + method.upper() != "HEAD" + and not 100 <= status < 200 + and status not in EMPTY_BODY_STATUSES + ) + + +class Headers: + """Small case-insensitive HTTP header view preserving original pairs.""" + + def __init__(self, pairs: HeaderInput = None): + self._pairs = normalize_headers(pairs) + + def __iter__(self): + return iter(self._pairs) + + def __len__(self) -> int: + return len(self._pairs) + + def __contains__(self, name: object) -> bool: + if not isinstance(name, str): + return False + folded = name.lower() + return any(header.lower() == folded for header, _ in self._pairs) + + def items(self) -> list[tuple[str, str]]: + return list(self._pairs) + + def get(self, name: str, default: str | None = None) -> str | None: + folded = name.lower() + for header, value in reversed(self._pairs): + if header.lower() == folded: + return value + return default + + def getall(self, name: str) -> list[str]: + folded = name.lower() + return [value for header, value in self._pairs if header.lower() == folded] + + def __getitem__(self, name: str) -> str: + value = self.get(name) + if value is None: + raise KeyError(name) + return value + + +class Response: + """Server response returned by an ``Endpoint.listen`` handler.""" + + def __init__( + self, + body: bytes | bytearray | memoryview | str | AsyncIterator[ + bytes] | Iterable[bytes] | None = b"", + *, + status: int = 200, + headers: HeaderInput = None, + trailers: HeaderInput = None, + ): + self.trailers = trailers + self.body = body + self.status = int(status) + self.headers = Headers(headers) + + @classmethod + def text( + cls, + text: str, + *, + status: int = 200, + headers: HeaderInput = None, + encoding: str = "utf-8", + ) -> "Response": + pairs = normalize_headers(headers) + if not any(name.lower() == "content-type" for name, _ in pairs): + pairs.append(("content-type", f"text/plain; charset={encoding}")) + return cls(text.encode(encoding), status=status, headers=pairs) + + @classmethod + def json( + cls, + data: Any, + *, + status: int = 200, + headers: HeaderInput = None, + ) -> "Response": + pairs = normalize_headers(headers) + if not any(name.lower() == "content-type" for name, _ in pairs): + pairs.append(("content-type", "application/json")) + return cls(_json.dumps(data).encode("utf-8"), status=status, headers=pairs) + + +def json_response( + data: Any, + *, + status: int = 200, + headers: HeaderInput = None, +) -> Response: + return Response.json(data, status=status, headers=headers) + + + +class DhttpError(RuntimeError): + def __init__(self, error: Exception): + super().__init__(str(error)) + self.code = getattr(error, "code", "ERR_NATIVE") + self.protocol_code = getattr(error, "protocol_code", None) + self.__cause__ = error + + +def native_sync(operation): + try: + return operation() + except Exception as error: + raise DhttpError(error) from error + + +async def native_call(awaitable): + try: + return await awaitable + except asyncio.CancelledError: + raise + except Exception as error: + raise DhttpError(error) from error + + +class StreamContent: + def __init__(self, native, release=None): + self._native = native + self._release = release + self._reading = False + self._eof = False + + async def _next(self): + if self._eof: + return None + try: + chunk = await native_call(self._native.read()) + except BaseException: + self._native.cancel() + if self._release: + await self._release() + raise + if chunk is None: + self._eof = True + return chunk + + async def iter_chunked(self, size=65536): + if size <= 0: + raise ValueError("chunk size must be positive") + if self._reading: + raise RuntimeError("body already has a reader") + self._reading = True + try: + while True: + chunk = await self._next() + if chunk is None: + break + for offset in range(0, len(chunk), size): + yield chunk[offset:offset + size] + finally: + self._reading = False + + def __aiter__(self): + return self.iter_chunked() + + async def read(self): + return b"".join([chunk async for chunk in self]) + + +class ClientResponse: + def __init__(self, native, *, method, url, release=None): + self._native = native + self._release = release + self.status = native.status + self.headers = Headers((name, value.decode(HEADER_ENCODING)) for name, value in native.headers) + self.remote_authority = native_sync(native.authority) + self.method = method + self.url = url + self.content = StreamContent(native, self.release) + self._body = None + self.ok = 200 <= self.status < 400 + + def authority(self): + return self.remote_authority + + async def read(self): + if self._body is None: + self._body = await self.content.read() + return self._body + + async def text(self, encoding="utf-8"): + return (await self.read()).decode(encoding) + + async def json(self): + return _json.loads(await self.text()) + + async def trailers(self): + fields = await native_call(self._native.trailers()) + return Headers((name, value.decode(HEADER_ENCODING)) for name, value in fields) + + async def release(self): + self._native.cancel() + if self._release: + await self._release() + + close = release + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + await self.release() diff --git a/api/scripts/build.cjs b/api/scripts/build.cjs new file mode 100644 index 0000000..5ab33d4 --- /dev/null +++ b/api/scripts/build.cjs @@ -0,0 +1,13 @@ +'use strict'; +const {spawnSync} = require('node:child_process'); +const {copyFileSync} = require('node:fs'); +const path = require('node:path'); +const api = path.resolve(__dirname, '..'); +const release = process.argv.includes('--release'); +const cargo = spawnSync('cargo', ['build', '-p', 'dhttp-api', '--features', 'napi', ...(release ? ['--release'] : [])], {cwd: api, stdio: 'inherit'}); +if (cargo.status !== 0) process.exit(cargo.status ?? 1); +const metadata = spawnSync('cargo', ['metadata', '--format-version', '1', '--no-deps'], {cwd: api, encoding: 'utf8'}); +if (metadata.status !== 0) throw new Error(metadata.stderr); +const target = JSON.parse(metadata.stdout).target_directory; +const name = process.platform === 'win32' ? 'dhttp_api.dll' : process.platform === 'darwin' ? 'libdhttp_api.dylib' : 'libdhttp_api.so'; +copyFileSync(path.join(target, release ? 'release' : 'debug', name), path.join(api, 'dhttp.node')); diff --git a/api/src/binding.rs b/api/src/binding.rs new file mode 100644 index 0000000..a3e2595 --- /dev/null +++ b/api/src/binding.rs @@ -0,0 +1,323 @@ +//! Conversion and native handle ownership shared by N-API and PyO3. +use crate::{ + Cancellation, Client, Error, ErrorCode, RequestOptions, ResponseFuture, Result, ServerRequest, + ServerResponse, UploadWriter, +}; +use http::HeaderMap; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::sync::oneshot; + +pub type Fields = Vec<(String, Vec)>; + +pub fn headers(fields: Fields) -> Result { + let mut headers = HeaderMap::new(); + for (name, value) in fields { + let name = http::HeaderName::from_bytes(name.as_bytes()).map_err(invalid)?; + let value = http::HeaderValue::from_bytes(&value).map_err(invalid)?; + headers.append(name, value); + } + Ok(headers) +} + +pub fn fields(headers: &HeaderMap) -> Fields { + headers + .iter() + .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) + .collect() +} + +pub fn invalid(error: impl std::fmt::Display) -> Error { + Error::new(ErrorCode::InvalidArgument, error.to_string()) +} + +pub struct Exchange { + pub response: tokio::sync::Mutex>, + pub upload: Option, + pub cancellation: Cancellation, +} + +impl Exchange { + // Positional values are the language boundary; HTTP stays in std Request. + #[allow(clippy::too_many_arguments)] + pub async fn start( + client: &Client, + method: String, + uri: String, + fields: Fields, + has_body: bool, + timeout_ms: Option, + owner_hash: Option, + trailers: Fields, + ) -> Result { + let (upload, body) = if has_body { + let (writer, body) = crate::upload_channel(); + (Some(writer), Some(body)) + } else { + (None, None) + }; + let mut request = http::Request::builder() + .method(method.as_str()) + .uri(uri) + .body(body) + .map_err(invalid)?; + *request.headers_mut() = headers(fields)?; + let end = h3x::Trailers::default(); + for (name, value) in headers(trailers)?.iter() { + end.append(name.clone(), value.clone()); + } + request.extensions_mut().insert(end); + let response = client + .request( + request, + RequestOptions { + timeout: timeout_ms.map(|millis| Duration::from_millis(millis.into())), + expected_remote_owner_hash: owner_hash + .as_deref() + .map(dhttp_home::certificate::OwnerHash::try_from) + .transpose() + .map_err(invalid)?, + }, + ) + .await?; + let cancellation = response.cancellation(); + Ok(Self { + response: tokio::sync::Mutex::new(Some(response)), + upload, + cancellation, + }) + } + pub async fn response(&self) -> Result { + let future = self + .response + .lock() + .await + .take() + .ok_or_else(|| Error::new(ErrorCode::Closed, "response already taken"))?; + future.await + } + pub fn writer(&self) -> Result<&UploadWriter> { + self.upload + .as_ref() + .ok_or_else(|| invalid("request has no upload body")) + } +} + +pub struct Incoming { + pub request: ServerRequest, + pub response: Mutex>>, +} +impl Incoming { + pub fn local_authority(&self) -> Result<&dhttp::LocalAuthority> { + self.request + .extensions() + .get::() + .and_then(|summary| summary.local.as_ref()) + .ok_or_else(|| Error::new(ErrorCode::Identity, "missing local handshake identity")) + } + pub fn new(request: ServerRequest) -> (Self, oneshot::Receiver) { + let (send, receive) = oneshot::channel(); + ( + Self { + request, + response: Mutex::new(Some(send)), + }, + receive, + ) + } + pub fn respond(&self, status: u16, fields: Fields) -> Result { + use http_body_util::BodyExt; + let status = http::StatusCode::from_u16(status).map_err(invalid)?; + if status.is_informational() { + return Err(invalid("intermediate responses are not supported")); + } + let fields = headers(fields)?; + let (writer, body) = crate::upload_channel(); + let mut response = http::Response::new( + body.map_err(|error| Box::new(error) as dhttp::BoxError) + .boxed_unsync(), + ); + *response.status_mut() = status; + *response.headers_mut() = fields; + self.response + .lock() + .unwrap() + .take() + .ok_or_else(|| Error::new(ErrorCode::Closed, "response already sent"))? + .send(response) + .map_err(|_| Error::new(ErrorCode::Closed, "handler cancelled"))?; + Ok(writer) + } +} + +#[derive(Clone)] +pub struct Authority { + pub name: String, + pub certificates: Vec>, + pub public_key: Vec, + pub owner_hash: String, + pub subject_key_identifier: Vec, +} +impl Authority { + pub fn new( + name: &str, + certificates: &[dhttp::CertificateDer<'_>], + public_key: &[u8], + ) -> Result { + let identifier = + dhttp_home::certificate::extract_dhttp_subject_key_identifier(certificates) + .map_err(|error| Error::new(ErrorCode::Identity, error.to_string()))?; + let ski = dhttp_home::certificate::extract_subject_key_identifier(certificates) + .map_err(invalid)? + .unwrap_or_default() + .to_vec(); + Ok(Self { + name: name.to_owned(), + certificates: certificates + .iter() + .map(|cert| cert.as_ref().to_vec()) + .collect(), + public_key: public_key.to_vec(), + owner_hash: identifier.owner_hash().to_string(), + subject_key_identifier: ski, + }) + } + pub fn remote(authority: &dhttp::RemoteAuthority) -> Result { + Self::new( + authority.name(), + authority.certificates(), + authority.public_key().as_ref(), + ) + } + pub fn local(authority: &dhttp::LocalAuthority) -> Result { + Self::new( + authority.name(), + authority.certificates(), + authority.public_key().as_ref(), + ) + } +} + +pub fn scopes(names: Vec) -> Result { + let mut scopes = None; + for name in names { + let scope = match name.as_str() { + "loopback" => dhttp::Scope::Loopback, + "internal" => dhttp::Scope::Internal, + "external" => dhttp::Scope::External, + _ => return Err(invalid(format!("unknown scope: {name}"))), + }; + scopes = Some(match scopes { + None => scope.into(), + Some(scopes) => scopes | scope, + }); + } + scopes.ok_or_else(|| invalid("at least one listening scope is required")) +} + +static PEERS: std::sync::LazyLock< + Arc>>, +> = std::sync::LazyLock::new(Default::default); + +#[derive(Debug)] +struct PeerResolver; +impl std::fmt::Display for PeerResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SDK explicit peers") + } +} +impl dhttp::resolve::Resolve for PeerResolver { + fn lookup<'a>( + &'a self, + name: &'a str, + _: &'a str, + family: Option, + ) -> dhttp::resolve::ResolveFuture<'a> { + use futures::{FutureExt, StreamExt}; + async move { + let peer = PEERS.read().unwrap().get(name).copied().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no explicit peer") + })?; + let _ = family; + Ok(futures::stream::iter([(dhttp::resolve::Source::System, peer)]).boxed()) + } + .boxed() + } +} + +pub async fn init(peers: Vec<(String, String)>, roots: Option>) -> Result<()> { + static INSTALLED: std::sync::Once = std::sync::Once::new(); + static ROOTS: tokio::sync::Mutex>> = tokio::sync::Mutex::const_new(None); + let peers = peers + .into_iter() + .map(|(name, address)| { + Ok(( + dhttp_home::normalize_name(&name).ok_or_else(|| invalid("invalid peer name"))?, + dhttp::resolve::EndpointAddr::direct(address.parse().map_err(invalid)?), + )) + }) + .collect::>>()?; + let mut configured = ROOTS.lock().await; + crate::init().await?; + if let Some(roots) = roots { + match configured.as_ref() { + Some(previous) if previous != &roots => { + return Err(invalid( + "root certificates already configured for this process", + )); + } + Some(_) => {} + None => { + let certificates = rustls_pemfile::certs(&mut roots.as_slice()) + .collect::, _>>() + .map_err(invalid)?; + qtls::RootCerts::set(certificates).map_err(invalid)?; + *configured = Some(roots); + } + } + } + INSTALLED.call_once(|| { + dhttp::resolve::Resolver::add(Arc::new(dhttp::resolve::SystemResolver)); + dhttp::resolve::Resolver::add(Arc::new(PeerResolver)); + }); + PEERS.write().unwrap().extend(peers); + Ok(()) +} + +pub fn addresses() -> Vec { + dhttp::AddressBook::global() + .inner_bindings() + .into_iter() + .map(|(address, _)| address.to_string()) + .collect() +} + +pub async fn sign(client: &Client, data: Vec) -> Result> { + let authority = client + .local_authority() + .await? + .ok_or_else(|| Error::new(ErrorCode::Identity, "anonymous client cannot sign"))?; + sign_authority(&authority, &data) +} + +pub fn sign_authority(authority: &dhttp::LocalAuthority, data: &[u8]) -> Result> { + for scheme in [ + qtls::SignatureScheme::ECDSA_NISTP256_SHA256, + qtls::SignatureScheme::ECDSA_NISTP384_SHA384, + qtls::SignatureScheme::ED25519, + qtls::SignatureScheme::RSA_PKCS1_SHA256, + ] { + match authority.sign(scheme, data) { + Ok(signature) => return Ok(signature), + Err(qtls::SignError::UnsupportedScheme { .. }) => continue, + Err(error) => return Err(Error::new(ErrorCode::Identity, error.to_string())), + } + } + Err(Error::new(ErrorCode::Identity, "unsupported signing key")) +} + +pub fn verify(public_key: Vec, data: Vec, signature: Vec) -> Result { + dhttp_home::certificate::verify_signature(&public_key, &data, &signature).map_err(invalid) +} diff --git a/api/src/client.rs b/api/src/client.rs new file mode 100644 index 0000000..b577f57 --- /dev/null +++ b/api/src/client.rs @@ -0,0 +1,278 @@ +use crate::{BodyReader, Error, ErrorCode, Result, UploadBody, lifecycle::Scope}; +use http_body_util::BodyExt; +use std::{ + future::{Future, IntoFuture}, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::AsyncWriteExt, + sync::{RwLock, oneshot}, +}; + +/// Shared SDK handle. Clones share ownership; separately loaded handles do not. +/// Closing one handle leaves the global network and other handles usable. +#[derive(Clone)] +pub struct Client { + pub(crate) inner: Arc, +} + +pub(crate) struct ClientInner { + pub endpoint: RwLock>, + pub scope: Scope, + pub profile: Option, + pub control: tokio::sync::Mutex<()>, +} + +impl Client { + pub fn anonymous() -> Self { + Self::new(None, None) + } + + pub async fn load(name: impl AsRef) -> Result { + Ok(Self::new(Some(dhttp::Endpoint::load(name).await?), None)) + } + + pub async fn load_from(path: impl AsRef) -> Result { + let path = std::path::absolute(path.as_ref())?; + Ok(Self::new( + Some(dhttp::Endpoint::load_from(&path).await?), + Some(path), + )) + } + + pub fn from_endpoint(endpoint: dhttp::Endpoint) -> Self { + Self::new(Some(endpoint), None) + } + + fn new(endpoint: Option, profile: Option) -> Self { + Self { + inner: Arc::new(ClientInner { + endpoint: RwLock::new(endpoint), + scope: Scope::new(), + profile, + control: tokio::sync::Mutex::new(()), + }), + } + } + + pub async fn name(&self) -> Option { + self.inner + .endpoint + .read() + .await + .as_ref() + .map(|endpoint| endpoint.name().to_owned()) + } + + pub async fn local_authority(&self) -> Result> { + self.inner.scope.ensure_open()?; + self.inner + .endpoint + .read() + .await + .as_ref() + .map(|endpoint| endpoint.local_authority().map_err(Into::into)) + .transpose() + } + + /// Apply a successful OCSP replacement only to this SDK handle's clones. + /// A failed reload leaves its previous endpoint intact. + pub async fn reload(&self) -> Result<()> { + let _control = self.inner.control.lock().await; + self.inner.scope.ensure_open()?; + let mut endpoint = self.inner.endpoint.write().await; + let current = endpoint.as_ref().ok_or_else(|| { + Error::new(ErrorCode::Identity, "anonymous client has no credentials") + })?; + let replacement = match &self.inner.profile { + Some(path) => current.reload_from(path).await?, + None => current.reload().await?, + }; + *endpoint = Some(replacement); + Ok(()) + } + + /// Start connecting now and return a cancellable response future. Callers + /// can immediately pump UploadWriter without pre-buffering the entire body. + pub async fn request( + &self, + request: http::Request>, + options: RequestOptions, + ) -> Result { + let operation = self.inner.scope.register()?; + let endpoint = self.inner.endpoint.read().await.clone(); + let (sender, response) = oneshot::channel(); + let state = operation.state.clone(); + let response_state = state.clone(); + tokio::spawn(async move { + let deadline = options.timeout; + let mut sender = Some(sender); + let result = state + .run(async { + let exchange = exchange( + endpoint, + request, + options, + operation.state.clone(), + &mut sender, + ); + match deadline { + Some(timeout) => { + tokio::time::timeout(timeout, exchange).await.map_err(|_| { + Error::new(ErrorCode::DeadlineExceeded, "request deadline exceeded") + })? + } + None => exchange.await, + } + }) + .await; + if let Err(error) = result { + state.fail(error.clone()); + if let Some(sender) = sender.take() { + let _ = sender.send(Err(error)); + } + } + drop(operation); + }); + Ok(ResponseFuture { + response, + state: response_state, + transferred: false, + }) + } + + /// Cancel all owned exchanges and listeners, waiting until their tasks exit. + /// There is no implicit request deadline and close cancels without draining. + pub async fn close(&self) { + let _control = self.inner.control.lock().await; + self.inner.scope.close().await; + } +} + +/// SDK send policy. HTTP method, URI, headers, body and extensions remain in +/// the standard http::Request instead of a second message representation. +#[derive(Default)] +pub struct RequestOptions { + pub expected_remote_owner_hash: Option, + /// Total exchange deadline, including upload and response consumption. + /// None leaves duration under caller control. + pub timeout: Option, +} + +/// A standard response. Verified RemoteAuthority remains in its extensions. +pub type ClientResponse = http::Response; + +/// Dropping before response headers cancels the entire exchange. +pub struct ResponseFuture { + response: oneshot::Receiver>, + state: Arc, + transferred: bool, +} + +impl ResponseFuture { + /// A separate handle for AbortSignal/task cancellation adapters. + pub fn cancellation(&self) -> Cancellation { + Cancellation(self.state.clone()) + } +} + +#[derive(Clone)] +pub struct Cancellation(Arc); +impl Cancellation { + pub fn cancel(&self) { + self.0.fail(Error::cancelled()); + } +} + +impl Future for ResponseFuture { + type Output = Result; + fn poll( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.get_mut(); + match Pin::new(&mut this.response).poll(cx) { + std::task::Poll::Pending => std::task::Poll::Pending, + std::task::Poll::Ready(result) => { + this.transferred = true; + std::task::Poll::Ready(result.unwrap_or_else(|_| Err(this.state.error()))) + } + } + } +} + +impl Drop for ResponseFuture { + fn drop(&mut self) { + if !self.transferred { + self.state.fail(Error::cancelled()); + } + } +} + +async fn exchange( + endpoint: Option, + message: http::Request>, + options: RequestOptions, + state: Arc, + sender: &mut Option>>, +) -> Result<()> { + crate::init().await?; + let (parts, upload) = message.into_parts(); + let message = http::Request::from_parts(parts, dhttp::Empty::new()); + let mut request = match endpoint { + Some(endpoint) => endpoint.from_request(message), + None => dhttp::Anonymous.from_request(message), + }; + if let Some(owner) = options.expected_remote_owner_hash { + request = request.expect_remote_owner_hash(owner); + } + // Uniformly use a fixed native window, including empty requests with trailers. + let (mut writer, response) = request + .body(dhttp::WndBuf::new(64 * 1024)) + .into_future() + .await?; + let pumping = async move { + if let Some(mut body) = upload { + while let Some(frame) = body.frame().await { + let frame = frame?; + match frame.into_data() { + Ok(bytes) => writer.write_all(&bytes).await?, + Err(frame) => { + if let Ok(trailers) = frame.into_trailers() { + for (name, value) in trailers.iter() { + writer.append_trailer(name.clone(), value.clone())?; + } + } + } + } + } + } + writer.shutdown().await?; + Ok(()) + }; + let receiving = async move { + let response = response.await?; + let (parts, body) = response.into_parts(); + let (body, reading) = BodyReader::bridge(body, state.clone()); + let response = http::Response::from_parts(parts, body); + sender + .take() + .expect("response headers sent once") + .send(Ok(response)) + .map_err(|_| Error::cancelled())?; + reading.await + }; + // Response headers can arrive while upload is waiting on the language producer. + // A failure in either direction drops the sibling future and its native handle. + futures::try_join!(pumping, receiving)?; + Ok(()) +} + +impl Default for Client { + fn default() -> Self { + Self::anonymous() + } +} diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..a52ac1c --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,135 @@ +use std::{fmt, sync::Arc}; + +pub type Result = std::result::Result; + +/// Stable categories for language adapters; never classified from error text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ErrorCode { + InvalidArgument, + Identity, + RemoteIdentityChanged, + AlreadyListening, + Network, + Protocol, + Io, + Cancelled, + Closed, + DeadlineExceeded, + BodyInUse, + Producer, +} + +impl ErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidArgument => "ERR_INVALID_ARGUMENT", + Self::Identity => "ERR_IDENTITY", + Self::RemoteIdentityChanged => "ERR_REMOTE_IDENTITY_CHANGED", + Self::AlreadyListening => "ERR_ALREADY_LISTENING", + Self::Network => "ERR_NETWORK", + Self::Protocol => "ERR_PROTOCOL", + Self::Io => "ERR_IO", + Self::Cancelled => "ERR_CANCELLED", + Self::Closed => "ERR_CLOSED", + Self::DeadlineExceeded => "ERR_DEADLINE_EXCEEDED", + Self::BodyInUse => "ERR_BODY_IN_USE", + Self::Producer => "ERR_PRODUCER", + } + } +} + +#[derive(Clone, Debug)] +pub struct Error { + pub code: ErrorCode, + pub message: String, + pub protocol_code: Option, + cause: Option>, +} + +impl Error { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + protocol_code: None, + cause: None, + } + } + + pub fn producer(cause: impl std::error::Error + Send + Sync + 'static) -> Self { + Self { + code: ErrorCode::Producer, + message: cause.to_string(), + protocol_code: None, + cause: Some(Arc::new(cause)), + } + } + + pub(crate) fn cancelled() -> Self { + Self::new(ErrorCode::Cancelled, "operation cancelled") + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code.as_str(), self.message) + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.cause.as_deref().map(|cause| cause as _) + } +} + +impl From for Error { + fn from(cause: dhttp::Error) -> Self { + use dhttp::Error as Core; + if let Core::Io { source } = &cause + && let Some(error) = source + .get_ref() + .and_then(|cause| cause.downcast_ref::()) + { + return error.clone(); + } + let code = match &cause { + Core::InvalidName { .. } | Core::InvalidRequest { .. } | Core::InvalidUri { .. } => { + ErrorCode::InvalidArgument + } + Core::RemoteIdentityChanged => ErrorCode::RemoteIdentityChanged, + Core::AlreadyListening | Core::NameInUse { .. } => ErrorCode::AlreadyListening, + Core::HomeUnavailable { .. } + | Core::Home { .. } + | Core::Credentials { .. } + | Core::TlsConfig { .. } => ErrorCode::Identity, + Core::NetworkNotInitialized | Core::Quic { .. } => ErrorCode::Network, + Core::Http3 { .. } => ErrorCode::Protocol, + Core::Io { .. } => ErrorCode::Io, + }; + let protocol_code = match &cause { + Core::Http3 { source } => Some(source.code.as_u64()), + _ => None, + }; + Self { + code, + message: cause.to_string(), + protocol_code, + cause: Some(Arc::new(cause)), + } + } +} + +impl From for Error { + fn from(cause: std::io::Error) -> Self { + Self::from(dhttp::Error::from(cause)) + } +} + +impl From for Error { + fn from(cause: dhttp::BoxError) -> Self { + match cause.downcast::() { + Ok(error) => *error, + Err(cause) => Self::from(dhttp::Error::from(cause)), + } + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..1c4e9ef --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,31 @@ +//! Language-neutral asynchronous bridge for the Node and Python SDKs. +//! +//! N-API and PyO3 reuse the same streaming and cancellation ownership. +//! Native SDK discovery currently supports System DNS and explicit peers. +#![doc = include_str!("../README.md")] +#[cfg(any(feature = "napi", feature = "pyo3"))] +mod binding; +mod client; +mod error; +mod lifecycle; +#[cfg(feature = "napi")] +pub mod napi; +#[cfg(feature = "pyo3")] +mod python; +mod server; +mod stream; + +pub use client::{Cancellation, Client, ClientResponse, RequestOptions, ResponseFuture}; +pub use dhttp::{HandshakeSummary, HeaderMap, LocalAuthority, RemoteAuthority, Scopes}; +pub use error::{Error, ErrorCode, Result}; +pub use server::{Listener, ServerRequest, ServerResponse}; +pub use stream::{BodyReader, UploadBody, UploadWriter, upload_channel}; + +/// Initialize the shared network. The embedding Tokio runtime must stay alive. +/// This currently leaves name discovery configuration to the caller. +pub async fn init() -> Result<()> { + dhttp::DhttpNetwork::init() + .await + .map(|_| ()) + .map_err(Into::into) +} diff --git a/api/src/lifecycle.rs b/api/src/lifecycle.rs new file mode 100644 index 0000000..957c421 --- /dev/null +++ b/api/src/lifecycle.rs @@ -0,0 +1,127 @@ +use crate::{Error, ErrorCode, Result}; +use std::{ + future::Future, + sync::{Arc, Mutex}, +}; +use tokio_util::{ + sync::CancellationToken, + task::{TaskTracker, task_tracker::TaskTrackerToken}, +}; + +#[derive(Clone)] +pub(crate) struct Scope(Arc); + +struct Inner { + closed: Mutex, + cancel: CancellationToken, + tasks: TaskTracker, +} + +pub(crate) struct Operation { + // Reuse Tokio's task ownership accounting, including for native response Bodies. + _tracked: TaskTrackerToken, + pub state: Arc, +} + +pub(crate) struct OperationState { + pub cancel: CancellationToken, + owner_cancel: Option, + error: Mutex>, +} + +impl OperationState { + pub fn new() -> Arc { + Arc::new(Self { + cancel: CancellationToken::new(), + owner_cancel: None, + error: Mutex::new(None), + }) + } + + fn owned_by(cancel: &CancellationToken) -> Arc { + Arc::new(Self { + cancel: cancel.child_token(), + owner_cancel: Some(cancel.clone()), + error: Mutex::new(None), + }) + } + + pub fn fail(&self, error: Error) { + self.error.lock().unwrap().get_or_insert(error); + self.cancel.cancel(); + } + + pub fn error(&self) -> Error { + self.error.lock().unwrap().clone().unwrap_or_else(|| { + if self + .owner_cancel + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + Error::new(ErrorCode::Closed, "SDK handle is closed") + } else { + Error::cancelled() + } + }) + } + + pub async fn run(&self, future: impl Future>) -> Result { + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(self.error()), + result = future => result, + } + } +} + +impl Scope { + pub fn new() -> Self { + Self(Arc::new(Inner { + closed: Mutex::new(false), + cancel: CancellationToken::new(), + tasks: TaskTracker::new(), + })) + } + + pub fn ensure_open(&self) -> Result<()> { + if *self.0.closed.lock().unwrap() { + Err(Error::new(ErrorCode::Closed, "SDK handle is closed")) + } else { + Ok(()) + } + } + + pub fn register(&self) -> Result { + // Serialize registration with close: TaskTracker itself allows tokens + // after close, whereas an SDK handle must reject new operations. + let closed = self.0.closed.lock().unwrap(); + if *closed { + return Err(Error::new(ErrorCode::Closed, "SDK handle is closed")); + } + Ok(Operation { + _tracked: self.0.tasks.token(), + state: OperationState::owned_by(&self.0.cancel), + }) + } + + pub fn cancel(&self) { + *self.0.closed.lock().unwrap() = true; + self.0.cancel.cancel(); + self.0.tasks.close(); + } + + pub async fn close(&self) { + self.cancel(); + self.0.tasks.wait().await; + } +} + +impl Drop for Inner { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +#[cfg(test)] +#[path = "../tests/unit/lifecycle.rs"] +mod tests; diff --git a/api/src/napi.rs b/api/src/napi.rs new file mode 100644 index 0000000..f5c0606 --- /dev/null +++ b/api/src/napi.rs @@ -0,0 +1,440 @@ +use crate::{Error, ErrorCode, binding as b}; +use ::napi::{ + Error as NError, Result as NResult, + bindgen_prelude::{Buffer, Either, FnArgs, Function, Promise}, +}; +use napi_derive::napi; +use std::sync::Arc; + +type Result = NResult; +fn err(error: Error) -> NError { + NError::from_reason(serde_json::json!({"dhttp":true,"code":error.code.as_str(),"message":error.message,"protocolCode":error.protocol_code}).to_string()) +} +fn callback_error(error: NError) -> Error { + Error::new(ErrorCode::Producer, error.reason) +} + +#[napi(object)] +pub struct Header { + pub name: String, + pub value: Buffer, +} +fn fields(headers: Vec
) -> b::Fields { + headers + .into_iter() + .map(|h| (h.name, h.value.to_vec())) + .collect() +} +fn headers(fields: b::Fields) -> Vec
{ + fields + .into_iter() + .map(|(name, value)| Header { + name, + value: value.into(), + }) + .collect() +} + +#[napi(object)] +pub struct Authority { + pub name: String, + pub certificates: Vec, + pub public_key: Buffer, + pub owner_hash: String, + pub subject_key_identifier: Buffer, +} +impl From for Authority { + fn from(a: b::Authority) -> Self { + Self { + name: a.name, + certificates: a.certificates.into_iter().map(Into::into).collect(), + public_key: a.public_key.into(), + owner_hash: a.owner_hash, + subject_key_identifier: a.subject_key_identifier.into(), + } + } +} + +#[napi] +pub async fn init(peers: Vec>, root_certificates: Option) -> Result<()> { + let peers = peers + .into_iter() + .map(|pair| match pair.as_slice() { + [name, address] => Ok((name.clone(), address.clone())), + _ => Err(b::invalid("peer must be [name, address]")), + }) + .collect::>() + .map_err(err)?; + b::init(peers, root_certificates.map(|bytes| bytes.to_vec())) + .await + .map_err(err) +} +#[napi] +pub fn addresses() -> Vec { + b::addresses() +} + +#[napi] +pub struct NativeClient { + inner: crate::Client, +} +#[napi] +impl NativeClient { + #[napi(factory)] + pub fn anonymous() -> Self { + Self { + inner: crate::Client::anonymous(), + } + } + #[napi(factory)] + pub async fn load(name: String) -> Result { + crate::Client::load(name) + .await + .map(|inner| Self { inner }) + .map_err(err) + } + #[napi(factory)] + pub async fn load_from(path: String) -> Result { + crate::Client::load_from(path) + .await + .map(|inner| Self { inner }) + .map_err(err) + } + #[napi] + pub async fn name(&self) -> Option { + self.inner.name().await + } + #[napi] + pub async fn sign(&self, data: Buffer) -> Result { + b::sign(&self.inner, data.to_vec()) + .await + .map(Into::into) + .map_err(err) + } + #[napi] + pub async fn reload(&self) -> Result<()> { + self.inner.reload().await.map_err(err) + } + #[napi] + pub async fn close(&self) { + self.inner.close().await; + } + #[napi] + pub async fn local_authority(&self) -> Result> { + self.inner + .local_authority() + .await + .map_err(err)? + .as_ref() + .map(b::Authority::local) + .transpose() + .map(|a| a.map(Into::into)) + .map_err(err) + } + #[napi] + #[allow(clippy::too_many_arguments)] + pub async fn start( + &self, + method: String, + url: String, + headers: Vec
, + has_body: bool, + timeout_ms: Option, + owner_hash: Option, + trailers: Vec
, + ) -> Result { + b::init(Vec::new(), None).await.map_err(err)?; + b::Exchange::start( + &self.inner, + method, + url, + fields(headers), + has_body, + timeout_ms, + owner_hash, + fields(trailers), + ) + .await + .map(|inner| NativeExchange { inner }) + .map_err(err) + } + #[napi] + pub async fn listen( + &self, + scopes: Vec, + handler: &NativeHandler, + ) -> Result { + let callback = handler.callback.clone(); + b::init(Vec::new(), None).await.map_err(err)?; + let listener = self + .inner + .listen(b::scopes(scopes).map_err(err)?, move |request| { + let callback = callback.clone(); + async move { + let (incoming, response) = b::Incoming::new(request); + let result = callback + .call_async_catch( + (NativeIncoming { + inner: Arc::new(incoming), + },) + .into(), + ) + .await + .map_err(callback_error)?; + if let Either::A(promise) = result { + promise.await.map_err(callback_error)?; + } + response.await.map_err(|_| { + Error::new(ErrorCode::Producer, "handler did not return a response") + }) + } + }) + .await + .map_err(err)?; + Ok(NativeListener { inner: listener }) + } +} +type Callback = ::napi::threadsafe_function::ThreadsafeFunction< + FnArgs<(NativeIncoming,)>, + Either, ()>, + FnArgs<(NativeIncoming,)>, + ::napi::Status, + false, + true, +>; +#[napi] +pub struct NativeHandler { + callback: Arc, +} +#[napi] +impl NativeHandler { + #[napi(constructor)] + pub fn new( + handler: Function<'_, FnArgs<(NativeIncoming,)>, Either, ()>>, + ) -> Result { + let callback = handler + .build_threadsafe_function::>() + .callee_handled::() + .weak::() + .build()?; + Ok(Self { + callback: Arc::new(callback), + }) + } +} +#[napi] +pub struct NativeExchange { + inner: b::Exchange, +} +#[napi] +impl NativeExchange { + #[napi] + pub async fn response(&self) -> Result { + self.inner + .response() + .await + .map(|inner| NativeResponse { inner }) + .map_err(err) + } + #[napi] + pub async fn write(&self, bytes: Buffer) -> Result<()> { + self.inner + .writer() + .map_err(err)? + .send(bytes) + .await + .map_err(err) + } + #[napi] + pub async fn finish(&self, trailers: Vec
) -> Result<()> { + self.inner + .writer() + .map_err(err)? + .finish(b::headers(fields(trailers)).map_err(err)?) + .await + .map_err(err) + } + #[napi] + pub fn fail(&self, message: String) { + if let Some(upload) = &self.inner.upload { + upload.fail(Error::new(ErrorCode::Producer, message)); + } + } + #[napi] + pub fn cancel(&self) { + self.inner.cancellation.cancel(); + } + #[napi] + pub async fn closed(&self) { + if let Some(upload) = &self.inner.upload { + upload.closed().await; + } + } +} +#[napi] +pub struct NativeResponse { + inner: crate::ClientResponse, +} +#[napi] +impl NativeResponse { + #[napi(getter)] + pub fn status(&self) -> u16 { + self.inner.status().as_u16() + } + #[napi(getter)] + pub fn headers(&self) -> Vec
{ + headers(b::fields(self.inner.headers())) + } + #[napi] + pub fn authority(&self) -> Result> { + self.inner + .extensions() + .get::() + .map(b::Authority::remote) + .transpose() + .map(|a| a.map(Into::into)) + .map_err(err) + } + #[napi] + pub async fn read(&self) -> Result> { + self.inner + .body() + .next() + .await + .map(|bytes| bytes.map(|bytes| bytes.to_vec().into())) + .map_err(err) + } + #[napi] + pub async fn trailers(&self) -> Result> { + self.inner + .body() + .trailers() + .await + .map(|map| headers(b::fields(&map))) + .map_err(err) + } + #[napi] + pub fn cancel(&self) { + self.inner.body().cancel(); + } +} +#[napi] +pub struct NativeIncoming { + inner: Arc, +} +#[napi] +impl NativeIncoming { + #[napi] + pub fn local_authority(&self) -> Result { + b::Authority::local(self.inner.local_authority().map_err(err)?) + .map(Into::into) + .map_err(err) + } + #[napi] + pub async fn sign(&self, data: Buffer) -> Result { + b::sign_authority(self.inner.local_authority().map_err(err)?, data.as_ref()) + .map(Into::into) + .map_err(err) + } + #[napi(getter)] + pub fn method(&self) -> String { + self.inner.request.method().to_string() + } + #[napi(getter)] + pub fn url(&self) -> String { + self.inner.request.uri().to_string() + } + #[napi(getter)] + pub fn headers(&self) -> Vec
{ + headers(b::fields(self.inner.request.headers())) + } + #[napi] + pub fn authority(&self) -> Result> { + self.inner + .request + .extensions() + .get::() + .and_then(|h| h.remote.as_ref()) + .map(b::Authority::remote) + .transpose() + .map(|a| a.map(Into::into)) + .map_err(err) + } + #[napi] + pub async fn read(&self) -> Result> { + self.inner + .request + .body() + .next() + .await + .map(|bytes| bytes.map(|bytes| bytes.to_vec().into())) + .map_err(err) + } + #[napi] + pub async fn trailers(&self) -> Result> { + self.inner + .request + .body() + .trailers() + .await + .map(|map| headers(b::fields(&map))) + .map_err(err) + } + #[napi] + pub fn cancel(&self) { + self.inner.request.body().cancel(); + } + #[napi] + pub async fn closed(&self) { + self.inner.request.body().closed().await; + } + #[napi] + pub fn respond(&self, status: u16, headers: Vec
) -> Result { + self.inner + .respond(status, fields(headers)) + .map(|inner| NativeUpload { inner }) + .map_err(err) + } +} +#[napi] +pub struct NativeUpload { + inner: crate::UploadWriter, +} +#[napi] +impl NativeUpload { + #[napi] + pub async fn write(&self, bytes: Buffer) -> Result<()> { + self.inner.send(bytes).await.map_err(err) + } + #[napi] + pub async fn finish(&self, trailers: Vec
) -> Result<()> { + self.inner + .finish(b::headers(fields(trailers)).map_err(err)?) + .await + .map_err(err) + } + #[napi] + pub fn fail(&self, message: String) { + self.inner.fail(Error::new(ErrorCode::Producer, message)); + } + #[napi] + pub async fn closed(&self) { + self.inner.closed().await; + } +} +#[napi] +pub struct NativeListener { + inner: crate::Listener, +} +#[napi] +impl NativeListener { + #[napi] + pub async fn close(&self) { + self.inner.close().await; + } +} + +#[napi] +pub fn verify_signature(public_key: Buffer, data: Buffer, signature: Buffer) -> Result { + b::verify(public_key.to_vec(), data.to_vec(), signature.to_vec()).map_err(err) +} diff --git a/api/src/python.rs b/api/src/python.rs new file mode 100644 index 0000000..3abbe3b --- /dev/null +++ b/api/src/python.rs @@ -0,0 +1,458 @@ +use crate::{Error, ErrorCode, binding as b}; +use ::pyo3::{exceptions::PyRuntimeError, prelude::*, types::PyBytes}; +use std::sync::Arc; + +fn err(error: Error) -> PyErr { + Python::attach(|py| { + let exception = PyRuntimeError::new_err(error.message); + let value = exception.value(py); + let _ = value.setattr("code", error.code.as_str()); + let _ = value.setattr("protocol_code", error.protocol_code); + exception + }) +} +fn producer(error: PyErr) -> Error { + Error::new(ErrorCode::Producer, error.to_string()) +} +fn bytes(py: Python<'_>, data: Vec) -> Py { + PyBytes::new(py, &data).unbind() +} +fn fields(py: Python<'_>, headers: b::Fields) -> Vec<(String, Py)> { + headers + .into_iter() + .map(|(name, value)| (name, bytes(py, value))) + .collect() +} + +#[pyclass(frozen, skip_from_py_object)] +pub struct Authority { + inner: b::Authority, +} +#[pymethods] +impl Authority { + fn verify(&self, data: Vec, signature: Vec) -> PyResult { + b::verify(self.inner.public_key.clone(), data, signature).map_err(err) + } + #[getter] + fn name(&self) -> String { + self.inner.name.clone() + } + #[getter] + fn owner_hash(&self) -> String { + self.inner.owner_hash.clone() + } + #[getter] + fn certificates(&self, py: Python<'_>) -> Vec> { + self.inner + .certificates + .iter() + .map(|value| bytes(py, value.clone())) + .collect() + } + #[getter] + fn public_key(&self, py: Python<'_>) -> Py { + bytes(py, self.inner.public_key.clone()) + } + #[getter] + fn subject_key_identifier(&self, py: Python<'_>) -> Py { + bytes(py, self.inner.subject_key_identifier.clone()) + } +} + +#[pyfunction] +#[pyo3(signature=(peers,root_certificates=None))] +fn init<'py>( + py: Python<'py>, + peers: Vec<(String, String)>, + root_certificates: Option>, +) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + b::init(peers, root_certificates).await.map_err(err) + }) +} +#[pyfunction] +fn addresses() -> Vec { + b::addresses() +} + +#[pyclass(frozen)] +pub struct NativeClient { + inner: crate::Client, +} +#[pymethods] +impl NativeClient { + #[staticmethod] + fn anonymous() -> Self { + Self { + inner: crate::Client::anonymous(), + } + } + #[staticmethod] + fn load(py: Python<'_>, name: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + crate::Client::load(name) + .await + .map(|inner| Self { inner }) + .map_err(err) + }) + } + #[staticmethod] + fn load_from(py: Python<'_>, path: String) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async move { + crate::Client::load_from(path) + .await + .map(|inner| Self { inner }) + .map_err(err) + }) + } + fn name<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(client.name().await) }) + } + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client.close().await; + Ok(()) + }) + } + fn sign<'py>(&self, py: Python<'py>, data: Vec) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let signature = b::sign(&client, data).await.map_err(err)?; + Ok(Python::attach(|py| bytes(py, signature))) + }) + } + fn reload<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py( + py, + async move { client.reload().await.map_err(err) }, + ) + } + fn local_authority<'py>(&self, py: Python<'py>) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + client + .local_authority() + .await + .map_err(err)? + .as_ref() + .map(b::Authority::local) + .transpose() + .map(|a| a.map(|inner| Authority { inner })) + .map_err(err) + }) + } + #[allow(clippy::too_many_arguments)] + fn start<'py>( + &self, + py: Python<'py>, + method: String, + url: String, + headers: b::Fields, + has_body: bool, + timeout_ms: Option, + owner_hash: Option, + trailers: b::Fields, + ) -> PyResult> { + let client = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + b::init(Vec::new(), None).await.map_err(err)?; + b::Exchange::start( + &client, method, url, headers, has_body, timeout_ms, owner_hash, trailers, + ) + .await + .map(|inner| NativeExchange { + inner: Arc::new(inner), + }) + .map_err(err) + }) + } + fn listen<'py>( + &self, + py: Python<'py>, + scopes: Vec, + handler: Py, + ) -> PyResult> { + let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; + let client = self.inner.clone(); + let handler = Arc::new(handler); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + b::init(Vec::new(), None).await.map_err(err)?; + let listener = client + .listen(b::scopes(scopes).map_err(err)?, move |request| { + let handler = handler.clone(); + let locals = locals.clone(); + async move { + let (incoming, response) = b::Incoming::new(request); + let future = Python::attach(|py| { + let request = Py::new( + py, + NativeIncoming { + inner: Arc::new(incoming), + }, + )?; + let result = handler.call1(py, (request,))?; + pyo3_async_runtimes::into_future_with_locals( + &locals, + result.into_bound(py), + ) + }) + .map_err(producer)?; + future.await.map_err(producer)?; + response.await.map_err(|_| { + Error::new(ErrorCode::Producer, "handler did not return a response") + }) + } + }) + .await + .map_err(err)?; + Ok(NativeListener { inner: listener }) + }) + } +} +#[pyclass(frozen)] +pub struct NativeExchange { + inner: Arc, +} +#[pymethods] +impl NativeExchange { + fn response<'py>(&self, py: Python<'py>) -> PyResult> { + let exchange = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + exchange + .response() + .await + .map(|inner| NativeResponse { + inner: Arc::new(inner), + }) + .map_err(err) + }) + } + fn write<'py>(&self, py: Python<'py>, data: Vec) -> PyResult> { + let exchange = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + exchange + .writer() + .map_err(err)? + .send(data) + .await + .map_err(err) + }) + } + fn finish<'py>(&self, py: Python<'py>, trailers: b::Fields) -> PyResult> { + let exchange = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + exchange + .writer() + .map_err(err)? + .finish(b::headers(trailers).map_err(err)?) + .await + .map_err(err) + }) + } + fn fail(&self, message: String) { + if let Some(upload) = &self.inner.upload { + upload.fail(Error::new(ErrorCode::Producer, message)); + } + } + fn cancel(&self) { + self.inner.cancellation.cancel(); + } + fn closed<'py>(&self, py: Python<'py>) -> PyResult> { + let exchange = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + if let Some(upload) = &exchange.upload { + upload.closed().await; + } + Ok(()) + }) + } +} +#[pyclass(frozen)] +pub struct NativeResponse { + inner: Arc, +} +#[pymethods] +impl NativeResponse { + #[getter] + fn status(&self) -> u16 { + self.inner.status().as_u16() + } + #[getter] + fn headers(&self, py: Python<'_>) -> Vec<(String, Py)> { + fields(py, b::fields(self.inner.headers())) + } + fn authority(&self) -> PyResult> { + self.inner + .extensions() + .get::() + .map(b::Authority::remote) + .transpose() + .map(|a| a.map(|inner| Authority { inner })) + .map_err(err) + } + fn read<'py>(&self, py: Python<'py>) -> PyResult> { + let response = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let data = response.body().next().await.map_err(err)?; + Ok(Python::attach(|py| { + data.map(|data| bytes(py, data.to_vec())) + })) + }) + } + fn trailers<'py>(&self, py: Python<'py>) -> PyResult> { + let response = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let data = response.body().trailers().await.map_err(err)?; + Ok(Python::attach(|py| fields(py, b::fields(&data)))) + }) + } + fn cancel(&self) { + self.inner.body().cancel(); + } +} +#[pyclass(frozen)] +pub struct NativeIncoming { + inner: Arc, +} +#[pymethods] +impl NativeIncoming { + fn local_authority(&self) -> PyResult { + b::Authority::local(self.inner.local_authority().map_err(err)?) + .map(|inner| Authority { inner }) + .map_err(err) + } + fn sign<'py>(&self, py: Python<'py>, data: Vec) -> PyResult> { + let incoming = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let signature = + b::sign_authority(incoming.local_authority().map_err(err)?, &data).map_err(err)?; + Ok(Python::attach(|py| bytes(py, signature))) + }) + } + #[getter] + fn method(&self) -> String { + self.inner.request.method().to_string() + } + #[getter] + fn url(&self) -> String { + self.inner.request.uri().to_string() + } + #[getter] + fn headers(&self, py: Python<'_>) -> Vec<(String, Py)> { + fields(py, b::fields(self.inner.request.headers())) + } + fn authority(&self) -> PyResult> { + self.inner + .request + .extensions() + .get::() + .and_then(|h| h.remote.as_ref()) + .map(b::Authority::remote) + .transpose() + .map(|a| a.map(|inner| Authority { inner })) + .map_err(err) + } + fn read<'py>(&self, py: Python<'py>) -> PyResult> { + let incoming = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let data = incoming.request.body().next().await.map_err(err)?; + Ok(Python::attach(|py| { + data.map(|data| bytes(py, data.to_vec())) + })) + }) + } + fn trailers<'py>(&self, py: Python<'py>) -> PyResult> { + let incoming = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let data = incoming.request.body().trailers().await.map_err(err)?; + Ok(Python::attach(|py| fields(py, b::fields(&data)))) + }) + } + fn cancel(&self) { + self.inner.request.body().cancel(); + } + fn closed<'py>(&self, py: Python<'py>) -> PyResult> { + let incoming = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + incoming.request.body().closed().await; + Ok(()) + }) + } + fn respond(&self, status: u16, headers: b::Fields) -> PyResult { + self.inner + .respond(status, headers) + .map(|inner| NativeUpload { + inner: Arc::new(inner), + }) + .map_err(err) + } +} +#[pyclass(frozen)] +pub struct NativeUpload { + inner: Arc, +} +#[pymethods] +impl NativeUpload { + fn write<'py>(&self, py: Python<'py>, data: Vec) -> PyResult> { + let upload = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + upload.send(data).await.map_err(err) + }) + } + fn finish<'py>(&self, py: Python<'py>, trailers: b::Fields) -> PyResult> { + let upload = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + upload + .finish(b::headers(trailers).map_err(err)?) + .await + .map_err(err) + }) + } + fn fail(&self, message: String) { + self.inner.fail(Error::new(ErrorCode::Producer, message)); + } + fn closed<'py>(&self, py: Python<'py>) -> PyResult> { + let upload = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + upload.closed().await; + Ok(()) + }) + } +} +#[pyclass(frozen)] +pub struct NativeListener { + inner: crate::Listener, +} +#[pymethods] +impl NativeListener { + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let listener = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + listener.close().await; + Ok(()) + }) + } +} + +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(init, module)?)?; + module.add_function(wrap_pyfunction!(addresses, module)?)?; + module.add_function(wrap_pyfunction!(verify_signature, module)?)?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} + +#[pyfunction] +fn verify_signature(public_key: Vec, data: Vec, signature: Vec) -> PyResult { + b::verify(public_key, data, signature).map_err(err) +} diff --git a/api/src/server.rs b/api/src/server.rs new file mode 100644 index 0000000..b2a9093 --- /dev/null +++ b/api/src/server.rs @@ -0,0 +1,209 @@ +use crate::{ + BodyReader, Client, Error, ErrorCode, Result, + lifecycle::{Operation, Scope}, +}; +use bytes::Bytes; +use futures::{FutureExt, future::BoxFuture}; +use http_body::{Body, Frame}; +use http_body_util::{BodyExt, Full}; +use std::{ + convert::Infallible, + future::Future, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::sync::Mutex; + +/// Standard request; verified HandshakeSummary remains in its extensions. +pub type ServerRequest = http::Request; +pub type ServerResponse = http::Response; + +type Handler = + Arc BoxFuture<'static, Result> + Send + Sync>; + +#[derive(Clone)] +struct Service { + handler: Handler, + scope: Scope, +} + +impl tower_service::Service> for Service { + type Response = ServerResponse; + type Error = dhttp::BoxError; + type Future = BoxFuture<'static, std::result::Result>; + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready( + self.scope + .ensure_open() + .map_err(|error| Box::new(error) as _), + ) + } + fn call(&mut self, request: http::Request) -> Self::Future { + let scope = self.scope.clone(); + let handler = self.handler.clone(); + Box::pin(async move { + let operation = scope.register()?; + let reading = scope.register()?; + let (parts, body) = request.into_parts(); + let (body, worker) = BodyReader::bridge(body, reading.state.clone()); + tokio::spawn(async move { + let _ = worker.await; + drop(reading); + }); + let request = http::Request::from_parts(parts, body); + // Includes synchronous callback panics and future panics. Neither is + // exposed to the caller as exception text or allowed to kill the service. + let handled = + std::panic::AssertUnwindSafe(async move { handler(request).await }).catch_unwind(); + let response = operation + .state + .run(async { + Ok(match handled.await { + Ok(Ok(response)) => response, + failed => { + if let Ok(Err(error)) = failed { + tracing::warn!(%error, "SDK handler failed"); + } else { + tracing::warn!("SDK handler panicked"); + } + http::Response::builder() + .status(500) + .body( + Full::new(Bytes::new()) + .map_err(|never: Infallible| match never {}) + .boxed_unsync(), + ) + .unwrap() + } + }) + }) + .await?; + Ok(response.map(|body| OwnedBody::new(body, operation).boxed_unsync())) + }) + } +} + +// Retain handler ownership through response EOF, not just until headers return. +struct OwnedBody { + body: Option, + operation: Option, + cancelled: BoxFuture<'static, ()>, +} +impl OwnedBody { + fn new(body: dhttp::Body, operation: Operation) -> Self { + let cancelled = operation.state.cancel.clone().cancelled_owned().boxed(); + Self { + body: Some(body), + operation: Some(operation), + cancelled, + } + } +} +impl Body for OwnedBody { + type Data = Bytes; + type Error = dhttp::BoxError; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + let Some(body) = &mut this.body else { + return Poll::Ready(None); + }; + if this.cancelled.as_mut().poll(cx).is_ready() { + let error = this.operation.as_ref().unwrap().state.error(); + this.body.take(); + this.operation.take(); + return Poll::Ready(Some(Err(Box::new(error)))); + } + let result = Pin::new(body).poll_frame(cx); + if matches!(result, Poll::Ready(None) | Poll::Ready(Some(Err(_)))) { + this.body.take(); + this.operation.take(); + } + result + } + fn is_end_stream(&self) -> bool { + self.body.is_none() + } +} + +#[derive(Clone)] +pub struct Listener(Arc); +struct ListenerInner { + state: Arc, + task: Mutex>>, +} + +impl Listener { + /// Stop new admission first, then cancel owned handlers and body tasks. + /// Idempotent; no implicit drain or promise to close shared connections. + pub async fn close(&self) { + self.0 + .state + .fail(Error::new(ErrorCode::Closed, "listener closed")); + let mut task = self.0.task.lock().await; + if let Some(task) = task.take() { + let _ = task.await; + } + } +} +impl Drop for ListenerInner { + fn drop(&mut self) { + self.state + .fail(Error::new(ErrorCode::Closed, "listener dropped")); + } +} + +impl Client { + /// Register a named endpoint's service. Anonymous handles cannot listen. + /// Default discovery/publication will be layered here in the next stage. + pub async fn listen(&self, scopes: dhttp::Scopes, handler: F) -> Result + where + F: Fn(ServerRequest) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let _control = self.inner.control.lock().await; + let operation = self.inner.scope.register()?; + let endpoint = self + .inner + .endpoint + .read() + .await + .clone() + .ok_or_else(|| Error::new(ErrorCode::Identity, "anonymous client cannot listen"))?; + let state = operation.state.clone(); + let scope = Scope::new(); + let listening = state + .run(async { + crate::init().await?; + endpoint + .listen( + scopes, + Service { + handler: Arc::new(move |request| Box::pin(handler(request))), + scope: scope.clone(), + }, + ) + .await + .map_err(Into::into) + }) + .await?; + let task_state = state.clone(); + let task = tokio::spawn(async move { + task_state.cancel.cancelled().await; + drop(listening); + scope.close().await; + drop(operation); + }); + Ok(Listener(Arc::new(ListenerInner { + state, + task: Mutex::new(Some(task)), + }))) + } +} + +#[cfg(test)] +#[path = "../tests/unit/server.rs"] +mod tests; diff --git a/api/src/stream.rs b/api/src/stream.rs new file mode 100644 index 0000000..beeb74d --- /dev/null +++ b/api/src/stream.rs @@ -0,0 +1,312 @@ +use crate::{Error, ErrorCode, Result, lifecycle::OperationState}; +use bytes::Bytes; +use futures::future::BoxFuture; +use http::HeaderMap; +use http_body::{Body, Frame}; +use http_body_util::BodyExt; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; + +const CHUNK_BYTES: usize = 16 * 1024; +const QUEUED_CHUNKS: usize = 4; + +enum UploadFrame { + Data(Bytes), + Finish(HeaderMap), +} + +/// Finite upload bridge: at most four 16 KiB chunks await native consumption. +/// Dropping the writer without finish is an error, not successful EOF. +pub fn upload_channel() -> (UploadWriter, UploadBody) { + let (sender, receiver) = mpsc::channel(QUEUED_CHUNKS); + let state = OperationState::new(); + let cancelled = Box::pin(state.cancel.clone().cancelled_owned()); + ( + UploadWriter { + sender: Mutex::new(Some(sender)), + state: state.clone(), + }, + UploadBody { + receiver, + state, + cancelled, + ended: false, + }, + ) +} + +pub struct UploadWriter { + sender: Mutex>>, + state: Arc, +} + +struct CancelWriteOnDrop(Option>); +impl Drop for CancelWriteOnDrop { + fn drop(&mut self) { + if let Some(state) = self.0.take() { + state.fail(Error::cancelled()); + } + } +} + +impl UploadWriter { + /// Splits large producer chunks before enqueueing, preserving backpressure. + /// Cancellation while writing fails the entire upload rather than losing bytes. + pub async fn send(&self, bytes: impl AsRef<[u8]>) -> Result<()> { + let mut guard = CancelWriteOnDrop(Some(self.state.clone())); + let result = self + .state + .run(async { + let lock = self.sender.lock().await; + let sender = lock + .as_ref() + .ok_or_else(|| Error::new(ErrorCode::Closed, "upload finished"))?; + for chunk in bytes.as_ref().chunks(CHUNK_BYTES) { + let permit = sender + .reserve() + .await + .map_err(|_| Error::new(ErrorCode::Closed, "upload consumer closed"))?; + permit.send(UploadFrame::Data(Bytes::copy_from_slice(chunk))); + } + Ok(()) + }) + .await; + guard.0 = None; + result + } + + /// Freeze trailers and publish EOF after all previously enqueued bytes. + pub async fn finish(&self, trailers: HeaderMap) -> Result<()> { + let mut guard = CancelWriteOnDrop(Some(self.state.clone())); + let result = self + .state + .run(async { + let mut lock = self.sender.lock().await; + let sender = lock + .as_ref() + .ok_or_else(|| Error::new(ErrorCode::Closed, "upload finished"))?; + sender + .send(UploadFrame::Finish(trailers)) + .await + .map_err(|_| Error::new(ErrorCode::Closed, "upload consumer closed"))?; + *lock = None; + Ok(()) + }) + .await; + guard.0 = None; + result + } + + pub fn fail(&self, error: Error) { + self.state.fail(error); + } + + /// Notify language producers when the native consumer has released its body. + pub async fn closed(&self) { + let sender = self.sender.lock().await.as_ref().cloned(); + if let Some(sender) = sender { + sender.closed().await; + } + } +} + +pub struct UploadBody { + receiver: mpsc::Receiver, + state: Arc, + cancelled: BoxFuture<'static, ()>, + ended: bool, +} + +impl Body for UploadBody { + type Data = Bytes; + type Error = Error; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + let this = self.get_mut(); + if this.ended { + return Poll::Ready(None); + } + if this.cancelled.as_mut().poll(cx).is_ready() { + this.ended = true; + this.receiver.close(); + return Poll::Ready(Some(Err(this.state.error()))); + } + match this.receiver.poll_recv(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Some(UploadFrame::Data(bytes))) => { + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(UploadFrame::Finish(trailers))) => { + this.ended = true; + this.receiver.close(); + if trailers.is_empty() { + Poll::Ready(None) + } else { + Poll::Ready(Some(Ok(Frame::trailers(trailers)))) + } + } + Poll::Ready(None) => { + this.ended = true; + Poll::Ready(Some(Err(Error::new( + ErrorCode::Producer, + "upload writer dropped before finish", + )))) + } + } + } + fn is_end_stream(&self) -> bool { + self.ended + } +} + +type ReadReply = oneshot::Sender>>; +type Completion = Option>; + +/// Demand-driven receive handle. No DATA is polled until `next()` requests it. +/// Only one `next()` may be active. Drop/cancel also releases the native Body. +pub struct BodyReader { + commands: mpsc::Sender, + completion: watch::Receiver, + state: Arc, + reading: Mutex<()>, +} + +impl BodyReader { + pub(crate) fn bridge( + body: B, + state: Arc, + ) -> (Self, BoxFuture<'static, Result<()>>) + where + B: Body + Send + Unpin + 'static, + B::Error: Into, + { + let (commands, mut receive) = mpsc::channel::(1); + let (complete, completion) = watch::channel(None); + let worker_state = state.clone(); + let worker = Box::pin(async move { + let mut body = body; + let result = worker_state + .run(async { + let mut trailers = HeaderMap::new(); + while let Some(reply) = receive.recv().await { + let next = loop { + match body.frame().await { + None => break None, + Some(Err(error)) => { + let cause: dhttp::BoxError = error.into(); + let error = Error::from(cause); + let _ = reply.send(Err(error.clone())); + return Err(error); + } + Some(Ok(frame)) => match frame.into_data() { + Ok(data) => break Some(data), + Err(frame) => { + if let Ok(fields) = frame.into_trailers() { + // append preserves duplicate fields across all frames. + for (name, value) in fields.iter() { + trailers.append(name.clone(), value.clone()); + } + } + } + }, + } + }; + let ended = next.is_none(); + if ended { + // Publish completion before waking the consumer. On a + // multithreaded runtime it may drop the reader as soon + // as it sees EOF; that must not cancel a live upload. + complete.send_replace(Some(Ok(std::mem::take(&mut trailers)))); + } + if reply.send(Ok(next)).is_err() { + return Err(Error::cancelled()); + } + if ended { + return Ok(()); + } + } + Err(Error::cancelled()) + }) + .await; + if let Err(error) = &result { + worker_state.fail(error.clone()); + complete.send_replace(Some(Err(error.clone()))); + } + result + }); + ( + Self { + commands, + completion, + state, + reading: Mutex::new(()), + }, + worker, + ) + } + + pub async fn next(&self) -> Result> { + let _reading = self + .reading + .try_lock() + .map_err(|_| Error::new(ErrorCode::BodyInUse, "body already has a reader"))?; + if let Some(result) = self.completion.borrow().clone() { + return result.map(|_| None); + } + let (reply, response) = oneshot::channel(); + let mut guard = CancelWriteOnDrop(Some(self.state.clone())); + let result = self + .state + .run(async { + self.commands + .send(reply) + .await + .map_err(|_| self.state.error())?; + response.await.map_err(|_| self.state.error())? + }) + .await; + guard.0 = None; + result + } + + /// Available after successful EOF. Does not implicitly consume body DATA. + pub async fn trailers(&self) -> Result { + let mut completion = self.completion.clone(); + loop { + if let Some(result) = completion.borrow().clone() { + return result; + } + self.state + .run(async { completion.changed().await.map_err(|_| self.state.error()) }) + .await?; + } + } + + #[cfg(any(feature = "napi", feature = "pyo3"))] + pub(crate) async fn closed(&self) { + self.state.cancel.cancelled().await; + } + + pub fn cancel(&self) { + self.state.fail(Error::cancelled()); + } +} + +impl Drop for BodyReader { + fn drop(&mut self) { + // Successful receive EOF does not cancel an upload still completing. + if !matches!(*self.completion.borrow(), Some(Ok(_))) { + self.cancel(); + } + } +} + +#[cfg(test)] +#[path = "../tests/unit/receive.rs"] +mod tests; diff --git a/api/tests/error.rs b/api/tests/error.rs new file mode 100644 index 0000000..8e58ed6 --- /dev/null +++ b/api/tests/error.rs @@ -0,0 +1,33 @@ +use dhttp_api::{Error, ErrorCode}; +use std::error::Error as _; + +#[test] +fn protocol_errors_retain_their_code_across_body_and_io_boundaries() { + let h3 = h3x::ErrorCode::MessageError.stream("invalid DATA"); + let direct = Error::from(dhttp::Error::from(h3.clone())); + let boxed: dhttp::BoxError = Box::new(h3.clone()); + let boxed = Error::from(boxed); + let io = Error::from(std::io::Error::other(h3.clone())); + let core_io = Error::from(dhttp::Error::from(std::io::Error::other(h3))); + for error in [direct, boxed, io, core_io] { + assert_eq!(error.code, ErrorCode::Protocol); + assert_eq!( + error.protocol_code, + Some(h3x::ErrorCode::MessageError.as_u64()) + ); + assert!(error.source().is_some()); + } +} + +#[test] +fn producer_errors_retain_their_original_source_and_category() { + let error = Error::producer(std::io::Error::other("broken producer")); + let boxed: dhttp::BoxError = Box::new(error.clone()); + let boxed = Error::from(boxed); + let io = Error::from(std::io::Error::other(error.clone())); + let core_io = Error::from(dhttp::Error::from(std::io::Error::other(error))); + for error in [boxed, io, core_io] { + assert_eq!(error.code, ErrorCode::Producer); + assert_eq!(error.source().unwrap().to_string(), "broken producer"); + } +} diff --git a/api/tests/node-handler.cjs b/api/tests/node-handler.cjs new file mode 100644 index 0000000..58627f1 --- /dev/null +++ b/api/tests/node-handler.cjs @@ -0,0 +1,24 @@ +'use strict'; +const assert = require('node:assert/strict'); +module.exports = () => { + const handler = async request => { + assert.equal(request.localAuthority.name, 'server.dhttp.net'); + if (request.url.endsWith('/echo')) { + assert.equal(request.remoteAuthority.name, 'alice.dhttp.net'); + assert.deepEqual(request.rawHeaders.filter(([name]) => name === 'x-repeat').map(([,value]) => value), ['one', 'two']); + const response = new Response(request.body, {headers: [['x-repeat', 'one'], ['x-repeat', 'two']]}); + Object.defineProperty(response, 'trailers', {value: async () => { + const end = await request.trailers; + assert.equal(end.get('x-end'), 'one, two'); + return [['x-response-end', 'one'], ['x-response-end', 'two']]; + }}); + return response; + } + if (request.url.endsWith('/error')) throw new Error('private exception'); + if (request.url.endsWith('/hang')) return new Promise(() => {}); + if (request.url.endsWith('/pending')) return new Response(new ReadableStream({pull() {return new Promise(() => {});}})); + if (request.url.endsWith('/anonymous')) assert.equal(request.remoteAuthority, null); + return new Response('ok'); + }; +return handler; +}; diff --git a/api/tests/node-server.cjs b/api/tests/node-server.cjs new file mode 100644 index 0000000..2cdddb2 --- /dev/null +++ b/api/tests/node-server.cjs @@ -0,0 +1,22 @@ +'use strict'; +const sdk = require(process.env.DHTTP_TEST_NODE_PACKAGE ?? '../js/index.js'); +const fs = require('node:fs'); +const path = require('node:path'); +const handler = require('./node-handler.cjs')(); +(async () => { + const root = process.argv[2]; + await sdk.init({rootCertificates: fs.readFileSync(path.join(root, 'ca.crt'))}); + const server = await sdk.Endpoint.loadFrom(path.join(root, 'server')); + let listener = await server.listen(['loopback', 'internal'], handler); + await server.reload(); + process.send({address: sdk.addresses().find(address => !address.startsWith('['))}); + process.on('message', async command => { + try { + if (command.action === 'relisten') { await listener.close(); await listener.close(); listener = await server.listen(['loopback', 'internal'], handler); } + if (command.action === 'reverse') { await sdk.init({peers: {'alice.dhttp.net': command.address}}); const response = await server.fetch('https://alice~/reverse'); command.result = await response.text(); } + if (command.action === 'close') { await listener.close(); await server.close(); await sdk.Anonymous.close(); } + process.send({id: command.id, result: command.result}); + if (command.action === 'close') process.disconnect(); + } catch (error) { process.send({id: command.id, error: error.stack}); } + }); +})().catch(error => { console.error(error); process.exitCode = 1; process.disconnect(); }); diff --git a/api/tests/node.test.cjs b/api/tests/node.test.cjs new file mode 100644 index 0000000..f6e92c1 --- /dev/null +++ b/api/tests/node.test.cjs @@ -0,0 +1,84 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const packageRoot = process.env.DHTTP_TEST_NODE_PACKAGE ?? path.resolve(__dirname, '..'); +const sdk = require(packageRoot); + +test('CommonJS/ESM exports and invalid profile error', async () => { + assert.equal(typeof sdk.Endpoint.loadFrom, 'function'); + assert.equal(typeof sdk.Anonymous.fetch, 'function'); + const esm = await import(require('node:url').pathToFileURL(path.join(packageRoot, 'js/index.mjs')).href); + assert.equal(esm.Endpoint, sdk.Endpoint); + await assert.rejects(sdk.Endpoint.loadFrom('/does-not-exist/alice'), error => error.code === 'ERR_IDENTITY'); +}); + +const root = process.env.DHTTP_TEST_PROFILE_ROOT; +test('real Node HTTP3 streaming, identity, cancellation and close', {skip: !root, timeout: 25000}, async () => { + await sdk.init({rootCertificates: fs.readFileSync(path.join(root, 'ca.crt'))}); + const server = await sdk.Endpoint.loadFrom(path.join(root, 'server')); + const alice = await sdk.Endpoint.loadFrom(path.join(root, 'alice')); + const bob = await sdk.Endpoint.loadFrom(path.join(root, 'bob')); + const payload = Buffer.alloc(512 * 1024, 0x5a); + const {spawn} = require('node:child_process'); + const child = spawn(process.execPath, [path.join(__dirname, 'node-server.cjs'), root], {stdio: ['ignore', 'inherit', 'inherit', 'ipc']}); + let id = 0; + const rpc = command => new Promise((resolve, reject) => { + const requestId = ++id; + const receive = message => { if (message.id === requestId) { child.off('message', receive); message.error ? reject(new Error(message.error)) : resolve(message); } }; + child.on('message', receive); child.send({id: requestId, ...command}); + }); + const ready = await new Promise((resolve,reject) => { child.once('message',resolve); child.once('error',reject); }); + const exited = new Promise(resolve => child.once('exit', resolve)); + try { + const address = ready.address; + await sdk.init({peers: {'server.dhttp.net': address, 'alice.dhttp.net': address}}); + await server.reload(); await alice.reload(); + const local = await alice.localAuthority(); + const signature = await local.sign(Buffer.from('hello')); + assert.equal(local.verify(Buffer.from('hello'), signature), true); + assert.equal(local.verify(Buffer.from('other'), signature), false); + const anonymous = await sdk.Anonymous.fetch('https://server~/anonymous'); + assert.equal(anonymous.remoteAuthority.name, 'server.dhttp.net'); + assert.equal(await anonymous.text(), 'ok'); + assert.equal((await anonymous.trailers).size, undefined); + let releaseUpload; + const gate = new Promise(resolve => { releaseUpload = resolve; }); + const input = new ReadableStream({async start(controller) { await gate; controller.enqueue(payload); controller.close(); }}); + const response = await alice.fetch('https://server~/echo', {method: 'POST', body: input, + headers: [['x-repeat', 'one'], ['x-repeat', 'two']], trailers: [['x-end', 'one'], ['x-end', 'two']]}); + // Headers arrive while the upload producer is still waiting. + assert.equal(response.status, 200); + releaseUpload(); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), payload); + assert.equal((await response.trailers).get('x-response-end'), 'one, two'); + const reverse = await alice.listen(['loopback', 'internal'], request => { + assert.equal(request.remoteAuthority.name, 'server.dhttp.net'); + return new Response('reverse'); + }); + assert.equal((await rpc({action: 'reverse', address: sdk.addresses().find(address => !address.startsWith('['))})).result, 'reverse'); + await reverse.close(); + let failUpload; + const failedInput = new ReadableStream({async pull() { await new Promise(resolve => { failUpload = resolve; }); throw new Error('producer exploded'); }}); + const broken = await alice.fetch('https://server~/pending', {method: 'POST', body: failedInput}); + failUpload(); + await assert.rejects(broken.text(), error => error.code === 'ERR_PRODUCER'); + const failed = await alice.fetch('https://server~/error'); + assert.equal(failed.status, 500); assert.equal(await failed.text(), ''); + await assert.rejects(alice.fetch('https://server~/ok', {ownerHash: 'f'.repeat(64)}), error => error.code === 'ERR_REMOTE_IDENTITY_CHANGED'); + const controller = new AbortController(); + const pending = alice.fetch('https://server~/hang', {signal: controller.signal}); + setTimeout(() => controller.abort(), 20); + await assert.rejects(pending, error => error.name === 'AbortError'); + await assert.rejects(alice.fetch('https://server~/hang', {timeout: 30}), error => error.code === 'ERR_DEADLINE_EXCEEDED'); + const stalled = await alice.fetch('https://server~/pending'); + await alice.close(); + await assert.rejects(stalled.text(), error => error.code === 'ERR_CLOSED'); + assert.equal(await (await bob.fetch('https://server~/ok')).text(), 'ok'); + await rpc({action: 'relisten'}); + assert.equal(await (await bob.fetch('https://server~/ok')).text(), 'ok'); + } finally { + await rpc({action: 'close'}); await exited; await Promise.all([server.close(), alice.close(), bob.close(), sdk.Anonymous.close()]); + } +}); diff --git a/api/tests/python_handler.py b/api/tests/python_handler.py new file mode 100644 index 0000000..a6dab5b --- /dev/null +++ b/api/tests/python_handler.py @@ -0,0 +1,25 @@ +import asyncio +import dhttpy +def make_handler(self): + async def handler(request): + self.assertEqual(request.local_authority.name, 'server.dhttp.net') + if request.url.endswith('/echo'): + self.assertEqual(request.remote_authority.name, 'alice.dhttp.net') + self.assertEqual(request.headers.getall('x-repeat'), ['one', 'two']) + async def output(): + async for chunk in request.content: yield chunk + async def end(): + fields = await request.trailers() + self.assertEqual(fields.getall('x-end'), ['one', 'two']) + return [('x-response-end','one'),('x-response-end','two')] + return dhttpy.Response(output(), headers=[('x-repeat','one'),('x-repeat','two')], trailers=end) + if request.url.endswith('/error'): raise RuntimeError('private exception') + if request.url.endswith('/hang'): await asyncio.Event().wait() + if request.url.endswith('/pending'): + async def output(): + await asyncio.Event().wait() + yield b'never' + return dhttpy.Response(output()) + if request.url.endswith('/anonymous'): self.assertIsNone(request.remote_authority) + return dhttpy.Response.text('ok') + return handler diff --git a/api/tests/python_server.py b/api/tests/python_server.py new file mode 100644 index 0000000..96e8c73 --- /dev/null +++ b/api/tests/python_server.py @@ -0,0 +1,30 @@ +import asyncio +import json +import sys +import unittest +from pathlib import Path +import dhttpy +from python_handler import make_handler + +async def main(): + root = Path(sys.argv[1]) + await dhttpy.init(root_certificates=(root/'ca.crt').read_bytes()) + server = await dhttpy.Endpoint.load_from(root/'server') + handler = make_handler(unittest.TestCase()) + listener = await server.listen(['loopback','internal'],handler) + await server.reload() + print(json.dumps({'address':next(address for address in dhttpy.addresses() if not address.startswith('['))}),flush=True) + try: + while line := await asyncio.to_thread(sys.stdin.readline): + action = json.loads(line)['action'] + if action == 'relisten': + await listener.close(); await listener.close() + listener = await server.listen(['loopback','internal'],handler) + if action == 'close': + await listener.close(); await server.close(); await dhttpy.Anonymous.close() + print(json.dumps({'ok':True}),flush=True) + if action == 'close': break + finally: + await listener.close(); await server.close() + +asyncio.run(main()) diff --git a/api/tests/quic_roundtrip.rs b/api/tests/quic_roundtrip.rs new file mode 100644 index 0000000..ff66ad3 --- /dev/null +++ b/api/tests/quic_roundtrip.rs @@ -0,0 +1,376 @@ +//! SDK bridge acceptance over real UDP/TLS/H3; isolated process and temporary profiles. +use bytes::Bytes; +use dhttp::resolve::{EndpointAddr, Family, Resolve, ResolveFuture, Source}; +use dhttp_api::{ + Client, Error, ErrorCode, RequestOptions, ServerRequest, ServerResponse, upload_channel, +}; +use futures::{FutureExt, StreamExt}; +use http::{HeaderMap, Method}; +use http_body::Frame; +use http_body_util::{BodyExt, Full, StreamBody}; +use qprotocol::{Dock, UdpSocket}; +use std::{fmt, fs, sync::Arc, time::Duration}; + +#[path = "../../dhttp/tests/support/credentials.rs"] +mod credentials; + +#[derive(Debug)] +struct Resolver(EndpointAddr); +impl fmt::Display for Resolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SDK local test resolver") + } +} +impl Resolve for Resolver { + fn lookup<'a>(&'a self, name: &'a str, _: &'a str, _: Option) -> ResolveFuture<'a> { + async move { + assert!(matches!(name, "server.dhttp.net" | "alice.dhttp.net")); + Ok(futures::stream::iter([(Source::System, self.0)]).boxed()) + } + .boxed() + } +} + +fn get(path: &str) -> http::Request> { + http::Request::builder() + .uri(format!("https://server~/{path}")) + .body(None) + .unwrap() +} + +async fn read_body(response: &dhttp_api::ClientResponse) -> dhttp_api::Result { + let mut bytes = Vec::new(); + while let Some(chunk) = response.body().next().await? { + bytes.extend_from_slice(&chunk); + } + Ok(Bytes::from(bytes)) +} + +async fn handler(request: ServerRequest) -> dhttp_api::Result { + let handshake = request + .extensions() + .get::() + .unwrap(); + assert_eq!(handshake.local.as_ref().unwrap().name(), "server.dhttp.net"); + match request.uri().path() { + "/echo" => { + assert_eq!(handshake.remote.as_ref().unwrap().name(), "alice.dhttp.net"); + assert_eq!(request.headers().get_all("x-repeat").iter().count(), 2); + // Return headers before reading any upload bytes. The standard Body + // keeps reading the request and writing the response concurrently. + let body = StreamBody::new(async_stream::try_stream! { + while let Some(bytes) = request.body().next().await? { yield Frame::data(bytes); } + let trailers = request.body().trailers().await?; + assert_eq!(trailers.get_all("x-upload-end").iter().count(), 2); + yield Frame::trailers(trailers); + }); + let body = + BodyExt::map_err(body, |error: Error| -> dhttp::BoxError { Box::new(error) }) + .boxed_unsync(); + Ok(http::Response::builder() + .header("x-repeat", "one") + .header("x-repeat", "two") + .body(body) + .unwrap()) + } + "/error" => Err(Error::producer(std::io::Error::other( + "private exception detail", + ))), + "/hang" => futures::future::pending().await, + "/pending-body" => { + let body = StreamBody::new(futures::stream::pending::< + std::result::Result, dhttp::BoxError>, + >()); + Ok(http::Response::new(body.boxed_unsync())) + } + "/anonymous" => { + assert!(handshake.remote.is_none()); + Ok(http::Response::new( + Full::new(Bytes::from_static(b"anonymous")) + .map_err(|never| match never {}) + .boxed_unsync(), + )) + } + _ => Ok(http::Response::new( + Full::new(Bytes::from_static(b"ok")) + .map_err(|never| match never {}) + .boxed_unsync(), + )), + } +} + +#[tokio::test] +#[ignore = "requires OpenSSL and local UDP socket permission"] +async fn shared_bridge_streams_and_reclaims_its_own_resources_over_quic() { + tokio::time::timeout(Duration::from_secs(30), acceptance()) + .await + .unwrap(); +} + +async fn acceptance() { + let root = std::env::temp_dir().join(format!("dhttp-sdk-roundtrip-{}", std::process::id())); + let _cleanup = scopeguard::guard(root.clone(), |root| { + let _ = fs::remove_dir_all(root); + }); + credentials::generate( + &root, + &[ + ("server", "server.dhttp.net"), + ("alice", "alice.dhttp.net"), + ("bob", "bob.dhttp.net"), + ], + ); + let original_home = std::env::var_os("DHTTP_HOME"); + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); + let address = EndpointAddr::direct(socket.local_addr().unwrap()); + Dock::global().add(socket.clone()).unwrap().unwrap(); + let _socket = scopeguard::guard(socket, |socket| { + Dock::global().remove(&socket); + }); + dhttp::resolve::Resolver::add(Arc::new(Resolver(address))); + dhttp_api::init().await.unwrap(); + let ca = fs::read(root.join("ca.crt")).unwrap(); + qtls::RootCerts::set( + rustls_pemfile::certs(&mut ca.as_slice()) + .collect::, _>>() + .unwrap(), + ) + .unwrap(); + let server = Client::load_from(root.join("server")).await.unwrap(); + let alice = Client::load_from(root.join("alice")).await.unwrap(); + let bob = Client::load_from(root.join("bob")).await.unwrap(); + assert_eq!(std::env::var_os("DHTTP_HOME"), original_home); + assert_eq!(alice.name().await.as_deref(), Some("alice.dhttp.net")); + alice.reload().await.unwrap(); + let listener = server + .listen(dhttp::Scope::Loopback.into(), handler) + .await + .unwrap(); + server.reload().await.unwrap(); + let second = server.listen(dhttp::Scope::Loopback.into(), handler).await; + assert!(matches!(second, Err(error) if error.code == ErrorCode::AlreadyListening)); + // A failed explicit-path reload retains the previous signing capability. + let original = alice.local_authority().await.unwrap().unwrap(); + let ocsp_path = root.join("alice/ssl/ocsp.der"); + let ocsp = fs::read(&ocsp_path).unwrap(); + fs::write(&ocsp_path, b"invalid OCSP").unwrap(); + assert!(alice.reload().await.is_err()); + assert_eq!( + alice.local_authority().await.unwrap().unwrap().ocsp(), + original.ocsp() + ); + fs::write(&ocsp_path, ocsp).unwrap(); + let core_alice = dhttp::Endpoint::load_from(root.join("alice")) + .await + .unwrap(); + assert!(core_alice.reload_from(root.join("bob")).await.is_err()); + assert!( + dhttp::Endpoint::load_from(root.join("*.alice")) + .await + .is_err() + ); + let anonymous = Client::anonymous(); + let response = anonymous + .request(get("anonymous"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + assert_eq!( + response + .extensions() + .get::() + .unwrap() + .name(), + "server.dhttp.net" + ); + assert_eq!(read_body(&response).await.unwrap(), "anonymous"); + assert!(response.body().trailers().await.unwrap().is_empty()); + + // Bidirectional requests reuse authenticated core connections. + let reverse_listener = alice + .listen( + dhttp::Scope::Loopback.into(), + |request: ServerRequest| async move { + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .remote + .as_ref() + .unwrap() + .name(), + "server.dhttp.net" + ); + Ok(http::Response::new( + Full::new(Bytes::from_static(b"reverse")) + .map_err(|never| match never {}) + .boxed_unsync(), + )) + }, + ) + .await + .unwrap(); + + let (writer, body) = upload_channel(); + let message = http::Request::builder() + .method(Method::POST) + .uri("https://server~/echo") + .header("x-repeat", "one") + .header("x-repeat", "two") + .body(Some(body)) + .unwrap(); + let response = alice + .request(message, RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + // No producer bytes have been sent yet: response headers really arrive early. + assert_eq!(response.headers().get_all("x-repeat").iter().count(), 2); + let payload = vec![0x5a; 512 * 1024]; + let (sent, received) = tokio::join!( + async { + writer.send(&payload).await.unwrap(); + let mut trailers = HeaderMap::new(); + trailers.append("x-upload-end", "one".parse().unwrap()); + trailers.append("x-upload-end", "two".parse().unwrap()); + writer.finish(trailers).await.unwrap(); + }, + read_body(&response) + ); + assert_eq!(sent, ()); + assert_eq!(received.unwrap().as_ref(), payload.as_slice()); + assert_eq!( + response + .body() + .trailers() + .await + .unwrap() + .get_all("x-upload-end") + .iter() + .count(), + 2 + ); + let reverse = server + .request( + http::Request::builder() + .uri("https://alice~/reverse") + .body(None) + .unwrap(), + RequestOptions::default(), + ) + .await + .unwrap() + .await + .unwrap(); + assert_eq!(read_body(&reverse).await.unwrap(), "reverse"); + + // A pin is checked on an already pooled connection before sending anything. + let options = RequestOptions { + expected_remote_owner_hash: Some( + dhttp_home::certificate::OwnerHash::try_from("f".repeat(64).as_str()).unwrap(), + ), + ..Default::default() + }; + let result = alice.request(get("ok"), options).await.unwrap().await; + assert!(matches!(result, Err(error) if error.code == ErrorCode::RemoteIdentityChanged)); + let response = alice + .request(get("error"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + assert_eq!(response.status(), 500); + assert!(read_body(&response).await.unwrap().is_empty()); + + // Cancellation before and after headers, and a deadline, leave the pool usable. + let pending = alice + .request(get("hang"), RequestOptions::default()) + .await + .unwrap(); + pending.cancellation().cancel(); + assert!(matches!(pending.await, Err(error) if error.code == ErrorCode::Cancelled)); + let response = alice + .request(get("pending-body"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + response.body().cancel(); + assert_eq!( + response.body().next().await.unwrap_err().code, + ErrorCode::Cancelled + ); + let options = RequestOptions { + timeout: Some(Duration::from_millis(50)), + ..Default::default() + }; + let result = alice.request(get("hang"), options).await.unwrap().await; + assert!(matches!(result, Err(error) if error.code == ErrorCode::DeadlineExceeded)); + + let (writer, body) = upload_channel(); + let mut message = get("pending-body"); + *message.body_mut() = Some(body); + let response = alice + .request(message, RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + writer.fail(Error::producer(std::io::Error::other("producer exploded"))); + assert_eq!( + response.body().next().await.unwrap_err().code, + ErrorCode::Producer + ); + assert_eq!( + response.body().trailers().await.unwrap_err().code, + ErrorCode::Producer + ); + + // Independently loaded same-name handles have independent SDK ownership. + let other_alice = Client::load_from(root.join("alice")).await.unwrap(); + + let response = alice + .request(get("pending-body"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + alice.close().await; + assert_eq!( + response.body().next().await.unwrap_err().code, + ErrorCode::Closed + ); + assert!( + matches!(alice.request(get("ok"), RequestOptions::default()).await, Err(error) if error.code == ErrorCode::Closed) + ); + reverse_listener.close().await; + let response = other_alice + .request(get("ok"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + assert_eq!(read_body(&response).await.unwrap(), "ok"); + other_alice.close().await; + let response = bob + .request(get("ok"), RequestOptions::default()) + .await + .unwrap() + .await + .unwrap(); + assert_eq!(read_body(&response).await.unwrap(), "ok"); + // Listener close cancels a stalled handler and releases its name for re-listen. + listener.close().await; + listener.close().await; + let listener = server + .listen(dhttp::Scope::Loopback.into(), handler) + .await + .unwrap(); + listener.close().await; + anonymous.close().await; + bob.close().await; + server.close().await; +} diff --git a/api/tests/stream.rs b/api/tests/stream.rs new file mode 100644 index 0000000..0233d6a --- /dev/null +++ b/api/tests/stream.rs @@ -0,0 +1,80 @@ +use dhttp_api::{Error, ErrorCode, upload_channel}; +use futures::FutureExt; +use http::{HeaderMap, HeaderValue}; +use http_body_util::BodyExt; + +#[tokio::test] +async fn large_producer_is_backpressured_and_keeps_all_bytes_and_duplicate_trailers() { + let (writer, mut body) = upload_channel(); + let bytes = vec![42u8; 512 * 1024]; + let mut sending = Box::pin(writer.send(&bytes)); + assert!(futures::poll!(&mut sending).is_pending()); + // Four chunks fit; the fifth cannot enter before consumption. + for _ in 0..4 { + let frame = body.frame().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(frame.data_ref().unwrap().len(), 16 * 1024); + } + assert!(body.frame().now_or_never().is_none()); + let (sent, received) = tokio::join!( + async { + sending.await.unwrap(); + let mut trailers = HeaderMap::new(); + trailers.append("x-end", HeaderValue::from_static("one")); + trailers.append("x-end", HeaderValue::from_static("two")); + writer.finish(trailers).await.unwrap(); + }, + async { + let collected = body.collect().await.unwrap(); + let trailers = collected.trailers().unwrap(); + assert_eq!(trailers.get_all("x-end").iter().count(), 2); + collected.to_bytes() + } + ); + assert_eq!(sent, ()); + assert_eq!(received.len(), bytes.len() - 64 * 1024); + assert!(received.iter().all(|byte| *byte == 42)); + assert_eq!( + writer.send(b"late").await.unwrap_err().code, + ErrorCode::Closed + ); +} + +#[tokio::test] +async fn abandoned_or_failed_producer_is_not_normal_eof() { + let (writer, mut body) = upload_channel(); + drop(writer); + assert_eq!( + body.frame().await.unwrap().unwrap_err().code, + ErrorCode::Producer + ); + assert!(body.frame().await.is_none()); + let (writer, mut body) = upload_channel(); + writer.fail(Error::producer(std::io::Error::other("producer failed"))); + let error = body.frame().await.unwrap().unwrap_err(); + assert_eq!(error.code, ErrorCode::Producer); + assert_eq!(error.message, "producer failed"); + assert!(body.frame().await.is_none()); +} + +#[tokio::test] +async fn cancelling_a_partly_sent_chunk_terminates_the_upload() { + let (writer, mut body) = upload_channel(); + let bytes = vec![0; 512 * 1024]; + let mut sending = Box::pin(writer.send(&bytes)); + assert!(futures::poll!(&mut sending).is_pending()); + drop(sending); + assert_eq!( + body.frame().await.unwrap().unwrap_err().code, + ErrorCode::Cancelled + ); +} + +#[tokio::test] +async fn closed_consumer_unblocks_a_waiting_producer() { + let (writer, body) = upload_channel(); + let bytes = vec![0; 512 * 1024]; + let mut sending = Box::pin(writer.send(&bytes)); + assert!(futures::poll!(&mut sending).is_pending()); + drop(body); + assert_eq!(sending.await.unwrap_err().code, ErrorCode::Closed); +} diff --git a/api/tests/test_python_sdk.py b/api/tests/test_python_sdk.py new file mode 100644 index 0000000..76a4996 --- /dev/null +++ b/api/tests/test_python_sdk.py @@ -0,0 +1,90 @@ +import asyncio +import os +from pathlib import Path +import unittest +import json +import sys +import dhttpy + +class SDKTests(unittest.IsolatedAsyncioTestCase): + async def test_shape_and_invalid_profile(self): + self.assertTrue(callable(dhttpy.Endpoint.load_from)) + with self.assertRaises(dhttpy.DhttpError) as caught: + await dhttpy.Endpoint.load_from('/does-not-exist/alice') + self.assertEqual(caught.exception.code, 'ERR_IDENTITY') + self.assertEqual(dhttpy.Headers([('x','one'),('X','two')]).getall('x'), ['one','two']) + + @unittest.skipUnless(os.getenv('DHTTP_TEST_PROFILE_ROOT'), 'requires temporary test profiles and UDP permission') + async def test_real_http3_streams_identity_cancellation_and_close(self): + root = Path(os.environ['DHTTP_TEST_PROFILE_ROOT']) + await dhttpy.init(root_certificates=(root/'ca.crt').read_bytes()) + server = await dhttpy.Endpoint.load_from(root/'server') + alice = await dhttpy.Endpoint.load_from(root/'alice') + bob = await dhttpy.Endpoint.load_from(root/'bob') + payload = b'Z' * (512 * 1024) + child = await asyncio.create_subprocess_exec(sys.executable, str(Path(__file__).with_name('python_server.py')), str(root), stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) + ready = json.loads(await asyncio.wait_for(child.stdout.readline(), 5)) + async def rpc(action): + child.stdin.write((json.dumps({'action':action})+'\n').encode()) + await child.stdin.drain() + return json.loads(await child.stdout.readline()) + try: + address = ready["address"] + await dhttpy.init(peers={'server.dhttp.net':address}) + await server.reload(); await alice.reload() + local = await alice.local_authority() + signature = await local.sign(b'hello') + self.assertTrue(local.verify(b'hello', signature)) + self.assertFalse(local.verify(b'other', signature)) + async with dhttpy.Anonymous.get('https://server~/anonymous') as response: + self.assertEqual(response.remote_authority.name, 'server.dhttp.net') + self.assertEqual(await response.text(), 'ok') + self.assertEqual(len(await response.trailers()), 0) + gate = asyncio.Event() + async def input(): + await gate.wait() + yield payload + response = await alice.post('https://server~/echo', data=input(), headers=[('x-repeat','one'),('x-repeat','two')], trailers=[('x-end','one'),('x-end','two')]) + self.assertEqual(response.status, 200) + gate.set() + self.assertEqual(await response.read(), payload) + self.assertEqual((await response.trailers()).getall('x-response-end'), ['one','two']) + await response.release() + fail = asyncio.Event() + async def failed_input(): + await fail.wait() + raise RuntimeError('producer exploded') + yield b'never' + broken = await alice.post('https://server~/pending', data=failed_input()) + fail.set() + with self.assertRaises(dhttpy.DhttpError) as caught: await broken.read() + self.assertEqual(caught.exception.code, 'ERR_PRODUCER') + async with alice.get('https://server~/error') as response: + self.assertEqual(response.status,500) + self.assertEqual(await response.read(),b'') + with self.assertRaises(dhttpy.DhttpError) as caught: + await alice.get('https://server~/ok',owner_hash='f'*64) + self.assertEqual(caught.exception.code,'ERR_REMOTE_IDENTITY_CHANGED') + task = asyncio.ensure_future(alice.get('https://server~/hang')) + await asyncio.sleep(.02); task.cancel() + with self.assertRaises(asyncio.CancelledError): await task + with self.assertRaises(dhttpy.DhttpError) as caught: + await alice.get('https://server~/hang',timeout=.03) + self.assertEqual(caught.exception.code,'ERR_DEADLINE_EXCEEDED') + stalled = await alice.get('https://server~/pending') + await alice.close() + with self.assertRaises(dhttpy.DhttpError) as caught: await stalled.read() + self.assertEqual(caught.exception.code,'ERR_CLOSED') + async with bob.get('https://server~/ok') as response: self.assertEqual(await response.text(),'ok') + await rpc('relisten') + finally: + await rpc('close') + child.stdin.close() + await asyncio.wait_for(child.wait(),5) + self.assertEqual(child.returncode,0) + await asyncio.gather(server.close(),alice.close(),bob.close(),dhttpy.Anonymous.close()) + # SDK-owned asyncio producer and handler tasks have all been reclaimed. + await asyncio.sleep(0) + self.assertFalse([task for task in asyncio.all_tasks() if task is not asyncio.current_task() and not task.done()]) + +if __name__ == '__main__': unittest.main() diff --git a/api/tests/types.test.ts b/api/tests/types.test.ts new file mode 100644 index 0000000..47c680a --- /dev/null +++ b/api/tests/types.test.ts @@ -0,0 +1,10 @@ +import {Endpoint, Anonymous, Response, type DhttpResponse, type ServerRequest} from '../js/index.js'; +async function example() { + const endpoint = await Endpoint.load('alice'); + const result: DhttpResponse = await Anonymous.fetch('https://bob~/hello'); + await result.trailers; + const listener = await endpoint.listen(['internal'], async (request: ServerRequest) => new Response(await request.text())); + await listener.close(); + await endpoint.close(); +} +void example; diff --git a/api/tests/unit/lifecycle.rs b/api/tests/unit/lifecycle.rs new file mode 100644 index 0000000..6b123c2 --- /dev/null +++ b/api/tests/unit/lifecycle.rs @@ -0,0 +1,37 @@ +use super::*; + +#[tokio::test] +async fn close_cancels_owned_operations_waits_for_cleanup_and_keeps_other_owners_open() { + let first = Scope::new(); + let second = Scope::new(); + let operation = first.register().unwrap(); + let task = tokio::spawn(async move { + operation.state.cancel.cancelled().await; + assert_eq!(operation.state.error().code, ErrorCode::Closed); + drop(operation); + }); + first.close().await; + task.await.unwrap(); + assert!(first.register().is_err()); + assert!(second.register().is_ok()); + first.close().await; +} + +#[tokio::test] +async fn dropping_the_last_owner_cancels_operations_without_a_reference_cycle() { + let owner = Scope::new(); + let clone = owner.clone(); + let operation = owner.register().unwrap(); + drop(owner); + assert!(!operation.state.cancel.is_cancelled()); + drop(clone); + assert!(operation.state.cancel.is_cancelled()); +} + +#[tokio::test] +async fn first_failure_survives_later_cancellation() { + let state = OperationState::new(); + state.fail(Error::new(ErrorCode::Producer, "upload failed")); + state.fail(Error::cancelled()); + assert_eq!(state.error().code, ErrorCode::Producer); +} diff --git a/api/tests/unit/receive.rs b/api/tests/unit/receive.rs new file mode 100644 index 0000000..165130c --- /dev/null +++ b/api/tests/unit/receive.rs @@ -0,0 +1,124 @@ +use super::*; +use futures::{StreamExt, stream}; +use http_body_util::{Full, StreamBody}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +#[tokio::test] +async fn demand_drives_native_body_and_preserves_duplicate_trailers() { + let polls = Arc::new(AtomicUsize::new(0)); + let counted = polls.clone(); + let mut trailers = HeaderMap::new(); + trailers.append("x-end", "one".parse().unwrap()); + trailers.append("x-end", "two".parse().unwrap()); + let frames = vec![ + Ok::<_, Error>(Frame::data(Bytes::from_static(b"abc"))), + Ok(Frame::trailers(trailers)), + ]; + let body = StreamBody::new(stream::iter(frames).inspect(move |_| { + counted.fetch_add(1, Ordering::SeqCst); + })); + let (reader, worker) = BodyReader::bridge(body, OperationState::new()); + let task = tokio::spawn(worker); + tokio::task::yield_now().await; + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert_eq!(reader.next().await.unwrap().unwrap(), "abc"); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert!(reader.next().await.unwrap().is_none()); + assert_eq!( + reader + .trailers() + .await + .unwrap() + .get_all("x-end") + .iter() + .count(), + 2 + ); + assert!(reader.next().await.unwrap().is_none()); + assert!(task.await.unwrap().is_ok()); +} + +struct PendingBody(Arc); +impl Body for PendingBody { + type Data = Bytes; + type Error = Error; + fn poll_frame(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll>>> { + Poll::Pending + } +} +impl Drop for PendingBody { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +#[tokio::test] +async fn simultaneous_read_is_rejected_and_cancel_releases_native_body() { + let dropped = Arc::new(AtomicBool::new(false)); + let (reader, worker) = BodyReader::bridge(PendingBody(dropped.clone()), OperationState::new()); + let task = tokio::spawn(worker); + let mut reading = Box::pin(reader.next()); + assert!(futures::poll!(&mut reading).is_pending()); + assert_eq!(reader.next().await.unwrap_err().code, ErrorCode::BodyInUse); + reader.cancel(); + assert_eq!(reading.await.unwrap_err().code, ErrorCode::Cancelled); + assert_eq!( + reader.trailers().await.unwrap_err().code, + ErrorCode::Cancelled + ); + assert_eq!(task.await.unwrap().unwrap_err().code, ErrorCode::Cancelled); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn cancelling_a_read_future_cancels_native_consumption() { + let dropped = Arc::new(AtomicBool::new(false)); + let (reader, worker) = BodyReader::bridge(PendingBody(dropped.clone()), OperationState::new()); + let task = tokio::spawn(worker); + let mut reading = Box::pin(reader.next()); + assert!(futures::poll!(&mut reading).is_pending()); + drop(reading); + assert_eq!(task.await.unwrap().unwrap_err().code, ErrorCode::Cancelled); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn dropping_a_reader_without_polling_it_releases_native_body() { + let dropped = Arc::new(AtomicBool::new(false)); + let (reader, worker) = BodyReader::bridge(PendingBody(dropped.clone()), OperationState::new()); + let task = tokio::spawn(worker); + drop(reader); + assert!(task.await.unwrap().is_err()); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn receive_errors_preserve_protocol_codes_and_never_supply_successful_trailers() { + let body = StreamBody::new(stream::iter(vec![Err::, _>( + h3x::ErrorCode::MessageError.stream("bad body"), + )])); + let (reader, worker) = BodyReader::bridge(body, OperationState::new()); + let task = tokio::spawn(worker); + let error = reader.next().await.unwrap_err(); + assert_eq!(error.code, ErrorCode::Protocol); + assert_eq!( + error.protocol_code, + Some(h3x::ErrorCode::MessageError.as_u64()) + ); + assert_eq!( + reader.trailers().await.unwrap_err().code, + ErrorCode::Protocol + ); + assert!(task.await.unwrap().is_err()); +} + +#[tokio::test] +async fn successful_receive_eof_does_not_cancel_a_pending_upload() { + let state = OperationState::new(); + let (reader, worker) = BodyReader::bridge(Full::new(Bytes::new()), state.clone()); + let task = tokio::spawn(worker); + assert!(reader.next().await.unwrap().is_none()); + drop(reader); + assert!(task.await.unwrap().is_ok()); + assert!(!state.cancel.is_cancelled()); +} diff --git a/api/tests/unit/server.rs b/api/tests/unit/server.rs new file mode 100644 index 0000000..3cc7baa --- /dev/null +++ b/api/tests/unit/server.rs @@ -0,0 +1,89 @@ +use super::*; +use tower_service::Service as _; + +fn empty_request() -> http::Request { + http::Request::new(dhttp::Body::default()) +} + +#[tokio::test] +async fn exceptions_and_panics_before_headers_produce_an_empty_500() { + let handlers: Vec = vec![ + Arc::new(|_: ServerRequest| { + async { Err(Error::producer(std::io::Error::other("private exception"))) }.boxed() + }), + Arc::new(|_: ServerRequest| { + panic!("synchronous callback panic"); + #[allow(unreachable_code)] + futures::future::ready(Ok(http::Response::new(dhttp::Body::default()))).boxed() + }), + Arc::new(|_: ServerRequest| { + async { + panic!("asynchronous callback panic"); + #[allow(unreachable_code)] + Ok(http::Response::new(dhttp::Body::default())) + } + .boxed() + }), + ]; + for handler in handlers { + let scope = Scope::new(); + let mut service = Service { + handler, + scope: scope.clone(), + }; + let response = service.call(empty_request()).await.unwrap(); + assert_eq!(response.status(), 500); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + scope.close().await; + } +} + +#[tokio::test] +async fn listener_cancellation_stops_handlers_before_headers_and_pending_response_bodies() { + let scope = Scope::new(); + let mut service = Service { + handler: Arc::new(|_: ServerRequest| { + futures::future::pending::>().boxed() + }), + scope: scope.clone(), + }; + let mut handling = service.call(empty_request()); + assert!(futures::poll!(&mut handling).is_pending()); + scope.cancel(); + assert!(handling.await.is_err()); + scope.close().await; + + let scope = Scope::new(); + let body = http_body_util::StreamBody::new(futures::stream::pending::< + std::result::Result, dhttp::BoxError>, + >()); + let operation = scope.register().unwrap(); + let mut body = OwnedBody::new(body.boxed_unsync(), operation); + scope.cancel(); + assert!(body.frame().await.unwrap().is_err()); + scope.close().await; + assert!(body.frame().await.is_none()); +} + +#[tokio::test] +async fn producer_errors_after_headers_stay_body_errors() { + let scope = Scope::new(); + let body = http_body_util::StreamBody::new(futures::stream::iter(vec![Err::< + Frame, + dhttp::BoxError, + >(Box::new( + Error::producer(std::io::Error::other("body failed")), + ))])); + let operation = scope.register().unwrap(); + let mut body = OwnedBody::new(body.boxed_unsync(), operation); + assert!(body.frame().await.unwrap().is_err()); + scope.close().await; +} diff --git a/dhttp/Cargo.toml b/dhttp/Cargo.toml index e2f094f..4fcf5ad 100644 --- a/dhttp/Cargo.toml +++ b/dhttp/Cargo.toml @@ -14,6 +14,7 @@ path = "src/dhttp.rs" [features] default = [] +online-identity = ["dhttp-home/ocsp"] access = ["dep:dhttp-access"] log = ["dep:dhttp-log"] access-cli = ["access", "dhttp-access/cli"] @@ -23,6 +24,7 @@ access-migration = ["access-orm", "dhttp-access/migration"] access-peg-trace = ["access", "dhttp-access/peg-trace"] [dependencies] +rustls = { workspace = true, features = ["ring", "std"] } bytes.workspace = true futures.workspace = true dhttp-home = { workspace = true, features = ["ssl"] } diff --git a/dhttp/src/dhttp.rs b/dhttp/src/dhttp.rs index 9fa9ec8..47963e3 100644 --- a/dhttp/src/dhttp.rs +++ b/dhttp/src/dhttp.rs @@ -1,8 +1,9 @@ //! Named HTTP/3 endpoints, anonymous outbound requests and a process-wide network. mod bootstrap; mod transport; -mod trust; +pub mod trust; mod uri; +pub use uri::expand_uri_with_base; pub mod client; pub mod endpoint; @@ -12,13 +13,18 @@ pub mod network; pub use endpoint::{Anonymous, Empty, Endpoint, Request, RequestWriter}; pub use error::{Error, Result}; pub use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; -pub use network::DhttpNetwork; +pub use network::{DhttpNetwork, NetworkBinding}; pub use qconn::{Scope, Scopes}; pub use qprotocol::AddressBook; pub use qrecovery::send::CancelStream; pub use qresolve as resolve; pub use qtls::{CertificateDer, HandshakeSummary, LocalAuthority, RemoteAuthority}; +/// Supply an application Context before network initialization on Android. +/// This is needed when the embedding framework does not initialize `ndk-context`. +#[cfg(target_os = "android")] +pub use netwatcher::set_android_context; + pub use h3x::{Body, BoxError, WndBuf}; pub type ListenFuture = std::pin::Pin + Send + 'static>>; pub type EmptyBody = http_body_util::Empty; diff --git a/dhttp/src/endpoint.rs b/dhttp/src/endpoint.rs index 0ecdd5a..a3dae6e 100644 --- a/dhttp/src/endpoint.rs +++ b/dhttp/src/endpoint.rs @@ -83,19 +83,13 @@ fn empty_request( /// ``` #[derive(Clone)] pub struct Endpoint { - pub(crate) quic: Arc, + pub(crate) identity: Arc, } impl Endpoint { /// Create a named endpoint from prepared local QUIC credentials. pub fn new(identity: Arc) -> Self { - let mut quic = qconn::QuicEndpoint::new(identity); - quic.alpn = vec![h3x::ALPN.to_vec()]; - quic.client_parameters = qbase::param::handy::client_parameters(); - quic.server_parameters = qbase::param::handy::server_parameters(); - Self { - quic: Arc::new(quic), - } + Self { identity } } /// Load the named identity's certificate chain, private key and OCSP staple. @@ -112,6 +106,57 @@ impl Endpoint { let profile = home .identity_profile(&name) .map_err(|_| Error::InvalidName { name })?; + Self::load_profile(profile).await + } + + /// Load credentials from an explicit identity profile directory. + /// The directory name identifies the endpoint, using the same home layout + /// and key permission checks as [`Self::load`]. Does not change DHTTP_HOME. + pub async fn load_from(path: impl AsRef) -> Result { + let profile = + dhttp_home::identity::IdentityProfile::try_from(path.as_ref()).map_err(|source| { + Error::Home { + path: path.as_ref().to_owned(), + source: Arc::new(source), + } + })?; + // Wildcard profiles describe a credential range, not an endpoint name. + if dhttp_home::normalize_name(profile.name()).as_deref() != Some(profile.name()) { + return Err(Error::InvalidName { + name: profile.name().to_owned(), + }); + } + Self::load_profile(profile).await + } + + /// Refresh missing or expired OCSP before loading this identity directory. + /// Uses ordinary HTTP(S) and the configured DHTTP trust anchor; unlike + /// `load_from`, this explicit operation can perform network access. + #[cfg(feature = "online-identity")] + pub async fn load_from_with_ocsp(path: impl AsRef) -> Result { + crate::trust::initialize()?; + let path = path.as_ref(); + let profile = dhttp_home::identity::IdentityProfile::try_from(path).map_err(|source| { + Error::Home { + path: path.to_owned(), + source: Arc::new(source), + } + })?; + if dhttp_home::normalize_name(profile.name()).as_deref() != Some(profile.name()) { + return Err(Error::InvalidName { + name: profile.name().to_owned(), + }); + } + let certificates = profile.load_certs().await.map_err(|source| Error::Home { + path: profile.cert_path(), + source: Arc::new(source), + })?; + dhttp_home::ocsp::load_or_refresh(&profile, &certificates, crate::trust::DHTTP_ROOT_CA_DER) + .await?; + Self::load_from(path).await + } + + async fn load_profile(profile: dhttp_home::identity::IdentityProfile) -> Result { let certificates = profile.load_certs().await.map_err(|source| Error::Home { path: profile.cert_path(), source: Arc::new(source), @@ -124,25 +169,19 @@ impl Endpoint { path: profile.ocsp_path(), source: Arc::new(source), })?; - let identity = qbase::endpoint::Endpoint::new( - &qtls::default_provider(), - profile.name(), - certificates, - key, - ocsp, - ) - .map_err(|source| Error::Credentials { - source: Arc::new(source), - })?; + let identity = qbase::endpoint::Endpoint::new(profile.name(), certificates, key, ocsp) + .map_err(|source| Error::Credentials { + source: Arc::new(source), + })?; Ok(Self::new(identity)) } /// The local identity's name. pub fn name(&self) -> &str { - self.quic.identity.name() + self.identity.name() } /// Build a signing authority from the same immutable credentials used by QUIC. pub fn local_authority(&self) -> Result { - let identity = &self.quic.identity; + let identity = &self.identity; qtls::LocalAuthority::from_signing_key( identity.name().into(), identity.cert_chain().to_vec(), diff --git a/dhttp/src/error.rs b/dhttp/src/error.rs index f3b8f67..b51951e 100644 --- a/dhttp/src/error.rs +++ b/dhttp/src/error.rs @@ -81,6 +81,26 @@ impl std::error::Error for Error { } } +impl Error { + // Body and AsyncWrite erase concrete errors into BoxError/io::Error. + // Recover core categories here so callers need only one error mapping. + fn recover(source: &(dyn std::error::Error + 'static)) -> Option { + if let Some(error) = source.downcast_ref::() { + return Some(error.clone()); + } + if let Some(error) = source.downcast_ref::() { + return Some(error.clone().into()); + } + if let Some(error) = source.downcast_ref::() { + return Some(error.clone().into()); + } + source + .downcast_ref::() + .and_then(std::io::Error::get_ref) + .and_then(|source| Self::recover(source)) + } +} + impl From for Error { fn from(source: http::uri::InvalidUri) -> Self { Self::InvalidUri { @@ -107,16 +127,20 @@ impl From for Error { impl From for Error { fn from(source: std::io::Error) -> Self { - Self::Io { + Self::recover(&source).unwrap_or_else(|| Self::Io { source: Arc::new(source), - } + }) } } impl From for Error { fn from(source: crate::BoxError) -> Self { - Self::Io { + Self::recover(source.as_ref()).unwrap_or_else(|| Self::Io { source: Arc::new(std::io::Error::other(source)), - } + }) } } + +#[cfg(test)] +#[path = "../tests/unit/error.rs"] +mod tests; diff --git a/dhttp/src/network.rs b/dhttp/src/network.rs index 191760c..e9e5d0b 100644 --- a/dhttp/src/network.rs +++ b/dhttp/src/network.rs @@ -17,7 +17,17 @@ pub(crate) type BoxService = static NETWORK: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +/// A live local binding owned by the DHTTP network. +/// Consumers can use this snapshot to synchronize discovery resources without +/// accessing transport sockets or creating another interface watcher. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetworkBinding { + pub device: String, + pub address: std::net::SocketAddr, +} + pub struct DhttpNetwork { + interfaces: interfaces::Interfaces, listeners: Mutex, BoxService>>, pool: h3x::Pool, } @@ -33,8 +43,9 @@ impl DhttpNetwork { NETWORK .get_or_try_init(|| async { crate::trust::initialize()?; - interfaces::init().await?; + let interfaces = interfaces::Interfaces::init().await?; Ok::<_, Error>(Self { + interfaces, listeners: Mutex::new(HashMap::new()), pool: h3x::Pool::new(connect), }) @@ -46,15 +57,44 @@ impl DhttpNetwork { NETWORK.get().ok_or(Error::NetworkNotInitialized) } + /// Snapshot the currently live bindings owned by this network. + /// The snapshot is unordered and can change after this call. Discovery libraries + /// should reconcile their resources against subsequent snapshots. + /// Returns an error if the network maintenance task has stopped. + pub fn active_bindings(&self) -> Result> { + self.interfaces.active_bindings() + } + + /// Refresh network resources after device wake or application foregrounding. + /// No preceding suspend call is required. State changes are serialized with interface polling. + /// Returns after refreshing the interface watcher and scanning current bindings, + /// preserving live sockets and their ports, and scheduling immediate STUN refreshes. + /// Individual binding failures are logged and retried by periodic maintenance. + /// Does not wait for STUN or guarantee Internet reachability, reconnect terminated + /// connections, or replay HTTP requests. The original Tokio runtime must remain alive. + pub async fn resume(&self) -> Result<()> { + self.interfaces.resume().await + } + pub(crate) async fn get_connection( &'static self, local: Option, remote: Arc, ) -> Result

{ let key = ConnectionKey::Outgoing { local, remote }; - tokio::time::timeout(CONNECT_TIMEOUT, self.pool.get(&key)) + let result = tokio::time::timeout(CONNECT_TIMEOUT, self.pool.get(&key)) .await - .map_err(std::io::Error::other)? + .map_err(std::io::Error::other)?; + match &result { + Ok(h3) => tracing::trace!( + local = ?h3.transport().handshake.local.as_ref().map(qtls::LocalAuthority::name), + remote = ?h3.transport().handshake.remote.as_ref().map(qtls::RemoteAuthority::name), + paths = ?h3.transport().connection.validated_paths(), + "HTTP/3 connection ready for outgoing request" + ), + Err(error) => tracing::trace!(%error, "HTTP/3 connection acquisition failed"), + } + result } pub(crate) async fn listen( @@ -63,8 +103,9 @@ impl DhttpNetwork { scopes: Scopes, service: BoxService, ) -> Result { - let quic = &endpoint.quic; - let name: Arc = Arc::from(quic.identity.name()); + let mut quic = qconn::QuicEndpoint::from(endpoint.identity.clone()); + quic.set_alpn(vec![h3x::ALPN.to_vec()]); + let name: Arc = Arc::from(endpoint.name()); { let mut listeners = self.listeners.lock().unwrap(); if listeners.contains_key(&name) { @@ -77,19 +118,24 @@ impl DhttpNetwork { } quic.listen(scopes, { let name = name.clone(); - move |result| { - let (remote, local, connection) = match result { - Ok(accepted) => accepted, - Err(error) => { - tracing::debug!(endpoint = %name, %error, "incoming connection rejected"); - return; - } + move |(remote, local, connection)| { + tracing::trace!( + local = %local.name(), + remote = ?remote.as_ref().map(qtls::RemoteAuthority::name), + paths = ?connection.validated_paths(), + "incoming QUIC handshake completed" + ); + let key = ConnectionKey::Incoming { + local: name.clone(), + remote: remote.as_ref().map(|authority| Arc::from(authority.name())), }; - let connection = QuicTransport::new( - connection, Some(local), remote, h3x::Role::Server, - ).and_then(|transport| { - H3::new(transport, h3x::Settings::default()).map_err(Error::from) - }); + let on_unreusable = self.pool.on_unreusable(key.clone()); + let connection = + QuicTransport::new(connection, Some(local), remote, h3x::Role::Server) + .and_then(|transport| { + H3::new(transport, h3x::Settings::default(), on_unreusable) + .map_err(Error::from) + }); let h3 = match connection { Ok(h3) => h3, Err(error) => { @@ -97,11 +143,6 @@ impl DhttpNetwork { return; } }; - let key = ConnectionKey::Incoming { - local: name.clone(), - remote: h3.transport().handshake.remote.as_ref() - .map(|remote| Arc::from(remote.name())), - }; let _ = self.pool.insert(key.clone(), h3.clone()); tokio::spawn(serve_connection(self, key, h3)); } @@ -125,26 +166,54 @@ impl Endpoint { /// The application callback, listener lifetime, scopes and connection pool remain owned by Network. /// Certificate/key rotation requires application restart; this operation renews only the staple. pub async fn reload(&self) -> Result { - let mut replacement = Self::load(self.name()).await?; - if replacement.quic.identity.cert_chain() != self.quic.identity.cert_chain() { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, - "certificate chain changed; restart required").into()); + self.replace_staple(Self::load(self.name()).await?) + } + + /// Reload the staple from an explicit profile without changing DHTTP_HOME. + /// The profile must retain this endpoint's name and certificate chain. + pub async fn reload_from(&self, path: impl AsRef) -> Result { + self.replace_staple(Self::load_from(path).await?) + } + + fn replace_staple(&self, replacement: Self) -> Result { + if replacement.name() != self.name() { + return Err(Error::InvalidName { + name: replacement.name().to_owned(), + }); } - qtls::validate_ocsp(replacement.quic.identity.ocsp(), - replacement.quic.identity.cert_chain(), qtls::UnixTime::now()) - .map_err(std::io::Error::other)?; + if replacement.identity.cert_chain() != self.identity.cert_chain() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "certificate chain changed; restart required", + ) + .into()); + } + qtls::validate_ocsp( + replacement.identity.ocsp(), + replacement.identity.cert_chain(), + qtls::UnixTime::now(), + ) + .map_err(std::io::Error::other)?; let network = DhttpNetwork::global()?; - let listeners = network.listeners.lock().map_err(|_| std::io::Error::other("listener registry poisoned"))?; + let listeners = network + .listeners + .lock() + .map_err(|_| std::io::Error::other("listener registry poisoned"))?; if listeners.contains_key(self.name()) { - let registered = qconn::ServerRegistry::global().get(self.name()) + let registered = qconn::ServerRegistry::global() + .get(self.name()) .ok_or_else(|| std::io::Error::other("listener TLS registration disappeared"))?; - let quic = Arc::get_mut(&mut replacement.quic) - .ok_or_else(|| std::io::Error::other("replacement endpoint unexpectedly shared"))?; - quic.server_parameters = registered.server_parameters.clone(); - quic.listen(registered.scopes, move |result| (registered.accept_cb)(result)) - .map_err(std::io::Error::other)?; + let mut quic = qconn::QuicEndpoint::from(replacement.identity.clone()); + quic.set_alpn(vec![h3x::ALPN.to_vec()]); + for (id, value) in registered.server_parameters.iter() { + quic.set_parameters(qbase::role::Role::Server, *id, value.clone()) + .map_err(std::io::Error::other)?; + } + quic.listen(registered.scopes, move |result| { + (registered.accept_cb)(result) + }) + .map_err(std::io::Error::other)?; } Ok(replacement) } - } diff --git a/dhttp/src/network/connection.rs b/dhttp/src/network/connection.rs index 3f85b68..b0c92ac 100644 --- a/dhttp/src/network/connection.rs +++ b/dhttp/src/network/connection.rs @@ -55,7 +55,10 @@ pub(super) async fn serve_connection(network: &'static DhttpNetwork, key: Connec break; } }; - tracing::debug!( + tracing::trace!( + local = ?key.names().0, + remote = ?key.names().1, + stream_id = writer.stream_id(), paths = ?h3.transport().connection.validated_paths(), "HTTP/3 request validated QUIC paths" ); @@ -81,31 +84,37 @@ pub(super) async fn serve_connection(network: &'static DhttpNetwork, key: Connec network.pool.remove_connection(&key, &h3); } -pub(super) async fn connect(key: ConnectionKey) -> Result

{ +pub(super) async fn connect( + key: ConnectionKey, + on_unreusable: h3x::UnreusableCallback, +) -> Result

{ let ConnectionKey::Outgoing { local, remote } = &key else { return Err(Error::InvalidRequest { message: "cannot initiate a connection with an incoming key".into(), }); }; let network = DhttpNetwork::global()?; + tracing::trace!(local = ?key.names().0, remote = ?key.names().1, "outgoing QUIC handshake started"); let (local, remote, connection) = match local { - Some(endpoint) => endpoint.quic.connect(remote.to_string()).await?, + Some(endpoint) => { + let mut quic = qconn::QuicEndpoint::from(endpoint.identity.clone()); + quic.set_alpn(vec![h3x::ALPN.to_vec()]); + quic.connect(remote.to_string()).await? + } None => { - qconn::connect_anonymously( - remote.to_string(), - qbase::param::handy::client_parameters(), - vec![h3x::ALPN.to_vec()], - ) - .await? + let mut quic = qconn::QuicEndpoint::anonymous(); + quic.set_alpn(vec![h3x::ALPN.to_vec()]); + quic.connect(remote.to_string()).await? } }; - tracing::debug!( + tracing::trace!( local = ?local.as_ref().map(qtls::LocalAuthority::name), remote = %remote.name(), - "outgoing QUIC handshake identities" + paths = ?connection.validated_paths(), + "outgoing QUIC handshake completed" ); let transport = QuicTransport::new(connection, local, Some(remote), h3x::Role::Client)?; - let h3 = H3::new(transport, h3x::Settings::default())?; + let h3 = H3::new(transport, h3x::Settings::default(), on_unreusable)?; tokio::spawn(serve_connection(network, key, h3.clone())); Ok(h3) } diff --git a/dhttp/src/network/interfaces.rs b/dhttp/src/network/interfaces.rs index 6561944..9ce4fac 100644 --- a/dhttp/src/network/interfaces.rs +++ b/dhttp/src/network/interfaces.rs @@ -1,4 +1,4 @@ -//! Own device bindings and serialize interface scans, NAT polling and withdrawal. +//! Own network interfaces and bindings; serialize scans, recovery, NAT polling and withdrawal. mod nat; #[cfg(test)] #[path = "../../tests/support/interface_bindings.rs"] @@ -7,8 +7,7 @@ mod test_support; #[path = "../../tests/unit/network/interfaces.rs"] mod tests; -use futures::{FutureExt, future::BoxFuture}; -use nat::probe_nat; +use futures::FutureExt; use qbase::net::addr::EndpointAddr; use qconn::Scope; use qprotocol::{AddressBook, Dock, UdpSocket}; @@ -17,23 +16,132 @@ use std::{ collections::HashMap, io, net::{IpAddr, SocketAddr, SocketAddrV6}, - sync::Arc, + sync::{Arc, Mutex}, + task::{Context, Poll, Waker}, time::Duration, }; const BINDING_CHECK_INTERVAL: Duration = Duration::from_secs(1); -/// Prepare the initial bindings before handing ownership to one maintenance task. -pub(super) async fn init() -> io::Result<()> { - let mut watcher = netwatcher::watch_interfaces_async::() - .map_err(io::Error::other)?; - qtransport::router::QuicRouter::global(); - let snapshot = watcher.changed().await.interfaces; - let mut bindings = - InterfaceBindings::new(Dock::global().clone(), AddressBook::global().clone()); - bindings.scan(&snapshot); - tokio::spawn(watch(watcher, snapshot, bindings)); - Ok(()) +/// Shared network interfaces and bindings, accessed by recovery and background polling. +/// The mutex is held only for synchronous state changes, never across an await. +pub(super) struct Interfaces { + state: Arc>>, +} + +struct InterfacesCtx { + watcher: netwatcher::AsyncWatch, + snapshot: HashMap, + dock: Arc, + addresses: Arc, + /// Only bindings created by Interfaces belong to this table. + bindings: HashMap, + check: tokio::time::Interval, + waker: Option, +} + +impl Interfaces { + /// Prepare the initial bindings before starting background polling. + pub(super) async fn init() -> io::Result { + let mut watcher = netwatcher::watch_interfaces_async::() + .map_err(io::Error::other)?; + qtransport::router::QuicRouter::global(); + // Subscribe first, then enumerate: mobile watchers can replay a cached snapshot. + let _ = watcher.changed().await; + let snapshot = netwatcher::list_interfaces().map_err(io::Error::other)?; + let mut state = InterfacesCtx::new( + watcher, + snapshot, + Dock::global().clone(), + AddressBook::global().clone(), + ); + state.scan(); + let (interfaces, _task) = Self::start(state); + Ok(interfaces) + } + + fn start(state: InterfacesCtx) -> (Self, tokio::task::JoinHandle<()>) { + let state = Arc::new(Mutex::new(Some(state))); + // Construct cleanup before spawning so cancellation before the first poll + // also withdraws bindings and marks interface polling as stopped. + let cleanup = scopeguard::guard(state.clone(), |state| { + state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + }); + let task = tokio::spawn(async move { + std::future::poll_fn(|cx| { + let mut state = cleanup.lock().unwrap(); + match state.as_mut() { + Some(state) => state.poll(cx), + None => Poll::Ready(()), + } + }) + .await; + }); + (Self { state }, task) + } + + /// Refresh the watcher, repair bindings and restart NAT measurements directly. + pub(super) async fn resume(&self) -> crate::Result<()> { + if self.state.lock().map_err(|_| Self::stopped())?.is_none() { + return Err(Self::stopped().into()); + } + let mut watcher = netwatcher::watch_interfaces_async::() + .map_err(io::Error::other)?; + // Android's initial replay can be cached. Subscribe before enumerating, + // and keep the previous state intact if either preparation step fails. + let _ = watcher.changed().await; + let waker = { + let mut state = self.state.lock().map_err(|_| Self::stopped())?; + let state = state.as_mut().ok_or_else(Self::stopped)?; + // Enumerate under the same lock as background updates so concurrent + // calls and old OS notifications cannot apply an earlier snapshot. + let snapshot = netwatcher::list_interfaces().map_err(io::Error::other)?; + state.watcher = watcher; + state.snapshot = snapshot; + state.scan(); + for binding in state.bindings.values_mut() { + if let Some(nat) = &mut binding.nat { + nat.resume(qprotocol::StunProtocol::stun_servers()); + } + } + state.waker.clone() + }; + // Poll the new watcher and NAT futures immediately to register their wakes. + if let Some(waker) = waker { + waker.wake(); + } + Ok(()) + } + + pub(super) fn active_bindings(&self) -> crate::Result> { + let state = self.state.lock().map_err(|_| Self::stopped())?; + let state = state.as_ref().ok_or_else(Self::stopped)?; + Ok(state + .bindings + .iter() + .filter_map(|(key, binding)| { + let live = !binding.receiver.is_finished() + && state + .dock + .find_socket(binding.bound) + .is_some_and(|socket| Arc::ptr_eq(&socket, &binding.socket)); + live.then(|| super::NetworkBinding { + device: key.name.clone(), + address: binding.bound, + }) + }) + .collect()) + } + + fn stopped() -> io::Error { + io::Error::new( + io::ErrorKind::BrokenPipe, + "network maintenance task stopped", + ) + } } /// A desired binding without a port. Hardware identity detects interface replacement @@ -50,25 +158,60 @@ struct Binding { bound: SocketAddr, socket: Arc, receiver: tokio::task::AbortHandle, - nat_probe: BoxFuture<'static, ()>, + nat: Option, } -/// Only bindings created by this maintenance task belong to this table. -struct InterfaceBindings { - dock: Arc, - addresses: Arc, - bindings: HashMap, -} - -impl InterfaceBindings { - fn new(dock: Arc, addresses: Arc) -> Self { +impl InterfacesCtx { + fn new( + watcher: netwatcher::AsyncWatch, + snapshot: HashMap, + dock: Arc, + addresses: Arc, + ) -> Self { + let mut check = tokio::time::interval_at( + tokio::time::Instant::now() + BINDING_CHECK_INTERVAL, + BINDING_CHECK_INTERVAL, + ); + check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); Self { + watcher, + snapshot, dock, addresses, bindings: HashMap::new(), + check, + waker: None, } } + fn poll(&mut self, cx: &mut Context<'_>) -> Poll<()> { + self.waker = Some(cx.waker().clone()); + if std::pin::pin!(self.watcher.changed()) + .poll_unpin(cx) + .is_ready() + { + // Old mobile notifications are only triggers to read current interfaces. + match netwatcher::list_interfaces() { + Ok(current) => self.snapshot = current, + Err(error) => tracing::warn!(%error, "interface enumeration failed"), + } + // Register for the next notification on the next poll. + cx.waker().wake_by_ref(); + } + if self.check.poll_tick(cx).is_ready() { + // Register the timer's next deadline after consuming this tick. + cx.waker().wake_by_ref(); + } + // Scan before polling: a ready old measurement cannot revive a dead binding. + self.scan(); + for binding in self.bindings.values_mut() { + if let Some(nat) = &mut binding.nat { + let _ = nat.poll_unpin(cx); + } + } + Poll::Pending + } + fn register(&self, socket: Arc) -> io::Result> { let bound = socket.local_addr()?; let Some(receiver) = self.dock.add(socket.clone())? else { @@ -88,36 +231,34 @@ impl InterfaceBindings { self.dock.remove(&socket); return Err(io::Error::other(error)); } - let nat_probe = if bound.ip().is_loopback() - || matches!(bound.ip(), IpAddr::V6(ip) if ip.is_unicast_link_local()) - { - std::future::pending().boxed() - } else { - probe_nat( + let nat = if supports_nat_probe(bound.ip()) { + Some(nat::Nat::new( self.dock.clone(), self.addresses.clone(), socket.clone(), + bound, qprotocol::StunProtocol::stun_servers(), - ) - .boxed() + )) + } else { + None }; Ok(Some(Binding { - nat_probe, + nat, bound, socket, receiver, })) } - fn withdraw(&self, binding: &Binding) { - // Probe futures are polled only by watch, so no publication can race withdrawal. + fn remove(&self, binding: &Binding) { + // The interface state lock serializes polling and withdrawal; publication cannot race. // Removing this Binding drops its future and outstanding STUN transactions. self.addresses.remove_bound(binding.bound); self.dock.remove(&binding.socket); } - fn scan(&mut self, snapshot: &HashMap) { - let current = interfaces(snapshot); + fn scan(&mut self) { + let current = interfaces(&self.snapshot); let obsolete = self .bindings .iter() @@ -132,8 +273,8 @@ impl InterfaceBindings { .collect::>(); for key in obsolete { let mut binding = self.bindings.remove(&key).unwrap(); - binding.nat_probe = std::future::pending().boxed(); - self.withdraw(&binding); + binding.nat = None; + self.remove(&binding); } for (key, device) in current { if self.bindings.contains_key(&key) { @@ -154,13 +295,13 @@ impl InterfaceBindings { } } -impl Drop for InterfaceBindings { +impl Drop for InterfacesCtx { fn drop(&mut self) { for binding in self.bindings.values_mut() { - binding.nat_probe = std::future::pending().boxed(); + binding.nat = None; } for binding in self.bindings.values() { - self.withdraw(binding); + self.remove(binding); } } } @@ -170,6 +311,11 @@ fn interfaces( ) -> HashMap { let mut current = HashMap::new(); for interface in snapshot.values() { + if excluded_interface(&interface.name, std::env::consts::OS) { + continue; + } + // Keep other candidates, including VPNs and empty Windows friendly names. + // Successful binding alone does not establish peer reachability. let device = match BoundDevice::new(interface.name.clone(), interface.index) { Ok(device) => device, Err(error) => { @@ -179,7 +325,10 @@ fn interfaces( }; for record in &interface.ips { let ip = record.ip; - if ip.is_unspecified() || ip.is_multicast() { + if ip.is_unspecified() + || ip.is_multicast() + || matches!(ip, IpAddr::V4(ip) if ip.is_broadcast()) + { continue; } let addr = match ip { @@ -209,33 +358,72 @@ fn interfaces( current } -async fn watch( - mut watcher: netwatcher::AsyncWatch, - mut snapshot: HashMap, - mut bindings: InterfaceBindings, -) { - let mut check = tokio::time::interval_at( - tokio::time::Instant::now() + BINDING_CHECK_INTERVAL, - BINDING_CHECK_INTERVAL, - ); - check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - bindings.scan(&snapshot); - tokio::select! { - biased; - update = watcher.changed() => snapshot = update.interfaces, - _ = check.tick() => {} - _ = std::future::poll_fn(|cx| { - // Scan before polling: a ready old measurement cannot revive a dead binding. - // No future owns a separate publishing task. - for binding in bindings.bindings.values_mut() { - if binding.nat_probe.poll_unpin(cx).is_ready() { - // Completed probes must not be polled again. - binding.nat_probe = std::future::pending().boxed(); - } - } - std::task::Poll::<()>::Pending - }) => {} +/// Exclude known auxiliary interfaces before binding or publishing addresses. +/// Names are a platform-specific policy, not a reachability check. Keep unknown +/// names, VPNs and generic Linux bridges, which may be the host's main network. +fn excluded_interface(name: &str, os: &str) -> bool { + match os { + "macos" | "ios" => { + ["bridge", "awdl", "llw", "vboxnet"] + .iter() + .any(|prefix| numbered_interface(name, prefix)) + || matches!(name, "vmnet1" | "vmnet8") + } + "linux" | "android" => { + [ + "docker", "podman", "cni", "flannel.", "virbr", "vboxnet", "dummy", "ifb", + ] + .iter() + .any(|prefix| numbered_interface(name, prefix)) + || matches!(name, "docker_gwbridge" | "kube-ipvs0" | "vmnet1" | "vmnet8") + || name + .strip_suffix("-nic") + .is_some_and(|base| numbered_interface(base, "virbr")) + || name.strip_prefix("br-").is_some_and(|suffix| { + suffix.len() == 12 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + || name.strip_prefix("veth").is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) } + "windows" => { + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "vethernet (default switch)" + | "vethernet (dockernat)" + | "vethernet (wsl)" + | "vethernet (wsl (hyper-v firewall))" + | "vmware network adapter vmnet1" + | "vmware network adapter vmnet8" + ) || [ + "virtualbox host-only ethernet adapter", + "virtualbox host-only network", + ] + .iter() + .any(|base| { + name == *base + || name + .strip_prefix(base) + .is_some_and(|suffix| numbered_interface(suffix, " #")) + }) + } + _ => false, } } + +fn numbered_interface(name: &str, prefix: &str) -> bool { + name.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +/// Local-only addresses remain bound for local communication, without STUN traffic. +/// Private and unique-local addresses may have an external path through NAT or a VPN. +fn supports_nat_probe(ip: IpAddr) -> bool { + !ip.is_loopback() + && !match ip { + IpAddr::V4(ip) => ip.is_link_local(), + IpAddr::V6(ip) => ip.is_unicast_link_local(), + } +} diff --git a/dhttp/src/network/interfaces/nat.rs b/dhttp/src/network/interfaces/nat.rs index 8325104..c84b718 100644 --- a/dhttp/src/network/interfaces/nat.rs +++ b/dhttp/src/network/interfaces/nat.rs @@ -1,9 +1,10 @@ //! One-shot NAT classification, STUN heartbeats and mapping publication. //! -//! A binding owns and polls its probe future. Classification finishes before any +//! A binding owns and polls its NAT state. Classification finishes before any //! heartbeat contacts the STUN servers; dropping the binding cancels both stages. //! Mapping updates register QUIC endpoints before publishing them and restore the //! previous endpoints if registration or publication fails. +use futures::{FutureExt, future::BoxFuture}; use qbase::net::{NatType, addr::EndpointAddr}; use qprotocol::{AddressBook, Dock, StunProtocol, UdpSocket}; use std::{ @@ -11,158 +12,268 @@ use std::{ future::Future, io, net::SocketAddr, + pin::Pin, sync::Arc, + task::{Context, Poll, ready}, time::Duration, }; const NAT_PROBE_TIMEOUT: Duration = Duration::from_secs(45); const STUN_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); +const STUN_DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(20); const MAPPING_TIMEOUT: Duration = Duration::from_secs(10); const NAT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20); -/// Each STUN server observes its own public mapping for the bound socket. -type NatMappings = HashMap; +/// STUN server address -> observed outer address for the bound socket. +type OuterAddrMappings = HashMap; -pub(super) async fn probe_nat( +/// NAT state owned and polled directly by the interface maintenance task. +pub(super) struct Nat { dock: Arc, addresses: Arc, socket: Arc, - servers: impl Future>> + Send + 'static, -) { - let bound = match socket.local_addr() { - Ok(bound) => bound, - Err(error) => { - tracing::warn!(%error, "NAT socket address unavailable"); - return; - } - }; - let Some(servers) = discover_stun_servers(bound, servers).await else { - return; - }; - let stun = dock.topology().stun(); - // Classify once before heartbeats create mappings at the other STUN nodes. - let nat = classify_nat(stun, bound, servers[0]).await; - if ensure_registered_socket(&dock, bound, &socket).is_err() { - return; - } - if let Some(nat) = nat { - addresses.set_nat(bound, nat); - tracing::info!(%bound, ?nat, "NAT classification complete"); - } + probe: Probe, +} - let mut mappings = NatMappings::new(); - let mut heartbeat = tokio::time::interval(NAT_HEARTBEAT_INTERVAL); - heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - // The first tick is immediate; subsequent ticks maintain the mappings. - heartbeat.tick().await; - let refreshed = refresh_nat_mappings(stun, bound, &servers).await; - if let Err(error) = apply_nat_mappings(&dock, &addresses, &socket, &mappings, &refreshed) { - tracing::warn!(%bound, %error, "NAT mapping synchronization failed"); - continue; - } - mappings = refreshed; - if let Some(nat) = nat { - for (&server, &outer) in &mappings { - tracing::debug!(%bound, ?nat, %server, %outer, "NAT mapping heartbeat"); +enum Probe { + Initial(BoxFuture<'static, Vec>), + Classifying { + servers: Vec, + nat_type: BoxFuture<'static, Option>, + }, + Heartbeat { + servers: Vec, + nat_type: Option, + // Preserve applied mappings when resume cancels an in-flight measurement. + mappings: OuterAddrMappings, + interval: tokio::time::Interval, + measurement: Option>, + }, +} + +impl Probe { + /// Resolve servers of the socket's address family, retrying after failure or timeout. + fn initial( + bound: SocketAddr, + servers: impl Future>> + Send + 'static, + mut retry: impl FnMut() -> F + Send + 'static, + ) -> Self + where + F: Future>> + Send, + { + Self::Initial( + async move { + let mut initial = Some(servers); + loop { + let result = match initial.take() { + Some(servers) => { + tokio::time::timeout(STUN_DISCOVERY_TIMEOUT, servers).await + } + None => tokio::time::timeout(STUN_DISCOVERY_TIMEOUT, retry()).await, + }; + match result { + Ok(Ok(servers)) => { + let servers: Vec<_> = servers + .iter() + .copied() + .filter(|server| server.is_ipv4() == bound.is_ipv4()) + .collect(); + if !servers.is_empty() { + return servers; + } + tracing::warn!(%bound, "no STUN server for socket address family"); + } + Ok(Err(error)) => { + tracing::warn!(%bound, %error, "STUN server discovery failed") + } + Err(error) => { + tracing::warn!(%bound, %error, "STUN server discovery timed out") + } + } + tokio::time::sleep(STUN_DISCOVERY_RETRY_INTERVAL).await; + } } - } else if !mappings.is_empty() { - // Maintain mappings without inventing a classification for punch advertisements. - tracing::warn!(%bound, "STUN mappings maintained without a NAT classification"); - } + .boxed(), + ) } -} -/// Resolve servers for this socket's address family, preserving discovery order. -async fn discover_stun_servers( - bound: SocketAddr, - servers: impl Future>>, -) -> Option> { - let servers = match tokio::time::timeout(STUN_DISCOVERY_TIMEOUT, servers).await { - Ok(Ok(servers)) => servers - .iter() - .copied() - .filter(|server| server.is_ipv4() == bound.is_ipv4()) - .collect::>(), - Ok(Err(error)) => { - tracing::warn!(%bound, %error, "STUN server discovery failed"); - return None; - } - Err(error) => { - tracing::warn!(%bound, %error, "STUN server discovery timed out"); - return None; + /// Classification failure still allows the binding to maintain outer addresses. + fn nat_probe(stun: Arc, bound: SocketAddr, servers: Vec) -> Self { + let server = servers[0]; + let nat_type = async move { + match tokio::time::timeout(NAT_PROBE_TIMEOUT, stun.detect_nat(bound, server)).await { + Ok(Ok(nat)) => Some(nat), + Ok(Err(error)) => { + tracing::warn!(%bound, %error, "NAT classification failed"); + None + } + Err(error) => { + tracing::warn!(%bound, %error, "NAT classification timed out"); + None + } + } } - }; - if servers.is_empty() { - tracing::warn!(%bound, "no STUN server for socket address family"); - return None; + .boxed(); + Self::Classifying { servers, nat_type } } - Some(servers) } -/// A classification failure still allows the binding to maintain STUN mappings. -async fn classify_nat( - stun: &Arc, - bound: SocketAddr, - server: SocketAddr, -) -> Option { - match tokio::time::timeout(NAT_PROBE_TIMEOUT, stun.detect_nat(bound, server)).await { - Ok(Ok(nat)) => Some(nat), - Ok(Err(error)) => { - tracing::warn!(%bound, %error, "NAT classification failed"); - None - } - Err(error) => { - tracing::warn!(%bound, %error, "NAT classification timed out"); - None +impl Nat { + pub(super) fn new( + dock: Arc, + addresses: Arc, + socket: Arc, + bound: SocketAddr, + servers: impl Future>> + Send + 'static, + ) -> Self { + Self { + dock, + addresses, + socket, + probe: Probe::initial(bound, servers, StunProtocol::stun_servers), } } -} -/// Probe concurrently; failed servers are omitted so their old mappings are withdrawn. -async fn refresh_nat_mappings( - stun: &Arc, - bound: SocketAddr, - servers: &[SocketAddr], -) -> NatMappings { - let responses = futures::future::join_all( - servers - .iter() - .map(|&server| async move { (server, probe_mapping(stun, bound, server).await) }), - ) - .await; - let mut mappings = NatMappings::new(); - for (server, result) in responses { - match result { - Ok(outer) => { - mappings.insert(server, outer); + pub(super) fn resume( + &mut self, + servers: impl Future>> + Send + 'static, + ) { + let Ok(bound) = self.socket.local_addr() else { + return; + }; + match &mut self.probe { + Probe::Initial(_) => { + self.probe = Probe::initial(bound, servers, StunProtocol::stun_servers); + } + Probe::Classifying { servers, .. } => { + self.probe = Probe::nat_probe( + self.dock.topology().stun().clone(), + bound, + std::mem::take(servers), + ); + } + Probe::Heartbeat { + interval, + measurement, + .. + } => { + interval.reset_immediately(); + *measurement = None; } - Err(error) => tracing::warn!(%bound, %server, %error, "STUN heartbeat failed"), } } - mappings } -async fn probe_mapping( - stun: &Arc, - bound: SocketAddr, - server: SocketAddr, -) -> io::Result { - let outer = tokio::time::timeout(MAPPING_TIMEOUT, stun.detect_outer(bound, server)) - .await - .map_err(io::Error::other)? - .map_err(io::Error::other)? - .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "STUN heartbeat unanswered"))?; - if outer.is_ipv4() != bound.is_ipv4() - || outer.port() == 0 - || !EndpointAddr::direct(outer).is_globally_routable() - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "STUN returned an unusable public mapping", - )); +impl Future for Nat { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let this = self.get_mut(); + let Ok(bound) = this.socket.local_addr() else { + return Poll::Pending; + }; + if ensure_registered_socket(&this.dock, bound, &this.socket).is_err() { + // The interface watcher withdraws this binding and drops its NAT future. + return Poll::Pending; + } + loop { + match &mut this.probe { + Probe::Initial(discovery) => { + let servers = ready!(discovery.poll_unpin(cx)); + this.probe = + Probe::nat_probe(this.dock.topology().stun().clone(), bound, servers); + } + Probe::Classifying { + servers, + nat_type: classification, + } => { + let nat_type = ready!(classification.poll_unpin(cx)); + if let Some(nat) = nat_type { + this.addresses.set_nat(bound, nat); + tracing::info!(%bound, ?nat, "NAT classification complete"); + } + let mut interval = tokio::time::interval(NAT_HEARTBEAT_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + this.probe = Probe::Heartbeat { + servers: std::mem::take(servers), + nat_type, + mappings: OuterAddrMappings::new(), + interval, + measurement: None, + }; + } + Probe::Heartbeat { + servers, + nat_type, + mappings, + interval, + measurement, + } => { + if measurement.is_none() { + ready!(interval.poll_tick(cx)); + let stun = this.dock.topology().stun().clone(); + let servers = servers.clone(); + *measurement = Some( + async move { + // Failed servers are omitted so their old mappings are withdrawn. + futures::future::join_all(servers.into_iter().map(|server| { + let stun = &stun; + async move { + let response = tokio::time::timeout( + MAPPING_TIMEOUT, + stun.detect_outer(bound, server), + ).await; + match response { + Ok(Ok(Some(outer))) + if outer.is_ipv4() == bound.is_ipv4() + && outer.port() != 0 + && EndpointAddr::direct(outer).is_globally_routable() => + { + Some((server, outer)) + } + response => { + tracing::warn!( + %bound, %server, ?response, + "STUN heartbeat failed or returned an unusable public mapping" + ); + None + } + } + } + })) + .await + .into_iter() + .flatten() + .collect() + } + .boxed(), + ); + } + let refreshed = ready!(measurement.as_mut().unwrap().poll_unpin(cx)); + *measurement = None; + if let Err(error) = apply_outer_mappings( + &this.dock, + &this.addresses, + &this.socket, + *nat_type, + mappings, + &refreshed, + ) { + tracing::warn!(%bound, %error, "NAT mapping synchronization failed"); + continue; + } + *mappings = refreshed; + if let Some(nat) = *nat_type { + for (&server, &outer) in mappings.iter() { + tracing::debug!(%bound, ?nat, %server, %outer, "NAT mapping heartbeat"); + } + } else if !mappings.is_empty() { + tracing::warn!(%bound, "STUN mappings maintained without a NAT classification"); + } + } + } + } } - Ok(outer) } /// Compare socket identity because the OS may reuse a withdrawn binding's address. @@ -187,12 +298,13 @@ fn ensure_registered_socket( /// Rebuild QUIC registrations, publish additions, then withdraw obsolete records. /// QuicProtocol revokes all endpoints for a bound socket together, so rollback /// must restore the direct binding as well as every previous mapped endpoint. -fn apply_nat_mappings( +fn apply_outer_mappings( dock: &Dock, addresses: &AddressBook, socket: &Arc, - previous: &NatMappings, - refreshed: &NatMappings, + nat_type: Option, + previous: &OuterAddrMappings, + refreshed: &OuterAddrMappings, ) -> io::Result<()> { let bound = socket.local_addr()?; ensure_registered_socket(dock, bound, socket)?; @@ -201,7 +313,31 @@ fn apply_nat_mappings( } let quic = dock.topology().quic(); - let desired = registered_endpoints(bound, refreshed); + // QUIC accepts the direct binding, direct public mappings and agent-mediated paths. + let registered_endpoints = |mappings: &OuterAddrMappings| { + let mut endpoints = vec![EndpointAddr::direct(bound)]; + for (&server, &outer) in mappings { + endpoints.push(EndpointAddr::direct(outer)); + endpoints.push(EndpointAddr::mediate(server, outer)); + } + endpoints.sort_unstable(); + endpoints.dedup(); + endpoints + }; + // Every mapping retains its STUN agent; FullCone also advertises a direct path. + let published_endpoints = |mappings: &OuterAddrMappings| -> HashSet { + mappings + .iter() + .flat_map(|(&agent, &outer)| { + // Preserve Mediate so the E record encodes outer-agent, even for FullCone. + std::iter::once(EndpointAddr::mediate(agent, outer)).chain( + (nat_type == Some(NatType::FullCone)).then_some(EndpointAddr::direct(outer)), + ) + }) + .filter(|endpoint| *endpoint != EndpointAddr::direct(bound)) + .collect() + }; + let desired = registered_endpoints(refreshed); // Reject known conflicts before removing any of our current registrations. for &endpoint in &desired { if quic @@ -215,9 +351,8 @@ fn apply_nat_mappings( } } - let nat = addresses.nat(bound); - let old = published_endpoints(bound, nat, previous); - let new = published_endpoints(bound, nat, refreshed); + let old = published_endpoints(previous); + let new = published_endpoints(refreshed); let mut added = Vec::new(); quic.unregister(bound); let update = (|| -> io::Result<()> { @@ -234,7 +369,7 @@ fn apply_nat_mappings( })(); if let Err(error) = update { quic.unregister(bound); - for endpoint in registered_endpoints(bound, previous) { + for endpoint in registered_endpoints(previous) { let _ = quic.register(endpoint, socket); } for endpoint in added { @@ -248,38 +383,6 @@ fn apply_nat_mappings( Ok(()) } -/// QUIC accepts the direct binding, direct public mappings and agent-mediated paths. -fn registered_endpoints(bound: SocketAddr, mappings: &NatMappings) -> Vec { - let mut endpoints = vec![EndpointAddr::direct(bound)]; - for (&server, &outer) in mappings { - endpoints.push(EndpointAddr::direct(outer)); - endpoints.push(EndpointAddr::mediate(server, outer)); - } - endpoints.sort_unstable(); - endpoints.dedup(); - endpoints -} - -/// Only FullCone mappings are advertised directly; others retain their STUN agent. -fn published_endpoints( - bound: SocketAddr, - nat: Option, - mappings: &NatMappings, -) -> HashSet { - mappings - .iter() - .map(|(&agent, &outer)| { - if nat == Some(NatType::FullCone) { - EndpointAddr::direct(outer) - } else { - // Preserve Mediate so the E record encodes outer-agent. - EndpointAddr::mediate(agent, outer) - } - }) - .filter(|endpoint| *endpoint != EndpointAddr::direct(bound)) - .collect() -} - #[cfg(test)] #[path = "../../../tests/unit/network/nat.rs"] mod tests; diff --git a/dhttp/src/transport.rs b/dhttp/src/transport.rs index 5087cbc..d02bc62 100644 --- a/dhttp/src/transport.rs +++ b/dhttp/src/transport.rs @@ -165,13 +165,10 @@ impl CancelStream for SendStream { mod tests { use super::*; use qbase::{ - ArcReceiving, - param::{ - ArcParameters, - handy::{client_parameters, server_parameters}, - }, + param::{ArcParameters, ClientParameters, ServerParameters}, sid::handy::DemandConcurrency, }; + use qtransport::terminate::ArcTerminator; #[test] fn quic_stream_resets_preserve_the_http3_error_code() { @@ -205,11 +202,11 @@ mod tests { ); } - fn connection(alpn: &'static [u8]) -> (qconn::ArcConnection, ArcReceiving) { + fn connection(alpn: &'static [u8]) -> (qconn::ArcConnection, ArcTerminator) { let parameters = ArcParameters::new( qbase::role::Role::Client, - Arc::new(client_parameters()), - Arc::new(server_parameters()), + Arc::new(ClientParameters::default()), + Arc::new(ServerParameters::default()), ); let streams = qconn::DataStreams::new( parameters, @@ -217,14 +214,15 @@ mod tests { qconn::ArcReliableFrames::with_capacity(0), None, ); - let close = ArcReceiving::default(); + let close = ArcTerminator::no_error(); + close.register(Arc::new(streams.clone())); ( qconn::ArcConnection::new(Bytes::from_static(alpn), streams, close.clone()), close, ) } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn invalid_alpn_releases_the_connection_and_notifies_its_driver() { for alpn in [b"".as_slice(), b"h2", b"h3-29"] { let (connection, close) = connection(alpn); @@ -232,16 +230,14 @@ mod tests { assert!( matches!(result, Err(crate::Error::Io { source }) if source.kind() == io::ErrorKind::InvalidData) ); - let reason = tokio::time::timeout(std::time::Duration::from_secs(1), close) + let reason = tokio::time::timeout(std::time::Duration::from_secs(4), close) .await - .unwrap() - .unwrap() .unwrap(); - assert!(matches!(reason, qconn::CloseReason::App(_))); + assert!(matches!(reason, qconn::Error::App(_))); } } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn h3_transport_keeps_the_negotiated_protocol_and_role() { for role in [Role::Client, Role::Server] { let (connection, mut close) = connection(b"h3"); @@ -250,10 +246,7 @@ mod tests { assert_eq!(transport.handshake.alpn.as_deref(), Some(b"h3".as_slice())); assert!(futures::poll!(&mut close).is_pending()); drop(transport); - assert!(matches!( - close.await.unwrap().unwrap(), - qconn::CloseReason::App(_) - )); + assert!(matches!(close.await, qconn::Error::App(_))); } } } diff --git a/dhttp/src/trust.rs b/dhttp/src/trust.rs index 8c52b26..b33ddde 100644 --- a/dhttp/src/trust.rs +++ b/dhttp/src/trust.rs @@ -1,12 +1,68 @@ -//! Private process-wide trust initialization using the qtls-owned TLS types. +use std::sync::{Arc, LazyLock}; -use std::sync::Arc; +use rustls::{ + RootCertStore, + client::WebPkiServerVerifier, + pki_types::CertificateDer, + server::{WebPkiClientVerifier, danger::ClientCertVerifier}, +}; -use crate::{Error, Result, bootstrap::DHTTP_ROOT_CA_DER}; +/// DER-encoded DHTTP ecosystem root CA certificate embedded at build time. +/// +/// The build script decodes and validates `DHTTP_ROOT_CA_PEM` when that environment +/// variable is present. Otherwise, it embeds the production default. +pub use crate::bootstrap::DHTTP_ROOT_CA_DER; -/// Called during Network initialization, before any qconn TLS configuration. -/// Install the build-time DHTTP root CA into qtls::RootCerts; compilation-time -/// DHTTP_ROOT_CA_PEM remains the override. Never disable peer verification. +/// Client certificate policy for DHTTP peer authentication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientIdentityPolicy { + /// Verify client certificates when peers provide them, but allow anonymous peers. + Optional, + /// Require peers to provide a certificate trusted by the DHTTP root store. + Required, +} + +/// Return the DHTTP ecosystem root certificate store. +/// +/// The current embedded certificate is the transitional genmeta root CA. It is +/// expected to be replaced by the dhttp.net root CA when the ecosystem switches +/// domains. +pub fn dhttp_root_cert_store() -> &'static Arc { + static STORE: LazyLock> = LazyLock::new(|| { + let mut store = RootCertStore::empty(); + store + .add(CertificateDer::from(DHTTP_ROOT_CA_DER)) + .expect("BUG: build script embedded an invalid DHTTP trust anchor"); + Arc::new(store) + }); + &STORE +} + +/// Build the default verifier for DHTTP clients validating server certificates. +pub fn dhttp_server_cert_verifier() -> Arc { + static VERIFIER: LazyLock> = LazyLock::new(|| { + WebPkiServerVerifier::builder(dhttp_root_cert_store().clone()) + .build() + .expect("BUG: webpki server verifier built from fixed DHTTP roots") + }); + VERIFIER.clone() +} + +/// Build the default verifier for DHTTP servers validating client certificates. +pub fn dhttp_client_cert_verifier(policy: ClientIdentityPolicy) -> Arc { + let builder = WebPkiClientVerifier::builder(dhttp_root_cert_store().clone()); + match policy { + ClientIdentityPolicy::Optional => builder + .allow_unauthenticated() + .build() + .expect("BUG: webpki client verifier built from fixed DHTTP roots"), + ClientIdentityPolicy::Required => builder + .build() + .expect("BUG: webpki client verifier built from fixed DHTTP roots"), + } +} + +use crate::{Error, Result}; pub(crate) fn initialize() -> Result<()> { qtls::RootCerts::set([DHTTP_ROOT_CA_DER.to_vec().into()]).map_err(|source| Error::TlsConfig { source: Arc::new(source), diff --git a/dhttp/src/uri.rs b/dhttp/src/uri.rs index 9727614..a71754c 100644 --- a/dhttp/src/uri.rs +++ b/dhttp/src/uri.rs @@ -1,7 +1,7 @@ use crate::{Error, Result}; /// Expand DHTTP authority shorthand before routing or delivering a request. -pub(crate) fn expand_uri_with_base(base: Option<&str>, uri: http::Uri) -> Result { +pub fn expand_uri_with_base(base: Option<&str>, uri: http::Uri) -> Result { let mut parts = uri.into_parts(); if let Some(authority) = parts.authority { parts.authority = Some(expand_authority_with_base(base, authority)?); diff --git a/dhttp/tests/dns_bootstrap.rs b/dhttp/tests/dns_bootstrap.rs index 4bdde2c..1e4ddc0 100644 --- a/dhttp/tests/dns_bootstrap.rs +++ b/dhttp/tests/dns_bootstrap.rs @@ -99,7 +99,6 @@ fn endpoint(root: &Path, directory: &str, name: &str) -> Endpoint { let certs = fs::read(ssl.join("fullchain.crt")).unwrap(); let key = fs::read(ssl.join("privkey.pem")).unwrap(); let identity = qbase::endpoint::Endpoint::new( - &qtls::default_provider(), name, rustls_pemfile::certs(&mut certs.as_slice()) .collect::, _>>() diff --git a/dhttp/tests/listener_lifecycle.rs b/dhttp/tests/listener_lifecycle.rs index d3477a5..c580aae 100644 --- a/dhttp/tests/listener_lifecycle.rs +++ b/dhttp/tests/listener_lifecycle.rs @@ -151,15 +151,30 @@ async fn listener_registers_service_and_releases_it_on_exit() { .get(endpoint.name()) .unwrap(); let second = qconn::ServerRegistry::global().get(other.name()).unwrap(); - (first.accept_cb)(Err(qbase::error::QuicError::with_default_fty( - qbase::error::ErrorKind::Internal, - "test rejected handshake", - ) - .into())); + // The new QUIC callback only delivers authenticated connections. Rejecting + // an unsupported negotiated ALPN at the H3 boundary must still be local to + // that connection and preserve the listener registration. + let parameters = qbase::param::ArcParameters::new( + qbase::role::Role::Server, + std::sync::Arc::new(qbase::param::ClientParameters::default()), + std::sync::Arc::new(qbase::param::ServerParameters::default()), + ); + let streams = qconn::DataStreams::new( + parameters, + Box::new(qbase::sid::handy::DemandConcurrency), + qconn::ArcReliableFrames::with_capacity(0), + None, + ); + let connection = qconn::ArcConnection::new( + Bytes::from_static(b"h2"), + streams, + qtransport::terminate::ArcTerminator::no_error(), + ); + (first.accept_cb)((None, endpoint.local_authority().unwrap(), connection)); tokio::task::yield_now().await; assert!( !listening.is_finished(), - "a bad handshake must not stop the listener" + "a rejected H3 setup must not stop the listener" ); assert!(std::sync::Arc::ptr_eq( &first, diff --git a/dhttp/tests/network_lifecycle.rs b/dhttp/tests/network_lifecycle.rs index 1755a04..42e6845 100644 --- a/dhttp/tests/network_lifecycle.rs +++ b/dhttp/tests/network_lifecycle.rs @@ -139,6 +139,35 @@ async fn network_initializes_once() { for retained in before.iter().filter(|endpoint| *endpoint != old) { assert!(after.contains(retained), "live bindings keep their ports"); } + + // Explicit wake recovery repairs a dead receiver before acknowledging callers, + // preserving unaffected ports and the singleton across repeated/concurrent calls. + let old = after + .iter() + .find(|endpoint| endpoint.addr().is_ipv4()) + .unwrap(); + let old_bound = old.addr(); + let socket = qprotocol::Dock::global().find_socket(old_bound).unwrap(); + assert!(qprotocol::Dock::global().remove(&socket)); + let (first_resume, second_resume) = tokio::join!(network.resume(), network.resume()); + first_resume.unwrap(); + second_resume.unwrap(); + assert!(AddressBook::global().mdns_endpoints(old_bound).is_empty()); + let resumed = network::loopback_endpoints(); + assert!( + resumed + .iter() + .any(|endpoint| endpoint.addr().ip() == old_bound.ip()) + ); + for retained in after.iter().filter(|endpoint| *endpoint != old) { + assert!( + resumed.contains(retained), + "resume must retain live sockets and ports" + ); + } + network.resume().await.unwrap(); + assert_eq!(network::loopback_endpoints(), resumed); + assert!(std::ptr::eq(network, DhttpNetwork::global().unwrap())); assert!( qprotocol::Dock::global() .find_socket(temporary_bound) diff --git a/dhttp/tests/quic_roundtrip.rs b/dhttp/tests/quic_roundtrip.rs index 22c2957..91bd6f5 100644 --- a/dhttp/tests/quic_roundtrip.rs +++ b/dhttp/tests/quic_roundtrip.rs @@ -153,7 +153,11 @@ async fn injected_resolver_drives_authenticated_http3_over_udp() { .post("https://server~/echo".parse().unwrap()) .body(dhttp::WndBuf::new(64 * 1024)) .await?; - request.write_all(&payload).await?; + let middle = payload.len() / 2; + request.write_all(&payload[..middle]).await?; + // Wake recovery must retain both the live upload and its pooled connection. + DhttpNetwork::global()?.resume().await?; + request.write_all(&payload[middle..]).await?; request.shutdown().await?; response.await }) diff --git a/dhttp/tests/support/credentials.rs b/dhttp/tests/support/credentials.rs index 4c8d371..18610ae 100644 --- a/dhttp/tests/support/credentials.rs +++ b/dhttp/tests/support/credentials.rs @@ -73,7 +73,8 @@ pub fn generate(root: &Path, names: &[(&str, &str)]) { ], ); fs::write(root.join("leaf.ext"), format!( - "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth,clientAuth\nsubjectAltName=DNS:{hostname}\n" + "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth,clientAuth\nsubjectAltName=DNS:{hostname}\nsubjectKeyIdentifier={}\n", + format!("0:0:{:064x}", index + 1).bytes().map(|byte| format!("{byte:02x}")).collect::>().join(":") )).unwrap(); openssl( root, diff --git a/dhttp/tests/support/endpoint.rs b/dhttp/tests/support/endpoint.rs index caf0725..dd07c0f 100644 --- a/dhttp/tests/support/endpoint.rs +++ b/dhttp/tests/support/endpoint.rs @@ -3,7 +3,6 @@ pub fn named(name: &str) -> crate::Endpoint { let name = dhttp_home::normalize_name(name).unwrap(); let generated = rcgen::generate_simple_self_signed(vec![name.clone()]).unwrap(); let identity = qbase::endpoint::Endpoint::new( - &qtls::default_provider(), &name, vec![generated.cert.der().clone()], qtls::PrivateKeyDer::Pkcs8(generated.signing_key.serialize_der().into()), diff --git a/dhttp/tests/support/interface_bindings.rs b/dhttp/tests/support/interface_bindings.rs index c2ca9c8..e5eafe3 100644 --- a/dhttp/tests/support/interface_bindings.rs +++ b/dhttp/tests/support/interface_bindings.rs @@ -1,7 +1,12 @@ use super::*; -pub(super) fn isolated_bindings() -> InterfaceBindings { - InterfaceBindings::new( +pub(super) async fn isolated_interfaces() -> InterfacesCtx { + let mut watcher = + netwatcher::watch_interfaces_async::().unwrap(); + let _ = watcher.changed().await; + InterfacesCtx::new( + watcher, + HashMap::new(), Dock::new(Arc::new(qprotocol::topology::Topology::new( Arc::new(qprotocol::StunProtocol::new()), Arc::new(qprotocol::ForwardProtocol::new()), diff --git a/dhttp/tests/support/transport.rs b/dhttp/tests/support/transport.rs index 2c6decd..b7de67a 100644 --- a/dhttp/tests/support/transport.rs +++ b/dhttp/tests/support/transport.rs @@ -215,8 +215,8 @@ pub fn connection_pair() -> (H3Connection, H3Connection) }; ( - H3Connection::new(client, Settings::default()).unwrap(), - H3Connection::new(server, Settings::default()).unwrap(), + H3Connection::new(client, Settings::default(), |_| {}).unwrap(), + H3Connection::new(server, Settings::default(), |_| {}).unwrap(), ) } diff --git a/dhttp/tests/unit/endpoint.rs b/dhttp/tests/unit/endpoint.rs index e4fbff7..f2583fa 100644 --- a/dhttp/tests/unit/endpoint.rs +++ b/dhttp/tests/unit/endpoint.rs @@ -10,6 +10,107 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[path = "../support/transport.rs"] mod support; +#[test] +fn endpoint_clones_share_loaded_identity_and_authority_material() { + let endpoint = super::test_support::named("alice"); + let cloned = endpoint.clone(); + assert!(Arc::ptr_eq(&endpoint.identity, &cloned.identity)); + assert_eq!(endpoint.name(), endpoint.identity.name()); + let authority = endpoint.local_authority().unwrap(); + assert_eq!(authority.name(), endpoint.name()); + assert_eq!(authority.certificates(), endpoint.identity.cert_chain()); +} + +#[tokio::test] +async fn extended_connect_preserves_protocol_and_small_bidirectional_data() { + bounded(async { + let (client, server) = support::connection_pair(); + let (writer, reader) = client.open_bi().await.unwrap(); + let (response_writer, request_reader) = server.accept_bi().await.unwrap(); + let app = tower::service_fn(|request: http::Request| async move { + assert_eq!(request.method(), http::Method::CONNECT); + assert_eq!(request.uri(), "https://bob.dhttp.net/api/websocket"); + assert_eq!( + request.extensions().get::>().unwrap().as_ref(), + "websocket" + ); + assert!( + request + .extensions() + .get::() + .is_some() + ); + let mut incoming = request.into_body(); + let stream = async_stream::stream! { + // HA speaks first, before the client ends or even starts its upload. + yield Ok::<_, BoxError>(Frame::data(Bytes::from_static(b"auth_required"))); + while let Some(frame) = incoming.frame().await { yield frame; } + yield Ok(Frame::data(Bytes::from_static(b"after-upload-eof"))); + }; + Ok::<_, BoxError>(http::Response::new(StreamBody::new(stream).boxed_unsync())) + }) + .boxed_clone(); + let serving = tokio::spawn(handle_request( + app, + response_writer, + request_reader, + server.qpack().clone(), + handshake("bob.dhttp.net"), + )); + let request = http::Request::builder() + .method(http::Method::CONNECT) + .uri("https://bob~/api/websocket") + .extension(Arc::::from("websocket")) + .header("sec-websocket-version", "13") + .body(WndBuf::new(64 * 1024)) + .unwrap(); + let (mut upload, response) = send_upload(request, writer, reader, client.qpack().clone()); + let mut response = response.await.unwrap(); + assert_eq!(response.status(), 200); + assert_eq!( + response + .body_mut() + .frame() + .await + .unwrap() + .unwrap() + .into_data() + .unwrap(), + "auth_required" + ); + for bytes in [b"auth".as_slice(), b"ping", b"pong"] { + upload.write_all(bytes).await.unwrap(); + upload.flush().await.unwrap(); + assert_eq!( + response + .body_mut() + .frame() + .await + .unwrap() + .unwrap() + .into_data() + .unwrap(), + bytes + ); + } + upload.shutdown().await.unwrap(); + assert_eq!( + response + .body_mut() + .frame() + .await + .unwrap() + .unwrap() + .into_data() + .unwrap(), + "after-upload-eof" + ); + assert!(response.body_mut().frame().await.is_none()); + serving.await.unwrap().unwrap(); + }) + .await; +} + async fn bounded(future: impl std::future::Future) -> T { tokio::time::timeout(Duration::from_secs(3), future) .await diff --git a/dhttp/tests/unit/error.rs b/dhttp/tests/unit/error.rs new file mode 100644 index 0000000..491f62d --- /dev/null +++ b/dhttp/tests/unit/error.rs @@ -0,0 +1,55 @@ +use super::*; +use std::error::Error as _; + +#[test] +fn protocol_category_and_source_survive_erased_and_nested_io_boundaries() { + let h3 = h3x::ErrorCode::MessageError.stream("invalid DATA"); + let boxed: crate::BoxError = Box::new(h3.clone()); + let boxed_io: crate::BoxError = Box::new(std::io::Error::other(h3.clone())); + let nested = std::io::Error::other(std::io::Error::other(h3.clone())); + for error in [ + Error::from(h3.clone()), + Error::from(boxed), + Error::from(boxed_io), + Error::from(nested), + ] { + assert!(matches!(&error, Error::Http3 { source } if source.as_ref() == &h3)); + assert_eq!( + error.source().unwrap().downcast_ref::(), + Some(&h3) + ); + } +} + +#[test] +fn core_categories_survive_box_and_io_boundaries() { + let boxed: crate::BoxError = Box::new(Error::RemoteIdentityChanged); + assert!(matches!(Error::from(boxed), Error::RemoteIdentityChanged)); + assert!(matches!( + Error::from(std::io::Error::other(Error::AlreadyListening)), + Error::AlreadyListening + )); +} + +#[test] +fn ordinary_io_and_unknown_body_errors_keep_their_original_causes() { + let error = Error::from(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "access denied", + )); + assert!( + matches!(&error, Error::Io { source } if source.kind() == std::io::ErrorKind::PermissionDenied) + ); + assert_eq!(error.source().unwrap().to_string(), "access denied"); + let boxed: crate::BoxError = Box::new(std::fmt::Error); + let Error::Io { source } = Error::from(boxed) else { + panic!("unknown body errors remain I/O errors"); + }; + assert!( + source + .get_ref() + .unwrap() + .downcast_ref::() + .is_some() + ); +} diff --git a/dhttp/tests/unit/network/interfaces.rs b/dhttp/tests/unit/network/interfaces.rs index f3d8e89..237663c 100644 --- a/dhttp/tests/unit/network/interfaces.rs +++ b/dhttp/tests/unit/network/interfaces.rs @@ -1,4 +1,4 @@ -use super::test_support::isolated_bindings; +use super::test_support::isolated_interfaces; use super::*; use qconn::Scopes; @@ -15,6 +15,7 @@ fn interface_snapshot_filters_unusable_ips_and_preserves_ipv6_scope() { "0.0.0.0", "::", "224.0.0.1", + "255.255.255.255", "ff02::1", "127.0.0.1", "192.168.1.2", @@ -65,6 +66,255 @@ fn interface_snapshot_filters_unusable_ips_and_preserves_ipv6_scope() { ); } +#[test] +fn interface_snapshot_preserves_platform_names_and_virtual_networks() { + let names = [ + "p2p0", + "", + "lo", + "lo0", + "en0", + // Raspberry Pi onboard, predictable, USB Ethernet/Wi-Fi and gadget names. + "eth0", + "eth1", + "end0", + "end1", + "eno1", + "enx001122aabbcc", + "enp1s0u2", + "usb0", + "usb1", + "wlx001122aabbcc", + "wlan1", + "bnep0", + "lan0", + "ens192", + "enp3s0", + "wlan0", + "wlp2s0", + "rmnet_data0", + "pdp_ip0", + "wwan0", + "Ethernet", + "Wi-Fi", + "tun0", + "tap0", + "utun4", + "wg0", + "tailscale0", + "ztabcdef", + "br0", + "vmnet0", + "vEthernet (External Switch)", + "br-office", + "docker-uplink", + "veth-uplink", + "veth", + "cni", + "以太网", + "无线网络连接", + "Ethernet 2", + "Docker0", + "bridge", + "bridge-office", + "bridge100-uplink", + "awdl", + "awdl-uplink", + "llw", + "llw0-uplink", + "Bridge100", + ]; + for name in names { + for os in ["macos", "ios", "linux", "android", "windows", "freebsd"] { + assert!(!excluded_interface(name, os), "interface {name:?} on {os}"); + } + // Preserve other networks and prefix lookalikes, regardless of MAC visibility. + let interface = netwatcher::Interface { + index: 7, + name: name.into(), + hw_addr: String::new(), + ips: ["192.168.1.2", "192.168.215.0", "fd00::1", "fe80::1"] + .map(|ip| netwatcher::IpRecord { + ip: ip.parse().unwrap(), + prefix_len: 64, + }) + .into(), + }; + let current = interfaces(&HashMap::from([(7, interface)])); + assert_eq!(current.len(), 4, "interface {name:?}"); + assert!(current.iter().all(|(key, device)| { + key.name == name && device.name() == name && device.index().get() == 7 + })); + assert!( + current + .keys() + .any(|key| key.addr == "[fe80::1%7]:0".parse().unwrap()) + ); + } +} + +#[test] +fn interface_snapshot_excludes_auxiliary_names_only_on_matching_platforms() { + let cases: &[(&[&str], &[&str])] = &[ + ( + &["macos", "ios"], + &[ + "bridge0", + "bridge100", + "bridge101", + "awdl0", + "awdl12", + "llw0", + "llw12", + ], + ), + ( + &["macos", "ios", "linux", "android"], + &["vboxnet0", "vboxnet12", "vmnet1", "vmnet8"], + ), + ( + &["linux", "android"], + &[ + "docker0", + "docker1", + "docker_gwbridge", + "podman0", + "br-012345abcdef", + "veth012abc", + "veth0", + "cni0", + "flannel.1", + "kube-ipvs0", + "virbr0", + "virbr0-nic", + "dummy0", + "ifb0", + ], + ), + ( + &["windows"], + &[ + "vEthernet (Default Switch)", + "vEthernet (DockerNAT)", + "vEthernet (WSL)", + "vEthernet (WSL (Hyper-V firewall))", + "VMware Network Adapter VMnet1", + "VMware Network Adapter VMnet8", + "VirtualBox Host-Only Ethernet Adapter", + "VirtualBox Host-Only Ethernet Adapter #2", + "VirtualBox Host-Only Network", + "VirtualBox Host-Only Network #3", + "VETHERNET (WSL)", + ], + ), + ]; + for &(excluded_on, names) in cases { + for &name in names { + for os in ["macos", "ios", "linux", "android", "windows", "freebsd"] { + assert_eq!( + excluded_interface(name, os), + excluded_on.contains(&os), + "{name:?} on {os}" + ); + } + let interface = netwatcher::Interface { + index: 7, + name: name.into(), + hw_addr: String::new(), + ips: ["192.168.215.0", "fd00::1", "fe80::1"] + .map(|ip| netwatcher::IpRecord { + ip: ip.parse().unwrap(), + prefix_len: 64, + }) + .into(), + }; + let current = interfaces(&HashMap::from([(7, interface)])); + assert_eq!( + current.len(), + if excluded_on.contains(&std::env::consts::OS) { + 0 + } else { + 3 + }, + "interface {name:?}" + ); + } + } +} + +#[test] +fn linux_preserves_generic_bridges_and_custom_interface_names() { + // Raspberry Pi bridge/hotspot setups may put the host address on bridge0. + for name in [ + "br0", + "bridge0", + "bridge100", + "br-lan", + "br-office", + "br-deadbeef", + "br-012345abcdeg", + "docker-uplink", + "docker", + "veth-uplink", + "veth", + "cni", + "virbr-office", + "vmnet0", + "vEthernet (External Switch)", + "my-uplink", + ] { + assert!(!excluded_interface(name, "linux"), "{name}"); + } +} + +#[test] +fn nat_probes_preserve_private_networks_but_skip_local_only_addresses() { + for ip in ["127.0.0.1", "127.1.2.3", "::1", "169.254.1.2", "fe80::1"] { + assert!(!supports_nat_probe(ip.parse().unwrap()), "{ip}"); + } + for ip in [ + "10.0.0.1", + "172.16.0.1", + "192.168.1.2", + "100.64.0.1", + "fd00::1", + "8.8.8.8", + "2001:4860::1", + ] { + assert!(supports_nat_probe(ip.parse().unwrap()), "{ip}"); + } +} + +#[test] +fn identical_ipv6_link_local_addresses_keep_distinct_interface_scopes() { + let snapshot = [7, 11].map(|index| { + ( + index, + netwatcher::Interface { + index, + name: format!("device{index}"), + hw_addr: String::new(), + // Duplicate OS records collapse within a device, never across devices. + ips: vec![ + netwatcher::IpRecord { + ip: "fe80::1".parse().unwrap(), + prefix_len: 64, + }; + 2 + ], + }, + ) + }); + let current = interfaces(&snapshot.into()); + assert_eq!(current.len(), 2); + for key in current.keys() { + let SocketAddr::V6(addr) = key.addr else { + panic!("expected an IPv6 binding"); + }; + assert_eq!(addr.scope_id(), key.index); + } +} + fn loopback_interface() -> netwatcher::Interface { let mut loopback = netwatcher::list_interfaces() .unwrap() @@ -82,11 +332,64 @@ fn loopback_interface() -> netwatcher::Interface { loopback } +#[tokio::test] +async fn failed_address_binding_does_not_remove_usable_bindings_on_the_device() { + let mut state = isolated_interfaces().await; + let loopback = loopback_interface(); + state.snapshot = HashMap::from([(loopback.index, loopback.clone())]); + state.scan(); + let socket = state.bindings.values().next().unwrap().socket.clone(); + let bound = socket.local_addr().unwrap(); + + let mut unavailable = loopback; + // IPV6_V6ONLY sockets reject mapped IPv4 addresses on all supported platforms. + unavailable.ips.push(netwatcher::IpRecord { + ip: "::ffff:127.0.0.1".parse().unwrap(), + prefix_len: 128, + }); + state.snapshot = HashMap::from([(unavailable.index, unavailable)]); + state.scan(); + assert_eq!(state.bindings.len(), 1); + assert!(Arc::ptr_eq( + &socket, + &state.dock.find_socket(bound).unwrap() + )); + assert!(!state.addresses.mdns_endpoints(bound).is_empty()); +} + +#[tokio::test] +async fn scan_withdraws_bindings_when_device_loses_usable_addresses() { + let mut state = isolated_interfaces().await; + let loopback = loopback_interface(); + state.snapshot = HashMap::from([(loopback.index, loopback.clone())]); + state.scan(); + let bound = state.bindings.values().next().unwrap().bound; + let endpoint = EndpointAddr::direct(bound); + + let mut unavailable = loopback.clone(); + unavailable.ips = ["0.0.0.0", "::", "224.0.0.1", "255.255.255.255", "ff02::1"] + .map(|ip| netwatcher::IpRecord { + ip: ip.parse().unwrap(), + prefix_len: 0, + }) + .into(); + state.snapshot = HashMap::from([(unavailable.index, unavailable)]); + state.scan(); + assert!(state.bindings.is_empty()); + assert!(state.dock.find_socket(bound).is_none()); + assert!(state.dock.topology().quic().find_socket(endpoint).is_none()); + assert!(state.addresses.mdns_endpoints(bound).is_empty()); + + state.snapshot = HashMap::from([(loopback.index, loopback)]); + state.scan(); + assert_eq!(state.bindings.len(), 1); +} + #[tokio::test] async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() { - let mut bindings = isolated_bindings(); - let dock = bindings.dock.clone(); - let addresses = bindings.addresses.clone(); + let mut state = isolated_interfaces().await; + let dock = state.dock.clone(); + let addresses = state.addresses.clone(); let loopback = loopback_interface(); let device = BoundDevice::new(loopback.name.clone(), loopback.index).unwrap(); let snapshot = HashMap::from([(loopback.index, loopback)]); @@ -98,11 +401,13 @@ async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() for socket in &foreign { dock.add(socket.clone()).unwrap().unwrap(); } - bindings.scan(&HashMap::new()); + state.snapshot.clear(); + state.scan(); assert_eq!(dock.len(), 2); - bindings.scan(&snapshot); - assert_eq!(bindings.bindings.len(), 1); - let first = bindings.bindings.values().next().unwrap().socket.clone(); + state.snapshot = snapshot.clone(); + state.scan(); + assert_eq!(state.bindings.len(), 1); + let first = state.bindings.values().next().unwrap().socket.clone(); let bound = first.local_addr().unwrap(); let direct = EndpointAddr::direct(bound); assert_eq!(addresses.mdns_endpoints(bound).as_ref(), &[direct]); @@ -110,9 +415,9 @@ async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() assert!( matches!(events.try_recv().unwrap(), qprotocol::AddressEvent::Added { endpoint, .. } if endpoint == direct) ); - bindings.scan(&snapshot); + state.scan(); assert!(Arc::ptr_eq(&first, &dock.find_socket(bound).unwrap())); - assert_eq!(bindings.bindings.values().next().unwrap().bound, bound); + assert_eq!(state.bindings.values().next().unwrap().bound, bound); assert!( events.try_recv().is_err(), "unchanged bindings must not be republished" @@ -125,7 +430,8 @@ async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() } addresses.insert_outer(&first, outer).unwrap(); addresses.set_nat(bound, qbase::net::NatType::RestrictedCone); - bindings.scan(&HashMap::new()); + state.snapshot.clear(); + state.scan(); assert!(dock.find_socket(bound).is_none()); assert!(addresses.mdns_endpoints(bound).is_empty()); assert!(addresses.ddns_endpoints().is_empty()); @@ -138,11 +444,12 @@ async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() removed |= matches!(event, qprotocol::AddressEvent::BoundRemoved { bound: candidate } if candidate == bound); } assert!(removed); - bindings.scan(&snapshot); - assert_eq!(bindings.bindings.len(), 1); - let replacement = bindings.bindings.values().next().unwrap().bound; + state.snapshot = snapshot.clone(); + state.scan(); + assert_eq!(state.bindings.len(), 1); + let replacement = state.bindings.values().next().unwrap().bound; assert_ne!(bound, replacement); - drop(bindings); + drop(state); assert!(addresses.mdns_endpoints(replacement).is_empty()); assert!(dock.find_socket(replacement).is_none()); for socket in &foreign { @@ -155,77 +462,74 @@ async fn scan_preserves_ports_and_foreign_sockets_and_withdraws_all_endpoints() #[tokio::test] async fn scan_rebinds_when_interface_identity_changes() { - let mut bindings = isolated_bindings(); + let mut state = isolated_interfaces().await; let loopback = loopback_interface(); let snapshot = HashMap::from([(loopback.index, loopback.clone())]); - bindings.scan(&snapshot); - let original = bindings.bindings.values().next().unwrap().socket.clone(); + state.snapshot = snapshot.clone(); + state.scan(); + let original = state.bindings.values().next().unwrap().socket.clone(); let bound = original.local_addr().unwrap(); // A new device may reuse both index and name; hardware identity still changes. let mut changed = loopback.clone(); changed.hw_addr.push_str("-replaced"); - bindings.scan(&HashMap::from([(changed.index, changed)])); - assert_eq!(bindings.bindings.len(), 1); - assert!(bindings.dock.find_socket(bound).is_none()); - assert!(bindings.addresses.mdns_endpoints(bound).is_empty()); - let replacement = bindings.bindings.values().next().unwrap().socket.clone(); + state.snapshot = HashMap::from([(changed.index, changed)]); + state.scan(); + assert_eq!(state.bindings.len(), 1); + assert!(state.dock.find_socket(bound).is_none()); + assert!(state.addresses.mdns_endpoints(bound).is_empty()); + let replacement = state.bindings.values().next().unwrap().socket.clone(); assert!(!Arc::ptr_eq(&original, &replacement)); let mut changed = loopback; - changed.index = u32::MAX; - bindings.scan(&HashMap::from([(changed.index, changed)])); - assert!( - bindings.bindings.is_empty(), - "invalid interface cannot bind" - ); + // A zero index is invalid on every platform. An arbitrary nonzero index + // may bind successfully on Linux before packet-info validation at send time. + changed.index = 0; + state.snapshot = HashMap::from([(changed.index, changed)]); + state.scan(); + assert!(state.bindings.is_empty(), "invalid interface cannot bind"); assert!( - bindings + state .addresses .mdns_endpoints(replacement.local_addr().unwrap()) .is_empty() ); - bindings.scan(&snapshot); - assert_eq!(bindings.bindings.len(), 1); + state.snapshot = snapshot.clone(); + state.scan(); + assert_eq!(state.bindings.len(), 1); } #[tokio::test] async fn registration_preserves_existing_owners_and_rolls_back_failed_publication() { - let bindings = isolated_bindings(); + let state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); let bound = socket.local_addr().unwrap(); let direct = EndpointAddr::direct(bound); - bindings.dock.add(socket.clone()).unwrap().unwrap(); - assert!(bindings.register(socket.clone()).unwrap().is_none()); - assert!(bindings.addresses.mdns_endpoints(bound).is_empty()); - assert!(bindings.dock.find_socket(bound).is_some()); - bindings.dock.remove(&socket); - bindings.addresses.insert_inner(&socket, direct).unwrap(); - assert!(bindings.register(socket.clone()).is_err()); - assert!(bindings.dock.find_socket(bound).is_none()); - assert!( - bindings - .dock - .topology() - .quic() - .find_socket(direct) - .is_none() - ); - assert_eq!(bindings.addresses.mdns_endpoints(bound).as_ref(), &[direct]); - bindings.addresses.remove_bound(bound); - let registration = bindings.register(socket).unwrap().unwrap(); - assert_eq!(bindings.addresses.mdns_endpoints(bound).as_ref(), &[direct]); - bindings.withdraw(®istration); + state.dock.add(socket.clone()).unwrap().unwrap(); + assert!(state.register(socket.clone()).unwrap().is_none()); + assert!(state.addresses.mdns_endpoints(bound).is_empty()); + assert!(state.dock.find_socket(bound).is_some()); + state.dock.remove(&socket); + state.addresses.insert_inner(&socket, direct).unwrap(); + assert!(state.register(socket.clone()).is_err()); + assert!(state.dock.find_socket(bound).is_none()); + assert!(state.dock.topology().quic().find_socket(direct).is_none()); + assert_eq!(state.addresses.mdns_endpoints(bound).as_ref(), &[direct]); + state.addresses.remove_bound(bound); + let registration = state.register(socket).unwrap().unwrap(); + assert_eq!(state.addresses.mdns_endpoints(bound).as_ref(), &[direct]); + state.remove(®istration); } #[tokio::test] async fn scan_withdraws_and_rebinds_after_receiver_exit_or_abort() { use qbase::{datagram::forward::Payload, net::route::Pathway}; for abort in [false, true] { - let mut bindings = isolated_bindings(); + let mut state = isolated_interfaces().await; let loopback = loopback_interface(); let snapshot = HashMap::from([(loopback.index, loopback)]); - bindings.scan(&snapshot); - let binding = bindings.bindings.values().next().unwrap(); + state.snapshot = snapshot.clone(); + state.scan(); + let binding = state.bindings.values().next().unwrap(); let socket = binding.socket.clone(); let bound = binding.bound; let receiver = binding.receiver.clone(); @@ -233,7 +537,7 @@ async fn scan_withdraws_and_rebinds_after_receiver_exit_or_abort() { receiver.abort(); } else { // Force a real receive-loop error by forwarding IPv6 through IPv4. - bindings.dock.topology().forward().serve(bound, &socket); + state.dock.topology().forward().serve(bound, &socket); let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let pathway = Pathway::new( EndpointAddr::direct("[::1]:12345".parse().unwrap()), @@ -253,13 +557,166 @@ async fn scan_withdraws_and_rebinds_after_receiver_exit_or_abort() { .await .unwrap(); assert!( - !bindings.addresses.mdns_endpoints(bound).is_empty(), + !state.addresses.mdns_endpoints(bound).is_empty(), "Dock leaves publication to Network" ); - bindings.scan(&snapshot); - assert!(bindings.addresses.mdns_endpoints(bound).is_empty()); - assert!(bindings.dock.find_socket(bound).is_none()); - assert_eq!(bindings.bindings.len(), 1); - assert_ne!(bindings.bindings.values().next().unwrap().bound, bound); + state.snapshot = snapshot.clone(); + state.scan(); + assert!(state.addresses.mdns_endpoints(bound).is_empty()); + assert!(state.dock.find_socket(bound).is_none()); + assert_eq!(state.bindings.len(), 1); + assert_ne!(state.bindings.values().next().unwrap().bound, bound); + } +} + +#[tokio::test] +async fn resume_uses_fresh_interfaces_and_repairs_bindings_before_returning() { + let state = isolated_interfaces().await; + let dock = state.dock.clone(); + let addresses = state.addresses.clone(); + // Simulate an empty cached snapshot after missing the interface's return. + let (interfaces, task) = Interfaces::start(state); + let task = scopeguard::guard(task, |task| task.abort()); + tokio::time::timeout(Duration::from_secs(2), async { + let (first, second) = tokio::join!(interfaces.resume(), interfaces.resume()); + first.unwrap(); + second.unwrap(); + }) + .await + .expect("resume must scan without waiting for another OS event or task poll"); + let mut events = addresses.subscribe_punch(Scopes::ALL); + let mut found_loopback = false; + while let Ok(qprotocol::AddressEvent::Added { + bound, endpoint, .. + }) = events.try_recv() + { + if endpoint.addr().ip() == std::net::Ipv4Addr::LOCALHOST { + found_loopback = true; + assert!(dock.find_socket(bound).is_some()); + } + } + assert!( + found_loopback, + "resume must return with fresh loopback bindings" + ); + let task = scopeguard::ScopeGuard::into_inner(task); + task.abort(); + let _ = task.await; + assert!(dock.is_empty()); + assert!(addresses.ddns_endpoints().is_empty()); +} + +#[tokio::test] +async fn resume_reports_a_stopped_maintenance_task() { + for before_first_poll in [true, false] { + let mut state = isolated_interfaces().await; + state.snapshot = netwatcher::list_interfaces().unwrap(); + state.scan(); + let dock = state.dock.clone(); + let addresses = state.addresses.clone(); + let (interfaces, task) = Interfaces::start(state); + if !before_first_poll { + tokio::task::yield_now().await; + assert!( + interfaces + .state + .lock() + .unwrap() + .as_ref() + .unwrap() + .waker + .is_some() + ); + } + task.abort(); + let _ = task.await; + assert!(dock.is_empty()); + assert!(addresses.ddns_endpoints().is_empty()); + let network = crate::DhttpNetwork { + interfaces, + listeners: Default::default(), + pool: h3x::Pool::new(super::super::connection::connect), + }; + let crate::Error::Io { source } = network.resume().await.unwrap_err() else { + panic!("expected a maintenance I/O error"); + }; + assert_eq!(source.kind(), io::ErrorKind::BrokenPipe); + } +} + +#[tokio::test(start_paused = true)] +async fn interfaces_keep_checking_bindings_after_resume_and_multiple_ticks() { + let loopback = loopback_interface(); + let snapshot = HashMap::from([(loopback.index, loopback)]); + let mut state = isolated_interfaces().await; + state.snapshot = snapshot.clone(); + state.scan(); + let dock = state.dock.clone(); + let addresses = state.addresses.clone(); + let (interfaces, task) = Interfaces::start(state); + let task = scopeguard::guard(task, |task| task.abort()); + tokio::task::yield_now().await; + interfaces.resume().await.unwrap(); + tokio::task::yield_now().await; + for _ in 0..3 { + let socket = { + let state = interfaces.state.lock().unwrap(); + state + .as_ref() + .unwrap() + .bindings + .values() + .find(|binding| binding.bound.ip() == std::net::Ipv4Addr::LOCALHOST) + .unwrap() + .socket + .clone() + }; + let bound = socket.local_addr().unwrap(); + assert!(dock.remove(&socket)); + tokio::time::advance(BINDING_CHECK_INTERVAL).await; + tokio::task::yield_now().await; + assert!(addresses.mdns_endpoints(bound).is_empty()); + assert!(dock.find_socket(bound).is_none()); + assert!( + interfaces + .state + .lock() + .unwrap() + .as_ref() + .unwrap() + .bindings + .values() + .any(|binding| binding.bound.ip() == bound.ip() && binding.bound != bound) + ); } + let task = scopeguard::ScopeGuard::into_inner(task); + task.abort(); + let _ = task.await; + assert!(dock.is_empty()); +} + +#[tokio::test] +async fn active_binding_snapshot_excludes_dead_sockets_and_stopped_maintenance() { + let mut state = isolated_interfaces().await; + let loopback = loopback_interface(); + let device_name = loopback.name.clone(); + state.snapshot = HashMap::from([(loopback.index, loopback)]); + state.scan(); + let bound = state.bindings.values().next().unwrap().bound; + let dock = state.dock.clone(); + let interfaces = Interfaces { + state: Arc::new(Mutex::new(Some(state))), + }; + assert_eq!( + interfaces.active_bindings().unwrap(), + vec![super::super::NetworkBinding { + device: device_name, + address: bound, + }] + ); + dock.remove_bound(bound); + assert!(interfaces.active_bindings().unwrap().is_empty()); + // A stopped maintenance task is different from a live network with no bindings. + interfaces.state.lock().unwrap().take(); + assert!(interfaces.active_bindings().is_err()); } diff --git a/dhttp/tests/unit/network/nat.rs b/dhttp/tests/unit/network/nat.rs index 5bad43e..82ad937 100644 --- a/dhttp/tests/unit/network/nat.rs +++ b/dhttp/tests/unit/network/nat.rs @@ -1,14 +1,56 @@ -use super::super::{InterfaceAddress, test_support::isolated_bindings}; +use super::super::{InterfaceAddress, test_support::isolated_interfaces}; use super::*; use futures::FutureExt; +#[tokio::test(start_paused = true)] +async fn initialization_retries_failed_discovery_without_resume() { + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server: SocketAddr = "127.0.0.1:3478".parse().unwrap(); + let Probe::Initial(discovery) = Probe::initial( + "127.0.0.1:40000".parse().unwrap(), + async { Err(io::Error::other("discovery unavailable")) }, + { + let attempts = attempts.clone(); + move || { + attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let servers: Arc<[SocketAddr]> = + if attempts.load(std::sync::atomic::Ordering::SeqCst) == 1 { + // An address-family mismatch also needs another discovery attempt. + Arc::from(["[::1]:3478".parse().unwrap()]) + } else { + Arc::from([server]) + }; + async move { Ok(servers) } + } + }, + ) else { + unreachable!() + }; + futures::pin_mut!(discovery); + assert!(futures::poll!(discovery.as_mut()).is_pending()); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 0); + tokio::time::advance(STUN_DISCOVERY_RETRY_INTERVAL - Duration::from_millis(1)).await; + assert!(futures::poll!(discovery.as_mut()).is_pending()); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 0, + "discovery must not retry in a busy loop" + ); + tokio::time::advance(Duration::from_millis(1)).await; + assert!(futures::poll!(discovery.as_mut()).is_pending()); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + tokio::time::advance(STUN_DISCOVERY_RETRY_INTERVAL).await; + assert_eq!(discovery.await, vec![server]); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); +} + // Drive the binding's probe on this task, just as the interface watcher does. async fn poll_probe_until(binding: &mut super::super::Binding, ready: impl Fn() -> bool) { tokio::time::timeout( NAT_PROBE_TIMEOUT * 2, std::future::poll_fn(|cx| { assert!( - binding.nat_probe.poll_unpin(cx).is_pending(), + binding.nat.as_mut().unwrap().poll_unpin(cx).is_pending(), "probe ended unexpectedly" ); if ready() { @@ -22,6 +64,41 @@ async fn poll_probe_until(binding: &mut super::super::Binding, ready: impl Fn() .expect("NAT probe did not reach the expected state"); } +#[tokio::test] +async fn resume_retries_failed_stun_discovery_on_the_same_socket() { + let state = isolated_interfaces().await; + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); + let bound = socket.local_addr().unwrap(); + let mut binding = state.register(socket.clone()).unwrap().unwrap(); + let server = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let server_addr = server.local_addr().unwrap(); + binding.nat = Some(Nat::new( + state.dock.clone(), + state.addresses.clone(), + socket.clone(), + bound, + async { Err(io::Error::other("discovery unavailable before wake")) }, + )); + let nat = binding.nat.as_mut().unwrap(); + assert!(futures::poll!(&mut *nat).is_pending()); + assert!(matches!(nat.probe, Probe::Initial(_))); + nat.resume(async move { Ok(Arc::from([server_addr])) }); + let mut packet = [0; 4096]; + tokio::time::timeout(Duration::from_secs(2), async { + tokio::select! { + _ = nat => panic!("maintenance ended"), + result = server.recv_from(&mut packet) => { result.unwrap(); }, + } + }) + .await + .expect("resume must retry discovery and send STUN immediately"); + assert!(Arc::ptr_eq( + &socket, + &state.dock.find_socket(bound).unwrap() + )); + state.remove(&binding); +} + #[tokio::test] async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { use qbase::{ @@ -30,20 +107,25 @@ async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { }; use qprotocol::protocol::stun::{Attr, Message, Response}; use std::sync::atomic::{AtomicUsize, Ordering}; - let mut bindings = isolated_bindings(); + let mut state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); let bound = socket.local_addr().unwrap(); - let mut binding = bindings.register(socket.clone()).unwrap().unwrap(); - let listener = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let mut binding = state.register(socket.clone()).unwrap().unwrap(); + // Retain the bound server after aborting replies so Windows does not deliver + // ICMP port-unreachable errors that close the client's Dock registration. + let listener = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap()); let server = listener.local_addr().unwrap(); let changes = Arc::new(AtomicUsize::new(0)); let heartbeats = Arc::new(AtomicUsize::new(0)); - let stun = bindings.dock.topology().stun().clone(); + let (unanswered, mut awaiting_unanswered) = tokio::sync::oneshot::channel(); + let stun = state.dock.topology().stun().clone(); let responder = tokio::spawn({ let changes = changes.clone(); let heartbeats = heartbeats.clone(); let socket = socket.clone(); + let listener = listener.clone(); async move { + let mut unanswered = Some(unanswered); let mut count = 0; let mut packet = vec![0; 4096]; loop { @@ -70,6 +152,12 @@ async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { bound } else { let heartbeat = heartbeats.fetch_add(1, Ordering::SeqCst); + if heartbeat == 1 { + // Leave a refresh in flight; a second resume must cancel it + // instead of waiting for its ten-second measurement timeout. + let _ = unanswered.take().unwrap().send(()); + continue; + } if heartbeat == 0 { "8.8.8.8:41000" } else { @@ -103,61 +191,78 @@ async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { } } }); - binding.nat_probe = probe_nat( - bindings.dock.clone(), - bindings.addresses.clone(), + binding.nat = Some(Nat::new( + state.dock.clone(), + state.addresses.clone(), socket.clone(), + bound, async move { Ok(Arc::from([server])) }, - ) - .boxed(); + )); let first = "8.8.8.8:41000".parse().unwrap(); let initial = EndpointAddr::direct(first); + let initial_endpoints = [initial, EndpointAddr::mediate(server, first)]; poll_probe_until(&mut binding, || { - bindings.addresses.ddns_endpoints().as_ref() == [initial] + state.addresses.ddns_endpoints().as_ref() == initial_endpoints }) .await; - assert_eq!(bindings.addresses.nat(bound), Some(NatType::FullCone)); + assert_eq!(state.addresses.nat(bound), Some(NatType::FullCone)); + assert!(matches!( + &binding.nat.as_ref().unwrap().probe, + Probe::Heartbeat { + nat_type: Some(NatType::FullCone), + .. + } + )); assert_eq!(changes.load(Ordering::SeqCst), 2); assert_eq!(heartbeats.load(Ordering::SeqCst), 1); - let mut ddns = bindings.addresses.subscribe_ddns(); - assert_eq!(ddns.borrow_and_update().as_ref(), &[initial]); + let mut ddns = state.addresses.subscribe_ddns(); + assert_eq!(ddns.borrow_and_update().as_ref(), &initial_endpoints); assert!( - bindings + state .dock .topology() .quic() .find_socket(EndpointAddr::mediate(server, first)) .is_some() ); - tokio::time::pause(); - tokio::time::advance(NAT_HEARTBEAT_INTERVAL).await; - tokio::time::resume(); - let second = "8.8.8.8:42000".parse().unwrap(); - poll_probe_until(&mut binding, || { - bindings.addresses.ddns_endpoints().as_ref() == [EndpointAddr::direct(second)] + binding.nat.as_mut().unwrap().resume(std::future::pending()); + tokio::time::timeout(Duration::from_secs(2), async { + tokio::select! { + _ = binding.nat.as_mut().unwrap() => panic!("probe ended unexpectedly"), + _ = &mut awaiting_unanswered => {}, + } }) - .await; - assert_eq!(bindings.addresses.nat(bound), Some(NatType::FullCone)); + .await + .expect("resume must start a heartbeat without waiting twenty seconds"); + assert_eq!( + state.addresses.ddns_endpoints().as_ref(), + &initial_endpoints + ); + binding.nat.as_mut().unwrap().resume(std::future::pending()); + let second = "8.8.8.8:42000".parse().unwrap(); + let refreshed_endpoints = [ + EndpointAddr::direct(second), + EndpointAddr::mediate(server, second), + ]; + tokio::time::timeout( + Duration::from_secs(2), + poll_probe_until(&mut binding, || { + state.addresses.ddns_endpoints().as_ref() == refreshed_endpoints + }), + ) + .await + .expect("resume must cancel the unanswered heartbeat and immediately refresh"); + assert_eq!(state.addresses.nat(bound), Some(NatType::FullCone)); assert_eq!( changes.load(Ordering::SeqCst), 2, "heartbeat must not rerun NAT classification" ); - assert_eq!(heartbeats.load(Ordering::SeqCst), 2); - assert_eq!( - ddns.borrow_and_update().as_ref(), - &[EndpointAddr::direct(second)] - ); + assert_eq!(heartbeats.load(Ordering::SeqCst), 3); + assert_eq!(ddns.borrow_and_update().as_ref(), &refreshed_endpoints); + assert!(state.dock.topology().quic().find_socket(initial).is_none()); assert!( - bindings - .dock - .topology() - .quic() - .find_socket(initial) - .is_none() - ); - assert!( - bindings + state .dock .topology() .quic() @@ -166,22 +271,20 @@ async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { ); // A heartbeat timeout withdraws its public mapping but keeps the direct socket. responder.abort(); + let _ = responder.await; tokio::time::pause(); tokio::time::advance(NAT_HEARTBEAT_INTERVAL).await; - poll_probe_until(&mut binding, || { - bindings.addresses.ddns_endpoints().is_empty() - }) - .await; - assert!(bindings.addresses.ddns_endpoints().is_empty()); + poll_probe_until(&mut binding, || state.addresses.ddns_endpoints().is_empty()).await; + assert!(state.addresses.ddns_endpoints().is_empty()); assert!( - bindings + state .dock .topology() .quic() .find_socket(EndpointAddr::direct(bound)) .is_some() ); - bindings.bindings.insert( + state.bindings.insert( InterfaceAddress { name: "test".into(), index: 1, @@ -190,55 +293,58 @@ async fn nat_classifies_once_then_heartbeats_replace_and_withdraw_mappings() { }, binding, ); - bindings.scan(&HashMap::new()); + state.snapshot.clear(); + state.scan(); tokio::time::advance(NAT_HEARTBEAT_INTERVAL * 3).await; - assert!(bindings.addresses.ddns_endpoints().is_empty()); - assert_eq!(bindings.addresses.nat(bound), None); - assert!(bindings.dock.find_socket(bound).is_none()); + assert!(state.addresses.ddns_endpoints().is_empty()); + assert_eq!(state.addresses.nat(bound), None); + assert!(state.dock.find_socket(bound).is_none()); } #[tokio::test] async fn nat_endpoint_conflict_preserves_previous_mapping_and_foreign_socket() { - let bindings = isolated_bindings(); + let state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); - let binding = bindings.register(socket.clone()).unwrap().unwrap(); + let binding = state.register(socket.clone()).unwrap().unwrap(); let relay = "8.8.4.4:20002".parse().unwrap(); let first = "8.8.8.8:41000".parse().unwrap(); let second = "8.8.8.8:42000".parse().unwrap(); let mappings = HashMap::from([(relay, first)]); - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &HashMap::new(), &mappings, ) .unwrap(); let foreign = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); - bindings.dock.add(foreign.clone()).unwrap().unwrap(); - bindings + state.dock.add(foreign.clone()).unwrap().unwrap(); + state .dock .topology() .quic() .register(EndpointAddr::direct(second), &foreign) .unwrap(); assert!( - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &mappings, &HashMap::from([(relay, second)]) ) .is_err() ); assert_eq!( - bindings.addresses.ddns_endpoints().as_ref(), + state.addresses.ddns_endpoints().as_ref(), &[EndpointAddr::mediate(relay, first)] ); assert!(Arc::ptr_eq( &socket, - &bindings + &state .dock .topology() .quic() @@ -247,30 +353,32 @@ async fn nat_endpoint_conflict_preserves_previous_mapping_and_foreign_socket() { )); assert!(Arc::ptr_eq( &foreign, - &bindings + &state .dock .topology() .quic() .find_socket(EndpointAddr::direct(second)) .unwrap() )); - bindings.withdraw(&binding); + state.remove(&binding); } #[tokio::test] async fn nat_publication_conflict_restores_registrations_and_preserves_existing_records() { - let bindings = isolated_bindings(); + let state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); - let binding = bindings.register(socket.clone()).unwrap().unwrap(); + let binding = state.register(socket.clone()).unwrap().unwrap(); + state.addresses.set_nat(binding.bound, NatType::FullCone); let agent = "8.8.4.4:20002".parse().unwrap(); let first = "8.8.8.8:41000".parse().unwrap(); let second = "8.8.8.8:42000".parse().unwrap(); let previous = HashMap::from([(agent, first)]); let refreshed = HashMap::from([(agent, second)]); - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &HashMap::new(), &previous, ) @@ -279,20 +387,21 @@ async fn nat_publication_conflict_restores_registrations_and_preserves_existing_ // A publication can conflict even after every QUIC registration succeeds. let foreign = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); let conflict = EndpointAddr::mediate(agent, second); - bindings.addresses.insert_outer(&foreign, conflict).unwrap(); - let published = bindings.addresses.ddns_endpoints(); + state.addresses.insert_outer(&foreign, conflict).unwrap(); + let published = state.addresses.ddns_endpoints(); assert!( - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &previous, &refreshed, ) .is_err() ); - assert_eq!(bindings.addresses.ddns_endpoints(), published); - let quic = bindings.dock.topology().quic(); + assert_eq!(state.addresses.ddns_endpoints(), published); + let quic = state.dock.topology().quic(); for endpoint in [ EndpointAddr::direct(binding.bound), EndpointAddr::direct(first), @@ -304,22 +413,77 @@ async fn nat_publication_conflict_restores_registrations_and_preserves_existing_ assert!(quic.find_socket(conflict).is_none()); // The caller retains the previous mappings and can retry after the conflict clears. - bindings.addresses.remove(conflict); - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + state.addresses.remove(conflict); + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &previous, &refreshed, ) .unwrap(); - assert_eq!(bindings.addresses.ddns_endpoints().as_ref(), &[conflict]); + assert_eq!( + state.addresses.ddns_endpoints().as_ref(), + &[EndpointAddr::direct(second), conflict] + ); assert!(quic.find_socket(EndpointAddr::direct(first)).is_none()); assert!(Arc::ptr_eq(&socket, &quic.find_socket(conflict).unwrap())); - bindings.withdraw(&binding); - bindings - .addresses - .remove_bound(foreign.local_addr().unwrap()); + state.remove(&binding); + state.addresses.remove_bound(foreign.local_addr().unwrap()); +} + +#[tokio::test] +async fn full_cone_shared_mapping_keeps_direct_and_remaining_relay_when_one_agent_fails() { + let state = isolated_interfaces().await; + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); + let binding = state.register(socket.clone()).unwrap().unwrap(); + let outer = "8.8.8.8:41000".parse().unwrap(); + let agents = [ + "1.1.1.1:20002".parse().unwrap(), + "8.8.4.4:20002".parse().unwrap(), + ]; + let previous = HashMap::from([(agents[0], outer), (agents[1], outer)]); + apply_outer_mappings( + &state.dock, + &state.addresses, + &socket, + Some(NatType::FullCone), + &HashMap::new(), + &previous, + ) + .unwrap(); + assert_eq!( + state.addresses.ddns_endpoints().as_ref(), + &[ + EndpointAddr::direct(outer), + EndpointAddr::mediate(agents[0], outer), + EndpointAddr::mediate(agents[1], outer), + ] + ); + apply_outer_mappings( + &state.dock, + &state.addresses, + &socket, + Some(NatType::FullCone), + &previous, + &HashMap::from([(agents[1], outer)]), + ) + .unwrap(); + assert_eq!( + state.addresses.ddns_endpoints().as_ref(), + &[ + EndpointAddr::direct(outer), + EndpointAddr::mediate(agents[1], outer), + ] + ); + let quic = state.dock.topology().quic(); + assert!(quic.find_socket(EndpointAddr::direct(outer)).is_some()); + assert!( + quic.find_socket(EndpointAddr::mediate(agents[0], outer)) + .is_none() + ); + state.remove(&binding); } #[tokio::test] @@ -327,10 +491,10 @@ async fn classification_failure_does_not_stop_binding_heartbeats_or_invent_nat_t use qbase::datagram::{Datagram, WriteDatagram, be_datagram}; use qprotocol::protocol::stun::{Attr, Message, Response}; use std::sync::atomic::{AtomicUsize, Ordering}; - let mut bindings = isolated_bindings(); + let mut state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); let bound = socket.local_addr().unwrap(); - let mut binding = bindings.register(socket.clone()).unwrap().unwrap(); + let mut binding = state.register(socket.clone()).unwrap().unwrap(); let listener = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let server = listener.local_addr().unwrap(); let calls = Arc::new(AtomicUsize::new(0)); @@ -358,21 +522,25 @@ async fn classification_failure_does_not_stop_binding_heartbeats_or_invent_nat_t } } }); - binding.nat_probe = probe_nat( - bindings.dock.clone(), - bindings.addresses.clone(), + binding.nat = Some(Nat::new( + state.dock.clone(), + state.addresses.clone(), socket, + bound, async move { Ok(Arc::from([server])) }, - ) - .boxed(); + )); poll_probe_until(&mut binding, || { - !bindings.addresses.ddns_endpoints().is_empty() + !state.addresses.ddns_endpoints().is_empty() }) .await; + assert!(matches!( + &binding.nat.as_ref().unwrap().probe, + Probe::Heartbeat { nat_type: None, .. } + )); assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!(bindings.addresses.nat(bound), None); + assert_eq!(state.addresses.nat(bound), None); assert_eq!( - bindings.addresses.ddns_endpoints().as_ref(), + state.addresses.ddns_endpoints().as_ref(), &[EndpointAddr::mediate( server, "8.8.8.8:41000".parse().unwrap() @@ -382,8 +550,8 @@ async fn classification_failure_does_not_stop_binding_heartbeats_or_invent_nat_t tokio::time::advance(NAT_HEARTBEAT_INTERVAL).await; tokio::time::resume(); poll_probe_until(&mut binding, || calls.load(Ordering::SeqCst) == 3).await; - assert_eq!(bindings.addresses.nat(bound), None); - bindings.bindings.insert( + assert_eq!(state.addresses.nat(bound), None); + state.bindings.insert( InterfaceAddress { name: "test".into(), index: 1, @@ -392,7 +560,8 @@ async fn classification_failure_does_not_stop_binding_heartbeats_or_invent_nat_t }, binding, ); - bindings.scan(&HashMap::new()); + state.snapshot.clear(); + state.scan(); tokio::time::pause(); tokio::time::advance(NAT_HEARTBEAT_INTERVAL * 3).await; tokio::task::yield_now().await; @@ -401,16 +570,16 @@ async fn classification_failure_does_not_stop_binding_heartbeats_or_invent_nat_t 3, "removed bindings must stop heartbeats" ); - assert!(bindings.addresses.ddns_endpoints().is_empty()); + assert!(state.addresses.ddns_endpoints().is_empty()); responder.abort(); } #[tokio::test] async fn filtering_nat_publishes_relay_records_and_keeps_direct_punch_endpoints() { - let bindings = isolated_bindings(); + let state = isolated_interfaces().await; let socket = Arc::new(UdpSocket::bind("127.0.0.1:0".parse().unwrap()).unwrap()); - let binding = bindings.register(socket.clone()).unwrap().unwrap(); - bindings + let binding = state.register(socket.clone()).unwrap().unwrap(); + state .addresses .set_nat(binding.bound, NatType::RestrictedPort); let first = "8.8.8.8:41000".parse().unwrap(); @@ -420,10 +589,11 @@ async fn filtering_nat_publishes_relay_records_and_keeps_direct_punch_endpoints( "1.1.1.1:20002".parse().unwrap(), ]; let previous = HashMap::from([(agents[0], first), (agents[1], first)]); - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &HashMap::new(), &previous, ) @@ -433,11 +603,11 @@ async fn filtering_nat_publishes_relay_records_and_keeps_direct_punch_endpoints( .to_vec(); expected.sort_unstable(); assert_eq!( - bindings.addresses.ddns_endpoints().as_ref(), + state.addresses.ddns_endpoints().as_ref(), expected.as_slice() ); assert!( - bindings + state .dock .topology() .quic() @@ -445,35 +615,29 @@ async fn filtering_nat_publishes_relay_records_and_keeps_direct_punch_endpoints( .is_some() ); let refreshed = HashMap::from([(agents[0], changed)]); - apply_nat_mappings( - &bindings.dock, - &bindings.addresses, + apply_outer_mappings( + &state.dock, + &state.addresses, &socket, + state.addresses.nat(binding.bound), &previous, &refreshed, ) .unwrap(); assert_eq!( - bindings.addresses.ddns_endpoints().as_ref(), + state.addresses.ddns_endpoints().as_ref(), &[EndpointAddr::mediate(agents[0], changed)] ); for endpoint in expected { - assert!( - bindings - .dock - .topology() - .quic() - .find_socket(endpoint) - .is_none() - ); + assert!(state.dock.topology().quic().find_socket(endpoint).is_none()); } assert!( - bindings + state .dock .topology() .quic() .find_socket(EndpointAddr::direct(first)) .is_none() ); - bindings.withdraw(&binding); + state.remove(&binding); } diff --git a/docs/api/pishoo-service-boundary.md b/docs/api/pishoo-service-boundary.md deleted file mode 100644 index 2d01e68..0000000 --- a/docs/api/pishoo-service-boundary.md +++ /dev/null @@ -1,75 +0,0 @@ -# Pishoo 服务接入边界 - -> 历史设计:本轮 h3x/dhttp/Pishoo 对接及结构成员以 [三仓接口冻结 v1](../../../pishoo/design/README.md) 和 [dhttp 结构清单](../../../pishoo/design/dhttp-interfaces.md) 为准。本文保留历史讨论,不作为本轮实现依据。 - -状态:职责与接口约定,尚未实现应用或网络运行行为。本轮决定由 Pishoo 完整负责 WASM,DHTTP 接收调用方组装的 Router。本文替代此前 DHTTP 内置 WasmApp、apis() 及私有 WIT 清单方案。 - -Pishoo 的当前应用约定与字段方法见 [三仓架构](../../../pishoo/design/h3x-dhttp-pishoo-architecture.md) 和 [Pishoo 结构清单](../../../pishoo/design/pishoo-interfaces.md)。旧 Pishoo 设计文件已经删除;下文只保留历史讨论。 - -## 1. 唯一应用入口 - -```rust -let endpoint = Endpoint::load(servername).await?; -// router 由 Pishoo 组装,包含静态、代理、原生及 WASM 路由。 -endpoint.listen(scopes, router).await?; -``` - -DHTTP 对 router 的约束继续是 `tower_service::Service, Response = http::Response>`。Pishoo 可以使用 Axum;DHTTP 不依赖、重导出或检查 Router 的具体实现,也不区分某次响应来自原生代码、代理还是 WASM。 - -| 责任 | DHTTP | Pishoo | -|---|---|---| -| 通信 | 名称与凭据、QUIC/TLS、HTTP/3、连接复用和共享网络 | 选择身份并交付应用服务 | -| HTTP 消息 | h3x 与标准 HTTP/body 的流式桥接、背压和取消 | handler、代理及 WASI HTTP 的业务适配 | -| 可信身份 | request.extensions 中的 ArcConnection / HandshakeSummary | Server 绑定、authority 核对及 daccess 授权 | -| 路由与发布 | 驱动同一个 Service 的 readiness 和 call | OpenAPI 解析、路径挂载、路由冲突、组件与文档配对发布 | -| WASM 运行 | 无专属接口或依赖 | 编译、imports 校验、Store/Instance、WASI 能力及 guest 任务 | -| 资源与关闭 | HTTP 交换、连接及网络任务的生命周期 | 身份总预算、guest/host I/O 回收、应用与身份取消 | - -## 2. Pishoo 的应用描述与准入 - -一个 `.wasm` 是包含多个 handler 的代码加载单位,配套同名 `.openapi.json`,例如 `chat.wasm` 与 `chat.openapi.json`。Pishoo 静态读取 OpenAPI 3.1.x JSON,不执行 guest 获取路由清单。操作的 method/path 用于匹配和授权;operationId 可选,提供时在文档中唯一,用于标识和日志。 - -Pishoo 首版仅接受显式方法与字面量路径;未支持的模板路径、callbacks、webhooks、路径级引用等影响路由的构造拒绝加载。schema 可供文档和 SDK 使用,首版不承诺通用运行时 schema 校验;不因此收集完整流式 body。具体业务数据由 handler 校验。 - -普通 OpenAPI 可以不带 Pishoo 扩展。可选 `x-access` 是部署时的默认权限建议,不绕过既有 daccess 决策;`x-pishoo-execution` 只能进一步收紧身份沙盒能力。扩展解释、可信 SubjectId 适配、默认规则导入与 ACL 管理均属于 Pishoo/daccess,DHTTP 不读取它们。 - -Pishoo 从组件相对路径确定 AppId 与挂载前缀,逐条注册声明的 method/path,并在进入 guest 前授权;`index.wasm` 只发布声明的根级入口。未声明路径返回 404,已声明路径的未声明方法返回 405;HEAD/OPTIONS 业务入口必须显式声明。OpenAPI 的 servers 字段不改变 Server 身份、网络资源或挂载位置。 - -授权后剥离一次挂载前缀并保留 query,将标准 HTTP 请求交给 guest 自身路由。宿主鉴权使用可信 extensions;Pishoo 负责清洗和注入其保留的身份/API headers,投影给 guest 的 header 不作为宿主鉴权依据。 - -组件与文档共同形成一个稳定内容快照。缺文件、解析失败或声明摘要不符时拒绝候选,热更新保留旧 Router;只有两份文件均确认删除时才移除该应用。可选摘要存在时验证,没有摘要时仍需部署工具保证成对提交,不能用文件监听去抖代替原子发布。 - -这些规则由 Pishoo 实现。DHTTP 不新增 OpenAPI 模型、清单格式或业务 Request/Response 类型,也不提供专门的应用打包 SDK。 - -## 3. 身份沙盒与 Server 执行边界 - -一个规范化 Server 身份对应一个 WASM 沙盒;同身份的所有 App 共享 `/data`、能力上限、聚合资源账户和身份取消域。每次调用创建独立的 Store、Instance、ResourceTable 和请求私有 `/tmp`,只共享不可变编译代码,不跨请求复用可变 guest 内存。 - -同身份 App 属于同一信任域,拥有写能力时可能修改彼此的共享数据;不同身份的数据、上下文和资源账户分离。身份总预算覆盖新旧应用版本和并发调用,更新或切换 App 不能重置资源账户。 - -Pishoo 的 Server 执行边界覆盖静态、代理、原生处理与 WASM 的身份校验、总预算和完整请求生命周期。WASM 身份沙盒只约束 WASM 分支;普通 native handler 不会因一层 middleware 获得 WASM 或操作系统隔离。 - -文件能力、出站授权和每 API 的能力收紧全部由 Pishoo 施加到实际执行实例与宿主 I/O。首版权限管理由前端以真实主体直接调用 daccess,guest 不获得 ACL 写能力,也不能借出站或自调用绕过该限制。 - -## 4. 标准服务与完整交换生命周期 - -```text -DHTTP HTTP/3 → http::Request + 可信握手 extensions - → Pishoo ServerGuard + Router - ├─ 静态 / 代理 / 原生 handler - └─ OpenAPI 路由 + daccess 准入 - → 身份沙盒账户与能力 → Pishoo WASI HTTP 适配 → guest - → http::Response → DHTTP HTTP/3 输出 -``` - -DHTTP 流式传递 HTTP body/trailers、保持背压、处理连接与流取消。Pishoo 将标准 body 与 WASI HTTP 流互相转换,允许提前响应,并持有 guest task、host I/O、预算和内容快照直至执行回收。 - -Service 返回响应头、body EOF 与实际网络输出完成是不同阶段。DHTTP 负责跟踪实际 HTTP 输出和交换终态,Pishoo 负责关联应用任务;完成/取消的通用通知接缝尚待实现核对,不新增 WASM 专属 Context 或宿主注入接口。不能用一次 Service::call 返回或外包 timeout 宣称 guest 已经结束。 - -响应头前的应用失败由 Pishoo 转成 HTTP 错误响应,未处理的 Service 错误走 DHTTP 通用错误契约;响应头后的失败通过 body 错误及当前流终止传播,不关闭共享连接。HEAD/204/304 或替换响应 body 时,Pishoo 回收原执行,DHTTP 按 HTTP 语义完成最终输出。 - -移除单个 App 时由 Pishoo 撤销其路由并取消该应用执行,其他 App 和共享数据继续存在;删除身份时由 Pishoo 协调所有应用停止与 结束 Endpoint.listen future。DHTTP 关闭交换时应触发通用应用取消衔接,Pishoo 等待 guest 与宿主任务清理;关闭某个身份不停止进程共享网络。 - -## 5. 当前同步范围 - -DHTTP 已删除 WASM 模块、feature、Wasmtime 依赖、专属错误及私有 WIT 声明。现存接口仍只有成员和 todo!() 占位。Pishoo 自身的 WASM、OpenAPI、沙盒与上述生命周期集成尚待实现;本轮未修改 Pishoo 仓库或运行其集成测试。 diff --git a/docs/api/top-level-review.md b/docs/api/top-level-review.md deleted file mode 100644 index 4e2b2ef..0000000 --- a/docs/api/top-level-review.md +++ /dev/null @@ -1,558 +0,0 @@ -# DHTTP 顶层接口审查稿 - -> 历史设计:本轮 h3x/dhttp/Pishoo 对接及结构成员以 [三仓接口冻结 v1](../../../pishoo/design/README.md) 和 [dhttp 结构清单](../../../pishoo/design/dhttp-interfaces.md) 为准。本文保留历史讨论,不作为本轮实现依据。 - -状态:接口审查与实现同步中。出站类型 `dhttp::Request<()>`、`dhttp::Request`、`dhttp::Response` 和 Endpoint 请求方法的签名已写入代码;Network 的配置校验、绑定监测与关闭已开始实现,Endpoint.listen 已接入 qconn 回调和入站 H3 服务驱动。出站请求构造仍为 `todo!()`,入站互通尚未端到端验证,不表示可实际收发。本文按当前讨论收敛;与早期架构提案冲突时,以本稿列出的约束为准。下方省略方法体的 Rust 是接口记法。WASM、OpenAPI 和身份沙盒归 Pishoo,DHTTP 只接收标准应用服务。证书辅助函数继续留待审查。本稿按最简单的目标模型设计,不以旧 identity/home API 或 gmutils 兼容为约束;gmutils 可后续适配。home 不在本轮展开设计。 - -## 1. 已确定的边界 - -- Endpoint 只持有自己的 `Arc` 名称;连接池保存已建立的 h3x 连接,Network 在未命中时按名称取得凭据并构造底层建连能力。 -- DhttpNetwork 通过配置显式初始化,是进程全局对象;所有身份共享 interface 和一个 h3x 连接池。Network 负责监听网络变化并维护这些共享资源。 -- home 本轮只作为目录定位和凭据读取的辅助模块,不展开配置、缓存、扫描注册表或生命周期设计;不为保留旧调用方而维持重复身份模型。 -- Endpoint 保留 load(servername) 简单入口,只保留规范化名称;不接受 home 或底层材料参数,不暴露 quic()。Network 在发起新连接或登记监听时获取本端凭据,不持有身份目录,也不提供 network.endpoint。 -- 各 Server 通过 scopes 指定允许的来源范围;`listen external` 持续覆盖所有适合 External 的网卡。Network 汇总活动 Server 的 scopes,处理网卡新增、删除和 up/down,并按实际 socket 统一检查入口;qconn 还按各 Server 的 scopes 检查来源。External 仅允许对应绑定接收外网来源的包,不触发 STUN 探测或外部地址发布。 -- Pishoo 自己发现 Server、解释应用目录和策略;若需显式凭据路径,由 Pishoo 确定凭据来源并在接入阶段交给 Network,不把路径或凭据放进逻辑 Endpoint。 -- 不在 Endpoint 里保存身份材料、QUIC endpoint、连接、state、profile 或 network。 -- HTTP 消息、body、trailers、异步读写优先复用 h3x,不重做一套 dhttp 消息。get 等方法同步返回绑定当前 Endpoint 的 `Request`;POST 等请求还返回等待响应头的 `dhttp::Response` future,await 后交付原生 `h3x::Response`。底层两个方向始终按流式读写处理;执行对象如何持有 h3x 请求并绑定连接尚待审查。 -- “交换”只表示一次请求及响应的完整生命周期;不公开 `dhttp::Exchange`、`ExchangeOutcome` 或独立取消订阅接口。h3x 提供流方向的显式取消,dhttp 在内部协调任务与关闭。 -- 本端、对端身份直接使用 qtls 类型;不定义 PreparedIdentity、RequestContext 或 PeerIdentity。 -- 服务接入只约束 tower_service::Service 和标准 HTTP body;不依赖、重导出或指定 Axum,也不自建 Router。Pishoo 负责 WASM 运行、WASI HTTP 适配、OpenAPI、授权与身份沙盒,组装好 Router 后连同 scopes 交给 Endpoint.listen(scopes, router)。 -- 本稿只覆盖 Rust 核心公共面。SDK、WebTransport 和身份轮换暂不扩展;匿名客户端复用情况单列核对,不新建身份包装。 - -## 2. 顶层导出 - -```rust -pub use endpoint::Endpoint; -pub use network::DhttpNetwork; -pub use qconn::{Scope, Scopes}; -// 服务边界使用现有 body 容器的别名,不定义新的消息或 body 状态机。 -pub type BoxError = Box; -pub type Body = http_body_util::combinators::UnsyncBoxBody; -pub use error::{Error, Result, ShutdownReport}; - -// dhttp::Request 是出站执行对象;dhttp::Response 等待响应头。 -// 已收到的原生消息写作 h3x::Request / h3x::Response。 -pub use client::{Request, Response}; -pub use h3x::{R, W, ArcWndBuf, Trailers}; -pub use qconn::ArcConnection; -pub use qtls::{LocalAuthority, RemoteAuthority, HandshakeSummary}; -pub use http::{Method, Uri, StatusCode, HeaderMap, HeaderName, HeaderValue}; - -// 本地目录与凭据读取由独立的 dhttp-home 提供。 -#[cfg(feature = "access")] -pub use dhttp_access as access; -#[cfg(feature = "log")] -pub use dhttp_log as log; -``` - -`qconn` 是当前本地 dquic 新实现所在的 crate,尚未通过 `dquic` 门面导出。底层 TLS 类型统一使用 qtls 的导出,避免混用它使用的 rustls fork 与 crates.io rustls 类型。旧 `trust` 模块的 rustls verifier 暂不纳入这份新顶层契约;全局信任配置需等 qconn 明确注入接口后再定。 - -## 3. Endpoint - -```rust -#[derive(Clone)] -pub struct Endpoint { - name: Arc, -} - -impl Endpoint { - // 解析并验证名称;仅保留名称,不保留凭据或传输对象。 - pub async fn load(servername: impl AsRef) -> Result; - pub fn name(&self) -> &str; - - // 出站请求同步构造;B 是请求 body 的类型,R/W 只用于 h3x 原生消息。 - // Uri 由调用方先解析,例如 endpoint.get("https://example.com/".parse()?)。 - pub fn get(&self, uri: Uri) -> Request<()>; - pub fn head(&self, uri: Uri) -> Request<()>; - pub fn delete(&self, uri: Uri) -> Request<()>; - pub fn options(&self, uri: Uri) -> Request<()>; - pub fn post(&self, uri: Uri) -> Request; - pub fn put(&self, uri: Uri) -> Request; - pub fn patch(&self, uri: Uri) -> Request; - pub fn request(&self, method: Method, uri: Uri) -> Request; - - // 只约束标准 Service,不依赖任何具体 HTTP 路由框架。 - pub async fn listen(&self, scopes: Scopes, app: S) -> Result<()> - where - S: tower_service::Service, Response = http::Response> - + Clone + Send + 'static, - S::Future: Send + 'static, - S::Error: Into, - B: http_body::Body + Send + 'static, - B::Error: Into; - - // 关闭这个逻辑 Endpoint 对应的 HTTP 接入、请求和池条目。 - pub async fn close(&self, deadline: Instant) -> Result; -} -``` - -Endpoint 只提供 `load(servername)` 作为命名入口,实例只保存自己的名称,不经过旧 Identity、DhttpName 或 authority traits 的转换链,也不保留 gmutils 兼容构造接口。池命中时复用已有 h3x 连接;未命中时 Network 用名称调用轻量 home 凭据读取,构造 qconn::QuicEndpoint 并建连。监听登记时同样由 Network 准备底层身份及服务器参数。仅凭远端 URL 不足以建连,池工厂还必须取得本端名称。 - -独立 `dhttp-home` 负责名称规范化、`DHTTP_HOME/<身份名>` 身份目录扫描和定位,并统一定义 `ssl/`、`db/`、`apps/`、`public/`、`logs/` 与 `config.db` 的路径。它读取身份凭据;核心 `network::quic` 使用这些接口装配底层端点。Pishoo 从统一路径读取自己的配置和应用,daccess 从统一路径管理权限库;各组件负责自己文件的内容和生命周期。`load` 保持简单形状;get/post 等同步返回可配置、可 await 的请求。 - -listen 登记该 Endpoint 的应用和 scopes。Network 汇总活动服务的 scopes,按实际接收 socket 的范围检查入口,qconn 再按该 Endpoint 的 scopes 检查来源;两层检查均通过才交付连接和请求。不因读取凭据自动监听,Endpoint 对象本身不保存 scopes。 - -`listen` future 运行接入循环;结束时撤销服务登记。端点仍能主动发起请求,也可再次 listen。 - -`close` 是最终关闭,影响共享同一个名称 Arc 的 Endpoint 克隆;后续请求返回 EndpointClosed。关闭登记和任务跟踪在全局网络内部维护,不增加 Endpoint.state。独立加载的同名 Endpoint 具有不同 Arc 实例,不因关闭其中一个而误关另一个的资源。 - -## 4. 请求消息与执行绑定:待审查 - -目标调用形状(`body` 是请求体写入端;第二例的 `response` 是 `dhttp::Response`): - -```rust -let response: h3x::Response = endpoint.get("https://example.com/".parse()?).header(...).await?; -// response 的 body 仍可逐块读取。 - -let (mut body, response) = endpoint.post("https://example.com/upload".parse()?).header(...).await?; -body.write_all(chunk).await?; -body.shutdown().await?; -let response: h3x::Response = response.await?; -``` - -GET、HEAD、DELETE、OPTIONS 的便捷调用按无请求体处理:首次 await 发送请求头、结束请求写入方向并等待响应头。POST、PUT、PATCH 的便捷调用按调用方写请求体处理:首次 await 在建连、发送请求头后返回请求 body 写入端和响应等待端,不等待响应头。需要给其他方法写请求体时使用显式 `request(method, url)`,其首次 await 也返回这一对结果。两类调用都不区分“普通”与“流式”底层消息;响应 body 始终可流式读取。空请求体也要显式结束其写入方向,否则等待请求结束的服务端可能一直不返回响应。 - -### 4.1 `Request` 的类型设计 - -`B` 是预备请求的 body 类型,而不是 h3x 的读写方向。`()` 表示空 body;`ArcWndBuf` 是可写的有界流式 body。`Request<()>` 首次 await 结束空请求方向并等待响应;`Request` 首次 await 返回可写的原生 `h3x::Request` 和独立响应 future。不定义额外的 `RequestBody`。`dhttp::Request` 负责发送前的请求编辑与 Endpoint 执行绑定;发送时把标准 `http::Request` 转为原生 `h3x::Request`。`R/W` 只用于 h3x 的消息方向。 - -```rust -pub struct Request { - endpoint: Endpoint, - message: http::Request, -} - -impl Request { - // header、append_header、method、uri 等请求头编辑方法共用同一实现。 - // 编辑方法保留 B;修改 HTTP method 不会偷偷改变 await 的输出类型。 -} - -impl Request<()> { - pub fn with_body(self) -> Request; -} - -impl Request { - pub fn without_body(self) -> Request<()>; -} - -impl IntoFuture for Request<()> { - type Output = Result>; - // IntoFuture 的具体类型待执行接缝确定。 -} - -impl IntoFuture for Request { - type Output = Result<(h3x::Request, Response)>; - // IntoFuture 的具体类型待执行接缝确定。 -} -``` - -上面是接口记法,并非当前可编译实现。`h3x::Request` 已实现 Tokio `AsyncWrite`,写入受其 `ArcWndBuf` 的有界缓冲背压约束;`shutdown()` 结束请求方向,trailer 必须在此之前设置。`with_body()` 允许带 body 的 GET 等请求,`without_body()` 明确发送空 body 的 POST;`request(method, uri)` 默认返回可写 body 的形状。URI 类型由调用方提供;字符串应使用 `.parse()?` 转为 `Uri`,不在请求对象里延迟保存解析错误。 - -### 4.2 `Response` 的类型设计 - -`dhttp::Response` 是一次性交付响应头的 future,不是另一套 HTTP 响应消息。它只持有一个已经启动的异步任务,该任务驱动 `read_response`,await 后交出原生 `h3x::Response`;后续通过原生消息流式读取 body。它不实现 `AsyncRead`,不提供 status/header 的预读取接口,也不允许 Clone。 - -```rust -#[must_use = "await the response or cancel the exchange"] -pub struct Response { - task: tokio::task::JoinHandle>>, -} - -impl Future for Response { - type Output = Result>; - // poll task;JoinError 转成 dhttp 错误。 -} - -impl Drop for Response { - // task.abort();任务内部的取消守卫负责通知交换并停止响应读取。 -} -``` - -任务须在首次 `Request.await` 返回前启动;`Response.await` 只等待任务结果,不能负责启动读取。否则调用方先写 body、稍后才 await 响应时,响应读取不会推进。`read_response` 读到最终响应头后返回 `h3x::Response`,并继续用 h3x 的后台任务读取响应 body。 - -取消契约分两段:响应头交付前,`Response::drop` 调用 `task.abort()`;被取消的读取任务对该 QUIC 接收方向发送 STOP_SENDING,并把本次交换标记为取消。响应头交付后,`Response` 已完成,取消责任转给原生响应 body;最后一个读取所有者未读到 EOF 就丢弃时,也要停止接收方向并回收交换。当前 h3x 的 `H3ReadStream::drop` 只调用 `finish()`,底层 Reader 的 Drop 也不会代替显式 `stop()`,因此不能只给 `Response` 增加 `task.abort()`。所需内部接缝见第 4.6 节。 - -`Request<()>` 立即结束空请求方向,再等待同一响应结果,直接返回 `h3x::Response`。`Request` 在请求头已交给底层写入后返回 `(h3x::Request, dhttp::Response)`,不等待上传完成或响应头;body 与响应继续并发推进。若首次 await 等待响应头,服务端先等待完整上传时就会卡住。当前 h3x 的 `write_request` 只在整个请求发送完后完成,尚不能报告“请求头已发送”,因此实现这条返回时机需要明确的写入接缝。 - -返回原生 `h3x::Request` 有两个需要解决的行为:它的 header/URI setter 在请求头发送后仍可调用,但修改已不能影响线上请求;它的 Clone 和 Drop 也没有“最后一个调用方写入句柄消失就取消上传”的语义。若坚持直接返回该类型,必须明确禁止或调整发送后修改头部的接口,并为未 `shutdown` 就丢弃请求体提供受控取消接缝;不能把这种修改静默当成成功。具体 h3x 接缝一起审查。当前 dhttp 代码只同步了请求类型签名,网络执行仍是占位。顶层调用不增加 `open()`、`send()`。 - -### 4.3 当前 h3x Request 不能独立完成交换 - -本地 h3x::Request 持有 HTTP 消息头、ArcWndBuf、Trailers 和读写方向标记。它没有绑定连接或请求流,也没有取得这些资源的执行入口。W 表示消息可写方向,并不表示它已经持有 QUIC 发送流。 - -write_request 消费请求消息;read_response 则需要同一交换的接收流、请求方法及连接的 QPACK。请求 body 缓冲无法提供这些资源。因此,仅增加 `impl IntoFuture for h3x::Request` 无法让 await 自动取得对应响应。 - -### 4.4 需要明确的执行接缝 - -无论执行对象放在 dhttp 还是 h3x,它都必须绑定当前 Endpoint 的执行能力:执行时取得连接、打开双向流,并配对发送请求和读取响应。它不必在同步构造时已经持有真实流,但必须知道如何取得并驱动这一对流。需要调用方写 body 时,首次 await 还必须交出可继续写入的请求方向,并独立驱动响应方向。 - -dhttp 提供使用当前 Endpoint 身份及全局连接池的逻辑;h3x 不反向依赖 dhttp,也不接管 home、DHTTP 名称或网络策略。绑定方式、绑定后的类型表达、错误传递、消费及 Clone 语义仍需审查,本文暂不增加回调 trait、泛型参数或新的公共包装类型。h3x 当前的协议错误也不能直接当作能够保留所有 dhttp 建连错误的执行错误。 - -若 h3x 保持纯消息类型,dhttp 的请求执行对象可承载 Endpoint 上下文并驱动原生 h3x 消息;若执行对象放进 h3x,也需表达上述两种 IntoFuture 输出及 dhttp 建连错误。具体放置仍需审查,但不能改变上面的直接 await 调用形状。不能同时假定请求保持完全未绑定、没有其他执行入口,却能独立 await 完成收发。 - -### 4.5 执行与流式约束 - -无论最终采用哪种绑定方式,执行方都必须完成: - -1. 校验请求,使用调用 Endpoint 的身份从全局池取得 h3x 连接。 -2. open_bi 获得同一交换的配对读写流。 -3. 通过 write_request 发送消息,通过 read_response 读取响应;上传与响应接收能够并发推进。返回请求 body 写入端前,不等待响应头或完整上传。 -4. 无请求体的便捷调用返回 h3x::Response;需调用方写请求体的调用先返回写入端与响应 future,后者 await 返回 h3x::Response。网络层负责上传和响应 body 的读写。 - -请求头、body 和 trailers 复用 h3x 的消息操作与有界缓冲。请求 body 写入端遵守异步写入和背压,结束写入只关闭请求方向,不关闭响应方向;不能要求调用方在发送开始前把整个 body 写入有界缓冲。返回响应头不代表整个交换已经结束。 - -### 4.6 h3x 请求与响应启动 - -`write_request` 发送 HEADERS 后继续发送请求 body 和 trailers;dhttp 在独立任务中驱动上传,并发读取响应。响应头可在上传结束前交给调用方。发送失败和读取失败按 h3x 错误返回。 - -### 4.7 dhttp 的请求执行分工 - -dhttp 不让 h3x 查找身份、建立连接或管理全局池。`Request::into_future` 被轮询后,使用当前 Endpoint 的身份与全局 Network 取得或建立 h3x 连接,调用 `open_bi` 得到配对的发送/接收方向,并在 Network 的交换登记中记录这次请求。连接与开流失败在首次 await 返回,不启动遗留任务。 - -发送方向由 dhttp 启动 `write_request` 任务,接收方向由 `dhttp::Response` 持有的任务驱动 `read_response`;二者使用同一 stream 对和连接的 QPACK,同时推进,不用一个方向等待另一个方向结束。h3x 当前的 `write_request` 本身不 spawn,dhttp 必须显式启动并跟踪它;h3x 当前的 `read_response` 在返回响应头后已经自行 spawn body 读取,不需要 dhttp 再启动第二个 body 读取任务。后台写入任务由 Network 跟踪,即使 `dhttp::Response` 先返回也继续发送未完成的请求 body。 - -`Request<()>` 在启动前关闭空请求 body,随后等待 `dhttp::Response` 得到原生 `h3x::Response`。`Request` 保留一个供调用方写入的 h3x 请求句柄,把共享 body 的另一个句柄交给写入任务;首次 await 等待 HEADERS 发送完成信号后返回 `(h3x::Request, dhttp::Response)`。返回时不等待响应头,避免服务端等上传结束时死锁。 - -写入任务把成功/失败送给交换登记。HEADERS 之前的失败使首次 await 报错;HEADERS 之后的失败传给请求 body 写入端和交换结果,并在响应尚未交付时唤醒 `dhttp::Response` 返回错误。若响应头先到,`dhttp::Response` 可以返回,写入任务仍由 Network 持有;后续写入错误不能追溯修改已交付的响应,只能由写入端和交换结果报告。读取失败取消本次写入方向;正常提前响应不自动取消仍在进行的上传。 - -这套 dhttp 分工可直接使用 h3x 已有的 `open_bi`、`write_request`、`read_response`、有界 `ArcWndBuf` 和显式流取消能力。第 4.6 节列出的 HEADERS 完成通知、发送后头部冻结及自动丢弃取消仍是保留当前顶层语义时的 h3x 接缝;若放弃这些保证,必须先修改这里的 await 和取消契约,不能把 dhttp 外部 `spawn(write_request(...))` 当作 HEADERS 已发送的证明。 - -本轮已同步 dhttp 的请求接口声明。待执行接缝审查通过后,再实现 dhttp 的网络收发及必要的 h3x 接缝;当前声明中的 `todo!()` 不是可调用的请求行为。 - -### 返回类型与客户端身份无关 - -`IntoFuture` 只表示请求可直接 await,不表示两种调用返回相同结果,也不表示调用方必须知道具体 future 类型。本文不再把它称为“匿名异步操作”。通过 Endpoint.load(servername) 创建的端点及其请求都使用该名称对应的身份;以下单列的不提供本端证书的客户端能力,是另一项底层能力,不是 Endpoint.get 的默认行为。 - -当前相邻 dquic 工作区的能力(2026-10-03 更新): - -| 路径 | 身份约束 | 入口 | -|---|---|---| -| qtls | ClientTlsConfig.local 可为 None;服务器身份必填 | 无客户端证书时仍验证服务器身份 | -| qconnection::QuicEndpoint | identity 保持必填,支持具名出站及监听 | QuicEndpoint::new(identity)、connect、listen | -| qconnection::connect_anonymously | 不创建无身份 QuicEndpoint,只发起连接 | 显式传入目标名称、客户端参数和 ALPN,与具名 connect 共用内部流程 | - -不新增 QuicClient、AnonymousIdentity 或 PreparedIdentity。dhttp 匿名请求调用底层匿名函数, -继续使用全局网络及连接池,不要求初始化 home。连接池区分匿名与具名连接,不能互相复用。 -握手失败返回错误,无效证书不会降级为匿名;取消未交付的连接由 dquic 生命周期清理。 - -dhttp::Endpoint 同样始终有身份:new(identity) 接收必填凭据,name() 返回 &str,支持请求和 listen。 -Anonymous 提供与 Endpoint 对称的出站方法:get/head/post/put/patch/delete/options/request/from_request。 -Anonymous.get(uri) 等方法构造匿名 HTTP 请求,不绑定 Endpoint,也不提供 listen;标准请求可交给 -Anonymous.from_request(http_request) 或 Request::new(http_request)。它们共用上传、响应及底层建连流程。 - -Endpoint.load(servername) 仍表示从 home 选择命名身份,空字符串不表示匿名。 - -核对入口:[qtls 客户端身份选项](../../../dquic/qtls/src/config.rs)、[qconnection 端点及匿名函数](../../../dquic/qconnection/src/endpoint.rs)。 - -## 5. Tower Service 与应用边界 - -### 5.1 使用已有服务边界 - -dhttp 只依赖最小的 `tower-service` 契约,接受调用方提供的 Service。HTTP 路由器和 middleware 由应用自行选择;dhttp 不依赖或重导出 Axum,不定义 dhttp::Router,也不实现路径匹配、404/405 或 fallback 规则。[tower-service](https://docs.rs/tower-service/latest/tower_service/trait.Service.html)。 - -listen 的服务边界为: - -```rust -S: tower_service::Service, Response = http::Response> -B: http_body::Body -``` - -Body 是 http_body_util::UnsyncBoxBody 的别名,只统一入站流的类型,要求 Send、不要求 Sync;响应 body 保持泛型,接受满足 http_body::Body 的响应类型。`dhttp::Request` 是出站执行对象,不是第二套 HTTP 消息;客户端响应与内部消息使用 h3x::Response/Request,服务适配使用标准 http::Request/Response。[UnsyncBoxBody](https://docs.rs/http-body-util/latest/http_body_util/combinators/struct.UnsyncBoxBody.html)。 - -```text -h3x Request - → dhttp 流式 http-body 桥接 - → http::Request - → 应用提供的 tower_service::Service - → http::Response - → dhttp 流式发送桥接 - → h3x Response -``` - -dhttp 从 h3x 消息的 parts 复用方法、URI、headers 和 extensions,把有界 body 与 trailers 映射为 DATA/trailers frames;响应方向反向桥接。不先 read_to_end,也不额外引入无界队列。 - -Service::poll_ready 必须被正确驱动,在得到 Ready 的同一个 service 实例上 call;不能在一个实例 ready 后对未经 ready 的 clone 调用。应用可以自行组合 Tower middleware;dhttp 只正确驱动 readiness 和服务调用,不要求特定 middleware 实现。即使 Service 已返回响应头,完整交换的生命周期仍持续到实际发送完成或取消。 - -### 5.2 连接与身份作为现有类型的 extensions - -不再定义接收 `(Request, connection, handshake)` 的 dhttp 专用 handler 签名。dhttp 在交给 Service 前,把同一次成功握手对应的下列对象写入标准 request.extensions: - -- `qconn::ArcConnection`:已有连接句柄。 -- `qtls::HandshakeSummary`:已有 local/remote authority 结果。 - -Service 直接读取标准 request.extensions,不使用特定框架的 extractor。它们来自底层握手,不从请求头或 URI 推断。remote 为 None 表示匿名,握手失败不会当成匿名请求分发。后续 dquic 若把身份查询并入连接,可以简化这些元数据;当前不新增 Context 包装。 - -Service 的错误由应用 middleware 优先转换为 HTTP 响应;未处理错误由接入层记录并在响应头发送前生成 500。响应头之后的 body 错误终止当前流,不再发送另一份响应头。路由语法错误属于所选 Router 的 API,不进入 dhttp::Error。 - -### 5.3 调用方组装 Router - -Pishoo 负责组装应用服务,DHTTP 只接收这个服务: - -```rust -let endpoint = Endpoint::load(servername).await?; -// router 由 Pishoo 完成路由、授权和执行环境装配。 -endpoint.listen(scopes, router).await?; -``` - -Router 内可以包含静态文件、代理、原生 handler 和 WASM,均通过同一个标准 HTTP Service 边界交给 DHTTP。DHTTP 不识别 handler 的执行技术,不负责 WASM 加载、WASI HTTP 转换、OpenAPI 解析或沙盒设置。 - -调用方可以使用 Axum,也可以使用其他实现 Tower Service 的应用。DHTTP 不依赖或重导出 Axum,响应 body 继续保持泛型。Server 目录、路由匹配、方法分发和内容更新均由调用方处理。 - -### 5.4 HTTP 交换与应用生命周期 - -DHTTP 负责 h3x 与标准 HTTP/body 的流式桥接、背压、trailers、连接/流取消及实际网络输出完成。正确驱动同一个 Service 的 readiness 和 call;返回响应头不表示 Exchange 已经结束。 - -调用方持有其应用任务、执行预算和业务状态。应用服务需要把取消与 body 终止衔接到自己的后台任务,并完成回收;DHTTP 不直接持有或控制 Wasmtime Store、guest 实例或应用沙盒。 - -Service 返回响应、body EOF 和网络输出完成属于不同阶段。通用的交换完成/取消通知及应用任务衔接仍需在实现时核对;不能靠一次 Service::call 返回或简单 timeout 认定所有应用任务已完成。这是标准 HTTP 应用生命周期要求,不引入 WASM 专属接口。 - -应用可在响应头前将错误转换为 HTTP 响应;未处理的 Service 错误按通用契约生成 500。响应头后的 body 错误只终止当前流,不再发送另一份响应头,也不关闭仍被其他交换使用的连接。HEAD/204/304 或应用替换 body 时,调用方负责原应用执行的清理,DHTTP 按 HTTP 语义完成最终输出。 - -### 5.5 Pishoo 的 OpenAPI 与身份沙盒 - -以下是 Pishoo 提供应用服务时的约定,不进入 DHTTP 公共类型或依赖。完整边界见 [Pishoo 服务接入说明](pishoo-service-boundary.md)。 - -- 一个 `.wasm` 包含多个业务 handler,配套同名 OpenAPI 3.1.x JSON;Pishoo 静态解析文档,以 method/path 注册入口,operationId 可选。不执行 guest 获取私有清单。 -- Pishoo 按声明的 API 逐条注册和授权。首版使用显式方法及字面量路径,未声明业务入口不进入 guest;OpenAPI schema 可服务于文档和 SDK,不要求 DHTTP 理解业务数据。 -- 一个规范化 Server 身份对应一个 WASM 沙盒。同身份 App 共享数据、能力上限和聚合资源账户,每请求独立创建 Store/Instance/ResourceTable;不可变编译代码可共享。 -- Pishoo 管理 WASM 编译、imports 校验、WASI HTTP 适配、文件/出站能力、guest 和 host I/O 任务,以及组件与 OpenAPI 的成对更新。 -- Server 执行边界覆盖所有路由的身份、总预算与生命周期;WASM 身份沙盒覆盖该身份的 WASM 分支,普通原生 handler 不因 middleware 获得 WASM 或操作系统隔离。 - -```text -DHTTP HTTP/3 → http::Request + 可信握手 extensions - → Pishoo ServerGuard + Router - ├─ 静态 / 代理 / 原生 handler - └─ OpenAPI 路由 + daccess 准入 - → 身份沙盒 → Pishoo WASI HTTP 适配 → guest - → http::Response → DHTTP HTTP/3 输出 -``` - -Pishoo 根据可信握手信息核对 Server 身份和 authority,再执行自己的逐 API 授权。身份向 guest 的投影和出站授权由 Pishoo 处理,普通请求 header 不产生可信身份。共享网络、QUIC/TLS 与 HTTP/3 运行层仍由 DHTTP 提供。 - -### 5.6 当前声明状态 - -DHTTP 已移除 WASM 专属模块、feature、Wasmtime 依赖、错误分支及私有 WIT 清单,不再定义 WasmApp 或 apis()。标准 Service、Body、Endpoint 与 Network 仍处于成员和接口占位阶段。 - -Pishoo 文档中的组件执行、OpenAPI 和身份沙盒方案属于应用层设计,尚不代表相应实现或集成测试完成。h3x 的 WASI HTTP 测试可作为 Pishoo 适配参考,不形成 DHTTP 内置 WASM 能力。 - -## 6. 身份与 home:最小模型 - -独立的 dhttp-identity crate 已删除;仍在使用的名称、证书链标识和证书规则迁入 dhttp-home。旧 Identity、LocalAuthority/RemoteAuthority traits 不再保留。 - -| 能力 | 归属与最小表达 | -|---|---| -| 名称输入与存储 | &str / String / Arc | -| DHTTP 名称规范化 | `dhttp-home` 的纯函数,供目录定位和运行层共用 | -| 请求 URI 简写展开 | dhttp 内部 URI 处理,不进入 home | -| 证书链、私钥加载后的签名能力 | 直接复用 dquic/qtls 的已有类型 | -| QUIC endpoint 使用的本地材料 | qbase::endpoint::Endpoint(当前 qconn 的既有输入) | -| 握手中的本端/对端身份 | qtls::LocalAuthority / RemoteAuthority | -| DHTTP 证书字段解析与校验 | 当前由 dhttp-home::certificate 提供,dhttp 不再包装这些规则 | -| 定位身份目录、读取默认凭据文件和身份级通信日志路径 | dhttp-home;network::quic 使用它装配端点 | - -`network::quic` 直接调用 `dhttp-home` 的目录和凭据读取接口,在内部完成端点装配: - -```rust -// 内部示意,不增加调用方需要理解的身份包装或构造步骤。 -async fn quic_endpoint(name: &str) -> Result; -``` - -本轮不定义 HomeConfig、HomeSettings、身份注册表、全局初始化、重扫快照、热替换、默认身份管理或保存事务等新 API,也不要求保留它们的旧签名。已有可用的文件读写代码可以按实现需要复用;具体如何组织不是这份顶层接口稿的前置条件。 - -名称和证书的 DHTTP 规则不能随结构一起丢掉。Pishoo 宿主直接使用 qtls 的签名能力和现有 DHTTP 算法选择;SKI 编解码与验签暂由 dhttp-home::certificate 提供。dhttp 不再提供只转发这些操作的 certificate 模块。 - -当前 workspace 已移除 identity crate 与对应 Cargo 依赖。home 不反向依赖 dhttp,access/log 直接使用 home 的名称和证书类型。 - -## 7. Network:汇总活动服务范围的全局网络 - -```rust -pub struct DhttpNetwork { - outbound_pool: h3x::Pool, - addresses: Arc, -} - -static NETWORK: OnceLock = OnceLock::new(); - -impl DhttpNetwork { - pub async fn init() -> Result<&'static Self>; - pub fn global() -> Result<&'static Self>; - pub async fn shutdown(&self, deadline: Instant) -> Result; -} -``` - -每个 Server 在 `Endpoint.listen` 时提供 scopes。Network 汇总活动 Server 的 scopes,并展开到当前和将来的适用网卡。调用方不指定网卡、索引、地址或端口;这些资源随系统变化,由 Network 选择。Network 不扫描身份目录。没有活动 Server 时不创建监听 socket。 - -### 7.1 当前 qprotocol 的资源边界 - -以当前 qprotocol / qudp 为依据;已废弃的 qinterface 不进入设计或依赖。qprotocol 没有网卡配置类型,也不负责枚举网卡: - -- `qprotocol::UdpSocket` 直接重导出 `qudp::UdpSocket`。qudp 提供 `bind(SocketAddr)` 和 `bind_to_device(SocketAddr, BoundDevice)`;`BoundDevice::new(name, index)` 接收已解析的名称及非零网卡索引。 -- `Dock::add(Arc)` 接管 socket 的接收分发,按实际 `local_addr()` 管理;DHTTP 使用它接收 QUIC。 -- `AddressBook` 将具体绑定地址关联到 inner / outer / agent 协议地址,不以网卡名作为地址键。 - -`Dock::global()` 已提供进程级 Dock,按绑定地址保存 socket 登记并持有收包任务;DhttpNetwork 持有已绑定 socket 的生存引用及范围,供撤销和重绑使用,不另建收包任务。`AddressBook` 已维护地址快照与订阅,DhttpNetwork 不复制 `NetworkState`、`SocketStatus`、`NetworkStatus` 或 watch 发布器。qprotocol 不枚举网卡,也不解释 Loopback / Internal / External 选择规则。 - -一个 scope 可匹配多张网卡,一张网卡又可产生多个 IPv4 / IPv6 socket。最终绑定的 socket 交给 Dock;调用方不提供易变化的索引、地址快照、端口或已打开的 socket。 - -核对入口:[qprotocol socket 重导出](/Users/lixiaofeng/code/genmeta/dquic/qprotocol/src/socket.rs)、[qudp 网卡绑定](/Users/lixiaofeng/code/genmeta/dquic/qudp/src/lib.rs)、[Dock](/Users/lixiaofeng/code/genmeta/dquic/qprotocol/src/dock.rs)、[AddressBook](/Users/lixiaofeng/code/genmeta/dquic/qprotocol/src/addr_book.rs)。 - -### 7.2 网卡选择与范围 - -`listen external` 的确定语义是 **listen all external device**:Network 持续监听所有适合 External 通信的网卡,包含以后新增或恢复的网卡。按通信范围监听已经包含“该范围内全部适用网卡”的含义。下面以系统分配端口为例: - -```rust -endpoint.listen(Scope::External.into(), app).await?; -``` - -若当前有 en0、en1 两张适用网卡,Network 为两张网卡都建立符合 External 范围的绑定;以后 en2 新增时再加入。Server 的 scopes 持续保留,不能用首次展开的结果替换。 - -scopes 在展开时关联到具体网卡及绑定地址;它在实际入口上约束允许的来源。沿用 qconn 的独立位语义,不默认累加。Endpoint.listen(scopes, app) 同时决定该身份的来源范围;每个服务也受 Network 实际 socket 的汇总策略限制。一个 Server 启用 External 不会扩大其他 Server 的允许范围。同一张网卡可通过一组 socket 同时承担 Internal 和 External。 - -| 范围 | 网卡及 socket 用途 | -|---|---| -| Loopback(localhost) | 回环网卡上的具体回环地址 | -| Internal | 所选网卡上用于内网通信的地址 | -| External | 所选网卡上允许接收外网来源包的地址 | - -Server 开始监听及每次系统变化时,Network 枚举网卡,按所有活动 Server 的 scopes 展开全部适用网卡,解析当前索引与符合所选范围的可用单播地址,分别构造具体 SocketAddr。IPv4 / IPv6 均可使用,不绑定通配地址;IPv6 链路本地地址保留系统 scope ID,只用于适合的局部通信。Loopback 必须使用回环资源;External 可以绑定 NAT 后的私网地址,也不要求网卡承担默认路由。绑定成功不表示公网路由或 NAT 转发已配置。具体可用性随地址和链路变化。 - -scope 当前没有匹配或可用网卡时,Network 仍保留活动 Server 的范围,等待网卡新增或恢复;没有可用路径的操作返回 NetworkUnavailable。设备暂时不可用时按恢复流程处理。 - -活动 Server 的 scopes 先合并,再展开到具体网卡与地址并绑定,因此多个 Server 使用同一范围不会创建多组 socket。绑定端口由 Network 内部决定;每个 socket 绑定后读取实际端口。当前 Dock 以 SocketAddr 为键,不支持把两个不同网卡上相同的绑定地址视为独立资源;出现该冲突应明确报错,不能把其中一个静默合并到另一张网卡。 - -### 接入检查由 Network 统一负责 - -```text -实际接收网卡 / socket + QUIC 路径来源 - → Network 检查该入口的 scopes - → 查找已监听的 servername,检查该 Endpoint 的 scopes 并完成 TLS/QUIC 接入 - → 交付该 Endpoint 的 HTTP Service -``` - -不符合范围的接入在 Network/传输接入层拒绝,不交给 Endpoint 的应用。尽可能在创建完整连接前检查;交付前仍应确认目标服务有效。来源取自实际收包与传输路径信息,不来自 HTTP header 或 URI。 - -范围按接收 socket 实际适用的 scopes 判断;回环与非回环、链路本地与其他地址的可用范围不同。共享 socket 合并后的策略只对应该共享资源,不能泄漏到其他入口。 - -首次 accept 不是唯一检查点。QUIC 后续新路径、地址迁移及网络资源重建也要遵守相同来源规则,已建立连接不能借换路径扩大准入范围。未知或无法可靠归属的入口不应直接交付到 Endpoint。 - -qconn 的 `QuicEndpoint::listen` 接收逐 servername 的 scopes,并在 QUIC 收包路径检查来源。dhttp 将 Endpoint.listen 指定的 scopes 原样传入;Network 还在实际接收 socket 上检查其配置。因此逐身份范围和逐入口范围共同生效,不能用所有网卡 scopes 的并集代替任一层。 - -复用依据:[来源分类](/Users/lixiaofeng/code/genmeta/dquic/qconn/src/endpoint.rs:215)、[QUIC 收包检查](/Users/lixiaofeng/code/genmeta/dquic/qconn/src/recv.rs:54)。 - -### 7.3 监听创建 - -Network 初始化后不绑定监听 socket。Server 开始监听时汇总活动 scopes,展开当前快照,通过 `UdpSocket::bind_to_device` 绑定具体地址与网卡,交给 Dock 接收分发,接入 `qtransport::QuicRouter` 并登记协议端点。External 只改变该绑定允许的来源范围;DHTTP 不因 External 解析 bootstrap、探测 STUN 或向 AddressBook 写入 outer 地址。当前没有可用资源时仍保留 Server 登记,等待网卡恢复。 - -### 7.4 网络变化监听与恢复 - -网络变化监听、网卡快照维护和资源恢复是 `DhttpNetwork` 的核心职责,与 init / shutdown 属于同一生命周期。Network 按活动 Server 的 scopes 决定使用哪些网卡,绑定端口由内部策略决定;qudp 提供 socket 绑定与 I/O,qprotocol 提供协议分发、登记和地址簿。恢复策略由 Network 统一管理,不依赖已废弃的 qinterface,也不要求每个 Endpoint 单独监听。 - -init 建立进程内共享的系统网络维护任务,但不创建监听 socket。Network 定期读取完整网卡快照,覆盖网卡新增、删除、up/down、索引及 IPv4 / IPv6 地址变化。每次变化都按活动 Server 的 scopes 重新求值,新网卡可自动加入。路由变化不会改变 External 的接收范围定义。 - -Network 根据最新快照更新实际资源: - -- 保留未受影响的 socket,只对新增、失效或需要替换的资源操作。新增或重新出现的网卡按最新快照判断规则匹配,并解析当前索引。 -- 地址失效、网卡删除或 down 时,停止使用对应入口,撤销 AddressBook 中的旧地址和 QUIC 登记,再移除 Dock 中的旧 socket。 -- 网卡新增或 up、新地址出现时,重新求值选择规则,为新匹配资源绑定、加入 Dock 并登记协议端点。External 不额外发布外部地址。 -- 初始化后的暂时断网或绑定失败只使受影响资源不可用;保留配置,等待网络变化后恢复。已有 Endpoint/app 登记继续保留。 - -Network 管理本地网络资源及可用地址变化;已有 QUIC 连接的路径验证和迁移由 qconn / qtransport 承担,具体变化通知接缝在实现时对接。网络资源恢复不等于所有既有请求都能继续,失去可用路径的连接仍遵守底层失败语义。 - -shutdown 先停止监听及重绑,再取消并等待相关后台任务,清理共享 socket 与协议登记;所有步骤共用同一个 deadline。锁只保护短暂的状态读取和提交;Endpoint.close 不停止进程的网络监听。 - -网络启动与身份加载互不依赖。network.init 不要求初始化 home 或先加载 servername;Endpoint.listen 才将身份和 app 交给运行层。凭据读取不启动服务。 - -重复 init 返回错误;global 在初始化前返回错误;shutdown 终结全进程网络,不隐式重新初始化。Endpoint.close 只清理自己的登记、交换和复用权限,不关闭其他身份仍使用的 interface。 - -连接池请求从发起方 Endpoint 得到其名称 Arc。内部池 key 按名称 Arc 的实例身份和远端 authority 区分连接;克隆的 Endpoint 共享池条目,独立加载的同名 Endpoint 不会误共享。池未命中时 Network 按名称读取凭据、构造 qconn::QuicEndpoint 并建连;这属于池工厂工作,不要求逻辑 Endpoint 持有底层端点。凭据来源更新时还需使旧池条目失效,不能只比较名称文本。活动连接的资源引用不等于 Network 拥有身份目录。 - -入站连接由 qconn 的 `listen` 回调交付,Network 将已接受的连接构造成 Server 角色的 H3 connection,并跟踪接入任务。`outbound_pool` 只复用 Client 角色的出站连接;已接受的 Server 角色连接不能作为新出站请求的池条目。 - -## 8. 错误与关闭报告 - -```rust -pub type Result = std::result::Result; - -pub enum Error { - InvalidName { name: String }, - InvalidUri { source: http::uri::InvalidUri }, - AlreadyListening, - EndpointClosed, - NetworkClosed, - NetworkUnavailable, - NetworkNotInitialized, - AlreadyInitialized, - IdentityNotFound { name: String }, - Credentials { source: qtls::RustlsError }, - Http { source: http::Error }, - Http3 { source: h3x::Error }, - Quic { source: qconn::Error }, - Io { source: std::io::Error }, - Home { path: PathBuf, source: Box }, -} - -pub struct ShutdownReport { - pub forced_connections: usize, - pub cancelled_exchanges: usize, - pub unfinished_tasks: usize, -} -``` - -请求入口接受已解析的 `Uri`;`impl From for Error` 支持在返回 `dhttp::Result` 的调用方直接写 `endpoint.get("https://example.com/".parse()?)`。源码中的可克隆错误包装保存同一个原始解析错误。 - -保留底层错误来源,不把 HTTP/3 stream 错误压成一个无内容的 Transport。名称、路由和关闭状态是 dhttp 自己的错误。Home 保留出错文件或目录及底层原因,包括读取、解析、权限及保存错误;具体转换与 Display/Error 实现不在本轮实现。 - -deadline 是同一个绝对 `std::time::Instant`,不是每个关闭步骤重新计时。达到 deadline 属于关闭报告:剩余交换被取消,连接被强关,未完成任务计数返回;无法完成清理本身的错误通过 Result 返回。Session 尚未进入本轮接口,报告中不放 session 字段。 - -## 9. 普通 DHTTP 应用的使用形状 - -```rust -// Endpoint 内部读取凭据并使用底层身份类型,调用方只给出名称。 -let endpoint = Endpoint::load("alice.dhttp.net").await?; - -// 共享网络先启动,监听规则由各 Server 随服务提交。 -let network = DhttpNetwork::init().await?; - -// 无请求体的便捷调用:await 得到带流式 body 的 h3x Response。 -let mut response = endpoint.get("https://bob.dhttp.net/info".parse()?).await?; -let mut body = Vec::new(); -response.read_to_end(&mut body).await?; - -// 调用方写请求体:首次 await 返回请求 body 和独立的响应 future。 -let (mut request_body, response) = endpoint.post("https://bob.dhttp.net/upload".parse()?).await?; -request_body.write_all(chunk).await?; -request_body.shutdown().await?; -let response = response.await?; - -// app 由应用提供,实现 Service>。 -// dhttp 不构造路由器。 -// app 由调用方提供;Network 统一判断来源,只有显式 listen 才启动服务。 -endpoint.listen(scopes, app).await?; -``` - -示例表达目标调用形状,所需请求执行绑定尚未定稿;不能据此认为当前 h3x::Request 已可 await。body 读写使用 Tokio AsyncReadExt / AsyncWriteExt。 - -## 10. Pishoo 接入边界 - -Pishoo 负责 Server 发现、应用内容、OpenAPI、策略、路由、发布、WASM 运行与身份沙盒;dhttp 负责凭据读取、通信、共享网络、标准 HTTP 消息和交换生命周期。具体 Server 文件路径如何交给轻量 home 读取,在接入时再定,不为这个问题设计完整 home 框架。 - -Endpoint 的上层使用形状保持简单: - -```rust -let endpoint = Endpoint::load(servername).await?; -endpoint.listen(scopes, app).await?; -``` - -Pishoo 组织一个混合 Router:静态、代理、原生 handler 和多个 WASM 应用共享 Server 执行边界,WASM 分支再受当前身份沙盒约束。Pishoo 自己加载组件和同名 OpenAPI,逐条注册声明入口,在 guest 执行前授权;完成组装后连同 Server 的 scopes 调用 Endpoint.listen(scopes, router),不为每个 handler 创建 Endpoint、监听或网络。 - -WASI HTTP body 桥接、Store/Instance、guest 调用和应用任务回收全部归 Pishoo;DHTTP 通过标准 Service/body 提供通用传输与生命周期衔接。不存在 dhttp::WasmApp 或 WASM 宿主注入接缝。Pishoo 既有设计中尚引用这些旧上游接口的部分按本次边界调整,见 [接入说明](pishoo-service-boundary.md)。gmutils 后续按新的接口适配,不要求本轮保留旧 identity/home 抽象。 - -## 11. 本轮审查与实施边界 - -1. 按最简单的模型重构,不以旧 identity/home 或 gmutils 兼容为接口约束。 -2. 删除重复身份和名称包装,直接使用 dquic/qtls/h3x 已有类型;保留必要的 DHTTP 名称与证书规则函数。 -3. home 暂时只是目录定位和凭据读取辅助,不详细设计其对象、配置、缓存、扫描或生命周期;Endpoint 只暴露 load(servername)。 -4. 每个 Server 只传 scopes,不指定网卡、端口或已打开的 socket;`listen external` 直接表达 External 范围,持续展开到全部适用网卡。External 不启动 STUN 探测。Network 汇总活动 Server 的 scopes,负责系统网卡变化时的资源重绑与恢复;使用 qprotocol 的全局 Dock 管理 socket,按入口和来源统一准入,Endpoint.listen 接收 scopes 和 app。 -5. HTTP 消息复用 h3x;Endpoint 返回的 `Request<()>` 和 `Request` 分别实现两种 IntoFuture 输出。get 等无请求体便捷调用直接 await 得到响应;post 等需要调用方写请求体的调用直接 await 得到请求 body 写入端和 `dhttp::Response`。接口声明已同步到代码,执行能力与消息的绑定仍待实现;只给未绑定的 h3x 请求增加 IntoFuture 无法补足配对接收流。 -6. listen 仅约束 tower_service::Service,不依赖 Axum;Pishoo 组装 Router,完整负责 WASM、WASI HTTP、OpenAPI、逐 API 授权和身份沙盒。DHTTP 不增加 WASM 类型、feature、依赖或私有清单。 -7. Pishoo 和 gmutils 的具体调用迁移留到接入阶段;本轮只收敛接口稿,后续同步修改依赖关系,不用兼容层长期保留旧 identity 模型。 diff --git a/docs/design/dhttp-next-architecture.md b/docs/design/dhttp-next-architecture.md deleted file mode 100644 index 217d6b3..0000000 --- a/docs/design/dhttp-next-architecture.md +++ /dev/null @@ -1,734 +0,0 @@ -# dhttp 下一版职责与架构设计 - -> 历史设计:本轮 h3x/dhttp/Pishoo 对接及结构成员以 [三仓接口冻结 v1](../../../pishoo/design/README.md) 和 [dhttp 结构清单](../../../pishoo/design/dhttp-interfaces.md) 为准。本文保留历史讨论,不作为本轮实现依据。 - -状态:**设计提案,尚未实现。** - -日期:2026-09-22。 - -本文保留早期分析基线;Rust 公共接口以 [顶层接口审查稿](../api/top-level-review.md) 为准。WASM 接入按本轮职责调整:Pishoo 负责执行、OpenAPI 与身份沙盒,DHTTP 只接收标准 Router/Service。详细边界见 [Pishoo 服务接入](../api/pishoo-service-boundary.md)。 - -本文分析当前 `dhttp` 的职责,并以本地 `h3x feat/wasm`、`dquic dev` 和 Pishoo design 文件为输入,定义下一版 dhttp 的边界、核心 API、SDK 形状及下一版 Pishoo 的接入方式。 - -用户输入中的 `h3x feat/awsm` 在本地不存在;本文按实际存在的 `feat/wasm` 分支处理。 - -## 1. 结论 - -下一版 dhttp 应成为 **带有身份的 HTTP/3 通信库**:向下组合 h3x 与 dquic,向上只暴露稳定的 DHTTP 端点、HTTP 消息、连接身份、服务登记和生命周期接口。 - -核心依赖方向固定为: - -```mermaid -flowchart TD - SDK[Node.js / Python SDK] --> D[dhttp 公共 API] - P[Pishoo] --> D - G[Pishoo gateway] --> D - D --> H[h3x: HTTP/3 协议] - D --> Q[dquic: QUIC / TLS / 网络路径] - P --> W[Wasmtime / WASI HTTP] - P --> DB[(proxy_locations)] -``` - -各层的关键职责如下: - -| 层 | 负责 | -| --- | --- | -| dquic | QUIC 连接、TLS 握手、双向/单向流、datagram、网络接口、路径与 NAT 穿透 | -| h3x | HTTP/3 SETTINGS、QPACK、HEADERS/DATA/trailers、GOAWAY、请求流状态与协议错误 | -| dhttp | DHTTP 名称和身份、DNS、共享网络资源、dquic→h3x 适配、连接复用、服务登记、已验证连接身份、标准 HTTP Body、取消与关闭、SDK 语义 | -| Pishoo | Server 目录、SQLite 代理路由、静态文件、WASM/WASI HTTP 运行、OpenAPI、身份沙盒、应用授权、发布版本和终端业务策略 | - -Pishoo、gateway 和 SDK 不再直接使用 `h3x::*`、`dquic::*`、`qconnection::*` 或 QUIC stream 类型。dhttp 内部允许替换 dquic 的连接实现,调用方 API 不随之变化。 - -## 2. 本地分析基线 - -本文只读分析了相邻仓库,没有切换或修改它们的分支。 - -| 仓库 | 本地基线 | 工作区状态 | 与设计有关的事实 | -| --- | --- | --- | --- | -| dhttp | `main@68744e0`,`v0.6.2` | 干净 | 依赖 `h3x 0.6.1`、`dquic 0.7.1`,仍使用旧 `H3Endpoint`/`QuicEndpoint` 组合 | -| h3x | `feat/wasm@1bd1a8e` | 6 个未提交文件 | crate 版本 `0.6.2`;已移除旧 dquic feature 和 Endpoint/Runtime,只保留 HTTP/3 primitives、`Transport`、`H3Connection`、`Pool` | -| dquic | `dev@bc039623` | 干净 | crate 版本 `0.7.2`;公开 `dquic` crate 仍基于 `qconnection 0.8.2`,新 `qconn/qtransport` 尚未接入公开 facade | -| Pishoo | `main@fcd203c` | 本地分支分叉,design 文件未跟踪 | 下一版 design 已选择单进程、多 Server、固定目录、单表代理配置和进程内 WASM | - -h3x 的未提交修改包含两个会影响关闭语义的修正: - -1. 本地 GOAWAY 后同时停止 `open_bi` 和 `accept_bi`; -2. 在最终 HEADERS 前收到 EOF 时取消配对方向,防止交换残留。 - -这些修改属于本文要求的“最新本地状态”,但 Git 依赖无法锁定未提交内容。实施前必须先形成一个可引用 commit。 - -dquic dev 的 workspace MSRV 是 Rust 1.88,当前 dhttp README 标注 Rust 1.85+。下一版若采用该 dev 基线,需要把 dhttp、SDK 构建镜像和 Pishoo 的 Rust 基线统一到 1.88,或者先由 dquic 明确降低并验证 MSRV。 - -### 2.1 当前依赖不能直接升级 - -当前 dhttp 在 `dhttp/src/endpoint.rs` 中使用: - -- `h3x::dquic::H3Endpoint`; -- `h3x::endpoint::H3Endpoint`; -- `h3x::connection::ConnectionBuilder`; -- dquic 旧 `QuicEndpoint` 及其 identity replacement API。 - -这些接口在本地 h3x `feat/wasm` 中已不存在。最新 h3x 的公开面是: - -- `Transport` 和 `TransportError`; -- `H3Connection`; -- `Request`、`Response`、`ArcWndBuf`; -- `ReadRequest`、`WriteRequest`、`ReadResponse`、`WriteResponse`; -- `Pool`。 - -因此升级工作本质上是重新建立运行层和适配层,不能只修改 Cargo 版本。 - -### 2.2 依赖源还有一个隐含风险 - -h3x 直接依赖 `qbase 0.6.4` 和 `qrecovery 0.6.2`,其 `Transport` 约束直接引用 `qrecovery::StopSending/CancelStream`。dquic dev workspace 也包含同版本包,但 Git/path 源和 crates.io 源可能被 Cargo 视为不同包,导致 trait 类型不相同。 - -首个实现阶段必须二选一: - -1. 让 h3x 定义自己的最小 receive/send cancellation trait,解除公开传输契约对 qrecovery 的类型耦合;这是推荐的长期方案。 -2. 在集成阶段把 h3x 与 dquic 使用的 `qbase/qrecovery` 固定到同一 source 和 revision,并在发布前先发布一致版本。 - -Pishoo 只能依赖 dhttp,不能用自己的 `[patch]` 修复这条底层依赖图。 - -## 3. 当前 dhttp 的实际职责 - -当前仓库已经承担六类职责。 - -### 3.1 DHTTP 门面 - -根 crate 重导出 identity、home、access、log、h3x 和 dquic。它让应用通过一个 crate 获得完整协议栈,但也把底层库的模块布局变成了 dhttp 的公开契约。 - -### 3.2 Endpoint 装配 - -当前 `Endpoint` 同时装配: - -- DHTTP identity; -- DNS resolver/publisher; -- STUN、mDNS 和共享 network; -- dquic 客户端/服务端配置; -- h3x endpoint 和连接 builder; -- 高层 GET/POST/listen; -- identity 热替换。 - -这个模型适合单应用单端点,但 Pishoo 需要一个进程共享网络设施并承载多个名称。 - -### 3.3 HTTP 消息和服务路由 - -`dhttp/src/message.rs`、`endpoint/client.rs` 和 `endpoint/server/*` 提供自有消息状态机、body 操作、方法路由与 service。它们与旧 h3x 消息 API 紧密耦合,并与最新 h3x 的 `Request/Response + ArcWndBuf` 发生重叠。 - -### 3.4 网络、发现和信任默认值 - -`network.rs`、`ddns.rs`、`bootstrap.rs` 和 `trust.rs` 管理 DHTTP 特有的发现、发布、bootstrap 与信任配置。这部分仍应由 dhttp 保留,因为它们表达的是 DHTTP 产品语义,而不是普通 QUIC 或 HTTP/3 语义。 - -### 3.5 Access、home 和 log 组件 - -这些 workspace crate 有独立价值,但当前核心 `dhttp` 对它们是硬依赖。下一版中: - -- 不保留独立的 `dhttp-identity` crate;当前使用的 DHTTP 名称和证书规则归 dhttp-home,QUIC/TLS 身份材料由 dquic 提供; -- home 的目录管理和本地凭据读取保留在独立 `dhttp-home` crate;核心 `network::quic` 调用它装配 QUIC 端点; -- access/log 作为可选 middleware 和工具 crate; -- 核心 DhttpNetwork 不读取 access 数据库,也不决定 Pishoo 策略。 - -### 3.6 Node.js/Python SDK - -`dhttp-api` 目前再次依赖 dhttp、ddns 和 h3x,并暴露 raw message reader/writer、connection 和 endpoint。这个方式把 Rust 内部类型变化扩散到了 N-API、PyO3、JS 包装和 Python 包装四层。 - -## 4. 当前模型的主要问题 - -| 问题 | 后果 | 下一版处理 | -| --- | --- | --- | -| 公开重导出 h3x/dquic | 上游重构直接破坏 Pishoo 和 SDK | 底层类型改为 private;只保留 dhttp 自有类型 | -| Endpoint 自带整套网络 | 多名称重复资源,关闭边界不清楚 | 引入可选共享的 `DhttpNetwork`,端点共享它 | -| h3x 与 dhttp 都有消息抽象 | body、取消和错误可能双重建模 | 客户端提供流式 builder,服务适配采用 `http/http-body`;协议状态归 h3x | -| SDK 直接包装 raw stream | 语言绑定被 Rust 内部 API 牵引 | SDK 绑定稳定的 Endpoint/Request/Response/Body | -| identity 替换跨多层 | 新旧证书、连接池、旧连接的行为不一致 | 引入 credential generation 和原子替换契约 | -| 连接池 owner 不唯一 | GOAWAY、终止、撤销和 idle 回收可能漏清理 | dhttp 制定复用策略,h3x Pool 保存唯一复用集合 | -| Drop 承担异步清理 | 任务可能失去 owner,测试和退出不可控 | 显式 stop/drain/shutdown;Drop 只发出同步撤销信号 | -| WebTransport 旧接口已删除 | 当前 Pishoo SSH 链路无法按原类型迁移 | 作为独立协议扩展交付,不伪装成普通 HTTP 已完成 | - -## 5. 目标领域模型 - -领域术语同时记录在仓库根目录 `CONTEXT.md`。公共对象采用以下关系: - -```mermaid -classDiagram - class DhttpNetwork { - +shutdown(deadline) ShutdownReport - } - class Endpoint { - +get(url) ResponseBuilder - +post(url) RequestBuilder - +listen(app) - +rotate_identity(identity, expected) CredentialGeneration - +close(deadline) ShutdownReport - } - class Exchange { - +Request~Body~ - +Response~Body~ - } - DhttpNetwork "1" o-- "many" Endpoint - Endpoint "1" --> "many" Exchange -``` - -### 5.1 共享网络资源(DhttpNetwork) - -`Endpoint` 是应用入口,不要求调用方先创建其他顶层对象。多身份应用可以显式注入同一个 `DhttpNetwork` 复用网络资源;普通应用由 Endpoint builder 创建并持有它。 - -`DhttpNetwork` 只表达共享网络资源及其关闭边界,不是容纳所有功能的通用 runtime。职责按具体模块分开:`discovery` 管理解析和发布;`connection` 管理连接池和连接任务;`listener` 管理多名称接入及服务登记;`transport` 适配 dquic 与 h3x。网络对象协调这些资源的关闭,不负责应用路由或身份文件加载。 - -注入隔离的网络资源后,同一进程可以创建多个网络用于测试。dquic 默认全局 router 只允许一个 listener 时,重复启动必须返回清晰的 `already_running` 错误。Pishoo 可让多个 Endpoint 共享一个网络;它与 Tokio runtime 无关。 - -### 5.2 Endpoint - -`Endpoint` 是逻辑 DHTTP 参与方,包含规范化名称、可选 identity、credential generation、网络 profile 引用和 DhttpNetwork 引用。它可以发起请求;具有 identity 的 Endpoint 可以登记服务。 - -Endpoint 不是 QUIC 连接,也不独占 UDP socket。多个 Endpoint 可以共享同一 DhttpNetwork 和物理监听资源。 - -### 5.3 监听与停止接入 - -同一个 Endpoint 同时只运行一个 app。同一共享 listener 中,一个规范化名称只绑定一个活动 Endpoint。内部记录名称与 app 的对应关系即可,不公开 ServiceRegistration 类型。 - -`endpoint.listen(app).await` 持续处理请求,直到停止或错误退出。结束 `endpoint.listen(app)` future 撤销该 Endpoint 的监听登记;已接入请求可继续完成。`endpoint.close(deadline).await` 排空并关闭该 Endpoint 的请求、连接和后台任务,不影响其他 Endpoint。 - -普通应用内容更新在 Router 或稳定 dispatcher 内完成,不重建监听。listen future 结束时清理监听登记。 - -### 5.4 Exchange - -一次 Exchange 覆盖请求头、请求体、响应头、响应体、trailers、stream 取消和实际网络输出完成。请求 handler 返回 `Response` 只表示应用产生了响应头,不能表示 Exchange 已结束。 - -## 6. 推荐的 Rust 公共 API - -下面是目标接口,尚未实现。应用通过 Endpoint 发起带身份的 dquic HTTP/3 请求,或者把 Router 交给 Endpoint 接收请求。 - -```rust -let endpoint = Endpoint::load("alice.example").await?; - -// GET 默认没有请求体,发完请求头即结束请求方向,等待响应头。 -let mut response = endpoint.get(url) - .header("accept", "application/json") - .await?; -let body = response.read_to_bytes().await?; - -// POST 默认打开流式请求体;首次 await 不等待响应头。 -let (mut request, response) = endpoint.post(url) - .header("content-type", "application/octet-stream") - .await?; -request.write(chunk).await?; -request.shutdown().await?; -let mut response = response.await?; -let body = response.read_to_bytes().await?; - -// app 是 Router;路由匹配和 handler 组合属于 app。 -let app = Router::new().route("/upload", post(upload)); -endpoint.listen(app).await?; -``` - -### 6.1 Builder 和流的语义 - -- `get(url)` 返回 `ResponseBuilder`;`.header(name, value)` 链式配置,`.await` 返回 `Result`。HEAD 等无请求体便捷调用具有相同语义。 -- `post(url)` 返回 `RequestBuilder`;`.header(name, value)` 链式配置,`.await` 返回 `Result<(RequestWriter, PendingResponse)>`。PUT/PATCH 和显式流式请求沿用这个形状。 -- 两类 builder 的返回类型静态确定,不因同一 builder 内的 method 值改变 await 输出类型。需要携带请求体的 GET 可使用显式 `endpoint.request(method, url)` 流式入口。 -- `RequestBuilder` 的 await 完成解析、连接和请求头发送后立即返回。`PendingResponse` 是独立响应 future,其 await 等待响应头并返回 `Result`。不能在返回 writer 前等待响应头,否则读取完整上传后才响应的服务端会导致死锁。 -- `RequestWriter::write` 是异步写入,受流量控制和有界缓冲背压约束;`shutdown` 发送剩余数据及 trailers,并结束请求方向,重复调用幂等。之后写入返回已关闭错误;它不关闭响应方向或 Endpoint。 -- 收响应和继续写请求可并发进行;服务端可提前响应。丢弃未完成 writer 或响应句柄会按交换取消规则通知 driver,禁止留下无人管理的流。 -- header/URI 校验错误通过 builder 的 await 返回。身份来自 Endpoint 配置和握手,不通过 header 注入。 - -### 6.2 服务端 Router - -`endpoint.listen(app).await` 运行该身份的请求接入循环,返回 `Result<()>`;正常停止返回 Ok,接入失败返回错误。app 是 dhttp Router,按方法和路径组合 handler;Tower 适配接受标准 `http::Request` 并返回 `http::Response`。 - -listen 不返回额外服务登记对象。需要同时执行其他工作时,可以 spawn listen 或使用 select 并发运行。应用通过 Endpoint 停止监听或关闭,通过请求关联的 dquic 连接读取对端身份。原生 Request/Response 提供 connection 访问,Tower 适配在 extensions 放入同一连接句柄,不复制身份字段,也不创建 Context 对象。 - -### 6.3 模块和 crate 边界 - -```text -dhttp - endpoint Endpoint 构造、身份关联、get/post/request/listen - client 请求 builder、RequestWriter、PendingResponse、Response - server 入站请求与响应适配、监听生命周期 - router 方法/路径路由与 handler 组合 - identity DHTTP 名称、证书校验、身份准备与更新 - discovery 名称解析、DNS 发布与 bootstrap - network 可共享的 DhttpNetwork 和网络配置 - connection 私有连接池与连接生命周期 - listener 私有多名称接入与准入控制 - transport 私有 dquic → h3x 适配 - body 标准 HTTP Body、背压与取消桥接 -``` - -不建立 `runtime` 模块或 crate。`dhttp-api` 保留语言绑定职责;access/log 按独立复用价值保留为可选组件。独立的 `dhttp-identity` crate 已移除,当前使用的名称和证书规则归 dhttp-home;通用 TLS 能力由 dquic 提供,底层库不反向依赖 dhttp。 - -稳定公共面包括 Endpoint、Router、请求 builder、RequestWriter、PendingResponse、Response、Body、连接访问与错误码。`DhttpNetwork` 仅为需要共享资源的高级配置入口;`Endpoint::load` 和 builder 是默认构造方式。H3Connection、QPACK 和原始 stream 类型不属于顶层接口;dquic 连接通过明确的 connection 访问口提供握手身份,不另外包装认证结构。 - -## 7. dquic → h3x 内部适配 - -dhttp 新增私有适配器: - -```text -DquicTransport - Arc - fixed h3x::Role - DquicRecvStream(StreamReader) - DquicSendStream(StreamWriter) -``` - -它实现 h3x `Transport`,映射关系如下: - -| h3x 操作 | dquic dev 操作 | -| --- | --- | -| `open_bi` | `Connection::open_bi_stream` | -| `accept_bi` | `Connection::accept_bi_stream` | -| `open_uni` | `Connection::open_uni_stream` | -| `accept_uni` | `Connection::accept_uni_stream` | -| `close(reason, code)` | `Connection::close` | -| 终止观察 | `Connection::terminated` | -| 本地/远端身份 | `local_authority` / `remote_authority` | - -适配器必须满足以下规则: - -1. 创建 `H3Connection` 前等待 QUIC handshake 完成,并验证协商 ALPN 为 `h3x::ALPN`。 -2. role 在构造时读取并固定,后续不把 `Connection::role()` 的失败伪装成另一 role。 -3. stream RESET/STOP code 保留为 stream scope 错误;连接终止映射为 connection scope 错误。 -4. accept/open future 被取消时,未交付 stream 仍由 dquic 拥有。 -5. HTTP/3 协议码只由 h3x 解释;dhttp 不复制一套 frame 或 QPACK 状态机。 -6. 连接终止必须唤醒 open/accept/body I/O 和 dhttp 的 drain waiter。 - -dquic dev 中的新 `qconn/qtransport` 仍处在 workspace 内部演进阶段,公开 dquic crate 当前继续依赖 `qconnection`。本轮适配只面向 dquic facade 和它公开的 qconnection re-export;后续 dquic 切到 qconn 时只替换本模块。 - -初版关闭 0-RTT。等重放语义、请求方法策略和身份上下文经过真实链路测试后再单独启用。 - -## 8. HTTP Body、背压和取消 - -### 8.1 公共 Body - -dhttp 公共 Body 实现标准 `http_body::Body`,帧类型保留 DATA 和 trailers。内部可使用 h3x `ArcWndBuf`,但调用方看不到窗口类型。 - -Body 只要求 `Send`,不额外要求 `Sync`,以适配 Axum、WASI HTTP 和语言 SDK 的异步流。所有桥接使用有界缓冲,不能把请求或响应先 `read_to_end`。 - -### 8.2 并行推进 - -client driver 同时推进: - -- 请求头和请求体发送; -- 响应头接收; -- 响应 body 交付。 - -server driver 同时推进: - -- 请求 body 接收; -- handler; -- 响应头和响应 body 发送。 - -因此服务端可以在未读完上传体时返回响应头,客户端也可以收到响应头后继续上传。这个行为是 Pishoo WASI 流式处理的前置条件。 - -### 8.3 取消矩阵 - -| 事件 | 请求接收方向 | 响应发送方向 | 连接 | -| --- | --- | --- | --- | -| handler 明确放弃未读请求体并正常响应 | STOP_SENDING `H3_NO_ERROR` | 继续 | 保留 | -| 客户端取消响应 body | 当前 response stream 停止 | 当前 request stream 按状态取消 | 保留 | -| 响应 headers 后 body 失败 | 不再发送第二组 headers | RESET 当前 stream | 保留,除非 h3x 判定连接级错误 | -| 服务登记停止准入 | 已接收交换继续 | 已接收交换继续 | 可继续服务其他登记 | -| DhttpNetwork deadline 到期 | 取消所有剩余交换 | 取消所有剩余交换 | dquic close | - -Body 的 Drop 只发出取消信号;清理、reset/stop 和 join 在 DhttpNetwork 所有的任务域中完成。正常 EOF、应用替换 response body、HEAD/204/304 抑制 body 是不同事件,driver 必须按实际发送状态决定协议动作。 - -## 9. 身份和请求上下文 - -### 9.1 PreparedIdentity - -identity 在交给 Endpoint 前完成: - -- DHTTP 名称规范化; -- 证书链解析和信任约束; -- 私钥与证书匹配; -- DHTTP subject key identifier 校验; -- 名称覆盖校验。 - -`rotate_identity` 使用 compare-and-swap generation。新材料验证失败或 expected generation 不匹配时,旧 identity 保持可用。 - -### 9.2 dquic 缺失接口 - -dquic dev 的 `Server` 已用 `ArcSwap`,公开接口只支持 `update_ocsp`,尚无完整证书链和私钥替换方法。需要在 dquic 增加原子 API: - -```rust -pub fn replace_certified_key( - &self, - expected: IdentityGeneration, - next: CertifiedKey, -) -> Result; -``` - -校验在 swap 前完成。已有连接继续持有握手时使用的身份;新握手使用新 generation。出站连接池 key 包含本地 credential generation,替换后不再复用旧凭据连接。 - -身份撤销比证书续期更严格:停止该名称准入,并按策略关闭旧连接和 Session。 - -### 9.3 直接使用 dquic 连接身份 - -不定义 RequestContext、ExchangeContext、PeerIdentity 等重复包装。请求和响应关联已有 dquic 连接,应用直接读取连接的 authority/证书信息。身份验证由 dquic/TLS 完成,dhttp 不再次验证或复制一份身份结论。读取认证信息失败必须传播错误,不能降级成匿名。 - -当前本地 dquic dev 的 connect 和 accept 可以返回尚未完成握手的连接,后续异步操作等待握手完成。dhttp 适配层遵循这个契约,在分发 HTTP 请求和提供身份访问时等待相关操作完成,并检查 HTTP/3 所需 ALPN;这不是新增认证层。 - -每个新请求仍需检查目标 Endpoint 正在监听,并确认 :authority 对应连接允许访问的本地名称。这是应用接入和 HTTP 寻址规则,不是重新做 TLS 认证。 - -请求取消、响应完成、超时分别由请求/响应流的错误、EOF、shutdown/stop 和调用方超时处理。driver 内部保留必要的任务与完成信号用于 close/drain;不把它们合成公共 Context。handler 返回响应头不代表响应已经发完,应用需要完成通知时应使用具体写入操作的完成结果。 - -## 10. 连接管理 - -连接池分成“容器机制”和“产品策略”,不维护两套连接缓存: - -- h3x `Pool` 保存 H3Connection,负责同 key 建连去重和通用的不可复用连接淘汰。 -- dhttp `connection` 模块持有 Pool,提供 factory:调用 dquic 建连、适配 transport、创建 H3Connection;同时确定身份隔离、容量、idle 和关闭策略。 -- dquic 管理 QUIC 连接、路径和握手,不管理 HTTP/3 复用策略。 - -默认每个 Endpoint 使用自己的池,key 为远端规范化 authority(含端口)。本地身份和网络配置由 Endpoint 隔离;凭据更新时使旧池条目失效。只有跨 Endpoint 共享池时,才需把本地身份、凭据版本及不同连接配置加入 key。不能只按远端名称在不同身份之间复用。 - -本地 h3x `pool.rs` 已有 DashMap + OnceCell 建连去重、remove 和按条目指针防止旧 observer 删除新条目。observe 当前只等待 local_goaway,仍需以下修改: - -1. H3Connection 提供不可再发起请求和终止的可观察状态,覆盖 peer GOAWAY、local GOAWAY、协议失败和 transport 终止;Pool 据此及时淘汰。GOAWAY 的两个方向仍按 HTTP/3 规则分别处理,移出复用集合不等于关闭所有在途流。 -2. get 返回前检查可复用状态;open stream 仍是最终准入检查,二者之间可能发生 GOAWAY。不得因此自动重放已经发送的 POST。 -3. 增加按预期条目淘汰、清空/关闭及可遍历条目的最小接口,供 dhttp 执行凭据失效、idle、容量和排空策略;close 后并发建连不能返回为可复用连接。 -4. observer 应可终止并被回收,不能只因等待永不出现的 local GOAWAY 而长期持有连接。建连失败/取消、淘汰后旧建连完成不得影响替换条目。 - -h3x 不加入 DNS、身份文件或 DHTTP 业务配置。dhttp 负责以上策略,h3x 补齐最小机制和竞态测试。活动请求和 Session 由 Endpoint 的连接任务管理,离开 Pool 后仍可完成,最终按 deadline 关闭。 - -### 10.1 DNS 解析与发布的任务归属 - -| 工作 | 配置与生命周期归属 | 执行位置 | -| --- | --- | --- | -| resolver 组合、DNS 服务地址和协议选择 | dhttp discovery,Endpoint/network 构造时配置 | ddns/dyns resolver 实现 | -| 出站名称解析与后续地址更新 | 单次 dquic 连接 | dquic connect 调用注入的 resolver,并消费地址流更新路径 | -| 本地名称的发布、续期、地址变更和撤销 | 对应 Endpoint 的监听生命周期 | dhttp discovery 持有并驱动 ddns publisher | -| mDNS socket、网络地址监测等共享资源 | DhttpNetwork | 底层共享 driver | - -当前 dquic dev 的 connect 已启动后续 DNS 地址流消费任务,任务使用弱连接引用并监听 terminated;dhttp 不再启动一份相同解析循环。h3x Pool 命中时直接复用连接,只有 factory 建连才进入解析流程。 - -当前 dhttp 的 dns_publication_loop 只构造循环,不自动启动。目标实现由 listen 在本地接入就绪后启动所配置的发布任务;必要的首次发布失败要返回错误并清理本次启动的资源,后续重试有界并可停止。监听结束时停止续期并撤销发布,失败需上报且受 deadline 限制。仅发请求的 Endpoint 默认不发布服务记录。网络地址变化由共享 driver 提供,各 Endpoint 更新自己的名称记录。 - -## 11. 服务端请求路径 - -```mermaid -sequenceDiagram - participant Peer - participant Q as dquic listener - participant D as dhttp DhttpNetwork - participant H as h3x - participant S as Tower Service - - Peer->>Q: QUIC + TLS (SNI / client cert) - Q->>D: connection, local name, authorities - D->>D: await dquic readiness, check ALPN and active endpoint - D->>H: H3Connection(DquicTransport) - H->>D: Request + streaming body - D->>D: http::Request + dquic connection handle - D->>S: call(request) - S-->>D: http::Response - D->>H: HEADERS / DATA / trailers - H->>Q: QUIC stream writes -``` - -DhttpNetwork 只有一个 dquic connection accept owner。每条 H3 connection 只有一个双向 stream accept owner。任何 Session 扩展也必须从同一 demux 接收流,不能与 h3x 并发抢 `accept_bi/accept_uni`。 - -## 12. SDK 设计 - -### 12.1 分层 - -```text -dhttp-core/private adapters - ↓ -dhttp stable Rust API - ↓ -dhttp-api language-neutral handles - ↓ -N-API / PyO3 - ↓ -JavaScript / Python ergonomic wrappers -``` - -`dhttp-api` 只依赖 dhttp 稳定 API。它不直接依赖 ddns 或 h3x,也不复制协议错误类型。 - -### 12.2 SDK 稳定语义 - -| 能力 | JavaScript | Python | Rust core | -| --- | --- | --- | --- | -| 创建共享网络(可选) | `DhttpNetwork.create()` | `await DhttpNetwork.create()` | `DhttpNetwork::new` | -| 创建端点 | `Endpoint.create()` | `await Endpoint.create()` | `Endpoint::builder / load` | -| 请求 | `endpoint.fetch()` | `await endpoint.request()` | `Endpoint::get/post/request` | -| 响应体 | `ReadableStream` | async iterator / `read()` | `Body` | -| 取消 | `AbortSignal` | task cancel / explicit close | Exchange cancellation | -| 服务登记 | Router / handler | Router / handler | `Endpoint::listen(Router)` | -| 关闭 | `close()` / `closed` | `await close()` | stop/drain/shutdown | - -SDK 错误使用 dhttp 自有稳定 code,例如:`invalid_name`、`identity_rejected`、`resolve_failed`、`connect_failed`、`request_cancelled`、`protocol_error`、`network_closed`。诊断链保留为 message/causes,不把 Rust enum 布局当 ABI。 - -### 12.3 兼容策略 - -现有 Node.js `Endpoint.create/fetch` 和 Python `Endpoint.create/get` 可以作为端点自有私有 DhttpNetwork 上的便捷包装继续存在。raw `MessageReader/MessageWriter` 移到明确的 experimental 模块,并改用 dhttp 自有 frame 类型;稳定 SDK 不再导出 h3x 类型名。 - -## 13. 下一版 Pishoo 如何接入 - -本文接受 Pishoo design 的以下前提: - -- 一个非 root 宿主进程承载多个 Server; -- `/servers//` 是固定发现边界; -- SQLite 只保存 `proxy_locations(server_name, location, proxy_pass)`; -- 静态内容在 `public/`,WASI HTTP component 与同名 OpenAPI JSON 在 `wasm/`,身份共享持久数据在 `data/`; -- 应用 generation 先准备成功再切换; -- 首版每个 Server 目录对应一个同名 DHTTP Endpoint; -- 同身份的 WASM App 共享身份沙盒、数据和资源总账户;Store/Instance/ResourceTable 按请求创建,只共享不可变编译代码; -- SSH 使用 Pishoo 运行账号和固定授权规则。 - -### 13.1 进程对象关系 - -```mermaid -flowchart TD - PR[PishooRuntime] --> DR[dhttp::DhttpNetwork] - PR --> RR[ServerRegistry] - PR --> SM[SessionManager] - RR --> SR1[ServerRuntime A] - RR --> SR2[ServerRuntime B] - SR1 --> EP1[dhttp::Endpoint A] - SR2 --> EP2[dhttp::Endpoint B] - SR1 --> GD1[稳定 Dispatcher A] - SR2 --> GD2[稳定 Dispatcher B] - GD1 --> GEN1[ArcSwap Generation A] - GD2 --> GEN2[ArcSwap Generation B] -``` - -启动顺序: - -1. 打开 state-dir 和实例锁; -2. 打开并校验只含 proxy_locations 的数据库; -3. 创建一个 dhttp DhttpNetwork; -4. 扫描每个 Server 目录,准备 identity 和首个 generation; -5. 成功后构造共享网络的 Endpoint,并调用 `endpoint.listen(app)`,app 路由到 stable dispatcher; -6. 启动目录、identity 和 proxy 表协调器。 - -新 Server 的 identity 或首个 generation 失败时不登记该名称,其他 Server 继续运行。 - -### 13.2 请求路径 - -Pishoo 从标准请求 extensions 读取可信 HandshakeSummary / ArcConnection,并由外层 ServerGuard 核对身份与 authority、管理总预算和生命周期。路由顺序为: - -1. 保留管理路径及专门授权; -2. proxy location 最长路径段前缀; -3. OpenAPI 显式声明的 WASM API; -4. 静态文件; -5. 404。 - -WASM 路由匹配后由 Pishoo 调用每身份 daccess,在实例化 guest 前完成授权;静态和代理保留自身访问默认。index.wasm 只发布文档中声明的根级 API,不提供任意路径 fallback。首版使用字面量路径,HEAD/OPTIONS 业务入口必须显式声明,方法不匹配返回 405。这些路由和权限由 Pishoo 装配进 Router,不进入 dhttp 网络配置。 - -### 13.3 发布与删除 - -普通内容更新只原子替换 dispatcher 的 generation。listener、Endpoint、连接和 SSH 管理入口保持稳定。在途请求持有旧 generation 直到自己的 Exchange 结束。 - -WASM 组件与同名 OpenAPI 共同形成一个内容快照。配对缺失、格式或摘要错误、路由冲突均拒绝候选并保留旧 Router;不通过执行 guest 获取路由。新旧应用版本共用身份资源总账户,删除单个 App 不关闭该身份的其他 App。 - -删除 Server 目录时: - -1. 结束 `endpoint.listen(app)` future; -2. 停止该 Server 新 Session; -3. 取消仍未发布的候选 generation; -4. 有界排空 Exchange 和 Session; -5. `Endpoint::close()` 撤销 DNS publication 和出站连接复用; -6. 超时后取消剩余交换。 - -坏候选不能恢复已删除的 Server,也不能阻断管理入口。 - -### 13.4 WASM 边界 - -h3x `tests/wasmtime_wasi_http.rs` 提供 WASI HTTP p2、提前响应和流式 body 桥接的测试参考;生产适配归 Pishoo,不形成 h3x 或 DHTTP 的 WASM 产品接口。 - -Pishoo 负责组件编译、imports 检查、WASI 能力、身份共享沙盒和执行任务: - -```text -http::Request - → Pishoo OpenAPI 路由与 daccess 准入 - → 身份沙盒能力和资源账户 - → Pishoo WASI incoming request adapter - → per-request Store/Instance/ResourceTable - → Pishoo WASI outgoing response adapter - → http::Response -``` - -Pishoo 的执行监督逻辑持有 guest task、host I/O tasks、内容快照、身份聚合预算和取消关系;DHTTP 负责实际 HTTP 输出与交换终态。二者通过通用 Service/body 生命周期衔接,完成/取消通知仍需实现核对,不增加 WASM 专属公共 Context。响应 headers 后 trap 通过 Body error 终止当前 stream;headers 前失败可返回 500/504。 - -Pishoo 静态读取配套 OpenAPI 3.1.x JSON,operationId 可选,schema 用于文档和 SDK,不承诺首版通用运行时校验。DHTTP 不依赖 Wasmtime、不扫描组件或解析 OpenAPI、不定义 apis() / 私有 WIT 清单;Pishoo 把组装好的 Router 传入 Endpoint.listen 即可。 - -### 13.5 Pishoo 需要删除的底层依赖 - -下一版 Pishoo 不再出现这些用法: - -- `dhttp::h3x::quic::Listen`; -- `dhttp::h3x::endpoint::H3Endpoint`; -- `dhttp::h3x::connection::ConnectionBuilder`; -- `dhttp::h3x::ipc::*`; -- `dhttp::h3x::webtransport::*`; -- `dhttp::dquic::*`; -- 自己管理 listener 与 raw QUIC connection 的 `RootState`。 - -Pishoo 只持有 `DhttpNetwork`、`Endpoint`、标准 HTTP 消息及请求关联的 dquic 连接 和后续 Session API。 - -## 14. WebTransport、DShell 与 SSH - -最新 h3x 支持 CONNECT/extended CONNECT 的 header 和普通双向 DATA,但还不是完整 WebTransport 实现。完整链路还需要: - -- SETTINGS 与 draft/version 协商; -- CONNECT session 建立和拒绝; -- session 关联的双向/单向 stream demux; -- HTTP Datagram/QUIC datagram 映射; -- session close 与连接 close 分离; -- 现有 genmeta/dshell 客户端兼容测试。 - -当前 h3x 在内部独占 `accept_uni`,应用驱动 `accept_bi`。WebTransport stream 不能被普通请求 parser 消费。推荐在 h3x 增加一个受控 extension demux,由 h3x 识别 stream 类型和协议状态,再把已归属某 Session 的流交给 dhttp Session 层。 - -dhttp 后续公开: - -```rust -pub trait SessionService { - async fn accept(&self, request: SessionRequest) -> Result; -} - -pub struct Session { - pub async fn accept_bi(&self) -> Result; - pub async fn open_bi(&self) -> Result; - pub async fn closed(&self) -> SessionClose; - pub async fn close(&self, code: u32, reason: &str) -> Result<()>; -} -``` - -Pishoo/dshell 只消费 Session 抽象。shell 账号、PTY、命令和授权仍由 Pishoo 管理。 - -普通 HTTP/WASM 可以先交付;SSH 交付必须等待 Session demux 和真实客户端验收完成。 - -## 15. 生命周期与关闭 - -### 15.1 状态 - -```text -DhttpNetwork: Building → Running → Draining → Closed -Endpoint: Prepared → Active → Stopping → Closed -Exchange: Admitted → Headers → Streaming → Finished | Cancelled -Session: Negotiating → Active → Draining → Closed -``` - -所有 stop/close 操作幂等。`stop_admission` 同步完成准入切断,`drain/shutdown` 异步等待资源回收并返回报告。 - -### 15.2 DhttpNetwork 关闭顺序 - -1. 拒绝新 Endpoint 和服务登记; -2. 停止每个登记的新 Exchange/Session,并开始撤销 DNS publication; -3. 从 pool 移除所有连接,停止新请求复用; -4. 等待活动 Exchange 和 Session 到 deadline; -5. 对 H3 connection 发 GOAWAY 并等待协议任务; -6. deadline 后对剩余 dquic connection close; -7. 等待 publication 撤销完成,停止 listener/network 驱动; -8. join DhttpNetwork task scope,返回未完成项和强制关闭数。 - -h3x `goaway()` 本身不能无限阻塞 daemon 退出;deadline 由 dhttp supervisor 管理。 - -## 16. 实施阶段 - -| 阶段 | 主要仓库 | 完成条件 | -| --- | --- | --- | -| D0 固定依赖图 | h3x/dquic/dhttp | 提交 h3x 本地修改;统一 qbase/qrecovery source;锁定可复现 revision | -| D1 传输适配 | dhttp | `DquicTransport` 通过真实 QUIC 双向/单向流、reset、close 测试 | -| D2 DhttpNetwork 与多名称登记 | dhttp/dquic | 一个 DhttpNetwork 承载两个名称;删除一个不影响另一个;完整 identity 原子替换 | -| D3 标准 HTTP 边界 | dhttp | Tower + `http_body`;early response、trailers、背压和取消通过 | -| D4 SDK 重构 | dhttp/api | Node/Python streaming、abort、serve、close 与 Rust 错误码一致 | -| P1 最小 Pishoo 切片 | pishoo | 固定目录一个 Server;static + 一个 WASM + 一条 proxy;只用 dhttp 稳定 API | -| P2 多 Server 与发布 | pishoo | generation 切换、删除优先、identity 续期、资源配额与排空 | -| D5 Session 扩展 | h3x/dhttp/dshell | 唯一 demux、现有客户端建连/断连、session 与 connection 独立关闭 | -| P3 SSH 与打包 | pishoo | 同账号 shell,反复断连后 PTY/进程/FD/任务回归基线 | - -当前 dhttp 0.6 的 `Endpoint::load/get/listen` 可以在 D2/D3 后改为兼容 wrapper。底层重导出先标记 deprecated;Pishoo 完成迁移后在下一个 breaking release 移除。 - -## 17. 验收矩阵 - -### 17.1 依赖与构建 - -- 干净 checkout 不依赖开发者绝对 path; -- `cargo tree -d` 不出现两套不兼容 qbase/qrecovery; -- Linux/macOS、x86_64/arm64 构建; -- dhttp-api 不直接依赖 h3x/dquic/ddns。 - -### 17.2 协议和 Body - -- 请求体未发送完成时先收到响应头; -- repeated trailers 无损; -- 服务端未读完请求体返回响应,不关闭连接; -- client RESET、server STOP_SENDING、headers 后 body 失败只终止当前 stream; -- 同一连接上的后续请求成功; -- GOAWAY 后不再 open/accept 新 request stream,已准入交换排空。 - -### 17.3 身份和多名称 - -- verified、anonymous 和 verification failure 三种状态严格区分; -- 两个名称共享 listener 和 network; -- 删除名称 A 后 A 的旧连接不能提交新请求,名称 B 不受影响; -- identity 替换失败保留旧材料; -- 替换成功后新握手使用新材料,旧 credential generation 不再进入 pool; -- `:authority` 与握手 binding 不一致时拒绝。 - -### 17.4 生命周期 - -- GET 自动结束请求方向;POST 首次 await 在服务端等待完整 body 时仍能返回 writer; -- request shutdown 后可继续读取响应,关闭后写入失败; -- listen 接收 Router,路由 handler 能获得握手验证身份; -- Endpoint/DhttpNetwork close 幂等; -- deadline 到期后任务、连接和 stream 数量回到基线; -- Drop 不产生无 owner 后台任务; -- DNS publisher、mDNS 和 network watcher 能停止并 join; -- daemon 关闭报告仍活动的 Exchange/Session。 - -### 17.5 Pishoo - -- 新 Server 只有 identity 与 generation 都成功后才对外; -- 坏 WASM/路由冲突不覆盖在线 generation; -- OpenAPI 配对、声明入口准入及 daccess 授权由 Pishoo 验证;缺失或非法候选不替换旧 Router; -- 同身份多个 App 共用数据及总预算,每请求独立实例;跨身份的数据和资源账户相互隔离; -- 删除目录优先于较早启动的编译任务; -- WASM 看到的 peer identity 来自请求关联的 dquic 连接,同名 header 无效; -- headers 前取消、headers 后 trap、替换 response body、HEAD/204/304 都无 guest/task 泄漏; -- 20 次 SSH 正常退出、强杀和 transport timeout 后进程/PTY/FD/任务回归基线。 - -## 18. 已定决策与待补接口 - -### 18.1 已定决策 - -1. dhttp 是 h3x+dquic 的唯一产品级组合层。 -2. Endpoint 是客户端和服务端顶层入口;Pishoo 的多个 Endpoint 可共享 DhttpNetwork。 -3. 客户端采用 get/post 链式调用和独立请求写入、响应读取;listen 接收 Router,服务适配采用 `http` + `http-body` + `Bytes`。 -4. 已验证身份只来自握手上下文。 -5. dhttp 拥有连接管理、取消和关闭;Pishoo 拥有应用 generation。 -6. WASM 运行、WASI HTTP 适配、OpenAPI 与身份沙盒均属于 Pishoo;DHTTP 只接受标准 Router/Service。 -7. SDK 绑定 dhttp 稳定语义,不镜像底层 Rust 类型。 -8. Session/WebTransport 单独交付,不作为普通 HTTP 已完成能力。 - -### 18.2 P0 待补接口 - -| 缺口 | 仓库 | 需要的结果 | -| --- | --- | --- | -| h3x 工作树不可锁定 | h3x | 提交最新 GOAWAY/EOF 修改 | -| qrecovery trait source 耦合 | h3x/dquic | h3x 自有最小 stream trait,或统一 source/revision | -| Rust 基线不一致 | dquic/dhttp/pishoo | 统一并在 CI 验证 MSRV;采用 dev 时目标为 1.88 | -| 完整 identity 原子替换 | dquic | validate-before-swap + generation/CAS | -| ALPN 结果确认 | dquic | 握手后公开只读 negotiated ALPN | -| pool 终止/GOAWAY 观察 | h3x/dhttp | h3x Pool 可观察失效并淘汰,dhttp 执行身份隔离和关闭策略 | -| Session stream demux | h3x | 单一 accept owner,HTTP 与扩展 stream 正确分流 | - -## 19. 本地核对入口 - -- 当前 dhttp facade:`dhttp/src/lib.rs` -- 当前 Endpoint 装配:`dhttp/src/endpoint.rs` -- 当前语言中立 API:`api/src/endpoint.rs` -- h3x 公共面:`../h3x/src/lib.rs` -- h3x Transport 契约:`../h3x/src/transport.rs` -- h3x connection/GOAWAY:`../h3x/src/connection.rs` -- h3x WASI HTTP 测试:`../h3x/tests/wasmtime_wasi_http.rs` -- dquic 多名称 listener:`../dquic/dquic/src/server.rs` -- dquic connection streams/authority:`../dquic/qconnection/src/lib.rs` -- Pishoo 总体设计:`../pishoo/design/h3x-dhttp-pishoo-architecture.md` -- Pishoo 既有接入草案:`../pishoo/design/dhttp-interfaces.md` - -本文结论来自静态代码和设计文件核对。所有接口、兼容性与可靠性条目均为实施要求,尚未宣称通过集成测试。 diff --git a/docs/design/h3x-body-without-wndbuf.md b/docs/design/h3x-body-without-wndbuf.md deleted file mode 100644 index 3fc2711..0000000 --- a/docs/design/h3x-body-without-wndbuf.md +++ /dev/null @@ -1,453 +0,0 @@ -# h3x 去掉 WndBuf 的详细设计 - -日期:2026-10-02。状态:未采用的备选设计。用户后续选择保留 WndBuf;下文删除窗口的方案仅供比较,不作为实施计划。接口和伪代码尚未编译验证。 - -## 当前选择:保留有界缓冲 - -保留 WndBuf 的价值是让 HTTP/3 解析和应用消费可以独立推进,在窗口容量范围内吸收短时速度差。去掉 WndBuf 后 QUIC 自身仍有缓冲,区别是没有额外的消息层预读队列;不能把两种方案理解成“有缓冲”和“完全无缓冲”。 - -| 维度 | 保留 WndBuf | 由应用直接驱动解析 | -| --- | --- | --- | -| 消费短暂停顿 | 解析任务可继续推进到窗口满 | HTTP/3 body 解析暂停,QUIC 仍可在自身额度内收包 | -| 长时间慢消费 | 窗口填满后同样背压 | 更早把背压传到传输读取 | -| trailers/EOF 发现 | 若剩余数据能装入窗口,可在应用读完前确认 | 随应用轮询推进 | -| 内存 | QUIC 缓冲之外增加消息队列和任务状态 | 无消息队列,仍有解析缓冲和应用持有的 Bytes | -| 调度 | 额外任务、锁和两端唤醒,可重叠解析与消费 | 少一层交接,解析工作在消费任务中执行 | -| 生命周期 | 要协调窗口两端、协议 reader/writer 和应用 Body | reader 直接归 Body,发送 writer 直接消费 Body | -| trailers 表达 | 字节窗口之外需要共享字段和完成顺序 | 解析器局部字段即可 | -| 迁移 | 可沿用现有窗口实现及行为测试 | 需重写消息收发和所有权路径 | - -当前建议是先保留现有收发窗口,把标准 Body 与窗口的适配统一收进 h3x。公开收发接口仍统一使用 `http::Request` / `http::Response`,不需要公开 IncomingBody。保留窗口不要求保留自定义消息容器或让 dhttp 管理窗口和 trailers。 - -发送窗口的独立收益是允许源 Body 提前生产到容量上限;是否值得其成本应通过突发生产、慢写入和高并发负载测量。后续可单独评估,不影响统一的公开接口。保留窗口也不会自动补齐源 Body 永远 Pending 时的远端终止观察能力。 - -当前实现已将标准 Body 适配移入 h3x,并在 dhttp 删除窗口转发代码。收发窗口继续保留;窗口消息容器保持内部实现;公开接口直接实现在 `src/stream/read.rs` 和 `src/stream/write.rs`,只保留一套读写 trait,消息集成测试直接验证标准 Body 接口。未实施下文备选方案中的去窗口和 TransportAbort 改造。 - -以下为原备选方案。 - -本文基于相邻 h3x、dquic 和当前 dhttp 的工作区源码。目标是让 HTTP 消息的生产和消费直接驱动 HTTP/3 流,删除消息体中间窗口,同时统一收发公开接口。 - -## 1. 设计结论 - -- 收发都使用 `http::Request` / `http::Response`。 -- `Body` 是现成 `UnsyncBoxBody` 的类型别名,不新增 `IncomingBody` 结构体。 -- 接收使用 `StreamBody` 包装持有 reader 的异步流;应用轮询 Body 才继续解析消息。 -- 发送编码器直接消费 Body 帧;写完当前数据后再拉下一帧。 -- 删除 `ArcWndBuf`、共享 `Trailers`、自定义 `Request` / `Response` 和 `R/W` 标记。 -- 每个接收消息不再启动 body 解析任务;连接级控制流、QPACK 指令流继续独立驱动。 -- dhttp 保留寻址、身份、服务接入,以及上传与响应的并发和生命周期策略。 - -```text -接收:QUIC reader → HTTP/3 解析 Stream → StreamBody → Body → 应用 - ↑ 由应用 poll_frame 驱动 - -发送:应用 Body → HTTP/3 编码器 → QUIC writer - ↑ 写入可继续时才消费后续数据 - -独立运行:QUIC 连接驱动、HTTP/3 control、QPACK encoder/decoder 指令流 -``` - -删除的是 HTTP 消息层的生产者/消费者队列。QUIC 的重组、收发缓冲和流控仍然存在,解析时仍需有限大小的工作缓冲;本方案不承诺零缓冲或端到端零拷贝。 - -## 2. 当前实现与具体改动 - -| 当前位置 | 当前行为 | 改动 | -| --- | --- | --- | -| h3x `common/request.rs`、`common/response.rs` | 消息绑定窗口,并通过 extensions 交接共享 trailers | 保留头部解析/校验函数和读写 trait,删除消息容器 | -| h3x `stream/read.rs` | 返回 headers 后 spawn,把 DATA 写入窗口 | 返回拥有 reader 的 Body | -| h3x `stream/write.rs` | 从窗口取 Bytes,再写 DATA | 从标准 Body 取帧并编码 | -| h3x `common/trailers.rs` | `Arc>` | 改为普通字段转换/校验函数 | -| h3x `stream.rs`、`stream/bi.rs` | 按方向登记流,只保存 I/O Pending 的 waker | 保留登记和错误传播,补齐非 I/O 等待的唤醒 | -| dhttp `endpoint/body.rs` | 双向转发 | 删除 | -| dhttp `endpoint/response.rs` | 窗口装配、转发、无 body 响应处理 | 合并成对 h3x 的调用,保留必要的调用方结果策略 | -| dhttp `endpoint/request.rs` | 网络寻址、上传任务、响应读取、窗口装配 | 删除窗口装配,保留交换驱动 | - -现有 `H3ReadStream::Drop` 和 `H3WriteStream::Drop` 调用的是 `finish()`。不能依赖它们完成未结束消息的协议取消;消息读写函数必须明确持有取消 guard。 - -## 3. 公开 API - -### 3.1 唯一的 Body 类型 - -```rust -pub type BoxError = Box; -pub type Body = http_body_util::combinators::UnsyncBoxBody; -``` - -`Body` 可移动到其他任务,但无需 `Sync`;每个 body 只有一个消费者。泛型只用于构造入口。`Empty`、`Full`、Axum Body、文件流和其他标准 Body 均在交给消息写入接口之前转换。 - -调用方使用 `BodyExt` 提供的 `source.map_err(Into::into).boxed_unsync()` 完成转换,空消息体可直接用 `Body::default()`。 - -已经是 `h3x::Body` 时直接传递,避免重复装箱。h3x 内部生成接收 Body 时仅在最外层装箱一次。 - -### 3.2 对称的读写 trait - -```rust -pub trait ReadRequest: Sized + Send { - fn read_request(self, qpack: ArcQpack) - -> impl Future>> + Send; -} - -pub trait WriteRequest: Sized + Send { - fn write_request(self, request: http::Request, qpack: ArcQpack) - -> impl Future> + Send; -} - -pub trait ReadResponse: Sized + Send { - fn read_response(self, method: http::Method, qpack: ArcQpack) - -> impl Future>> + Send; -} - -pub trait WriteResponse: Sized + Send { - fn write_response( - self, response: http::Response, method: http::Method, qpack: ArcQpack, - ) -> impl Future> + Send; -} -``` - -`Result` 仍为 h3x 的协议错误结果。响应读写都保留请求方法参数,以正确处理 HEAD 和 CONNECT。连接的 `open_bi` / `accept_bi` 继续返回分开的 reader 和 writer。 - -### 3.3 消息头处理 - -将原消息对象的方法抽成内部函数,例如: - -```rust -decode_request_head(fields) -> Result -encode_request_head(&parts) -> Result> -decode_response_head(fields) -> Result -encode_response_head(&parts) -> Result> -decode_trailers(fields) -> Result -encode_trailers(&fields) -> Result> -``` - -保留现有伪头、字段大小、重复字段和 CONNECT 校验。普通重复字段使用 `HeaderMap::append`,敏感标记在 QPACK 转换时保留。`:protocol` 继续沿用现有 extensions 表示;重构时不顺带更改它的公开表示。接收消息的 version 明确设为 HTTP/3。 - -## 4. 接收流程 - -### 4.1 headers 与 body 的所有权交接 - -1. 调用 `read_request/read_response` 时,在返回 future 前创建 reader guard。 -2. future 解析初始 HEADERS 并完成 QPACK 解码和头部校验。 -3. 响应保持当前行为:跳过合法的临时 1xx,返回最终响应。 -4. 根据请求方法和状态生成内部接收策略:是否允许内容、是否按普通消息检查 Content-Length。 -5. 将 reader、QPACK 和策略移动到接收 Stream。 -6. 包成 `StreamBody` 并装箱,立即返回标准消息。 - -初始 headers future 的取消使用 `H3_REQUEST_CANCELLED`。成功交接给 Body 后,提前放弃消费使用 `H3_NO_ERROR`,表示不再需要这一接收方向的剩余数据。完整交换是否取消,由 dhttp 决定。 - -两个 guard 的交接必须在同步代码中完成,中间不插入 await。Body 的 guard 也必须在创建异步 Stream 之前建立,并被 Stream 捕获,保证 Body 从未被轮询就 drop 时仍会清理。 - -### 4.2 Stream 持有的内部状态 - -无需公开结构体,局部变量由编译器保存到异步 Stream 中: - -| 状态 | 用途 | -| --- | --- | -| reader guard | 独占接收方向并承担提前丢弃清理 | -| QPACK 句柄 | 解析尾部字段 | -| 当前 DATA 剩余长度 | 处理跨多次读取的大帧 | -| 一块 `BytesMut` 工作缓冲 | 读取当前数据片段 | -| `Option` | 暂存尾部字段,直到确认消息结束 | -| 已接收内容长度及适用的预期长度 | EOF 时校验消息完整性 | -| trailers 阶段标志 | 禁止后续 DATA 或第二段尾部 HEADERS | - -不保留窗口容量、生产者句柄、共享 trailer 锁、body 任务 JoinHandle。 - -### 4.3 DATA 解析与内存 - -沿用 `read_next_frame` 和现有帧解析器:读取 DATA 的帧头后,只消费其声明长度内的内容。 - -- 单次向应用交付的数据片段目标上限沿用 8 KiB。 -- 大 DATA 帧分多次读取和交付,不按对端声明的整帧长度分配内存。 -- 必须显式限制一次读取可写入的缓冲长度;不能仅依赖 `BytesMut::reserve` 的容量作为上限。 -- 短读产生的剩余缓冲容量在下一次读取中复用,避免每个一字节片段都另占一个 8 KiB backing allocation。 -- 长度为零的 DATA 合法,跳过并继续;不能把它当成 QUIC EOF。 -- 声明的 payload 尚未读完就 EOF,报告 framing 错误。 -- 内容长度计数使用 checked_add;适用 Content-Length 时,超出声明长度的数据在交付应用前拒绝,长度不足在正常 EOF 时报告。 -- 未知扩展帧分块丢弃;HEADERS 保留现有 64 KiB 编码 payload 上限及解码字段限制。 - -应用持有已交付的 Bytes 可能保留其 backing allocation;h3x 无法限制应用积累的数据。发送侧源 Body 也可能一次交付很大的 Bytes,不能宣称整个交换只占 8 KiB。 - -### 4.4 trailers 与正常结束 - -本设计保持当前 dhttp 的交付时机:**先确认合法流结束,再向应用交付 trailers。** - -```text -读取 DATA → 逐段 yield DATA -读取尾部 HEADERS → 解码为局部 HeaderMap -检查其后只有允许跳过的扩展帧,直至 QUIC EOF -校验适用的 Content-Length -正常完成接收方向,解除取消 guard -如存在尾部 HEADERS,yield trailers(允许空 HeaderMap) -Stream 结束,Body 返回 None -``` - -因此,应用收到 trailers 时,协议尾部已经验证完成。没有 trailers 时,必须等到 Body 返回 None 才知道消息完整结束。读完预期 DATA 字节数不能提前结束解析。 - -帧顺序和长度校验依据 [RFC 9114 §4.1–4.1.2](https://www.rfc-editor.org/rfc/rfc9114.html#section-4.1)。HEAD/304 中描述假想内容的 Content-Length 不作为实际接收长度;无内容响应和成功 CONNECT 的策略分别处理。 - -### 4.5 EOF 与登记清理 - -当前 `H3ReadStream::poll_read` 读到 EOF 就通知 registry 正常结束。重构应把“底层 EOF”与“消息校验完成”分开: - -- 底层 EOF 将方向 I/O 标为结束,释放底层 reader。 -- 消息解析器完成帧边界、长度和字段校验后,再通知 registry 正常结束。 -- EOF 后的最终校验失败仍经过 `fail(error)` 上报,不能被正常结束吞掉。 -- guard、正常完成和错误路径的通知要幂等,每个方向只完成一次有效清理。 - -这可以复用现有 H3Stream 状态并增加完成通知记录,不需要新的公开消息类型。 - -### 4.6 轮询取消与公平性 - -接收 Stream 必须持久保存当前解析 future。应用暂时取消一次 `body.frame()` 等待,之后重新轮询同一个 Body,不能丢失已读取的 varint 或 payload 前缀。 - -`async_stream` 中持续存在的 await 满足这个实现方向;不能每次 poll 都新建并丢弃 `read_next_frame()` future。 - -大量零长度 DATA、未知帧或临时响应可能长时间不向应用 yield。内部循环设置协作预算,例如连续处理 32 个不产出数据的帧后 `yield_now().await`;丢弃大未知 payload 时也按字节预算让出执行权。这是调度预算,不是新的消息缓存。 - -### 4.7 读取伪代码 - -以下 `report_failure`、`complete` 等为待实现的内部辅助函数名: - -```rust -fn receive_body(reader, qpack, policy) -> Body { - let guarded = guard_reader(reader, qpack.clone(), ErrorCode::NoError); - let frames = async_stream::try_stream! { - let mut reader = guarded; - let mut trailers = None; - let mut received = 0_u64; - - // 每次解析错误都先调用 report_failure,再向 Body 返回错误。 - while let Some(frame) = read_next_checked(&mut reader, trailers.is_some()).await? { - match frame { - Data(frame) => { - // 校验 policy;分块读取完整 payload,每段更新 received 并 yield。 - } - Trailer(frame) => { - // 校验 policy;QPACK 解码并保存 HeaderMap。 - } - } - } - // 校验长度,完成登记并解除 guard;释放 reader 后再交付 trailers。 - complete(reader, policy, received)?; - if let Some(fields) = trailers { - yield http_body::Frame::trailers(fields); - } - }; - StreamBody::new(frames).map_err(Into::into).boxed_unsync() -} -``` - -实际实现需要将整个解析过程的错误汇总到统一出口,不能让 `?` 绕过错误作用域传播和 QPACK 清理。 - -## 5. 发送流程 - -请求、响应只分别处理初始头部,共用内部 body 编码函数。 - -1. 在构造发送 future 时创建 writer 取消 guard,覆盖未轮询就 drop。 -2. 拆出标准 parts 和 Body,校验并编码初始 HEADERS。 -3. 若响应禁止内容,直接 drop 源 Body,不轮询,发送 headers 后正常 shutdown。 -4. 轮询源 Body,将 DATA 编码发送;每次写完当前片段后才继续。 -5. 收到 trailers 后校验并保存到局部 HeaderMap,随后继续轮询确认源 Body 结束。 -6. trailers 后再出现任何 DATA 或第二个 trailers,视为本地源 Body 违反消息契约;reset 当前发送流,不向网络发送非法顺序。 -7. 源 Body 返回 None,检查适用的内容长度,编码并写出暂存 trailers,然后 shutdown。 -8. shutdown 成功后解除 guard,返回成功。 - -暂存 trailers 到源 Body EOF 保持现有时机,避免把错误源 Body 的后续帧发送到线上。源 Body 在 trailers 后必须及时返回 None;若 Pending,仍需允许终止信号打断。 - -DATA 的大 Bytes 用共享切片拆成当前 8 KiB 的发送片段;编码帧头使用小缓冲,payload 不拼入同一个 Vec。当前传输接口是 AsyncWrite,是否在 QUIC 内部复制数据取决于传输实现。 - -发送长度累计同样检查溢出;超出有效 Content-Length 的帧在写入前拒绝,长度不足在源 EOF 时 reset。发送成功仍按当前底层 shutdown 契约定义;当前 dquic writer 的 shutdown 会等待已写数据被确认,不能在源 Body 返回 None 时提前报告发送成功。 - -`write_response` 在本版表示最终响应;需要发送 1xx 时应另行设计 interim headers API,不把 1xx 当成会结束发送方向的最终响应。 - -## 6. 终止与错误 - -### 6.1 生命周期契约 - -| 事件 | 行为 | -| --- | --- | -| headers future 被丢弃 | 取消正在读取的消息,清理已登记 QPACK 解码 | -| Body 未读完被丢弃 | STOP_SENDING H3_NO_ERROR,清理当前接收方向;不自动中断另一方向 | -| Body 正常读尽 | 完成接收方向,不发送停止信号 | -| 本地发送 future 被丢弃 | RESET_STREAM H3_REQUEST_CANCELLED,并 drop 源 Body | -| 源 Body 返回错误 | reset 发送方向,返回带原始 source 的 h3x 流错误 | -| 收到流级协议错误 | 由现有 registry 按策略结束该交换,不影响其他交换 | -| 收到连接级错误 | 通过连接错误通道关闭连接,并唤醒相关方向 | -| 对端停止当前发送方向 | 结束发送并释放源 Body;保留原因,调用方决定交换结果 | - -Body 的通用 Drop 表示放弃这一个接收方向。客户端放弃完整交换时,由 dhttp 同时取消上传任务。服务端提前返回响应时,drop 请求 Body 不应把合法响应也取消。提前响应和停止请求上传的关系见 [RFC 9114 §4.1](https://www.rfc-editor.org/rfc/rfc9114.html#section-4.1)。 - -### 6.2 必须区分的三种等待 - -1. 正在等底层 read/write:传输的 I/O waker 负责唤醒。 -2. 正在等 QPACK:解码器的 waker 和解码取消负责唤醒。 -3. 正在等源 Body:应用 Body 的 waker 不会自动感知 QUIC 终止。 - -当前 `ArcH3Stream` 只在 I/O Pending 时保存 waker。删除窗口后,不能假设取消信号仍会经由窗口唤醒转发循环。设计增加每方向一个独立的终止等待槽,在非 I/O 等待时登记调用任务;状态转为错误时唤醒它。 - -登记 waker 与检查终态使用同一锁,或采用 register 后再次检查的模式,避免终止发生在检查与登记之间。不持有状态锁调用应用 `poll_frame`、QPACK poll 或外部回调;取出需要的状态后释放锁。 - -### 6.3 传输接口缺口与具体补齐方案 - -当前 `Transport` 只要求 AsyncRead/AsyncWrite、主动 stop/cancel 和错误映射。它没有“没有执行 I/O 时观察远端终止”的接口。只给 h3x 增加 waker,无法凭空观察远端 STOP_SENDING。 - -若要求源 Body 永远 Pending 时也能及时结束,本设计需要扩展 h3x 与 dquic 之间的传输契约: - -```rust -// 拟新增的传输能力,当前仓库尚未提供。 -pub trait TransportAbort { - fn poll_aborted( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll; -} -``` - -契约: - -- 返回当前方向的 reset、peer stop 或连接失败;保留 HTTP/3 错误码与作用域。 -- 健康方向 Pending 并登记独立 waker;普通 FIN/正常 shutdown 不作为错误返回。 -- 不消费字节、不 flush、不触发 shutdown,不与 I/O 共用会互相覆盖的 waker 槽。 -- 终止状态持久保存,事件发生后首次调用也能立即观察;重复轮询返回同一终止原因。 -- h3x reader/writer 都可使用:writer 等源 Body,reader 等 QPACK 时观察底层 reset。 - -实现位置:dquic `qrecovery` 的收发共享状态保存终止 waker,并在 reset/stop/连接错误转换后唤醒;dhttp 的 `RecvStream` / `SendStream` 映射为 h3x Error;h3x 将该能力纳入 Transport 的关联类型约束。测试传输也必须实现。 - -h3x 内部 `poll_aborted` 同时检查 registry 中已记录的错误和底层终止事件,释放锁后汇报事件。发送在等待下一帧时同时 poll 此方法与源 Body;QPACK 等待采用相同原则。失败胜出时丢弃未完成的解析/生产 future,再走统一清理。 - -不能用空写、反复 flush、定时空轮询代替这个契约。它们不保证通知,并可能改变 I/O 语义。 - -这是实现完整终止保证所需的相邻仓库改动。若第一阶段只改 h3x 的消息路径,可以保留当前“下一次 I/O 才观察远端停止”的限制,但不能宣称已经满足永远 Pending 源 Body 的及时取消测试。删除 WndBuf 本身不依赖这个新 trait。 - -### 6.4 保留错误来源 - -公开 Body 使用 BoxError,接收帧错误装箱保存 h3x Error;发送源 Body 失败需转换为当前发送流的 RequestCancelled,并保留 source。 - -建议给现有 `ErrorDetail` 增加可选的 `Arc` source,并让 Error::source 返回它;保持现有 Stream/Connection 分支及 Clone。任意 source 无法自然支持结构性 Eq/Hash,因此相应移除派生,测试改为检查 scope、code、reason 和 source。此项属于本次 API 破坏性变更的一部分。 - -不能因为源 Body 的错误恰好是另一个连接的 h3x Connection error,就关闭本次发送所用的连接;例如代理正在转发另一连接的接收 Body。源 Body 错误在本次发送边界始终作为本地流失败处理,原始作用域只保留在 source 中供诊断。 - -## 7. QPACK、背压与资源回收 - -### 7.1 QPACK 保持独立驱动 - -当前 H3Connection 已分别驱动 encoder/decoder 指令流,继续保留。应用暂停消费某条消息不会停止这些连接级任务。 - -初始 HEADERS 和 trailers 解码都可能 Pending。沿用 QPACK 的 `StreamDecoder` drop guard 清理已注册解码;registry 取消也可能触发相同清理,必须保持幂等。取消未注册或已经完成的解码不能产生多余的解码取消指令。 - -### 7.2 背压的实际边界 - -- 接收:应用不 poll,h3x 不继续 read;QUIC 可以继续在其接收额度内缓冲,但不会无限前进。 -- 发送:当前帧写入 Pending 时不拉下一帧;源 Body Pending 时不再写数据。 -- 移除窗口减少一层队列和跨任务唤醒,不等于移除 QUIC 缓冲或所有内存分配。 -- h3x 不主动 collect Body,不自动启动后台 drain。 - -连接级流控可能受未消费数据影响。应保留 QUIC 资源限制,并验证一个暂停的大消息不会饿死控制/QPACK 流;独立任务本身不保证传输额度充足。 - -### 7.3 懒读取带来的行为变化 - -原来后台任务最多提前解析到窗口满;现在 body 部分由应用请求下一帧时解析。因此: - -- trailers、后续协议错误和 EOF 的发现时机随应用消费推进。 -- 不轮询也不 drop 的 Body 会继续持有流登记;连接优雅排空可能等待它。 -- 调用方不需要 Body 时应直接 drop;需要完整验证时应消费到 None。 -- 消息期限和持有期限由上层管理,h3x 不增加隐式超时或自动排空。 - -HEAD/204/304 的接收 Body 也需解析到合法结束才能确认没有非法 DATA,不能仅根据 headers 返回无验证的 Empty body。`size_hint` 初版采用保守值;`is_end_stream` 不得仅因 Content-Length 为零就报告结束。 - -## 8. dhttp 的接入 - -### 8.1 类型和服务入口 - -`dhttp::Body`、`BoxError` 直接复用 h3x 的别名,EmptyBody 仍可用标准 Empty。dhttp 的 `.body(B)` 和 Tower service 接入可保留泛型便利入口,在调用 h3x 前转换为统一 Body;h3x 四个公开读写接口始终对称。 - -服务端大致流程: - -```rust -let mut request = reader.read_request(qpack.clone()).await?; -let method = request.method().clone(); -// dhttp 完成 URI 校验、可信身份 extensions 和 Service readiness。 -let response = app.oneshot(request).await?; -writer.write_response(response, method, qpack).await?; -``` - -实际代码保留当前应用失败和 421 响应的 writer 清理 guard。URI 不合法时 drop 请求 Body 即可停止接收,无需提取窗口。 - -### 8.2 上传与响应并发 - -客户端仍需独立驱动上传,不能先 await `write_request` 完成再 `read_response`,也不能用等待两边都完成的简单 try_join 延迟交付响应头。 - -保持当前所有权策略: - -1. 请求 future 持有上传任务的 abort guard。 -2. 收到响应头后,把上传任务 guard 转交给响应 Body 的包装 Stream。 -3. 响应 Body 提前 drop 或发生读取错误,取消上传。 -4. 响应 Body 完整消费成功后,释放 abort 所有权,允许尚未完成的上传继续运行。 - -这层 Stream 只管理交换生命周期,不搬运到中间窗口,也不需要自定义 Body 结构体。避免将它理解为 HTTP/3 接收适配。 - -上传错误需要观察:响应头返回前,上传失败应唤醒请求 future 并返回错误;对端正常停止上传时仍等待并接收响应。响应头返回后,沿用流登记传播真正的交换失败;上传任务的最终结果应被明确记录或观察,不能丢掉 JoinHandle 后悄悄忽略 panic/失败。完整响应成功后不因请求上传被提前停止而追溯性否定响应。 - -h3x 返回原始停止原因;dhttp 仅按流方向、错误作用域及完成阶段处理 NoError。不能把连接级 NoError 或任意 Body 错误统一吞成成功。 - -## 9. 文件组织与依赖 - -```text -h3x/src/ - h3x.rs Body/BoxError 类型别名 - common/request.rs request head 编解码、读写 trait - common/response.rs response head 编解码、读写 trait - common/trailers.rs 无状态 trailer 字段转换和校验 - stream/read.rs headers 读取及持有 reader 的接收 Stream - stream/write.rs headers 写入及共享 body 编码循环 - stream.rs 方向状态、终止唤醒、完成通知 - stream/bi.rs 方向登记和交换错误传播 - transport.rs 传输约束,完整版本增加 TransportAbort - error.rs 保留原始 Body 错误 source -``` - -删除 `common/wnd_buf.rs` 和公共 WndBuf/ArcWndBuf/R/W/Trailers 重导出。`Trailers` 模块保留函数但不保留共享容器类型。 - -`http-body`、`http-body-util` 从 dev-dependencies 提升到普通 dependencies,新增 `async-stream`;继续使用已有 scopeguard 和 futures。 - -dhttp 删除 body 转发函数、BODY_WINDOW_BYTES/BODY_READ_CHUNK_BYTES 常量及窗口专用错误转换。具体是否删除整个 response.rs 取决于剩余调用方逻辑,不以减少文件数为目标。 - -## 10. 实施步骤 - -1. **冻结行为测试。** 将现有 DATA/trailers、早响应、取消、HEAD/204/304 测试保留为迁移基线;补充未轮询 Drop 和 Pending/终止竞态用例。 -2. **补齐终止契约。** 完整版本先在 dquic、测试传输和 h3x 接通非 I/O 等待的终止通知,验证不丢唤醒。若分阶段实施,明确标注此保证尚未提供。 -3. **抽离头部函数与统一类型。** 提供 Body 别名,调用方使用 BodyExt 转换;增加 source 错误支持。 -4. **替换接收路径。** 用持有 reader 的 Stream 返回标准消息,验证部分帧取消可恢复和 Drop 清理。 -5. **替换发送路径。** 直接消费标准 Body,验证背压、trailer 顺序和源错误。 -6. **迁移 dhttp 和 h3x 使用方。** 更新服务接入、上传驱动、测试与示例;覆盖真实 QUIC 往返。 -7. **删除旧接口。** 搜索所有 WndBuf、R/W、共享 Trailers、旧消息类型和转发函数引用,删除窗口代码和仅测试窗口自身的测试。 - -这是破坏性 API 变更。工作区路径依赖应协调修改;发布前搜索其他消费 h3x 的项目并安排版本升级。暂存迁移步骤可以保留旧接口,最终方案不保留双套消息体系。 - -## 11. 验收测试 - -| 分类 | 必须验证的场景 | -| --- | --- | -| 数据 | 空 body、零长度 DATA、大 DATA、多次短读、任意分片的帧头,内容顺序和字节数一致 | -| trailers | 多值字段、空尾部字段、敏感标记;收到 trailers 前已经确认 EOF;尾部之后非法 DATA/HEADERS 报错 | -| 帧完整性 | DATA 长度不足、截断 varint、截断 HEADERS、超限字段;错误作用域保持正确 | -| 内容语义 | HEAD/204/304 的源 Body 完全不被轮询;接收非法内容报错;Content-Length 正确/过长/过短;CONNECT 分支不按普通内容误判 | -| 懒读取 | headers 返回后不主动读取 body;应用请求一帧时只做必要读取;不启动 per-body 接收任务 | -| 背压 | writer Pending 时不继续 poll 后续源帧;应用暂停时不创建消息队列;慢流下控制/QPACK 仍可前进 | -| 取消 | headers future、发送 future、接收 Body 在从未 poll 时 drop 仍清理;正常完成不重复 stop/reset | -| 部分轮询 | 取消一次 frame 等待后继续消费,不丢 varint/payload 前缀;取消整个 Body 只清理一次 | -| 非 I/O 等待 | 源 Body 永远 Pending 时远端 stop/连接关闭能结束发送;QPACK Pending 时 reset/GOAWAY/连接关闭能结束解码 | -| 竞态 | 终止发生于 waker 登记前/中/后均可观察;同时 Body 就绪与终止时不在已失败流继续写 | -| 错误来源 | 源 Body 错误保留 source;代理另一连接的 Body 错误不会错误关闭本连接 | -| 交换 | 上传未结束就交付响应头;服务端 drop 请求 body 后响应仍可发送;客户端 drop 响应取消上传;完整响应后允许上传继续 | -| 排空 | Body 持有时 GOAWAY 等待,正常消费或 drop 后解除登记;错误终态不被正常 EOF 覆盖 | - -优先运行 h3x 单元/协议测试、dhttp endpoint 生命周期测试和真实 QUIC roundtrip。本文只完成设计,尚未执行上述实现验收。 - -## 12. 参考 - -- [HTTP/3 消息帧与取消语义](https://www.rfc-editor.org/rfc/rfc9114.html#section-4.1) -- [StreamBody:将标准帧 Stream 包装为 Body](https://docs.rs/http-body-util/latest/http_body_util/struct.StreamBody.html) -- [UnsyncBoxBody:Send 的装箱 Body](https://docs.rs/http-body-util/latest/http_body_util/combinators/struct.UnsyncBoxBody.html) - -接收的具体 trailers 交付时机、装箱策略、终止通知接口和迁移顺序是本设计的选择;RFC 并不指定这些 Rust API。 diff --git a/docs/design/network-detailed-design.md b/docs/design/network-detailed-design.md deleted file mode 100644 index 9a0838e..0000000 --- a/docs/design/network-detailed-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# Network 详细设计 - -日期:2026-10-03。本文描述 dhttp 对当前 dquic 接口的适配,与[职责划分](network-quic-responsibilities.md)配套。 - -## 1. 资源归属 - -`DhttpNetwork::init` 为全部适用网卡准备 socket。网卡维护任务保存自己创建的绑定,负责地址发布、撤回和重建;Dock 负责收包任务及协议登记。停止 listener 只撤销名称和 Service,共享 socket 继续供出站和其他服务使用。 - -```text -DhttpNetwork(进程单例) - listeners → 本端名称 → Tower Service - pool → ConnectionKey → h3x Pool - -网卡维护任务 - watcher → netwatcher 系统事件 - snapshot → 最近一次完整网卡快照 - bindings → 网卡身份 + IP/scope → 实际 bound、socket、接收任务句柄 - dock → Dock - addresses → AddressBook - -Dock - 实际 bound → socket 登记、接收任务 - topology → QUIC / STUN / forward 协议 - -AddressBook - inner / outer 地址、网卡元数据、NAT 记录及订阅 - -qconn ServerRegistry - 本端名称 → TLS 材料、监听 scopes、接入回调 -``` - -Dock 不持有 AddressBook。维护任务中的绑定表只记录 Network 的资源所有权和实际端口,不复制协议地址簿,也不接管其他所有者或打洞流程创建的 socket。 - -## 2. 结构和入口 - -实现按职责拆分,子模块均为私有,公开入口仍是 `network::DhttpNetwork`: - -| 文件 | 职责 | -| --- | --- | -| `dhttp/src/network.rs` | 进程单例、初始化装配、连接池访问及名称到 Service 的登记 | -| `dhttp/src/network/connection.rs` | 连接池键、具名和匿名建连、双向 HTTP/3 请求驱动 | -| `dhttp/src/network/interfaces.rs` | 首次网卡扫描、绑定所有权、事件与存活检查、串行 NAT future 轮询及撤回 | -| `dhttp/src/network/interfaces/nat.rs` | 一次性 NAT 分类、STUN 心跳、QUIC 端点地址和外部映射同步 | - -各模块的回归测试统一放在 `dhttp/tests/unit/network/`,共享绑定测试夹具放在 `dhttp/tests/support/interface_bindings.rs`;源模块通过 `#[cfg(test)]` 和 `#[path]` 接入,以保留对私有实现的单元测试。NAT 模块属于网卡绑定维护,其 future 由绑定持有并由唯一维护任务轮询;拆分不增加后台任务,也不改变发布与撤回顺序。 - -### 2.1 Network 与连接池 - -```rust -pub struct DhttpNetwork { - listeners: Mutex, BoxService>>, - pool: h3x::Pool, -} - -enum ConnectionKey { - Incoming { local: Arc, remote: Option> }, - Outgoing { local: Option, remote: Arc }, -} -``` - -`ConnectionKey` 的 Eq/Hash 比较本端、远端名称值。双方具名时,同名 Incoming 与 Outgoing 共享池条目;匿名本端和匿名对端分别保留 `None`,不会匹配具名连接。远端 authority 的端口参与出站键,避免不同服务端口共用连接。 - -`NETWORK` 使用 `tokio::sync::OnceCell::get_or_try_init`。重复和并发 init 返回同一实例。取得连接的等待期限保持 30 秒。Network 沿用进程生命周期,Tokio runtime 必须持续存活。 - -### 2.2 网卡维护的私有状态 - -```rust -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct InterfaceAddress { - name: String, - index: u32, - hw_addr: String, - addr: SocketAddr, // 端口为 0,保留 IPv6 scope -} - -struct Binding { - bound: SocketAddr, // local_addr 返回的实际端口 - socket: Arc, - receiver: tokio::task::AbortHandle, - nat_probe: futures::future::BoxFuture<'static, ()>, -} - -struct InterfaceBindings { - dock: Arc, - addresses: Arc, - bindings: HashMap, -} -``` - -名称、索引和硬件地址共同描述快照中的网卡身份;硬件地址变化也会触发替换。不同 IP 或 IPv6 scope 是不同绑定。socket 引用供按实例撤销,并让旧绑定在地址撤回完成前继续占用自己的端口。AbortHandle 用于识别接收任务已经结束的情况。nat_probe 直接持有该绑定的一次性 NAT 分类及后续 STUN 绑定心跳 future,由唯一维护任务轮询,不 spawn 独立发布任务。NAT 分类只在新 socket 创建时执行一次;之后每20秒向同地址族的 STUN 节点发送绑定心跳并读取各节点的映射,失败的映射撤回,下次心跳重试。NAT 分类失败不重新分类,也不阻止心跳继续保活;映射仍可更新 DDNS,但不伪造 NAT 类型,外部打洞广告等待有效分类。Loopback 和 IPv6 link-local 不探测。 - -### 2.3 函数职责 - -| 入口 | 职责 | -| --- | --- | -| `DhttpNetwork::init/global` | 初始化信任、路由、首次扫描和维护任务;读取单例 | -| `interfaces::init` | 装配网卡监听器和路由,完成首次扫描后启动唯一维护任务 | -| `interfaces(snapshot)` | 生成以网卡身份和 IP/scope 为键的目标绑定集合 | -| `InterfaceBindings::register(socket)` | Dock 登记成功后显式发布地址;发布失败时回滚本次登记 | -| `InterfaceBindings::scan(snapshot)` | 保留存活绑定,撤回失效或过期绑定,补齐缺少的绑定 | -| `InterfaceBindings::withdraw(binding)` | 先撤回 AddressBook,再按 socket 移除 Dock 登记 | -| `InterfaceBindings::drop` | 维护任务退出时撤回它仍拥有的绑定 | -| `watch(watcher, snapshot, bindings)` | 优先处理网卡事件和每秒一次的存活检查,串行扫描并轮询各绑定的 NAT/心跳 future | -| `nat::probe_nat/apply_nat_mappings` | 一次性分类和持续心跳,无返回结果,内部记录日志,先更新 QUIC 端点地址再同步地址簿,局部失败回滚 | -| `DhttpNetwork::listen` | 登记具名 QUIC 服务及 Tower Service,取消时撤销名称 | -| `get_connection/connect` | 查 H3 Pool;分别启动具名或匿名连接,装配 H3 并启动请求驱动 | -| `serve_connection` | 接收双向请求流,读取当前 Service,退出时按连接实例移出池 | - -## 3. 初始化与扫描 - -### 3.1 初始化 - -1. 初始化信任,建立 netwatcher 监听器和全局 QuicRouter。 -2. 消费监听器立即交付的初始快照。 -3. 创建 `InterfaceBindings`,扫描全部适用网卡。 -4. 将监听器、快照和绑定表交给唯一的维护任务。 -5. 发布 Network 单例。 - -信任或监听器创建失败时,OnceCell 仍可重试。单个绑定失败只记录错误,其他绑定继续;空快照也允许初始化成功。首次扫描结束到启动维护任务之间没有 await。初始化不读取身份目录,也不等待 NAT 探测。 - -### 3.2 目标地址 - -使用 netwatcher 提供的启用接口及 IP 列表。忽略 unspecified 和 multicast 地址;IPv4 直接绑定具体 IP,IPv6 link-local 使用该接口索引作为 scope ID,其他 IPv6 地址使用 scope 0。端口请求值为 0,实际端口由操作系统分配。 - -`BoundDevice::new` 和 `bind_to_device` 的实际返回值决定能否使用该接口。重复地址记录在目标表中合并,不产生重复绑定。 - -### 3.3 扫描算法 - -```text -根据当前快照生成目标集合 - → 遍历 Network 自己的绑定 - key 仍存在 - 且接收任务尚未结束 - 且 Dock::find_socket(bound) 返回原 socket - → 保留原绑定和原端口 - 否则 - → 撤回地址并移除原登记 - → 遍历缺少绑定的目标 - bind_to_device(IP:0, device) - → register(socket) - → 只有新登记和发布成功才写入绑定表 -``` - -查询始终是 `Dock::find_socket(bound: SocketAddr)`。Network 不枚举 Dock,也不根据“是否绑定网卡”推断资源所有权。其他所有者的网卡 socket 和临时打洞 socket 都留给其所有者处理。 - -名称、索引、硬件地址、IP 或 scope 变化时,旧键被撤回。未变化且仍存活的绑定不会重复发布,也不会重新分配端口。 - -## 4. 登记、发布与撤回 - -### 4.1 新建绑定 - -当前下层入口为: - -```rust -Dock::add(Arc) -> io::Result> -Dock::find_socket(SocketAddr) -> Option> -Dock::remove(&UdpSocket) -> bool -QuicProtocol::unregister(SocketAddr) -``` - -`Dock::add` 登记 STUN socket、直接 QUIC 地址并安装接收任务。返回值含义: - -| 返回值 | Network 的处理 | -| --- | --- | -| `Ok(Some(handle))` | 本次新登记成功,可以开始本次地址发布 | -| `Ok(None)` | 已有登记;不发布、不加入自己的绑定表、不撤销已有登记 | -| `Err(error)` | 下层登记失败,记录错误并等待下一轮重试 | - -Dock 已登记 `EndpointAddr::direct(bound)`,Network 不重复登记它。需要额外映射或中继端点地址时,由相应所有者向 QuicProtocol 登记额外端点地址。 - -当前 Network 对直接地址采用以下发布策略: - -| `EndpointAddr::scope()` | 操作 | -| --- | --- | -| Loopback / Internal | `AddressBook::insert_inner(&socket, direct)` | -| External | `AddressBook::insert_outer(&socket, direct)` | -| 无可用 scope | 保留 socket 登记,不发布地址 | - -AddressBook 从 socket 读取实际 bound 和网卡元数据。是否发布及 inner/outer 的选择属于上层策略,不由 listener 的 scopes 决定。写入 AddressBook 也不等于已经向 DDNS 或 mDNS 服务发布名称。 - -AddressBook 的单次插入是原子的。插入失败时,Network 调用 `Dock::remove(&socket)` 回滚本次新登记;不能调用 `remove_bound` 删除导致本次插入失败的既有地址记录。 - -### 4.2 主动撤回 - -```text -停止轮询并丢弃该绑定的 nat_probe future - → AddressBook::remove_bound(bound) - → Dock::remove(&socket) - → 释放 Network 的 socket 引用 -``` - -NAT future 只由维护任务轮询,分类、心跳及同步地址更新均不另起任务。扫描与 future 轮询串行,绑定移除时丢弃 nat_probe,立即取消尚未完成的 STUN transaction;不存在后台写入或需要等待的独立探测任务。映射变化时先更新 QUIC 直接/中介端点地址,再更新 AddressBook;端点地址冲突保留原发布,失效映射的端点地址及发布一起撤回。 - -`AddressBook::remove_bound` 清除 inner/outer 地址、NAT 和网卡记录,并发出撤回通知。Dock 移除接收任务和 STUN 登记,内部调用 `QuicProtocol::unregister(bound)` 清理全部协议端点地址;Network 不遍历或逐个撤销端点地址。 - -### 4.3 接收任务自行结束 - -Dock 的接收任务结束时会清除自己的登记和 QUIC 端点地址,AddressBook 保持原状。因此 watch 同时等待两种触发: - -- netwatcher 交付新的完整快照; -- 每秒一次的绑定检查,使用最近快照。 - -检查同时读取 `AbortHandle::is_finished()` 和 `Dock::find_socket(bound)`。即使外部取消任务后 Dock 仍能升级 socket 引用,也能识别该绑定已失效。随后执行相同撤回流程;网卡仍适用时,在同轮扫描中重建并发布。 - -这项定时检查不轮询操作系统网卡。间隔采用 Skip 策略,运行时阻塞后的过期 tick 不连续补跑。正常调度下,失效地址在下一次检查时撤回;不承诺故障和撤回原子发生。绑定失败也会在后续检查重试。 - -维护任务退出时,其私有绑定表的 Drop 执行同样的撤回流程,不影响其他所有者的 socket。 - -## 5. 具名端点与匿名请求 - -### 5.1 具名端点 - -```rust -pub struct Endpoint { - pub(crate) quic: Arc, -} -``` - -`Endpoint::new(identity)` 把必填身份传给 `QuicEndpoint::new`,显式配置 h3 ALPN 和现有 client/server transport parameters。Endpoint 始终持有 `Arc`,不能构造无身份端点;name() 返回 &str,读取 `quic.identity.name()`。 - -`Endpoint::load` 规范化 DHTTP 名称,并一次性读取证书链、私钥和 OCSP。克隆共享内存中的 QUIC endpoint;新连接和 listen 不重新读取身份文件。凭据加载或验证失败返回错误。 - -### 5.2 匿名出站 - -`Anonymous` 是无状态的匿名出站入口,提供与 Endpoint 对称的 get/head/post/put/patch/delete/options/request/from_request 方法。GET/HEAD/DELETE/OPTIONS 返回 Request,POST/PUT/PATCH/request 返回 Request,上传和响应接口与具名请求相同。 - -`Anonymous.from_request` 和 `Request::new(http::Request)` 不绑定 Endpoint;Anonymous 与 Request 都不提供 listen,也不能使用需要本端名称的裸 `~` URI。Endpoint.from_request 绑定具名 Endpoint;可选状态只存在于出站 Request 和连接池键中,不存在于任何监听端点中。 - -连接池 factory 选择 dquic 的两种入口,两者共用内部建连实现: - -```text -Some(endpoint) → endpoint.quic.connect(server_name) -None → qconn::connect_anonymously(server_name, client_parameters, alpn) -``` - -qconn 的具名 connect 提供本端 LocalAuthority,匿名函数传入 None;共有的内部流程统一创建初始 CID、密钥、TLS context 和 Paths,并启动 `client_growing` 与 `recv::tick`。dhttp 只装配 HTTP/3 所需配置,不实现匿名 QUIC 启动。 - -解析源、地址流、路径发现和 QUIC/TLS 生命周期由下层客户端流程处理。TLS 验证目标服务器身份;匿名只表示不提交本端身份。握手名称去掉 authority 的端口,解析和连接池键保留端口。取消尚未交付的 connect future 会发出关闭信号,停止解析并按现有关闭流程清理路径及 CID;回调无法交付已经建立的连接时同样关闭该连接。 - -匿名请求可复用同目标的匿名连接,并与具名请求隔离。dhttp Endpoint 和 qconn QuicEndpoint.identity 都保持必填;实际认证身份来自握手结果,错误不表示匿名。 - -## 6. H3 接入与服务生命周期 - -- 具名出站继续调用 `connect(server_name)`;Network 初始化不覆盖全局 resolver 注册。 -- 入站回调和出站 factory 都通过 `QuicTransport::new` 核对实际 h3 ALPN,保存实际握手摘要和 Client/Server 角色,再创建 H3Connection。 -- 每条 H3 连接启动一个 `serve_connection`。控制及 QPACK 单向流由 h3x 驱动。 -- 每次请求读取当前名称对应的 Service;没有 Service 时停止接收方向并取消发送方向。 -- HTTP authority 必须匹配握手本端身份,可信身份放入请求或响应 extensions。 -- 取消 listen 在服务表锁内移除 Service 和 ServerRegistry 名称。已取得 Service 的请求继续,同名服务重新监听后原连接可交付给新 Service。 -- 请求驱动退出时按具体连接移出池,保留同键的替换连接。h3x 负责建连去重和复用。 - -旧 path 沿用下层发送错误退出机制;新的本地地址通过现有 AddressBook 订阅、puncher 和路径验证接入。已因最后一条路径失败而结束的连接不会被恢复;网络恢复后新请求可重新建连。 - -## 7. 验收与边界 - -| 测试 | 覆盖内容 | -| --- | --- | -| Network 单元测试 | IPv6 scope、无效地址过滤、端口复用、网卡身份变化、空快照恢复、其他所有者 socket 保留 | -| Network 登记测试 | 已有 Dock 登记不被认领或发布;发布失败回滚且保留既有地址 | -| Network 撤回测试 | inner/outer、NAT、全部 QUIC 端点地址撤销;任务退出清理 | -| Network 接收任务测试 | 实际接收循环错误及任务取消后的撤回和重建 | -| `network_lifecycle` | 并发/重复初始化、显式发布、接口元数据、全局 resolver 保留、无网卡事件时的周期撤回与重建、临时 socket 保留 | -| `listener_lifecycle` | 具名服务登记、重复监听、匿名 listen 拒绝、取消后 socket 保留 | -| `quic_roundtrip` | 真实 UDP/TLS/H3、具名复用、两个匿名入口、服务器证书名称不匹配时拒绝、身份摘要、反向请求、停止和恢复监听 | -| `dns_bootstrap` | 全局 resolver 通过 H3 引导解析、不同 origin 端口隔离 | - -本机验证(2026-10-02):`cargo test -p dhttp --lib --tests --offline -- --include-ignored` 全部 43 项通过,其中 33 项单元测试、6 项 bootstrap 配置测试、4 项集成测试,包含真实 UDP/TLS/HTTP/3。测试在允许本机 UDP socket 的环境执行。 - -网卡测试使用受控快照,不修改宿主系统网卡;Linux/Windows 的真实 down/up、地址增删仍需实机验证。普通 Network 初始化自动装配一次性 NAT 分类及持续 STUN 心跳;不启动独立 DDNS/mDNS 名称发布任务。NAT/心跳回归使用本地脚本化 STUN 响应,不访问线上节点。 - -2026-10-04 中转上报修正:NAT 心跳得到的 agent/outer 对在外部地址簿中保留为 Mediate,不能仅公布 Direct(outer)。FullCone 映射可以直达;受限/未知分类使用中转。QUIC 仍登记 Direct 与 Mediate 端点地址,DNS 的 E-record 使用现有编码器输出 outer-agent;内部 EndpointAddr Display 保持 agent-outer。AddressBook 内部范围仍仅接受 Direct,外部范围接受有效 Mediate,已有成员/签名/错误变体均不变。 diff --git a/docs/design/network-quic-responsibilities.md b/docs/design/network-quic-responsibilities.md deleted file mode 100644 index 1459e89..0000000 --- a/docs/design/network-quic-responsibilities.md +++ /dev/null @@ -1,150 +0,0 @@ -# Network 与 QUIC 接入:职责划分和实施清单 - -日期:2026-10-03。本文以当前相邻 dquic 工作区的接口为准;结构、生命周期及调用顺序见 [Network 详细设计](network-detailed-design.md)。 - -## 1. 职责边界 - -**dhttp 负责网卡维护、地址发布生命周期和 HTTP 接入;dquic 提供 socket、地址簿及 QUIC/TLS 能力;h3x 负责 HTTP/3 和连接池机制。** - -| 工作 | 下层职责 | dhttp 职责 | -| --- | --- | --- | -| 网卡与绑定 | qudp 按具体 IP、scope 和 BoundDevice 绑定 | 监听网卡变化,保存自己创建的实际 bound,决定增删和重建 | -| socket 收包 | Dock 保存登记、接收任务和协议 topology | 使用实际 bound 查询,检查任务存活,不接管临时或其他所有者的绑定 | -| 协议地址 | Dock 自动登记 direct QUIC 地址,移除时清理全部协议端点地址 | 仅在需要时登记额外端点地址 | -| 地址簿 | AddressBook 提供 inner/outer、NAT、网卡元数据和订阅 | 选择发布范围,显式发布、撤回并回滚局部失败 | -| 接收失败 | Dock 清除本次 socket、STUN 和 QUIC 登记 | 即使没有网卡事件,也检查失效绑定并撤回地址;必要时重建 | -| STUN/NAT | 提供协议探测、映射和打洞能力 | 决定何时启用;负责停止本绑定的探测及发布任务后再撤回 | -| 名称解析 | qresolve 提供全局 resolver;qconnection 消费地址流 | 保留应用注册的解析源,规范化请求名称和 authority | -| 名称发布 | AddressBook 提供准确的地址集合与变化订阅 | 将 DHTTP 名称与地址关联;装配实际 DDNS/mDNS 发布服务 | -| 具名端点 | QuicEndpoint 持有必填身份,提供 listen/connect | 加载身份材料,保存和复用内存凭据 | -| 匿名请求 | connect_anonymously 独立发起连接,与具名 connect 共用内部流程,仍验证服务器身份 | 调用匿名入口、配置 h3 和传输参数、隔离连接池条目 | -| TLS 与来源 | qtls 验证身份;qconn 处理逐名称 scopes、QUIC 路径与流 | 传入 scopes、核对实际 h3 ALPN、检查 HTTP authority | -| HTTP/3 | h3x 负责协议、QPACK、Body、流和通用 Pool | 名称到 Service 映射、池键、双向请求驱动及可信 extensions | - -NAT 映射对应的 QUIC 端点地址登记、更新及失败回滚由 dhttp 维护,通过现有 QuicProtocol 接口完成。 - -全部适用网卡在 init 时准备,纯客户端也有发送和接收资源。listener 的 scopes 只约束该名称的来源,不决定进程 socket 集合。停止 listener 撤销名称和 Service,保留共享网络资源。 - -## 2. 当前下层接口 - -```rust -// qconnection -QuicEndpoint::new(identity: Arc) -quic.identity.name() -quic.connect(server_name: String) - -// qprotocol -Dock::new(topology: Arc) -Dock::add(socket: Arc) -> io::Result> -Dock::find_socket(bound: SocketAddr) -> Option> -Dock::remove(socket: &UdpSocket) -> bool -QuicProtocol::unregister(bound: SocketAddr) -AddressBook::insert_inner(socket: &UdpSocket, endpoint: EndpointAddr) -AddressBook::insert_outer(socket: &UdpSocket, endpoint: EndpointAddr) -AddressBook::remove_bound(bound: SocketAddr) -``` - -Dock 管理接收与协议登记;AddressBook 由发布它的上层管理。两者没有隐式生命周期关联,Network 必须组合调用。 - -`Some(handle)` 才表示本次新登记成功。`None` 表示已有登记,不授予 Network 发布或撤销该登记的所有权。Dock 已完成 direct QUIC 登记,上层只登记额外映射或中继端点地址。 - -## 3. Network 的绑定所有权 - -维护任务保存: - -```text -网卡名称 + 索引 + 硬件地址 + IP/scope - → 实际 bound + 原 socket 引用 + 接收任务 AbortHandle -``` - -每次扫描只核对这张所有权表。键仍在目标快照中,接收任务未结束,且 `Dock::find_socket(bound)` 返回原 socket 时,保留实际端口。网卡、地址或身份变化则撤回旧绑定,再创建新绑定。 - -临时打洞 socket 由 EphemeralSocket 的所有者管理。其他组件创建的 socket 即使绑定了同一张网卡,也不属于 Network;扫描和维护任务退出都不会移除它们。 - -IPv6 link-local 的 scope 使用接口索引。无效接口和局部绑定错误不阻断其他接口;空快照允许启动,后续恢复时补建。 - -## 4. 地址生命周期 - -### 登记与发布 - -```text -bind_to_device - → Dock::add - → Some(handle) - → 按上层策略 insert_inner / insert_outer - → 写入 Network 的绑定表 -``` - -当前直接地址策略为 Loopback/Internal 写入 inner,External 写入 outer,无可发布 scope 的地址只保留 socket 登记。listener 的 External 不自动触发 STUN、NAT 或名称发布。 - -AddressBook 单次插入失败没有部分写入。失败后仅回滚本次新建的 Dock 登记,保留已有地址记录;重复 Dock 登记不进入发布步骤。 - -### 主动撤回 - -```text -停止并等待本绑定的探测、刷新和发布任务 - → AddressBook::remove_bound(bound) - → Dock::remove(&socket) -``` - -当前发布操作都在维护任务内同步完成,没有额外探测或发布任务需要等待。未来引入这些任务时,必须先结束它们,避免撤回后重新发布旧地址。 - -AddressBook 清理地址、NAT、网卡记录和订阅状态。Dock 调用 `QuicProtocol::unregister(bound)` 清理全部端点地址,并停止接收及移除 STUN 登记。dhttp 不根据 AddressBook 返回的 direct 列表逐个撤销协议端点地址。 - -### 接收任务退出 - -Dock 的接收任务可能在没有网卡事件时退出。此时协议登记已清理,但地址簿记录仍需上层撤回。 - -watch 在系统事件之外,每秒检查一次自己的绑定,复用最近网卡快照。检查 `AbortHandle::is_finished()` 和按 bound 的 Dock 查询结果;失效时撤回地址,仍需该接口则重建。对任务被取消但 socket 引用仍存活的情况也适用。 - -系统接口只由 netwatcher 监测,定时器不枚举网卡。该机制允许最多一个正常检查间隔的地址滞后;运行时被阻塞时可能更久。所有写入与撤回由一个维护任务串行执行。 - -## 5. 具名端点和匿名请求 - -`Endpoint::new(identity)` 必须提供身份,构造具名 QuicEndpoint。`Endpoint::load` 读取 DHTTP home 的证书、私钥和 OCSP,之后连接与监听直接使用已加载材料。dhttp 的 Endpoint 始终持有 `Arc`,不能构造无身份 Endpoint,name() 返回 &str。 - -`Anonymous.get(uri)` 等方法创建未绑定 Endpoint 的匿名出站请求,与 Endpoint 的请求方法对称;`Anonymous.from_request` 和 `Request::new(http_request)` 用于标准请求。不创建 QuicEndpoint,也没有可供裸 `~` 展开的本端名称。Anonymous 和 Request 不提供监听能力;Request 的可选 Endpoint 只表达出站身份。dquic 的 QuicEndpoint 保持必填身份,匿名函数只有出站建连能力。 - -具名请求调用 QuicEndpoint::connect,匿名请求调用 qconn::connect_anonymously;两者共用 dquic 的内部建连流程。dhttp 显式配置 h3 ALPN 和 transport parameters。CID、初始密钥、TLS、路径、解析及后台任务全部由 dquic 创建和管理;dhttp 不再保留匿名建连模块。匿名请求仍验证服务器身份,握手失败不降级为匿名。 - -取消尚未交付的 connect future 时,dquic 发出连接关闭信号;客户端生命周期停止解析,并按既有 Closing/Draining 流程清理路径和 CID。已经排队但尚未被调用方取得的连接同样受此取消保护;成功交付后由连接句柄管理生命周期。 - -池键沿用名称值和远端端口。同目标的匿名请求可复用连接;具名请求与匿名请求隔离。匿名入站不会被当作某个具名远端复用。 - -## 6. 保留的连接与服务语义 - -- 应用可以在 Network 初始化前后注册全局 resolver,init 不替换它。 -- 入站和出站共用 QuicTransport 的实际 ALPN 校验和 H3 构造;握手结果直接提供可信身份。 -- 每条 H3 连接只有一个双向请求接入循环。无 Service 时停止和取消本次流。 -- listen 取消后撤销名称和 Service;已有出站连接继续,同名重新监听后可沿用原连接。 -- 按具体连接移出 Pool,防止旧驱动退出时删掉替换连接。 -- 旧路径沿用发包失败退出机制;新本地地址经现有地址订阅、打洞及 QUIC 验证接入。 -- 本轮不承诺恢复已经终止的连接,也不自动重放已发送的 HTTP 请求。 - -## 7. 验证范围 - -| 文件 | 主要验证 | -| --- | --- | -| `dhttp/tests/unit/network/connection.rs` | 具名双向连接池键复用、匿名连接隔离、服务端口隔离 | -| `dhttp/tests/unit/network/interfaces.rs` | 端口复用、IPv6 scope、网卡身份变化、空快照恢复、其他所有者 socket 保留、发布失败回滚、全部协议端点地址撤回、接收任务实际失败和取消 | -| `dhttp/tests/unit/network/nat.rs` | 一次性分类、映射替换与失败撤回、端点地址冲突、分类失败后的持续心跳及中转地址发布 | -| `dhttp/tests/network_lifecycle.rs` | 初始化幂等、地址显式发布、全局 resolver 保留、无网卡事件的失效撤回和重建、真实 EphemeralSocket 保留 | -| `dhttp/tests/quic_roundtrip.rs` | 单参数 Dock.add、具名和匿名实际 UDP/TLS/H3 请求、两个匿名构造入口、连接复用、双向请求和监听生命周期 | -| `dhttp/tests/dns_bootstrap.rs` | 单参数 Dock.add、H3 引导解析、authority 端口保留 | -| `dhttp/tests/listener_lifecycle.rs` | 具名身份、匿名监听拒绝、Service 撤销及共享 socket 保留 | - -测试及实际执行结果见详细设计。系统网卡 down/up 的跨平台行为、NAT 映射和跨进程名称发布仍需独立验收。 - -## 8. 代码入口 - -- [Network](../../dhttp/src/network.rs):单例初始化、Pool 访问和服务登记。 -- [Connection](../../dhttp/src/network/connection.rs):连接池键、建连与请求驱动。 -- [Interfaces](../../dhttp/src/network/interfaces.rs):绑定维护、地址生命周期与串行探测轮询。 -- [NAT](../../dhttp/src/network/interfaces/nat.rs):NAT 分类、STUN 心跳与映射同步。 -- [Endpoint](../../dhttp/src/endpoint.rs):HTTP/3 配置与具名端点构造;匿名启动由 qconnection 的 connect_anonymously 提供。 -- [QuicTransport](../../dhttp/src/transport.rs):h3 校验、握手摘要和流适配。 -- [qconnection endpoint](../../../dquic/qconnection/src/endpoint.rs):具名 listen/connect。 -- [Dock](../../../dquic/qprotocol/src/dock.rs)、[AddressBook](../../../dquic/qprotocol/src/addr_book.rs)、[QuicProtocol](../../../dquic/qprotocol/src/protocol/quic.rs):各自登记和清理契约。 -- [EphemeralSocket](../../../dquic/qprotocol/src/socket/ephemeral.rs):临时 socket 所有权。 - -相邻仓库链接按当前 `genmeta/{dhttp,dquic,h3x}` 目录布局解析。 diff --git a/home/Cargo.toml b/home/Cargo.toml index 4c338b0..b6fe074 100644 --- a/home/Cargo.toml +++ b/home/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-home" description = "Local identity home and profile management for DHttp" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -22,9 +22,23 @@ tokio = { workspace = true, features = ["fs"], optional = true } x509-parser = { workspace = true, optional = true } ring = { version = "0.17", optional = true } +dhttp-identity = { version = "0.3.0", optional = true } +serde = { workspace = true, optional = true } +toml = { workspace = true, optional = true } +qtls = { workspace = true, optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls"], optional = true } +der = { version = "0.7", optional = true } +sha1 = { version = "0.10", optional = true } +x509-cert = { version = "0.2", optional = true } +x509-ocsp = { version = "0.2", optional = true } +tempfile = { version = "3", optional = true } + [features] default = ["ssl"] +manage = ["ssl", "dep:dhttp-identity", "dep:serde", "dep:toml", "tokio/io-util"] +ocsp = ["ssl", "dep:qtls", "dep:reqwest", "dep:der", "dep:sha1", "dep:x509-cert", "dep:x509-ocsp", "dep:tempfile"] ssl = ["dep:tokio", "dep:futures", "dep:rustls", "dep:x509-parser", "dep:ring"] [dev-dependencies] +tempfile = "3" tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/home/src/certificate.rs b/home/src/certificate.rs index a1def08..2912830 100644 --- a/home/src/certificate.rs +++ b/home/src/certificate.rs @@ -115,6 +115,8 @@ pub enum InvalidOwnerHash { Invalid, } +// Keep the established fallible conversion API. +#[allow(unknown_lints, clippy::try_from_instead_of_from_str)] impl TryFrom<&str> for OwnerHash { type Error = InvalidOwnerHash; diff --git a/home/src/dhttp_home.rs b/home/src/dhttp_home.rs index c8f3eeb..91a4ab4 100644 --- a/home/src/dhttp_home.rs +++ b/home/src/dhttp_home.rs @@ -1,5 +1,7 @@ pub mod certificate; pub mod identity; +#[cfg(feature = "ocsp")] +pub mod ocsp; mod bootstrap; @@ -33,10 +35,26 @@ pub fn normalize_name(input: &str) -> Option { is_valid_dns_name(&name).then_some(name) } +/// Normalize a lookup name, expanding only an explicit trailing `~`. +/// Ordinary DNS names retain their namespace; single-label hosts are allowed. +pub fn normalize_dns_name(input: &str) -> Option { + let name = input.trim().to_ascii_lowercase(); + let name = name.strip_suffix('.').unwrap_or(&name); + let name = if let Some(partial) = name.strip_suffix('~') { + format!("{partial}{DHTTP_SUFFIX}") + } else { + name.to_owned() + }; + is_valid_dns_labels(&name).then_some(name) +} + /// Check certserver's canonical lowercase ASCII DNS label rules. pub fn is_valid_dns_name(name: &str) -> bool { + name.split('.').count() >= 2 && is_valid_dns_labels(name) +} + +fn is_valid_dns_labels(name: &str) -> bool { name.len() <= 253 - && name.split('.').count() >= 2 && name.split('.').all(|label| { (1..=63).contains(&label.len()) && !label.starts_with('-') diff --git a/home/src/identity.rs b/home/src/identity.rs index 51c5483..19b7950 100644 --- a/home/src/identity.rs +++ b/home/src/identity.rs @@ -13,6 +13,8 @@ pub(crate) fn normalize_profile_name(input: &str) -> Option { } } +#[cfg(feature = "manage")] +pub mod settings; #[cfg(feature = "ssl")] pub mod ssl; @@ -40,6 +42,10 @@ pub enum IdentityProfileFromPathError { } impl IdentityProfile { + pub const SERVER_CONF_FILE: &'static str = "server.conf"; + pub fn server_conf_path(&self) -> PathBuf { + self.join(Self::SERVER_CONF_FILE) + } pub const CONFIG_DB_FILE: &'static str = "config.db"; pub const DB_DIR: &'static str = "db"; pub const ACCESS_DB_FILE: &'static str = "access.db"; diff --git a/home/src/identity/management.rs b/home/src/identity/management.rs new file mode 100644 index 0000000..5c9d4f6 --- /dev/null +++ b/home/src/identity/management.rs @@ -0,0 +1,814 @@ +use super::{ + CERT_FILE_NAME, KEY_FILE_NAME, ListIdentityProfilesError, LoadCertsError, LoadKeyError, + ResolveIdentityProfileError, SSL_DIR_NAME, list_identity_profiles_error, +}; +use crate::{ + DhttpHome, + identity::{IdentityProfile, IdentityProfileFromPathError}, +}; +use dhttp_identity::{identity::Identity, name::DhttpName}; +use snafu::{IntoError, ResultExt, Snafu}; +use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; +use tokio::{ + fs, + io::{self, AsyncWriteExt}, +}; +static SAVE_IDENTITY_TRANSACTION_ID: AtomicU64 = AtomicU64::new(0); +#[derive(Snafu, Debug)] +#[snafu(module)] +pub enum LoadIdentityError { + #[snafu(display("invalid DHTTP identity name"))] + InvalidName { + source: dhttp_identity::name::InvalidDhttpName, + }, + #[snafu(display("failed to load identity certificates at {}", path.display()))] + LoadCerts { + path: PathBuf, + source: LoadCertsError, + }, + + #[snafu(display("failed to load identity private key at {}", path.display()))] + LoadKey { path: PathBuf, source: LoadKeyError }, +} + +#[derive(Snafu, Debug)] +#[snafu(module)] +pub enum SaveIdentityError { + #[snafu(display("failed to create identity directory at {}", path.display()))] + CreateIdentityDir { path: PathBuf, source: io::Error }, + #[snafu(display("failed to create staged identity material at {}", path.display()))] + CreateStageDir { path: PathBuf, source: io::Error }, + #[snafu(display("failed to get metadata for path {}", path.display()))] + Metadata { path: PathBuf, source: io::Error }, + #[snafu(display( + "failed to preserve old identity material from {} at {}", + from.display(), + to.display() + ))] + PreserveOld { + from: PathBuf, + to: PathBuf, + source: io::Error, + }, + #[snafu(display("failed to create file at {}", path.display()))] + Create { path: PathBuf, source: io::Error }, + #[snafu(display("failed to write to file at {}", path.display()))] + Write { path: PathBuf, source: io::Error }, + #[snafu(display("failed to commit identity material at {}", path.display()))] + Commit { path: PathBuf, source: io::Error }, + #[snafu(display( + "failed to restore old identity material from {} to {}", + from.display(), + to.display() + ))] + Rollback { + from: PathBuf, + to: PathBuf, + source: io::Error, + }, +} + +#[derive(Snafu, Debug)] +#[snafu(module)] +pub enum IdentityProfileCandidateError { + #[snafu(display("failed to inspect identity profile entry {}", path.display()))] + EntryMetadata { path: PathBuf, source: io::Error }, + #[snafu(display("invalid identity profile directory {}", path.display()))] + InvalidProfile { + path: PathBuf, + source: IdentityProfileFromPathError, + }, + #[snafu(display( + "identity profile {} is missing SSL directory {}", + profile.name(), + path.display() + ))] + MissingSslDirectory { + profile: IdentityProfile, + path: PathBuf, + }, + #[snafu(display( + "failed to inspect SSL directory {} for identity profile {}", + path.display(), + profile.name() + ))] + SslMetadata { + profile: IdentityProfile, + path: PathBuf, + source: io::Error, + }, + #[snafu(display( + "SSL path {} for identity profile {} is not a directory", + path.display(), + profile.name() + ))] + SslNotDirectory { + profile: IdentityProfile, + path: PathBuf, + }, +} + +impl IdentityProfile { + /// Load this profile's identity (certificate chain + private key) from disk. + pub async fn load_identity(&self) -> Result { + let certs_path = self.ssl_dir().join(CERT_FILE_NAME); + let certs = self + .load_certs() + .await + .context(load_identity_error::LoadCertsSnafu { path: certs_path })?; + + let key_path = self.ssl_dir().join(KEY_FILE_NAME); + let key = self + .load_key() + .await + .context(load_identity_error::LoadKeySnafu { path: key_path })?; + + Ok(Identity::new( + DhttpName::try_from(self.name.clone()) + .context(load_identity_error::InvalidNameSnafu)? + .into_name(), + certs, + key, + )) + } + + pub async fn save_identity(&self, cert: &[u8], key: &[u8]) -> Result<(), SaveIdentityError> { + self.save_identity_transaction(cert, key, || Ok(())).await + } + + async fn save_identity_transaction( + &self, + cert: &[u8], + key: &[u8], + before_install: F, + ) -> Result<(), SaveIdentityError> + where + F: FnOnce() -> io::Result<()>, + { + fs::create_dir_all(self.path()) + .await + .context(save_identity_error::CreateIdentityDirSnafu { path: self.path() })?; + + let transaction_id = SAVE_IDENTITY_TRANSACTION_ID.fetch_add(1, Ordering::Relaxed); + let unique_suffix = format!("{}-{transaction_id}", std::process::id()); + let stage_dir = self.join(format!(".{SSL_DIR_NAME}-stage-{unique_suffix}")); + let backup_dir = self.join(format!(".{SSL_DIR_NAME}-backup-{unique_suffix}")); + let ssl_dir = self.ssl_dir(); + + fs::create_dir(stage_dir.as_path()).await.context( + save_identity_error::CreateStageDirSnafu { + path: stage_dir.clone(), + }, + )?; + + if let Err(error) = Self::write_material_file(stage_dir.join(CERT_FILE_NAME), cert).await { + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(error); + } + if let Err(error) = Self::write_material_file(stage_dir.join(KEY_FILE_NAME), key).await { + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(error); + } + + let had_old_material = match fs::symlink_metadata(ssl_dir.as_path()).await { + Ok(_) => true, + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => { + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(save_identity_error::MetadataSnafu { path: ssl_dir }.into_error(error)); + } + }; + + if had_old_material + && let Err(error) = fs::rename(ssl_dir.as_path(), backup_dir.as_path()).await + { + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(save_identity_error::PreserveOldSnafu { + from: ssl_dir, + to: backup_dir, + } + .into_error(error)); + } + + let commit_result = match before_install() { + Ok(()) => fs::rename(stage_dir.as_path(), ssl_dir.as_path()).await, + Err(error) => Err(error), + }; + + if let Err(commit_error) = commit_result { + if had_old_material + && let Err(rollback_error) = + fs::rename(backup_dir.as_path(), ssl_dir.as_path()).await + { + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(save_identity_error::RollbackSnafu { + from: backup_dir, + to: ssl_dir, + } + .into_error(rollback_error)); + } + + let _ = fs::remove_dir_all(stage_dir.as_path()).await; + return Err(save_identity_error::CommitSnafu { path: ssl_dir }.into_error(commit_error)); + } + + if had_old_material { + // The new material is already committed. Backup removal is best-effort so a + // housekeeping error cannot turn a successful replacement into a reported + // failure whose observable state contradicts the result. + let _ = fs::remove_dir_all(backup_dir.as_path()).await; + } + + Ok(()) + } + + async fn write_material_file(path: PathBuf, contents: &[u8]) -> Result<(), SaveIdentityError> { + let mut open_options = fs::OpenOptions::new(); + open_options.create_new(true).write(true); + #[cfg(unix)] + open_options.mode(0o400); + + let mut file = open_options + .open(path.as_path()) + .await + .context(save_identity_error::CreateSnafu { path: path.clone() })?; + file.write_all(contents) + .await + .context(save_identity_error::WriteSnafu { path: path.clone() })?; + file.flush() + .await + .context(save_identity_error::WriteSnafu { path: path.clone() })?; + file.sync_all() + .await + .context(save_identity_error::WriteSnafu { path }) + } +} + +impl DhttpHome { + pub async fn identity_profile_candidates( + &self, + ) -> Result< + Box<[Result]>, + ListIdentityProfilesError, + > { + use identity_profile_candidate_error::*; + use list_identity_profiles_error::ReadDirSnafu; + + let home_path = self.as_path(); + let mut read_dir = fs::read_dir(home_path) + .await + .context(ReadDirSnafu { path: home_path })?; + let mut paths = Vec::new(); + while let Some(entry) = read_dir + .next_entry() + .await + .context(ReadDirSnafu { path: home_path })? + { + paths.push(entry.path()); + } + paths.sort(); + + let mut candidates = Vec::new(); + for path in paths { + let metadata = match fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(source) => { + candidates.push(Err(EntryMetadataSnafu { path }.into_error(source))); + continue; + } + }; + if !metadata.is_dir() { + continue; + } + + let profile = match IdentityProfile::try_from(path.clone()) { + Ok(profile) => profile, + Err(source) => { + candidates.push(Err(InvalidProfileSnafu { path }.into_error(source))); + continue; + } + }; + if DhttpName::try_from(profile.name()).is_err() { + candidates.push(Err(InvalidProfileSnafu { path: path.clone() }.into_error( + IdentityProfileFromPathError::InvalidName { + name: profile.name().to_owned(), + }, + ))); + continue; + } + let ssl_path = profile.ssl_dir(); + + match fs::symlink_metadata(&ssl_path).await { + Ok(_) => {} + Err(source) if source.kind() == io::ErrorKind::NotFound => { + candidates.push( + MissingSslDirectorySnafu { + profile, + path: ssl_path, + } + .fail(), + ); + continue; + } + Err(source) => { + candidates.push(Err(SslMetadataSnafu { + profile, + path: ssl_path, + } + .into_error(source))); + continue; + } + } + + let ssl_metadata = match fs::metadata(&ssl_path).await { + Ok(metadata) => metadata, + Err(source) => { + candidates.push(Err(SslMetadataSnafu { + profile, + path: ssl_path, + } + .into_error(source))); + continue; + } + }; + if !ssl_metadata.is_dir() { + candidates.push( + SslNotDirectorySnafu { + profile, + path: ssl_path, + } + .fail(), + ); + continue; + } + + candidates.push(Ok(profile)); + } + + Ok(candidates.into_boxed_slice()) + } + + pub async fn identity_profile_exists_exactly(&self, name: &str) -> bool { + self.resolve_identity_profile_exactly(name).await.is_ok() + } + pub async fn identity_profile_exists_wildcard(&self, name: &str) -> bool { + self.resolve_identity_profile_wildcard(name).await.is_ok() + } + pub async fn identity_profile_exists(&self, name: &str) -> bool { + self.resolve_identity_profile(name).await.is_ok() + } +} +mod settings_integration { + use snafu::{OptionExt, ResultExt, Snafu}; + + use super::ResolveIdentityProfileError; + use crate::{ + DhttpHome, + identity::{ + IdentityProfile, + settings::{DhttpSettingsFile, FileLineCol, LoadDhttpSettingsError}, + }, + }; + + #[derive(Snafu, Debug)] + #[snafu(module, display( + "failed to resolve default identity profile{}", + location.as_ref().map_or(String::new(), |loc| format!(" at {loc}")) + ))] + pub struct ResolveDefaultIdentityFromSettingsError { + location: Option, + source: ResolveIdentityProfileError, + } + + #[derive(Debug, Snafu)] + #[snafu(module)] + pub enum ResolveDefaultIdentityProfileError { + #[snafu(transparent)] + LoadSettings { source: LoadDhttpSettingsError }, + #[snafu(display("no default identity configured"))] + NoDefaultIdentity, + #[snafu(transparent)] + Resolve { + source: ResolveDefaultIdentityFromSettingsError, + }, + } + + impl DhttpSettingsFile { + /// Resolve the default identity profile referenced by `[default].name`, + /// or return `None` if no default is configured in this settings file. + pub async fn resolve_default_identity_profile( + &self, + home: &DhttpHome, + ) -> Option> { + let name = self.settings().default.name.as_ref()?; + + Some( + home.resolve_identity_profile(name.as_ref().as_full()) + .await + .context( + resolve_default_identity_from_settings_error::ResolveDefaultIdentityFromSettingsSnafu { + location: self.locate(name.span().start), + }, + ), + ) + } + } + + impl DhttpHome { + /// Read the settings file and resolve the default identity profile it points to. + pub async fn resolve_default_identity_profile( + &self, + ) -> Result { + Ok(self + .load_settings() + .await? + .resolve_default_identity_profile(self) + .await + .context(resolve_default_identity_profile_error::NoDefaultIdentitySnafu)??) + } + } +} + +pub use settings_integration::*; + +#[cfg(test)] +mod tests { + use futures::StreamExt; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + let path = std::env::temp_dir() + .join(format!("dhttp-home-{name}-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).expect("test temp dir should be creatable"); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn create_profile(home: &std::path::Path, name: &str) -> PathBuf { + let profile = home.join(name); + fs::create_dir_all(profile.join(SSL_DIR_NAME)) + .expect("identity profile ssl directory should be creatable"); + profile + } + + #[tokio::test] + async fn bad_candidate_does_not_hide_valid_sibling() { + let temp = TempDir::new("candidate-sibling-isolation"); + fs::create_dir_all(temp.path().join("123")).unwrap(); + create_profile(temp.path(), "z.good.dhttp.net"); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates + .iter() + .filter(|candidate| candidate.is_ok()) + .count(), + 1 + ); + assert_eq!( + candidates + .iter() + .filter(|candidate| candidate.is_err()) + .count(), + 1 + ); + assert!(candidates.iter().any(|candidate| { + candidate + .as_ref() + .is_ok_and(|profile| profile.name() == "z.good.dhttp.net") + })); + } + + #[tokio::test] + async fn candidates_are_sorted_by_native_path_before_validation() { + let temp = TempDir::new("candidate-native-order"); + create_profile(temp.path(), "z.example.dhttp.net"); + create_profile(temp.path(), "a.example.dhttp.net"); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + let names: Vec<_> = candidates + .iter() + .map(|candidate| candidate.as_ref().unwrap().name()) + .collect(); + + assert_eq!(names, ["a.example.dhttp.net", "z.example.dhttp.net"]); + } + + #[tokio::test] + async fn candidates_ignore_regular_home_files() { + let temp = TempDir::new("candidate-ignore-files"); + fs::write(temp.path().join("settings.toml"), b"[default]\n").unwrap(); + let profile = create_profile(temp.path(), "reimu.pilot"); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].as_ref().unwrap().path(), profile); + } + + #[tokio::test] + async fn invalid_profile_name_is_one_candidate_error() { + let temp = TempDir::new("candidate-invalid-name"); + let path = temp.path().join("123"); + fs::create_dir_all(&path).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert!(matches!( + &candidates[0], + Err(IdentityProfileCandidateError::InvalidProfile { + path: error_path, + source: crate::identity::IdentityProfileFromPathError::InvalidName { .. }, + }) if *error_path == path + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn candidate_preserves_profile_entry_metadata_error() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new("candidate-entry-metadata"); + let path = temp.path().join("loop.pilot"); + symlink("loop.pilot", &path).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + match &candidates[0] { + Err(IdentityProfileCandidateError::EntryMetadata { + path: error_path, + source, + }) => { + assert_eq!(*error_path, path); + assert!(source.raw_os_error().is_some()); + } + other => panic!("expected entry metadata error, got {other:?}"), + } + } + + #[tokio::test] + async fn missing_ssl_directory_is_one_candidate_error() { + let temp = TempDir::new("candidate-missing-ssl"); + let path = temp.path().join("reimu.pilot"); + fs::create_dir_all(&path).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert!(matches!( + &candidates[0], + Err(IdentityProfileCandidateError::MissingSslDirectory { profile, path: ssl_path }) + if profile.path() == path && *ssl_path == path.join(SSL_DIR_NAME) + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn broken_ssl_symlink_is_metadata_error_not_missing() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new("candidate-broken-ssl"); + let profile_path = temp.path().join("reimu.pilot"); + fs::create_dir_all(&profile_path).unwrap(); + let ssl_path = profile_path.join(SSL_DIR_NAME); + symlink("missing-target", &ssl_path).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert!(matches!( + &candidates[0], + Err(IdentityProfileCandidateError::SslMetadata { profile, path, .. }) + if profile.path() == profile_path && *path == ssl_path + )); + } + + #[tokio::test] + async fn ssl_path_that_is_not_directory_is_one_candidate_error() { + let temp = TempDir::new("candidate-ssl-file"); + let profile_path = temp.path().join("reimu.pilot"); + fs::create_dir_all(&profile_path).unwrap(); + let ssl_path = profile_path.join(SSL_DIR_NAME); + fs::write(&ssl_path, b"not a directory").unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + assert!(matches!( + &candidates[0], + Err(IdentityProfileCandidateError::SslNotDirectory { profile, path }) + if profile.path() == profile_path && *path == ssl_path + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn candidate_preserves_ssl_metadata_error() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new("candidate-ssl-metadata"); + let profile_path = temp.path().join("reimu.pilot"); + fs::create_dir_all(&profile_path).unwrap(); + let ssl_path = profile_path.join(SSL_DIR_NAME); + symlink(SSL_DIR_NAME, &ssl_path).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let candidates = home.identity_profile_candidates().await.unwrap(); + + match &candidates[0] { + Err(IdentityProfileCandidateError::SslMetadata { + profile, + path, + source, + }) => { + assert_eq!(profile.path(), profile_path); + assert_eq!(*path, ssl_path); + assert!(source.raw_os_error().is_some()); + } + other => panic!("expected ssl metadata error, got {other:?}"), + } + } + + #[tokio::test] + async fn lenient_profile_names_remains_compatible() { + let temp = TempDir::new("strict-lenient-compatible"); + create_profile(temp.path(), "reimu.pilot"); + fs::create_dir_all(temp.path().join("123")).unwrap(); + let home = DhttpHome::new(temp.path().to_path_buf()); + + let names: Vec<_> = home.identity_profile_names().collect().await; + + assert_eq!(names.len(), 1); + assert_eq!(names[0].as_ref().unwrap().as_str(), "reimu.pilot.dhttp.net"); + } + + #[tokio::test] + async fn missing_certificate_reports_certificate_path() { + let temp = TempDir::new("missing-certificate"); + let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap(); + + let error = profile.load_certs().await.unwrap_err(); + + match error { + LoadCertsError::Read { path, .. } => { + assert_eq!(path, profile.ssl_dir().join(CERT_FILE_NAME)); + } + other => panic!("expected certificate read error, got {other:?}"), + } + } + + #[tokio::test] + async fn missing_key_reports_key_path() { + let temp = TempDir::new("missing-key"); + let profile = IdentityProfile::try_from(temp.path().join("reimu.pilot")).unwrap(); + + let error = profile.load_key().await.unwrap_err(); + + #[cfg(unix)] + assert!(matches!( + error, + LoadKeyError::Metadata { path, .. } if path == profile.ssl_dir().join(KEY_FILE_NAME) + )); + #[cfg(not(unix))] + assert!(matches!( + error, + LoadKeyError::Read { path, .. } if path == profile.ssl_dir().join(KEY_FILE_NAME) + )); + } + + #[tokio::test] + async fn save_identity_replaces_material_without_touching_profile_files() { + let temp = TempDir::new("replace-material"); + let profile = IdentityProfile::try_from(temp.path().join("alice.smith")).unwrap(); + tokio::fs::create_dir_all(profile.ssl_dir()).await.unwrap(); + tokio::fs::write(profile.ssl_dir().join(CERT_FILE_NAME), b"old cert") + .await + .unwrap(); + tokio::fs::write(profile.ssl_dir().join(KEY_FILE_NAME), b"old key") + .await + .unwrap(); + tokio::fs::write(profile.join("server.conf"), b"keep me") + .await + .unwrap(); + + profile + .save_identity(b"new cert", b"new key") + .await + .unwrap(); + + assert_eq!( + tokio::fs::read(profile.ssl_dir().join(CERT_FILE_NAME)) + .await + .unwrap(), + b"new cert" + ); + assert_eq!( + tokio::fs::read(profile.ssl_dir().join(KEY_FILE_NAME)) + .await + .unwrap(), + b"new key" + ); + assert_eq!( + tokio::fs::read(profile.join("server.conf")).await.unwrap(), + b"keep me" + ); + } + + #[tokio::test] + async fn failed_commit_restores_the_complete_old_material_set() { + let temp = TempDir::new("failed-commit"); + let profile = IdentityProfile::try_from(temp.path().join("alice.smith")).unwrap(); + tokio::fs::create_dir_all(profile.ssl_dir()).await.unwrap(); + tokio::fs::write(profile.ssl_dir().join(CERT_FILE_NAME), b"old cert") + .await + .unwrap(); + tokio::fs::write(profile.ssl_dir().join(KEY_FILE_NAME), b"old key") + .await + .unwrap(); + + let error = profile + .save_identity_transaction(b"new cert", b"new key", || { + Err(io::Error::other("injected commit failure")) + }) + .await + .unwrap_err(); + + assert!( + !error.to_string().contains("injected commit failure"), + "the semantic error layer must not repeat its source: {error}" + ); + assert!( + snafu::Report::from_error(&error) + .to_string() + .contains("injected commit failure"), + "the full error report must retain the source chain" + ); + assert_eq!( + tokio::fs::read(profile.ssl_dir().join(CERT_FILE_NAME)) + .await + .unwrap(), + b"old cert" + ); + assert_eq!( + tokio::fs::read(profile.ssl_dir().join(KEY_FILE_NAME)) + .await + .unwrap(), + b"old key" + ); + } + + #[tokio::test] + async fn missing_identity_profile_reports_exact_and_wildcard_paths() { + let temp = TempDir::new("missing-identity-profile"); + let home = DhttpHome::new(temp.path().to_path_buf()); + let name = "reimu.pilot".parse::().unwrap(); + + let error = home + .resolve_identity_profile((name).as_full()) + .await + .unwrap_err(); + + match error { + ResolveIdentityProfileError::NotFound { exact, wildcard } => { + assert_eq!(exact, temp.path().join("reimu.pilot")); + assert_eq!(wildcard, temp.path().join("*.pilot")); + } + other => panic!("expected not-found error, got {other:?}"), + } + } +} diff --git a/home/src/identity/settings.rs b/home/src/identity/settings.rs new file mode 100644 index 0000000..045b4db --- /dev/null +++ b/home/src/identity/settings.rs @@ -0,0 +1,210 @@ +use std::{ + fmt::Display, + ops::ControlFlow, + path::{Path, PathBuf}, +}; + +use dhttp_identity::name::DhttpName; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; +use tokio::fs; +use toml::Spanned; + +use crate::DhttpHome; + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct DefaultSection { + pub name: Option>>, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct DhttpSettings { + #[serde(default)] + pub default: DefaultSection, +} + +impl DhttpSettings { + pub const FILE_NAME: &'static str = "settings.toml"; + + pub fn default_identity_name(&self) -> Option<&DhttpName<'static>> { + self.default.name.as_ref().map(|s| s.as_ref()) + } + + pub fn set_default_identity_name(&mut self, name: DhttpName<'static>) { + let span = match &self.default.name { + Some(spanned) => spanned.span(), + None => 0..0, + }; + self.default.name = Some(Spanned::new(span, name)); + } +} + +#[derive(Debug)] +pub struct DhttpSettingsFile { + path: PathBuf, + #[allow(dead_code)] + content: Option, + settings: DhttpSettings, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct LineCol { + line: usize, + column: usize, +} + +impl LineCol { + fn locate(source: &str, offset: usize) -> LineCol { + let fold = |last: LineCol, (index, char)| { + let current = match char { + '\n' => LineCol { + line: last.line + 1, + column: 1, + }, + _ => LineCol { + line: last.line, + column: last.column + 1, + }, + }; + if index == offset { + ControlFlow::Break(current) + } else { + ControlFlow::Continue(current) + } + }; + let (ControlFlow::Continue(line_col) | ControlFlow::Break(line_col)) = + (source.chars().enumerate()).try_fold(LineCol { line: 1, column: 1 }, fold); + line_col + } +} + +impl Display for LineCol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.line, self.column) + } +} + +#[derive(Debug)] +pub(crate) struct FileLineCol { + path: PathBuf, + line_col: LineCol, +} + +impl Display for FileLineCol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.path.display(), self.line_col) + } +} + +#[derive(Snafu, Debug)] +#[snafu(module)] +pub enum LoadDhttpSettingsError { + #[snafu(display("failed to read settings file {}", path.display()))] + Io { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to deserialize settings file {}", path.display()))] + Deserialize { + path: PathBuf, + source: toml::de::Error, + }, +} + +#[derive(Snafu, Debug)] +#[snafu(module)] +pub enum SaveDhttpSettingsError { + Serialize { + path: PathBuf, + source: toml::ser::Error, + }, + Io { + path: PathBuf, + source: std::io::Error, + }, +} + +impl DhttpSettingsFile { + pub fn new(path: PathBuf) -> Self { + Self { + path, + content: None, + settings: DhttpSettings::default(), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn load(path: PathBuf) -> Result { + let source = fs::read_to_string(&path) + .await + .context(load_dhttp_settings_error::IoSnafu { path: &path })?; + let settings: DhttpSettings = toml::from_str(&source) + .context(load_dhttp_settings_error::DeserializeSnafu { path: &path })?; + Ok(Self { + path, + content: Some(source), + settings, + }) + } + + pub fn settings(&self) -> &DhttpSettings { + &self.settings + } + + pub fn settings_mut(&mut self) -> &mut DhttpSettings { + &mut self.settings + } + + pub(crate) fn locate(&self, offset: usize) -> Option { + let line_col = LineCol::locate(self.content.as_ref()?, offset); + let path = self.path.clone(); + Some(FileLineCol { path, line_col }) + } + + pub async fn save(&self) -> Result<(), SaveDhttpSettingsError> { + let source = toml::to_string_pretty(&self.settings) + .context(save_dhttp_settings_error::SerializeSnafu { path: &self.path })?; + fs::write(&self.path, source) + .await + .context(save_dhttp_settings_error::IoSnafu { path: &self.path })?; + Ok(()) + } +} + +impl DhttpHome { + pub fn settings_path(&self) -> PathBuf { + self.join(DhttpSettings::FILE_NAME) + } + + pub async fn load_settings(&self) -> Result { + DhttpSettingsFile::load(self.settings_path()).await + } + + pub fn new_settings(&self) -> DhttpSettingsFile { + DhttpSettingsFile::new(self.settings_path()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn default_identity_round_trips_in_the_selected_home() { + let dir = tempfile::tempdir().unwrap(); + let home = DhttpHome::new(dir.path().to_owned()); + let mut settings = home.new_settings(); + settings + .settings_mut() + .set_default_identity_name("alice.pilot".parse().unwrap()); + settings.save().await.unwrap(); + let loaded = home.load_settings().await.unwrap(); + assert_eq!( + loaded.settings().default_identity_name().unwrap().as_full(), + "alice.pilot.dhttp.net" + ); + assert_eq!(loaded.path(), home.settings_path()); + } +} diff --git a/home/src/identity/ssl.rs b/home/src/identity/ssl.rs index 8f5ccd3..8f2ff61 100644 --- a/home/src/identity/ssl.rs +++ b/home/src/identity/ssl.rs @@ -16,6 +16,12 @@ use crate::identity::normalize_profile_name; use crate::{DHTTP_SUFFIX, DhttpHome, identity::IdentityProfile}; +#[cfg(feature = "manage")] +#[path = "management.rs"] +mod management; +#[cfg(feature = "manage")] +pub use management::*; + pub const SSL_DIR_NAME: &str = "ssl"; pub const CERT_FILE_NAME: &str = "fullchain.crt"; pub const KEY_FILE_NAME: &str = "privkey.pem"; diff --git a/home/src/ocsp.rs b/home/src/ocsp.rs new file mode 100644 index 0000000..a3d5b72 --- /dev/null +++ b/home/src/ocsp.rs @@ -0,0 +1,164 @@ +//! Explicit OCSP preparation for an identity profile, shared by clients and services. +use crate::identity::IdentityProfile; + +pub async fn load_or_refresh( + profile: &IdentityProfile, + certificates: &[rustls::pki_types::CertificateDer<'_>], + root_ca_der: &[u8], +) -> std::io::Result> { + if let Ok(response) = profile.load_ocsp().await + && qtls::validate_ocsp(&response, certificates, qtls::UnixTime::now()).is_ok() + { + return Ok(response); + } + let response = fetch_ocsp(certificates, root_ca_der).await?; + let mut file = tempfile::NamedTempFile::new_in(profile.ssl_dir())?; + use std::io::Write; + file.write_all(&response)?; + file.as_file().sync_all()?; + file.persist(profile.ocsp_path()) + .map_err(|error| error.error)?; + Ok(response) +} + +async fn fetch_ocsp( + certificates: &[rustls::pki_types::CertificateDer<'_>], + root_ca_der: &[u8], +) -> std::io::Result> { + use der::{Decode, Encode}; + use sha1::{Digest, Sha1}; + use x509_parser::prelude::FromDer; + let error = |error: String| std::io::Error::other(format!("OCSP: {error}")); + let leaf = certificates + .first() + .ok_or_else(|| error("missing certificate".into()))?; + let issuer = certificates + .get(1) + .ok_or_else(|| error("missing issuer certificate".into()))?; + let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(leaf.as_ref()) + .map_err(|e| error(e.to_string()))?; + if !parsed.validity().is_valid() { + return Err(error("certificate is outside its validity period".into())); + } + let url = parsed + .extensions() + .iter() + .find_map(|extension| { + let x509_parser::extensions::ParsedExtension::AuthorityInfoAccess(aia) = + extension.parsed_extension() + else { + return None; + }; + aia.accessdescs.iter().find_map(|access| { + if access.access_method.to_id_string() != "1.3.6.1.5.5.7.48.1" { + return None; + } + match &access.access_location { + x509_parser::extensions::GeneralName::URI(uri) => Some((*uri).to_owned()), + _ => None, + } + }) + }) + .ok_or_else(|| error("certificate has no OCSP responder URI".into()))?; + let leaf = x509_cert::Certificate::from_der(leaf.as_ref()).map_err(|e| error(e.to_string()))?; + let issuer = + x509_cert::Certificate::from_der(issuer.as_ref()).map_err(|e| error(e.to_string()))?; + let request = x509_ocsp::OcspRequest { + tbs_request: x509_ocsp::TbsRequest { + request_list: vec![x509_ocsp::Request { + req_cert: x509_ocsp::CertId { + hash_algorithm: x509_cert::spki::AlgorithmIdentifierOwned { + oid: "1.3.14.3.2.26" + .parse::() + .map_err(|e| error(e.to_string()))?, + parameters: Some(der::asn1::Any::null()), + }, + issuer_name_hash: der::asn1::OctetString::new( + Sha1::digest( + issuer + .tbs_certificate + .subject + .to_der() + .map_err(|e| error(e.to_string()))?, + ) + .to_vec(), + ) + .map_err(|e| error(e.to_string()))?, + issuer_key_hash: der::asn1::OctetString::new( + Sha1::digest( + issuer + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(), + ) + .to_vec(), + ) + .map_err(|e| error(e.to_string()))?, + serial_number: leaf.tbs_certificate.serial_number, + }, + single_request_extensions: None, + }], + ..Default::default() + }, + optional_signature: None, + } + .to_der() + .map_err(|e| error(e.to_string()))?; + tokio::time::timeout(std::time::Duration::from_secs(15), async { + let root = reqwest::Certificate::from_der(root_ca_der).map_err(|e| error(e.to_string()))?; + let client = reqwest::Client::builder() + .tls_certs_merge([root]) + .redirect(reqwest::redirect::Policy::none()) + .https_only(false) + .build() + .map_err(|e| error(e.to_string()))?; + let uri = reqwest::Url::parse(&url).map_err(|e| error(e.to_string()))?; + if !matches!(uri.scheme(), "http" | "https") { + return Err(error("unsupported responder scheme".into())); + } + let mut response = client + .post(uri) + .header("Content-Type", "application/ocsp-request") + .header("Accept", "application/ocsp-response") + .body(request) + .send() + .await + .map_err(|e| error(e.to_string()))?; + if !response.status().is_success() { + return Err(error(format!("responder returned {}", response.status()))); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| error(e.to_string()))? { + if body.len().saturating_add(chunk.len()) > 64 * 1024 { + return Err(error("response exceeds 64KiB".into())); + } + body.extend_from_slice(&chunk); + } + qtls::validate_ocsp(&body, certificates, qtls::UnixTime::now()) + .map_err(|e| error(e.to_string()))?; + Ok(body) + }) + .await + .map_err(|_| error("responder timed out after 15 seconds".into()))? +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn malformed_cached_staple_is_not_accepted_or_overwritten_on_refresh_failure() { + let dir = tempfile::tempdir().unwrap(); + let profile = IdentityProfile::try_from(dir.path().join("alice.pilot")).unwrap(); + tokio::fs::create_dir_all(profile.ssl_dir()).await.unwrap(); + tokio::fs::write(profile.ocsp_path(), b"invalid OCSP") + .await + .unwrap(); + let error = load_or_refresh(&profile, &[], &[]).await.unwrap_err(); + assert!(error.to_string().contains("missing certificate")); + assert_eq!( + tokio::fs::read(profile.ocsp_path()).await.unwrap(), + b"invalid OCSP" + ); + } +} diff --git a/home/tests/unit/home.rs b/home/tests/unit/home.rs index 5eedbf3..14ae71b 100644 --- a/home/tests/unit/home.rs +++ b/home/tests/unit/home.rs @@ -29,6 +29,41 @@ fn normalizes_identity_names() { assert!(super::validate_name("alice~").is_err()); } +#[test] +fn lookup_names_expand_only_explicit_shorthand() { + for (input, expected) in [ + (" NAT.Example.CN. ", "nat.example.cn"), + ("Alice~", "alice.dhttp.net"), + ("Alice.DHTTP.NET", "alice.dhttp.net"), + ("Alice", "alice"), + ("localhost", "localhost"), + ] { + assert_eq!(super::normalize_dns_name(input).as_deref(), Some(expected)); + } + for invalid in [ + "", + "~", + "alice~.example.cn", + "alice_bob", + "*.example.cn", + "-alice", + "alice..smith", + "alice..", + "../alice", + ] { + assert!(super::normalize_dns_name(invalid).is_none(), "{invalid}"); + } + let max_length = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "a".repeat(63), + "a".repeat(63), + "a".repeat(61) + ); + assert!(super::normalize_dns_name(&max_length).is_some()); + assert!(super::normalize_dns_name(&format!("{max_length}a")).is_none()); +} + #[test] fn canonical_dns_name_uses_certserver_lengths_and_numeric_labels() { assert!(super::is_valid_dns_name("123.dhttp.net")); diff --git a/home/tests/unit/ssl.rs b/home/tests/unit/ssl.rs index 6b1c62c..b3197ee 100644 --- a/home/tests/unit/ssl.rs +++ b/home/tests/unit/ssl.rs @@ -44,6 +44,7 @@ async fn async_names_match_sync_discovery_for_direct_ssl_directories() { let temp = TempDir::new("discovery-consistency"); create_profile(temp.path(), "alice.pilot"); create_profile(temp.path(), "123"); + #[cfg(not(windows))] create_profile(temp.path(), "*.pilot"); create_profile(temp.path(), "Bad"); create_profile(temp.path(), "bad_name"); @@ -88,6 +89,7 @@ async fn async_names_match_sync_discovery_for_direct_ssl_directories() { assert_eq!( sync_names, [ + #[cfg(not(windows))] "*.pilot.dhttp.net", "123.dhttp.net", "alice.pilot.dhttp.net" diff --git a/log/Cargo.toml b/log/Cargo.toml index 9c6f340..612709c 100644 --- a/log/Cargo.toml +++ b/log/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-log" description = "Typed DHTTP domain log records and formatting" -version = "0.1.0" +version = "0.2.0" edition = "2024" license.workspace = true repository.workspace = true