diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5bef..db4621b45 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,8 +1,31 @@ +# No global [build] target — the workspace contains adapters for multiple targets: +# trusted-server-adapter-fastly → wasm32-wasip1 (Fastly Compute) +# trusted-server-adapter-axum → native (dev server) +# Future: trusted-server-adapter-cloudflare → wasm32-unknown-unknown +# +# Both adapters are workspace members so `-p` resolves both. +# default-members = [fastly] — required so Viceroy can locate the binary via `cargo run --bin`. +# Use the aliases below to target each adapter with the correct toolchain. + [alias] -test_details = ["test", "--target", "aarch64-apple-darwin"] +# Fastly adapter + shared crates (wasm32-wasip1 via Viceroy) +test-fastly = ["test", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +# Axum dev server adapter (native) +test-axum = ["test", "-p", "trusted-server-adapter-axum"] +# CI convenience — runs both in sequence (shell aliases can't chain; use a script or CI steps) +# cargo test-fastly && cargo test-axum + +# Clippy — target-matched to avoid cross-target compile failures +clippy-fastly = ["clippy", "--workspace", "--exclude", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--target", "wasm32-wasip1", "--", "-D", "warnings"] +clippy-axum = ["clippy", "-p", "trusted-server-adapter-axum", "--all-targets", "--all-features", "--", "-D", "warnings"] + +# Build — target-matched, same split as test/clippy +build-fastly = ["build", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +build-axum = ["build", "-p", "trusted-server-adapter-axum"] -[build] -target = "wasm32-wasip1" +# Check — fast compile validation, same split +check-fastly = ["check", "--workspace", "--exclude", "trusted-server-adapter-axum", "--target", "wasm32-wasip1"] +check-axum = ["check", "-p", "trusted-server-adapter-axum"] [target.'cfg(all(target_arch = "wasm32"))'] runner = "viceroy run -C ../../fastly.toml -- " diff --git a/.claude/agents/build-validator.md b/.claude/agents/build-validator.md index a35b28245..b5382c0c5 100644 --- a/.claude/agents/build-validator.md +++ b/.claude/agents/build-validator.md @@ -8,10 +8,10 @@ Validate that the project builds correctly across all targets. ## Steps -1. **Native build** +1. **Per-target builds** (no global target — fastly is wasm-only, axum is native) ```bash - cargo build --workspace + cargo build-fastly && cargo build-axum ``` 2. **WASM build** (production target) @@ -23,7 +23,7 @@ Validate that the project builds correctly across all targets. 3. **Clippy** ```bash - cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo clippy-fastly && cargo clippy-axum ``` 4. **Format check** diff --git a/.claude/agents/pr-creator.md b/.claude/agents/pr-creator.md index 182f7aada..eb13c94aa 100644 --- a/.claude/agents/pr-creator.md +++ b/.claude/agents/pr-creator.md @@ -21,8 +21,8 @@ Before creating the PR, verify the branch is healthy: ``` cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace +cargo clippy-fastly && cargo clippy-axum +cargo test-fastly && cargo test-axum cd crates/js/lib && npx vitest run cd crates/js/lib && npm run format cd docs && npm run format diff --git a/.claude/agents/pr-reviewer.md b/.claude/agents/pr-reviewer.md index e260ac270..ffdddec13 100644 --- a/.claude/agents/pr-reviewer.md +++ b/.claude/agents/pr-reviewer.md @@ -108,7 +108,7 @@ For each changed file, evaluate: - Are new code paths tested? - Are edge cases covered (empty input, max values, error paths)? - If config-derived regex/pattern compilation changed: are invalid enabled-config startup failures and explicit `enabled = false` bypass cases both covered? -- Rust tests: `cargo test --workspace` +- Rust tests: `cargo test-fastly && cargo test-axum` - JS tests: `npx vitest run` in `crates/js/lib/` ### 5. Classify findings diff --git a/.claude/agents/verify-app.md b/.claude/agents/verify-app.md index 5d26c679a..bf3402972 100644 --- a/.claude/agents/verify-app.md +++ b/.claude/agents/verify-app.md @@ -19,13 +19,13 @@ cargo fmt --all -- --check ### 2. Clippy ```bash -cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy-fastly && cargo clippy-axum ``` ### 3. Rust Tests ```bash -cargo test --workspace +cargo test-fastly && cargo test-axum ``` ### 4. JS Tests diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md index dc4a63c05..98a1948be 100644 --- a/.claude/commands/check-ci.md +++ b/.claude/commands/check-ci.md @@ -1,8 +1,8 @@ Run all CI checks locally, in order. Stop and report if any step fails. 1. `cargo fmt --all -- --check` -2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -3. `cargo test --workspace` +2. `cargo clippy-fastly && cargo clippy-axum` +3. `cargo test-fastly && cargo test-axum` 4. `cd crates/js/lib && npx vitest run` 5. `cd crates/js/lib && npm run format` 6. `cd docs && npm run format` diff --git a/.claude/commands/test-all.md b/.claude/commands/test-all.md index 51faeb7c0..428911a8a 100644 --- a/.claude/commands/test-all.md +++ b/.claude/commands/test-all.md @@ -1,7 +1,7 @@ Run the full test suite for both Rust and JavaScript. ```bash -cargo test --workspace +cargo test-fastly && cargo test-axum ``` Then run JS tests: diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md index 04fc89319..ef67732d8 100644 --- a/.claude/commands/verify.md +++ b/.claude/commands/verify.md @@ -1,10 +1,10 @@ Full verification: build, test, and lint the entire project. -1. `cargo build --workspace` +1. `cargo build-fastly && cargo build-axum` 2. `cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1` 3. `cargo fmt --all -- --check` -4. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -5. `cargo test --workspace` +4. `cargo clippy-fastly && cargo clippy-axum` +5. `cargo test-fastly && cargo test-axum` 6. `cd crates/js/lib && npx vitest run` 7. `cd crates/js/lib && npm run format` 8. `cd docs && npm run format` diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index b034ad2c2..7d8a8338c 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -17,6 +17,10 @@ inputs: description: Build the trusted-server WASM binary for integration tests. required: false default: "true" + build-axum: + description: Build the trusted-server-axum native binary for integration tests. + required: false + default: "true" build-test-images: description: Build the framework Docker images used by integration tests. required: false @@ -86,6 +90,16 @@ runs: TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 + - name: Build Axum native binary + if: ${{ inputs.build-axum == 'true' }} + shell: bash + env: + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} + TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret + TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY: integration-test-secret-key + TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" + run: cargo build -p trusted-server-adapter-axum + - name: Build WordPress test container if: ${{ inputs.build-test-images == 'true' }} shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index fbe958473..5dd0ba8a8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,8 +23,8 @@ Closes # -- [ ] `cargo test --workspace` -- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test-fastly && cargo test-axum` +- [ ] `cargo clippy-fastly && cargo clippy-axum` - [ ] `cargo fmt --all -- --check` - [ ] JS tests: `cd crates/js/lib && npx vitest run` - [ ] JS format: `cd crates/js/lib && npm run format` diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b6aba137d..f4334f478 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -32,8 +32,11 @@ jobs: - name: Run cargo fmt uses: actions-rust-lang/rustfmt@v1 - - name: Run cargo clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Run cargo clippy (Fastly — wasm32-wasip1) + run: cargo clippy-fastly + + - name: Run cargo clippy (Axum — native) + run: cargo clippy-axum format-typescript: runs-on: ubuntu-latest diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index da467583c..2c570ef31 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -14,6 +14,7 @@ env: ORIGIN_PORT: 8888 ARTIFACTS_DIR: /tmp/integration-test-artifacts WASM_ARTIFACT_PATH: /tmp/integration-test-artifacts/wasm/trusted-server-adapter-fastly.wasm + AXUM_ARTIFACT_PATH: /tmp/integration-test-artifacts/axum/trusted-server-axum DOCKER_ARTIFACT_PATH: /tmp/integration-test-artifacts/docker/test-images.tar jobs: @@ -32,8 +33,9 @@ jobs: - name: Package integration test artifacts run: | - mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" + mkdir -p "$(dirname "$WASM_ARTIFACT_PATH")" "$(dirname "$AXUM_ARTIFACT_PATH")" "$(dirname "$DOCKER_ARTIFACT_PATH")" cp target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm "$WASM_ARTIFACT_PATH" + cp target/debug/trusted-server-axum "$AXUM_ARTIFACT_PATH" docker save \ --output "$DOCKER_ARTIFACT_PATH" \ test-wordpress:latest test-nextjs:latest @@ -69,6 +71,9 @@ jobs: name: integration-test-artifacts path: ${{ env.ARTIFACTS_DIR }} + - name: Make binaries executable + run: chmod +x "$AXUM_ARTIFACT_PATH" + - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" @@ -80,6 +85,7 @@ jobs: -- --include-ignored --skip test_wordpress_fastly --skip test_nextjs_fastly --test-threads=1 env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + AXUM_BINARY_PATH: ${{ env.AXUM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} RUST_LOG: info diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2da273aa0..950aee406 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,33 @@ jobs: run: cargo install --git https://github.com/fastly/Viceroy --tag v${{ steps.viceroy-version.outputs.viceroy-version }} viceroy - name: Run tests - run: cargo test --workspace + run: cargo test-fastly + + test-axum: + name: cargo test (axum native) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + # wasm32-wasip1 is required by the "Verify Fastly WASM release + # build" step below; the axum build and tests are native. + target: wasm32-wasip1 + cache-shared-key: cargo-${{ runner.os }} + + - name: Build Axum adapter + run: cargo build -p trusted-server-adapter-axum + + - name: Run Axum adapter tests + run: cargo test-axum - name: Verify Fastly WASM release build env: diff --git a/.gitignore b/.gitignore index af70c452a..0e58da93c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /bin /pkg /target + +# EdgeZero local KV store (created by edgezero-adapter-axum framework) +.edgezero/ /crates/integration-tests/target # env diff --git a/AGENTS.md b/AGENTS.md index bcbcd179f..8aaf5cf7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,11 @@ If you cannot read `CLAUDE.md`, follow these rules: 1. Present a plan and get approval before coding. 2. Keep changes minimal — do not refactor unrelated code. -3. Run `cargo test --workspace` after every code change. -4. Run `cargo fmt --all -- --check` and `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +3. Run tests after every code change — use the workspace aliases defined in `.cargo/config.toml`: + - `cargo test-fastly` — Fastly adapter + core (wasm32-wasip1 via Viceroy) + - `cargo test-axum` — Axum dev server adapter (native) + Do NOT use bare `cargo test --workspace` — it will attempt to compile the Fastly adapter for the host target. +4. Run `cargo fmt --all -- --check` and `cargo clippy-fastly && cargo clippy-axum`. 5. Run JS tests with `cd crates/js/lib && npx vitest run` when touching JS/TS code. 6. Use `error-stack` (`Report`) for error handling — not anyhow, eyre, or thiserror. 7. Use `log` macros (not `println!`) and `expect("should ...")` (not `unwrap()`). diff --git a/CLAUDE.md b/CLAUDE.md index f37a1ac30..db8585e93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ real-time bidding integration, and publisher-side JavaScript injection. crates/ trusted-server-core/ # Core library — shared logic, integrations, HTML processing trusted-server-adapter-fastly/ # Fastly Compute entry point (wasm32-wasip1 binary) + trusted-server-adapter-axum/ # Axum dev server entry point (native binary) js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -38,8 +39,8 @@ Supporting files: `fastly.toml`, `trusted-server.toml`, `.env.dev`, ### Rust ```bash -# Build -cargo build +# Build (per-target aliases — bare `cargo build` fails at the workspace root) +cargo build-fastly && cargo build-axum # Production build for Fastly cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 @@ -49,22 +50,30 @@ fastly compute serve # Deploy to Fastly fastly compute publish + +# Run Axum dev server (native — no Viceroy) +cargo run -p trusted-server-adapter-axum + +# Test Axum adapter only +cargo test-axum ``` ### Testing & Quality ```bash -# Run all Rust tests (uses viceroy) -cargo test --workspace +# Run all Rust tests — use workspace aliases (see .cargo/config.toml) +# default-members = [fastly] so Viceroy can locate the binary via `cargo run --bin`. +cargo test-fastly # Fastly adapter + core (wasm32-wasip1 via Viceroy) +cargo test-axum # Axum dev server adapter (native) # Format cargo fmt --all -- --check # Lint -cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy-fastly && cargo clippy-axum -# Check compilation -cargo check +# Check compilation (per-target aliases — bare `cargo check` fails at the workspace root) +cargo check-fastly && cargo check-axum # JS tests cd crates/js/lib && npx vitest run @@ -276,8 +285,8 @@ IntegrationRegistration::builder(ID) Every PR must pass: 1. `cargo fmt --all -- --check` -2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -3. `cargo test --workspace` +2. `cargo clippy-fastly && cargo clippy-axum` +3. `cargo test-fastly && cargo test-axum` 4. JS build and test (`cd crates/js/lib && npx vitest run`) 5. JS format (`cd crates/js/lib && npm run format`) 6. Docs format (`cd docs && npm run format`) @@ -290,7 +299,7 @@ Every PR must pass: 2. **Get approval** — for non-trivial changes, present a plan first. 3. **Implement incrementally** — small, testable changes. Every change should impact as little code as possible. -4. **Test after every change** — `cargo test --workspace`. +4. **Test after every change** — `cargo test-fastly && cargo test-axum`. 5. **Explain as you go** — describe what you changed and why. 6. **If blocked** — explain what's blocking and why. diff --git a/Cargo.lock b/Cargo.lock index 4b10150cb..c40c463ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,11 +126,91 @@ dependencies = [ "syn 2.0.117", ] +[[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.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] [[package]] name = "base16ct" @@ -158,9 +238,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -194,9 +274,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -205,9 +285,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -221,9 +301,9 @@ checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -239,11 +319,13 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -253,6 +335,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -279,9 +367,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -353,6 +441,34 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -373,9 +489,9 @@ checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "config" -version = "0.15.22" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -445,6 +561,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -516,7 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -528,7 +654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -683,9 +809,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -722,6 +848,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ed25519" version = "2.2.3" @@ -740,13 +872,36 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "edgezero-adapter-axum" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "http", + "log", + "redb", + "reqwest 0.13.4", + "simple_logger", + "thiserror 2.0.18", + "tokio", + "tower 0.5.3", + "tracing", +] + [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" @@ -784,7 +939,7 @@ dependencies = [ "http", "http-body", "log", - "matchit", + "matchit 0.9.2", "serde", "serde_json", "serde_urlencoded", @@ -812,9 +967,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -828,7 +983,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -870,6 +1025,16 @@ dependencies = [ "typeid", ] +[[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 = "error-stack" version = "0.6.0" @@ -962,7 +1127,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1015,6 +1180,12 @@ 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.32" @@ -1127,6 +1298,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -1135,7 +1320,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1147,7 +1332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1183,24 +1368,27 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.2.0", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1232,9 +1420,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1250,6 +1438,91 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iab_gpp" version = "0.1.2" @@ -1413,9 +1686,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1428,7 +1701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1442,6 +1715,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-terminal" version = "0.4.17" @@ -1450,7 +1729,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1477,6 +1756,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "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.117", +] + +[[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.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "jose-b64" version = "0.1.2" @@ -1515,10 +1853,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1595,16 +1935,16 @@ dependencies = [ [[package]] name = "lol_html" -version = "2.7.2" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cssparser", "encoding_rs", "foldhash 0.2.0", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "memchr", "mime", "precomputed-hash", @@ -1612,6 +1952,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matchit" version = "0.9.2" @@ -1620,9 +1972,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -1640,6 +1992,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1648,9 +2011,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -1666,16 +2029,16 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1719,8 +2082,17 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" @@ -1736,6 +2108,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -1897,6 +2275,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2015,6 +2413,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2024,6 +2478,12 @@ 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" @@ -2037,8 +2497,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2048,7 +2518,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "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]] @@ -2060,13 +2540,31 @@ 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 = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2098,13 +2596,100 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "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", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "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 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "ron" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -2125,7 +2710,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2157,6 +2742,82 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +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-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2178,6 +2839,15 @@ 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 = "scopeguard" version = "1.2.0" @@ -2197,13 +2867,36 @@ dependencies = [ "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.0", + "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 = "selectors" -version = "0.33.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", @@ -2277,6 +2970,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2344,9 +3048,19 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] [[package]] name = "signature" @@ -2355,7 +3069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2364,11 +3078,39 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +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 = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.61.2", +] + [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2382,6 +3124,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2450,6 +3202,15 @@ dependencies = [ "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" @@ -2518,7 +3279,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2570,6 +3333,58 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2609,6 +3424,61 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "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" @@ -2621,6 +3491,7 @@ 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", @@ -2646,6 +3517,25 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trusted-server-adapter-axum" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "edgezero-adapter-axum", + "edgezero-core", + "error-stack", + "futures", + "log", + "reqwest 0.12.28", + "simple_logger", + "temp-env", + "tokio", + "tower 0.4.13", + "trusted-server-core", +] + [[package]] name = "trusted-server-adapter-fastly" version = "0.1.0" @@ -2686,7 +3576,6 @@ dependencies = [ "ed25519-dalek", "edgezero-core", "error-stack", - "fastly", "flate2", "futures", "getrandom 0.2.17", @@ -2697,9 +3586,9 @@ dependencies = [ "jose-jwk", "log", "lol_html", - "matchit", + "matchit 0.9.2", "mime", - "rand", + "rand 0.8.6", "regex", "serde", "serde_json", @@ -2734,6 +3623,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -2742,9 +3637,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2760,9 +3655,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -2780,6 +3675,12 @@ dependencies = [ "subtle", ] +[[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" @@ -2806,9 +3707,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2861,6 +3762,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2887,9 +3797,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2898,11 +3808,21 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2910,9 +3830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2923,9 +3843,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2958,12 +3878,22 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2974,11 +3904,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -2989,7 +3937,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3051,6 +3999,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3060,11 +4026,140 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -3075,7 +4170,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3093,7 +4188,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3145,7 +4240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap", "log", "serde", @@ -3183,9 +4278,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -3194,9 +4289,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3217,18 +4312,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -3237,9 +4332,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 05a0eaf77..0fa1a82a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,19 +3,23 @@ resolver = "2" members = [ "crates/trusted-server-core", "crates/trusted-server-adapter-fastly", + "crates/trusted-server-adapter-axum", "crates/js", "crates/openrtb", ] -# integration-tests is intentionally excluded from workspace members because it -# requires a native target (testcontainers, reqwest) while the workspace default -# is wasm32-wasip1. Run it via: ./scripts/integration-tests.sh +# Crates excluded from workspace — must be built/tested outside the workspace: +# cargo test-cloudflare → trusted-server-adapter-cloudflare (wasm32-unknown-unknown) exclude = [ "crates/integration-tests", "crates/openrtb-codegen", ] +# Viceroy (cargo test-fastly runner) calls `cargo run --bin trusted-server-adapter-fastly` +# against the default-run packages. It must be the sole default member so Cargo can +# locate the binary. Use aliases to test each adapter with the correct target: +# cargo test-fastly → wasm32-wasip1 (Fastly + core, via Viceroy) +# cargo test-axum → native (Axum) default-members = [ - "crates/trusted-server-core", "crates/trusted-server-adapter-fastly", ] @@ -46,6 +50,7 @@ debug = 1 [workspace.dependencies] async-trait = "0.1" +axum = "0.8" base64 = "0.22" brotli = "8.0" build-print = "1.0.1" @@ -77,13 +82,16 @@ matchit = "0.9" mime = "0.3" rand = "0.8" regex = "1.12.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } +simple_logger = "5" serde_json = "1.0.149" sha2 = "0.10.9" subtle = "2.6" temp-env = "0.3.6" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } toml = "1.1" +tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" urlencoding = "2.1" diff --git a/README.md b/README.md index 82dfe7b56..0c58d173b 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,22 @@ The guide in `docs/guide/` (published at the link below) is the source of truth See the [Getting Started guide](https://iabtechlab.github.io/trusted-server/guide/getting-started) for installation and setup instructions. ```bash -# Build -cargo build +# Build Axum dev server (native) +cargo build -p trusted-server-adapter-axum -# Run tests -cargo test +# Build Fastly adapter (WASM) +cargo build -p trusted-server-adapter-fastly --target wasm32-wasip1 -# Start local server +# Run tests (Fastly/WASM crates — requires Viceroy) +cargo test-fastly + +# Run tests (Axum native adapter) +cargo test-axum + +# Start local server — Axum (no Fastly CLI or Viceroy required) +cargo run -p trusted-server-adapter-axum + +# Start local server — Fastly (requires Fastly CLI + Viceroy) fastly compute serve ``` @@ -39,10 +48,11 @@ fastly compute serve cargo fmt # Lint -cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy-fastly && cargo clippy-axum -# Run tests -cargo test +# Run all tests +cargo test-fastly # Fastly/WASM (requires Viceroy) +cargo test-axum # Axum native adapter ``` See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index a3eb5dad7..74aeba233 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -179,7 +179,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -190,7 +190,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -201,9 +201,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -268,15 +268,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -290,15 +284,6 @@ dependencies = [ "no_std_io2", ] -[[package]] -name = "block-buffer" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -316,7 +301,7 @@ checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ "async-stream", "base64", - "bitflags 2.11.1", + "bitflags", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -345,7 +330,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -387,9 +372,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -398,9 +383,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -423,9 +408,9 @@ checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -441,9 +426,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -481,9 +466,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -530,9 +515,9 @@ checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "config" -version = "0.15.22" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -727,7 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -739,7 +724,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -754,7 +739,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -788,7 +773,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -801,7 +786,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -812,7 +797,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -823,7 +808,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -854,7 +839,7 @@ checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -876,26 +861,17 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn", "unicode-xid", ] -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - [[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", + "block-buffer", "const-oid", "crypto-common", "subtle", @@ -903,13 +879,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -923,9 +899,9 @@ dependencies = [ [[package]] name = "docker_credential" -version = "1.3.3" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ "base64", "serde", @@ -941,12 +917,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dtoa" version = "1.0.11" @@ -988,7 +958,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2 0.10.9", + "sha2", "subtle", "zeroize", ] @@ -1013,7 +983,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror", "toml", "tower-service", "tracing", @@ -1030,7 +1000,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn", "toml", "validator", ] @@ -1043,9 +1013,9 @@ checksum = "7c6ba7d4eec39eaa9ab24d44a0e73a7949a1095a8b3f3abb11eddf27dbb56a53" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1055,7 +1025,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", @@ -1065,16 +1035,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "elsa" -version = "1.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" -dependencies = [ - "indexmap 2.14.0", - "stable_deref_trait", -] - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1155,67 +1115,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fastly" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f767502306f09f6dcb76302d09cd2ea8542e228d5f155166f0c2da925e16c61" -dependencies = [ - "anyhow", - "bytes", - "downcast-rs", - "elsa", - "fastly-macros", - "fastly-shared", - "fastly-sys", - "http", - "itertools 0.13.0", - "lazy_static", - "mime", - "serde", - "serde_json", - "serde_repr", - "serde_urlencoded", - "sha2 0.9.9", - "smallvec", - "thiserror 1.0.69", - "time", - "url", -] - -[[package]] -name = "fastly-macros" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51ae08eeeb5ed0c1a8b454fc89dca0e316e13b7889e81fc9a435503c1e84a2d7" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "fastly-shared" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64ed1bba12ca45d1a2a80c2c55d903297adb3eeb4edc9d327c1d51ee709d404" -dependencies = [ - "bitflags 1.3.2", - "http", -] - -[[package]] -name = "fastly-sys" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1b82ebd99583740a074d8962ca75d7d17065b185a94e4919c3a3f2193268b6" -dependencies = [ - "bitflags 1.3.2", - "fastly-shared", - "wasip2", - "wit-bindgen 0.46.0", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -1372,7 +1271,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1522,6 +1421,15 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1535,11 +1443,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1560,7 +1468,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -1586,9 +1494,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1631,9 +1539,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1766,8 +1674,8 @@ dependencies = [ "proc-macro2", "quote", "strum_macros", - "syn 2.0.117", - "thiserror 2.0.18", + "syn", + "thiserror", "walkdir", ] @@ -1779,7 +1687,7 @@ checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1981,15 +1889,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2007,9 +1906,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -2020,13 +1919,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2067,9 +1966,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2154,7 +2053,7 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "cssparser 0.36.0", "encoding_rs", @@ -2164,7 +2063,7 @@ dependencies = [ "mime", "precomputed-hash", "selectors 0.37.0", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -2195,7 +2094,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2212,9 +2111,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -2234,9 +2133,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2326,9 +2225,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2338,7 +2237,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2402,11 +2301,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2422,7 +2321,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2433,9 +2332,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2518,7 +2417,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.117", + "syn", ] [[package]] @@ -2563,7 +2462,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2573,7 +2472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -2647,7 +2546,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2660,7 +2559,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2698,7 +2597,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2797,7 +2696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2828,7 +2727,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2842,9 +2741,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -2852,22 +2751,22 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -2974,7 +2873,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags", ] [[package]] @@ -2994,7 +2893,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3090,7 +2989,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags 2.11.1", + "bitflags", "once_cell", "serde", "serde_derive", @@ -3105,7 +3004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", - "digest 0.10.7", + "digest", "num-bigint-dig", "num-integer", "num-traits", @@ -3149,7 +3048,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -3173,9 +3072,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3307,7 +3206,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3330,7 +3229,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cssparser 0.34.0", "derive_more 0.99.20", "fxhash", @@ -3349,7 +3248,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cssparser 0.36.0", "derive_more 2.1.1", "log", @@ -3407,7 +3306,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3431,7 +3330,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3457,9 +3356,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -3477,14 +3376,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3496,19 +3395,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.10.9" @@ -3517,14 +3403,14 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" @@ -3532,7 +3418,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", + "digest", "rand_core 0.6.4", ] @@ -3562,9 +3448,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3632,7 +3518,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.117", + "syn", ] [[package]] @@ -3643,7 +3529,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3655,7 +3541,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3664,17 +3550,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -3703,7 +3578,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3712,7 +3587,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3772,7 +3647,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -3780,33 +3655,13 @@ dependencies = [ "url", ] -[[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.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[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.117", + "thiserror-impl", ] [[package]] @@ -3817,7 +3672,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3908,7 +3763,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4055,11 +3910,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags", "bytes", "futures-util", "http", @@ -4102,7 +3957,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4130,7 +3985,6 @@ dependencies = [ "ed25519-dalek", "edgezero-core", "error-stack", - "fastly", "flate2", "futures", "getrandom 0.2.17", @@ -4147,7 +4001,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "subtle", "toml", "trusted-server-js", @@ -4164,7 +4018,7 @@ version = "0.1.0" dependencies = [ "build-print", "hex", - "sha2 0.10.9", + "sha2", "which", ] @@ -4191,9 +4045,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4219,9 +4073,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4323,9 +4177,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4359,7 +4213,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4419,9 +4273,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4432,9 +4286,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4442,9 +4296,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4452,22 +4306,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4500,7 +4354,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4508,9 +4362,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4528,9 +4382,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] @@ -4587,7 +4441,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4598,7 +4452,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4736,15 +4590,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4759,9 +4604,6 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -dependencies = [ - "bitflags 2.11.1", -] [[package]] name = "wit-bindgen-core" @@ -4784,7 +4626,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4800,7 +4642,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4812,7 +4654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags", "indexmap 2.14.0", "log", "serde", @@ -4860,9 +4702,9 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -4871,9 +4713,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4888,28 +4730,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4929,7 +4771,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4972,7 +4814,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/crates/integration-tests/tests/environments/axum.rs b/crates/integration-tests/tests/environments/axum.rs new file mode 100644 index 000000000..b6bfdc4db --- /dev/null +++ b/crates/integration-tests/tests/environments/axum.rs @@ -0,0 +1,123 @@ +use crate::common::runtime::{ + RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, +}; +use error_stack::ResultExt as _; +use std::io::{BufRead as _, BufReader}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; + +/// Default port the Axum dev server binds to when no `PORT` env var is supplied. +const AXUM_DEFAULT_PORT: u16 = 8787; + +/// Axum native dev-server runtime environment. +/// +/// Spawns the pre-built `trusted-server-axum` binary directly (no WASM, no +/// Viceroy). The binary must have been built before running integration tests: +/// +/// ```sh +/// cargo build -p trusted-server-adapter-axum +/// ``` +/// +/// The WASM binary path argument is unused — it exists only to satisfy the +/// [`RuntimeEnvironment`] trait shared with Fastly. +pub struct AxumDevServer; + +impl RuntimeEnvironment for AxumDevServer { + fn id(&self) -> &'static str { + "axum" + } + + fn spawn(&self, _wasm_path: &Path) -> TestResult { + let binary = self.binary_path(); + let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); + + let mut child = Command::new(&binary) + .env("PORT", port.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "Failed to spawn trusted-server-axum binary at {}", + binary.display() + ))?; + + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if !line.is_empty() { + log::debug!("axum: {line}"); + } + } + }); + } + + let handle = AxumHandle { child }; + let base_url = format!("http://127.0.0.1:{port}"); + + // The Axum dev server returns 403 at root (no publisher config in test env), + // so we poll until we get any HTTP response rather than a specific status. + wait_for_any_response(&base_url)?; + + Ok(RuntimeProcess { + inner: Box::new(handle), + base_url, + }) + } + + fn health_check_path(&self) -> &str { + "/health" + } +} + +impl AxumDevServer { + /// Resolve the path to the compiled `trusted-server-axum` binary. + /// + /// Respects the `AXUM_BINARY_PATH` environment variable for CI overrides. + /// Falls back to the workspace `target/debug/` directory. + fn binary_path(&self) -> std::path::PathBuf { + if let Ok(path) = std::env::var("AXUM_BINARY_PATH") { + return std::path::PathBuf::from(path); + } + + // CARGO_MANIFEST_DIR is crates/integration-tests → go up two levels to workspace root + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug/trusted-server-axum") + } +} + +/// Poll until the Axum dev server responds with any HTTP status code. +/// +/// The Axum server returns 403 at root when no publisher config is present, +/// which is neither success nor 404, so the standard [`super::wait_for_ready`] +/// helper cannot be used. Any HTTP response means the server is up. +fn wait_for_any_response(base_url: &str) -> TestResult<()> { + use error_stack::Report; + + let url = format!("{base_url}/"); + for _ in 0..30 { + if reqwest::blocking::get(&url).is_ok() { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Err(Report::new(TestError::RuntimeNotReady) + .attach(format!("Axum dev server at {base_url} not ready after 15s"))) +} + +/// Process handle for a running Axum dev-server instance. +/// +/// Implements [`Drop`] to ensure the process is killed on test cleanup. +struct AxumHandle { + child: Child, +} + +impl RuntimeProcessHandle for AxumHandle {} + +impl Drop for AxumHandle { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/crates/integration-tests/tests/environments/mod.rs b/crates/integration-tests/tests/environments/mod.rs index b53fa4bc3..c3797e20c 100644 --- a/crates/integration-tests/tests/environments/mod.rs +++ b/crates/integration-tests/tests/environments/mod.rs @@ -1,3 +1,4 @@ +pub mod axum; pub mod fastly; use crate::common::runtime::{RuntimeEnvironment, TestError, TestResult}; @@ -18,7 +19,10 @@ type RuntimeFactory = fn() -> Box; /// 1. Create `tests/environments/.rs` /// 2. Implement [`RuntimeEnvironment`] trait /// 3. Add factory closure here -pub static RUNTIME_ENVIRONMENTS: &[RuntimeFactory] = &[|| Box::new(fastly::FastlyViceroy)]; +pub static RUNTIME_ENVIRONMENTS: &[RuntimeFactory] = &[ + || Box::new(fastly::FastlyViceroy), + || Box::new(axum::AxumDevServer), +]; /// Readiness polling configuration for runtimes and frontend containers. pub(crate) struct ReadyCheckOptions { diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs index 5c6dcf606..95e2abf4c 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -136,6 +136,22 @@ fn test_nextjs_fastly() { test_combination(&runtime, &framework).expect("should pass Next.js on Fastly"); } +#[test] +#[ignore = "requires Docker and pre-built trusted-server-axum binary"] +fn test_wordpress_axum() { + let runtime = environments::axum::AxumDevServer; + let framework = frameworks::wordpress::WordPress; + test_combination(&runtime, &framework).expect("should pass WordPress on Axum"); +} + +#[test] +#[ignore = "requires Docker and pre-built trusted-server-axum binary"] +fn test_nextjs_axum() { + let runtime = environments::axum::AxumDevServer; + let framework = frameworks::nextjs::NextJs; + test_combination(&runtime, &framework).expect("should pass Next.js on Axum"); +} + // --------------------------------------------------------------------------- // EC identity lifecycle tests (no frontend framework container needed) // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml new file mode 100644 index 000000000..86f896ea6 --- /dev/null +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "trusted-server-adapter-axum" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +name = "trusted_server_adapter_axum" +path = "src/lib.rs" + +[[bin]] +name = "trusted-server-axum" +path = "src/main.rs" + +[dependencies] +async-trait = { workspace = true } +edgezero-adapter-axum = { workspace = true, features = ["axum"] } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +futures = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +simple_logger = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } +trusted-server-core = { path = "../trusted-server-core" } + +[dev-dependencies] +axum = { workspace = true } +temp-env = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tower = { workspace = true, features = ["util"] } diff --git a/crates/trusted-server-adapter-axum/axum.toml b/crates/trusted-server-adapter-axum/axum.toml new file mode 100644 index 000000000..48224aa77 --- /dev/null +++ b/crates/trusted-server-adapter-axum/axum.toml @@ -0,0 +1,8 @@ +[app] +name = "trusted-server" +version = "0.1.0" +kind = "http" + +[adapters.axum.logging] +level = "info" +echo_stdout = true diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs new file mode 100644 index 000000000..d3b42defc --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -0,0 +1,432 @@ +use core::future::Future; +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{ + HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, +}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::ec::EcContext; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::proxy::{ + handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, + handle_first_party_proxy_sign, +}; +use trusted_server_core::publisher::{ + buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, +}; +use trusted_server_core::request_signing::{ + handle_trusted_server_discovery, handle_verify_signature, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings; + +use trusted_server_core::platform::RuntimeServices; + +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::platform::build_runtime_services; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once at startup and shared across all requests. +pub struct AppState { + settings: Arc, + orchestrator: Arc, + registry: Arc, +} + +/// Build the application state, loading settings and constructing all per-application components. +/// +/// # Errors +/// +/// Returns an error when settings, the auction orchestrator, or the integration +/// registry fail to initialise. +fn build_state() -> Result, Report> { + let settings = get_settings()?; + build_state_with_settings(settings) +} + +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the auction orchestrator or the integration +/// registry fail to initialise. +fn build_state_with_settings( + settings: Settings, +) -> Result, Report> { + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + })) +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Convert a [`Report`] into an HTTP [`Response`]. +pub(crate) fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +// --------------------------------------------------------------------------- +// Shared handler executor +// --------------------------------------------------------------------------- + +async fn execute_handler( + state: Arc, + ctx: RequestContext, + handler: F, +) -> Result +where + F: FnOnce(Arc, RuntimeServices, Request) -> Fut, + Fut: Future>>, +{ + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handler(state, services, req) + .await + .unwrap_or_else(|e| http_error(&e))) +} + +// --------------------------------------------------------------------------- +// Fallback dispatcher (tsjs / integration proxy / publisher) +// --------------------------------------------------------------------------- + +async fn dispatch_fallback( + state: &AppState, + services: &RuntimeServices, + req: Request, +) -> Result> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + if method == Method::GET && path.starts_with("/static/tsjs=") { + return handle_tsjs_dynamic(&req, &state.registry); + } + + if state.registry.has_route(&method, &path) { + let mut ec_context = EcContext::default(); + return state + .registry + .handle_proxy(ProxyDispatchInput { + method: &method, + path: &path, + settings: &state.settings, + kv: None, + ec_context: &mut ec_context, + services, + req, + }) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }); + } + + handle_publisher_request(&state.settings, &state.registry, services, req) + .await + .and_then(|pr| buffer_publisher_response(pr, &state.settings, &state.registry)) +} + +fn fallback_handler( + state: Arc, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(execute_handler( + state, + ctx, + |state, services, req| async move { dispatch_fallback(&state, &services, req).await }, + )) + } +} + +// --------------------------------------------------------------------------- +// Named route table +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum NamedRouteHandler { + TrustedServerDiscovery, + VerifySignature, + AdminNotSupported, + Auction, + FirstPartyProxy, + FirstPartyClick, + FirstPartySign, + FirstPartyProxyRebuild, +} + +struct NamedRoute { + path: &'static str, + primary_methods: &'static [Method], + handler: NamedRouteHandler, +} + +fn named_routes() -> [NamedRoute; 9] { + [ + NamedRoute { + path: "/.well-known/trusted-server.json", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TrustedServerDiscovery, + }, + NamedRoute { + path: "/verify-signature", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::VerifySignature, + }, + NamedRoute { + path: "/admin/keys/rotate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::AdminNotSupported, + }, + NamedRoute { + path: "/admin/keys/deactivate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::AdminNotSupported, + }, + NamedRoute { + path: "/auction", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::Auction, + }, + NamedRoute { + path: "/first-party/proxy", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyProxy, + }, + NamedRoute { + path: "/first-party/click", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyClick, + }, + NamedRoute { + path: "/first-party/sign", + primary_methods: &[Method::GET, Method::POST], + handler: NamedRouteHandler::FirstPartySign, + }, + NamedRoute { + path: "/first-party/proxy-rebuild", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::FirstPartyProxyRebuild, + }, + ] +} + +fn named_route_handler( + state: Arc, + handler: NamedRouteHandler, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(execute_handler( + state, + ctx, + move |state, services, req| async move { + match handler { + NamedRouteHandler::TrustedServerDiscovery => { + handle_trusted_server_discovery(&state.settings, &services, req) + } + NamedRouteHandler::VerifySignature => { + handle_verify_signature(&state.settings, &services, req) + } + NamedRouteHandler::AdminNotSupported => { + // Config/secret-store writes are backed by read-only env vars on the + // Axum dev server. Returning 501 is clearer than failing on the first + // store write. + let body = edgezero_core::body::Body::from( + "Admin key management is not supported on the Axum dev server.\n\ + Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", + ); + let mut resp = Response::new(body); + *resp.status_mut() = StatusCode::NOT_IMPLEMENTED; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + Ok(resp) + } + NamedRouteHandler::Auction => { + let ec_context = EcContext::default(); + handle_auction( + &state.settings, + &state.orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + } + NamedRouteHandler::FirstPartyProxy => { + handle_first_party_proxy(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartyClick => { + handle_first_party_click(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartySign => { + handle_first_party_proxy_sign(&state.settings, &services, req).await + } + NamedRouteHandler::FirstPartyProxyRebuild => { + handle_first_party_proxy_rebuild(&state.settings, &services, req).await + } + } + }, + )) + } +} + +// --------------------------------------------------------------------------- +// Startup error fallback +// --------------------------------------------------------------------------- + +fn publisher_fallback_methods() -> [Method; 7] { + [ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + ] +} + +/// Returns a [`RouterService`] that responds to every route with the startup error. +fn startup_error_router(e: &Report) -> RouterService { + let message = Arc::new(format!("{}\n", e.current_context().user_message())); + let status = e.current_context().status_code(); + + let make_handler = |msg: Arc| { + move |_ctx: RequestContext| { + let body = edgezero_core::body::Body::from((*msg).clone()); + let mut resp = Response::new(body); + *resp.status_mut() = status; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + async move { Ok::(resp) } + } + }; + + let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( + Arc::new(Settings::default()), + )); + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), make_handler(Arc::clone(&message))); + router = router.route("/{*rest}", method, make_handler(Arc::clone(&message))); + } + router.build() +} + +// --------------------------------------------------------------------------- +// TrustedServerApp +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. +pub struct TrustedServerApp; + +impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + + fn routes() -> RouterService { + let state = match build_state() { + Ok(s) => s, + Err(ref e) => { + log::error!("failed to build application state: {:?}", e); + return startup_error_router(e); + } + }; + + build_router(&state) + } +} + +impl TrustedServerApp { + /// Build the full application router from explicit settings. + /// + /// Testing seam: integration tests use this to drive the router with + /// known-good settings instead of the baked `get_settings()` result, + /// whose embedded placeholder secrets fail validation by design. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or the integration + /// registry fail to initialise. + pub fn routes_with_settings( + settings: Settings, + ) -> Result> { + let state = build_state_with_settings(settings)?; + Ok(build_router(&state)) + } +} + +fn build_router(state: &Arc) -> RouterService { + let fallback = fallback_handler(Arc::clone(state)); + + let mut router = RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); + + router = router.route("/health", Method::GET, |_ctx: RequestContext| async { + Ok::( + edgezero_core::http::response_builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")) + .body(edgezero_core::body::Body::from("ok")) + .expect("should build health response"), + ) + }); + + for route in named_routes() { + for method in route.primary_methods { + router = router.route( + route.path, + method.clone(), + named_route_handler(Arc::clone(state), route.handler), + ); + } + for method in publisher_fallback_methods() { + if !route.primary_methods.contains(&method) { + router = router.route(route.path, method, fallback.clone()); + } + } + } + + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), fallback.clone()); + router = router.route("/{*rest}", method, fallback.clone()); + } + + router.build() +} diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs new file mode 100644 index 000000000..2f15e566d --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -0,0 +1,12 @@ +//! Native Axum adapter for local Trusted Server development. +//! +//! Runs a full Axum HTTP server on `localhost` as a drop-in dev alternative to +//! the Fastly Compute adapter (via Viceroy). All routes and middleware mirror +//! the Fastly adapter; store and geo primitives fall back to env vars and no-ops. + +/// Application routing and handler registration for the Axum dev server. +pub mod app; +/// Request middleware (auth, response finalisation). +pub mod middleware; +/// Platform-trait implementations backed by env vars and `reqwest`. +pub mod platform; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs new file mode 100644 index 000000000..755a72d65 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -0,0 +1,50 @@ +use edgezero_core::app::Hooks as _; +use trusted_server_adapter_axum::app::TrustedServerApp; + +#[allow(clippy::print_stderr)] +fn main() { + if let Err(e) = simple_logger::SimpleLogger::new().init() { + eprintln!("warning: logger init failed: {e}"); + } + + let config = match port_from_env() { + // When PORT is set, bind to a specific address so integration tests + // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. + Some(port) => edgezero_adapter_axum::AxumDevServerConfig { + addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), + enable_ctrl_c: true, + }, + // Normal development path: read bind address from axum.toml. + None => edgezero_adapter_axum::AxumDevServerConfig::default(), + }; + + log::info!("Listening on http://{}", config.addr); + let router = TrustedServerApp::routes(); + if let Err(err) = edgezero_adapter_axum::AxumDevServer::with_config(router, config).run() { + log::error!("trusted-server-adapter-axum failed: {err}"); + std::process::exit(1); + } +} + +/// Read a port number from the `PORT` environment variable. +/// +/// Returns `None` when the variable is unset. Exits non-zero if the value +/// is set but cannot be parsed — silently falling back to a different port +/// would surprise tooling that expects the server at the requested address. +#[allow(clippy::print_stderr)] +fn port_from_env() -> Option { + let raw = std::env::var("PORT").ok()?; + match raw.parse() { + Ok(port) => Some(port), + Err(e) => { + eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn crate_compiles() {} +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs new file mode 100644 index 000000000..8ad362a97 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderName, HeaderValue, Response}; +use edgezero_core::middleware::{Middleware, Next}; +use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::settings::Settings; + +// --------------------------------------------------------------------------- +// FinalizeResponseMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: injects all standard TS response headers. +/// +/// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` +/// is always emitted. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are +/// skipped because the corresponding env vars are not set in a local dev context. +/// +/// Registered first in the middleware chain so that every outgoing response — +/// including auth-rejected ones — carries a consistent set of headers. +pub struct FinalizeResponseMiddleware { + settings: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for FinalizeResponseMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let mut response = next.run(ctx).await?; + apply_finalize_headers(&self.settings, &mut response); + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AuthMiddleware +// --------------------------------------------------------------------------- + +/// Inner middleware: enforces basic-auth before the handler runs. +/// +/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). +/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. +/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. +pub struct AuthMiddleware { + settings: Arc, +} + +impl AuthMiddleware { + /// Creates a new [`AuthMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AuthMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + match enforce_basic_auth(&self.settings, ctx.request()) { + Ok(Some(response)) => return Ok(response), + Ok(None) => {} + Err(report) => { + log::error!("auth check failed: {:?}", report); + return Ok(crate::app::http_error(&report)); + } + } + + next.run(ctx).await + } +} + +// --------------------------------------------------------------------------- +// apply_finalize_headers — extracted for unit testing +// --------------------------------------------------------------------------- + +/// Applies standard Trusted Server response headers to the given response. +/// +/// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` +/// is unconditionally emitted. Fastly-specific headers are omitted. +/// Operator-configured `settings.response_headers` are applied last and can override +/// any managed header. +pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + + for (key, value) in &settings.response_headers { + let header_name = HeaderName::from_bytes(key.as_bytes()); + let header_value = HeaderValue::from_str(value); + if let (Ok(header_name), Ok(header_value)) = (header_name, header_value) { + response.headers_mut().insert(header_name, header_value); + } else { + log::warn!( + "Skipping invalid configured response header value for {}", + key + ); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use edgezero_core::body::Body; + use edgezero_core::http::response_builder; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { + let mut s = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should load test settings"); + s.response_headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + s + } + + #[test] + fn sets_geo_unavailable_header() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false" + ); + } + + #[test] + fn operator_response_headers_override_geo_header() { + let settings = + settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("operator-override"), + "should override the managed geo header with the operator-configured value" + ); + } + + #[test] + fn applies_custom_operator_headers() { + let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, &mut response); + + assert_eq!( + response + .headers() + .get("x-custom-header") + .and_then(|v| v.to_str().ok()), + Some("custom-value"), + "should apply operator-configured response headers" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs new file mode 100644 index 000000000..c4125c0f0 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -0,0 +1,802 @@ +use std::future::Future; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use async_trait::async_trait; +use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use error_stack::{Report, ResultExt as _}; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, StoreName, +}; + +// --------------------------------------------------------------------------- +// Env-var naming helpers +// --------------------------------------------------------------------------- + +/// Normalize a store name or key for use as an environment-variable segment. +/// +/// Uppercases and replaces hyphens, dots, and spaces with underscores. +fn normalize_env_segment(s: &str) -> String { + s.to_uppercase().replace(['-', '.', ' '], "_") +} + +fn config_env_var(store_name: &str, key: &str) -> String { + format!( + "TRUSTED_SERVER_CONFIG_{}_{}", + normalize_env_segment(store_name), + normalize_env_segment(key), + ) +} + +fn secret_env_var(store_name: &str, key: &str) -> String { + format!( + "TRUSTED_SERVER_SECRET_{}_{}", + normalize_env_segment(store_name), + normalize_env_segment(key), + ) +} + +// --------------------------------------------------------------------------- +// PlatformConfigStore +// --------------------------------------------------------------------------- + +/// Environment-variable–backed config store for the Axum dev server. +/// +/// Reads from `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` (uppercased, hyphens→underscores). +/// Write operations are unsupported in local development. +pub struct AxumPlatformConfigStore; + +impl PlatformConfigStore for AxumPlatformConfigStore { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + let var_name = config_env_var(store_name.as_ref(), key); + std::env::var(&var_name).map_err(|_| { + Report::new(PlatformError::ConfigStore).attach(format!( + "env var '{var_name}' not set — export it to supply this config value" + )) + }) + } + + fn put( + &self, + store_id: &StoreId, + key: &str, + _value: &str, + ) -> Result<(), Report> { + log::warn!( + "AxumPlatformConfigStore: write to store '{}' key '{}' ignored \ + (config store writes are not supported on the Axum dev server)", + store_id.as_ref(), + key + ); + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on the Axum dev server")) + } + + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { + log::warn!( + "AxumPlatformConfigStore: delete from store '{}' key '{}' ignored \ + (config store deletes are not supported on the Axum dev server)", + store_id.as_ref(), + key + ); + Err(Report::new(PlatformError::ConfigStore) + .attach("config store deletes are not supported on the Axum dev server")) + } +} + +// --------------------------------------------------------------------------- +// PlatformSecretStore +// --------------------------------------------------------------------------- + +/// Environment-variable–backed secret store for the Axum dev server. +/// +/// Reads from `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` as raw UTF-8 bytes. +/// Write operations are unsupported in local development. +pub struct AxumPlatformSecretStore; + +impl PlatformSecretStore for AxumPlatformSecretStore { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let var_name = secret_env_var(store_name.as_ref(), key); + std::env::var(&var_name) + .map(String::into_bytes) + .map_err(|_| { + Report::new(PlatformError::SecretStore).attach(format!( + "env var '{var_name}' not set — export it to supply this secret value" + )) + }) + } + + fn create( + &self, + store_id: &StoreId, + name: &str, + _value: &str, + ) -> Result<(), Report> { + log::warn!( + "AxumPlatformSecretStore: create '{}' in store '{}' ignored \ + (secret store writes are not supported on the Axum dev server)", + name, + store_id.as_ref() + ); + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on the Axum dev server")) + } + + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { + log::warn!( + "AxumPlatformSecretStore: delete '{}' from store '{}' ignored \ + (secret store deletes are not supported on the Axum dev server)", + name, + store_id.as_ref() + ); + Err(Report::new(PlatformError::SecretStore) + .attach("secret store deletes are not supported on the Axum dev server")) + } +} + +// --------------------------------------------------------------------------- +// PlatformBackend +// --------------------------------------------------------------------------- + +/// No-op backend for the Axum dev server. +/// +/// Returns a deterministic name; `ensure` is a no-op returning the same name. +/// The Axum HTTP client sends directly to URIs and ignores backend names. +pub struct AxumPlatformBackend; + +impl PlatformBackend for AxumPlatformBackend { + fn predict_name(&self, spec: &PlatformBackendSpec) -> Result> { + let port = spec + .port + .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + Ok(format!( + "{}_{}_{}", + normalize_env_segment(&spec.scheme), + normalize_env_segment(&spec.host), + port, + )) + } + + fn ensure(&self, spec: &PlatformBackendSpec) -> Result> { + self.predict_name(spec) + } +} + +// --------------------------------------------------------------------------- +// PlatformGeo +// --------------------------------------------------------------------------- + +/// No-op geo implementation — geographic lookup is unavailable in local development. +pub struct AxumPlatformGeo; + +impl PlatformGeo for AxumPlatformGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// PlatformHttpClient +// --------------------------------------------------------------------------- + +type SpawnedRequestResult = Result<(u16, Vec<(String, Vec)>, Vec), Report>; + +fn sanitized_response_headers(headers: &HeaderMap) -> Vec<(String, Vec)> { + let connection_tokens = connection_header_tokens(headers); + + headers + .iter() + .filter(|(name, _)| !is_hop_by_hop_response_header(name, &connection_tokens)) + .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) + .collect() +} + +fn connection_header_tokens(headers: &HeaderMap) -> Vec { + headers + .get_all(header::CONNECTION) + .iter() + .filter_map(header_value_to_str) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|token| !token.is_empty()) + .filter_map(|token| HeaderName::from_bytes(token.as_bytes()).ok()) + .collect() +} + +fn header_value_to_str(value: &HeaderValue) -> Option<&str> { + value.to_str().ok() +} + +fn is_hop_by_hop_response_header(name: &HeaderName, connection_tokens: &[HeaderName]) -> bool { + name == header::CONNECTION + || name == header::PROXY_AUTHENTICATE + || name == header::PROXY_AUTHORIZATION + || name == header::TE + || name == header::TRAILER + || name == header::TRANSFER_ENCODING + || name == header::UPGRADE + || name.as_str().eq_ignore_ascii_case("keep-alive") + || connection_tokens.iter().any(|token| token == name) +} + +/// Buffered response parts from a spawned outbound request. +/// +/// Stored inside [`PlatformPendingRequest`] so that [`AxumPlatformHttpClient::select`] +/// can poll multiple in-flight handles concurrently via +/// [`futures::future::select_all`]. +struct AxumPendingHandle { + backend_name: String, + handle: tokio::task::JoinHandle, +} + +impl Drop for AxumPendingHandle { + fn drop(&mut self) { + // Abort instead of detaching: when the orchestrator hits the auction + // deadline and drops the remaining pending requests, the abandoned + // bidder tasks would otherwise keep running for up to the 30s + // transport timeout. + self.handle.abort(); + } +} + +/// Resolves to the backend name together with the task result so that +/// [`futures::future::select_all`] callers never have to reconstruct which +/// backend a completion belongs to by position. `select_all` removes the +/// ready future with `swap_remove` and makes no ordering guarantee for the +/// remaining futures, so positional bookkeeping would mislabel them. +impl Future for AxumPendingHandle { + type Output = (String, Result); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match Pin::new(&mut self.handle).poll(cx) { + Poll::Ready(result) => { + let backend_name = std::mem::take(&mut self.backend_name); + Poll::Ready((backend_name, result)) + } + Poll::Pending => Poll::Pending, + } + } +} + +/// reqwest-backed HTTP client for the Axum dev server. +/// +/// `send_async` buffers any `Body::Stream` in the calling context, then spawns +/// a `tokio` task for each outbound request so that multiple `send_async` calls +/// run concurrently. `select` uses [`futures::future::select_all`] to wait for +/// the first completing handle, preserving fan-out semantics. +pub struct AxumPlatformHttpClient { + client: reqwest::Client, +} + +impl AxumPlatformHttpClient { + /// Create a new client with sensible dev-server timeouts. + /// + /// # Panics + /// + /// Panics if the underlying `reqwest::Client` cannot be built (should not + /// happen with the default TLS configuration on any supported platform). + #[must_use] + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + // Disable automatic redirects: core proxy code enforces redirect + // limits and allowed_domains checks itself. Without this, reqwest + // would follow Location headers internally and bypass those checks. + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("should build reqwest client"), + } + } + + /// Drain `body` to a `Vec`. + /// + /// For `Body::Stream` this awaits every chunk in the current async context + /// (where `LocalBoxStream` is valid) before the bytes are moved into a + /// `tokio::spawn` task that requires `Send`. + async fn buffer_body( + body: edgezero_core::body::Body, + ) -> Result, Report> { + match body { + edgezero_core::body::Body::Once(bytes) => Ok(bytes.to_vec()), + edgezero_core::body::Body::Stream(mut stream) => { + log::debug!("buffering Body::Stream into Vec for outbound request"); + use futures::StreamExt as _; + let mut buf = Vec::new(); + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|e| { + Report::new(PlatformError::HttpClient) + .attach(format!("failed to buffer outbound streaming body: {e}")) + })?; + buf.extend_from_slice(&bytes); + } + Ok(buf) + } + } + } + + async fn execute( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let uri = request.request.uri().to_string(); + let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) + .change_context(PlatformError::HttpClient)?; + + let mut builder = self.client.request(method, &uri); + for (name, value) in request.request.headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + let (_, body) = request.request.into_parts(); + let body_bytes = Self::buffer_body(body).await?; + if !body_bytes.is_empty() { + builder = builder.body(body_bytes); + } + + let resp = builder + .send() + .await + .change_context(PlatformError::HttpClient) + .attach(format!("outbound request to {uri} failed"))?; + + let status = resp.status().as_u16(); + let mut edge_builder = edgezero_core::http::response_builder().status(status); + for (name, value) in sanitized_response_headers(resp.headers()) { + edge_builder = edge_builder.header(name.as_str(), value.as_slice()); + } + let resp_bytes = resp + .bytes() + .await + .change_context(PlatformError::HttpClient)?; + // Upstream responses are buffered whole with no cap — acceptable for + // the dev server, but log the size so a large or hostile upstream is + // visible instead of silently growing the heap. + log::debug!( + "buffered {} upstream response bytes from {uri}", + resp_bytes.len() + ); + let edge_resp = edge_builder + .body(edgezero_core::body::Body::from(resp_bytes.to_vec())) + .change_context(PlatformError::HttpClient)?; + + Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) + } +} + +impl Default for AxumPlatformHttpClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl PlatformHttpClient for AxumPlatformHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + self.execute(request).await + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name.clone(); + + // Extract all Send-compatible parts before spawning. + let uri = request.request.uri().to_string(); + let method_bytes = request.request.method().as_str().as_bytes().to_vec(); + let headers: Vec<(String, Vec)> = request + .request + .headers() + .iter() + .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) + .collect(); + + // Buffer any LocalBoxStream body here in the ?Send context before spawn. + let (_, body) = request.request.into_parts(); + let body_bytes = Self::buffer_body(body).await?; + + let client = self.client.clone(); + let handle = tokio::spawn(async move { + let method = reqwest::Method::from_bytes(&method_bytes) + .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))?; + let mut builder = client.request(method, &uri); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_slice()); + } + if !body_bytes.is_empty() { + builder = builder.body(body_bytes); + } + let resp = builder.send().await.map_err(|e| { + Report::new(PlatformError::HttpClient) + .attach(format!("outbound request to {uri} failed: {e}")) + })?; + let status = resp.status().as_u16(); + let resp_headers = sanitized_response_headers(resp.headers()); + let body = resp + .bytes() + .await + .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))? + .to_vec(); + // Same unbounded-buffering note as the synchronous path: log the + // size so large upstream responses are visible in dev. + log::debug!("buffered {} upstream response bytes from {uri}", body.len()); + Ok::<_, Report>((status, resp_headers, body)) + }); + + let pending = AxumPendingHandle { + backend_name: backend_name.clone(), + handle, + }; + Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + if pending_requests.is_empty() { + return Err(Report::new(PlatformError::HttpClient) + .attach("select called with an empty pending_requests list")); + } + + let handles: Vec = pending_requests + .into_iter() + .map(|pr| { + pr.downcast::().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in AxumPlatformHttpClient::select") + }) + }) + .collect::, _>>()?; + + // Each AxumPendingHandle resolves to (backend_name, result), so the + // remaining handles keep their own backend names — no positional + // reconstruction (select_all does not preserve the order of the + // remaining futures). + let ((backend_name, result), _ready_idx, remaining_handles) = + futures::future::select_all(handles).await; + + let remaining: Vec = remaining_handles + .into_iter() + .map(|handle| { + let backend_name = handle.backend_name.clone(); + PlatformPendingRequest::new(handle).with_backend_name(backend_name) + }) + .collect(); + + // Map join panics and per-request errors into ready: Err(...) so that the + // auction orchestrator can log the failure and continue with remaining providers + // rather than treating one bad provider as a fatal select() failure. + let ready = result + .map_err(|e| { + Report::new(PlatformError::HttpClient) + .attach(format!("auction request task panicked: {e}")) + }) + .and_then(|inner| inner) + .and_then(|(status, headers, body)| { + let mut builder = edgezero_core::http::response_builder().status(status); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_slice()); + } + builder + .body(edgezero_core::body::Body::from(body)) + .change_context(PlatformError::HttpClient) + }) + .map(|edge_resp| { + PlatformResponse::new(edge_resp).with_backend_name(backend_name.clone()) + }); + + // Attribute the failure to its backend so the orchestrator can remove + // the provider and record a BidStatus::Error, matching the Fastly + // adapter. Without this, a failed provider silently vanishes through + // the orchestrator's "backend not identified" branch. + let failed_backend_name = ready.as_ref().err().map(|_| backend_name); + + Ok(PlatformSelectResult { + ready, + remaining, + failed_backend_name, + }) + } +} + +// --------------------------------------------------------------------------- +// build_runtime_services +// --------------------------------------------------------------------------- + +/// Construct [`RuntimeServices`] for an incoming Axum request. +/// +/// # Degraded features in dev +/// +/// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route +/// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is +/// emitted once per process. +pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { + static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + KV_WARNED.get_or_init(|| { + log::warn!( + "Axum dev server: KV store is unavailable (UnavailableKvStore). \ + Routes that depend on synthetic-ID or consent KV will degrade gracefully." + ); + }); + + let client_ip = edgezero_adapter_axum::AxumRequestContext::get(ctx.request()) + .and_then(|c| c.remote_addr) + .map(|addr| addr.ip()); + + use trusted_server_core::platform::{ + PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformKvStore, PlatformSecretStore, + }; + + // Stateless shims are promoted to process-wide statics so callers clone + // an existing Arc instead of allocating a new one per request. + static CONFIG_STORE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + static SECRET_STORE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + static KV_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); + static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); + static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); + + RuntimeServices::builder() + .config_store(Arc::clone(CONFIG_STORE.get_or_init(|| { + Arc::new(AxumPlatformConfigStore) as Arc + }))) + .secret_store(Arc::clone(SECRET_STORE.get_or_init(|| { + Arc::new(AxumPlatformSecretStore) as Arc + }))) + .kv_store(Arc::clone(KV_STORE.get_or_init(|| { + Arc::new(trusted_server_core::platform::UnavailableKvStore) as Arc + }))) + .backend(Arc::clone(BACKEND.get_or_init(|| { + Arc::new(AxumPlatformBackend) as Arc + }))) + // Keep the HTTP client request-scoped in the dev adapter. Sharing a pooled + // client across requests previously regressed the Next.js server-action → + // API-route integration flow by reusing a poisoned connection after a + // truncated POST. Revisit pooling if profiling shows allocation cost. + .http_client(Arc::new(AxumPlatformHttpClient::new())) + .geo(Arc::clone(GEO.get_or_init(|| { + Arc::new(AxumPlatformGeo) as Arc + }))) + .client_info(ClientInfo { + client_ip, + tls_protocol: None, + tls_cipher: None, + }) + .build() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::body::Body as EdgeBody; + use std::time::Duration; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + #[test] + fn config_store_reads_from_env_var() { + temp_env::with_var( + "TRUSTED_SERVER_CONFIG_MY_STORE_MY_KEY", + Some("test-value"), + || { + let store = AxumPlatformConfigStore; + let result = store + .get(&StoreName::from("my-store"), "my-key") + .expect("should read env var"); + assert_eq!(result, "test-value", "should return env var value"); + }, + ); + } + + #[test] + fn config_store_returns_error_for_missing_env_var() { + let store = AxumPlatformConfigStore; + let result = store.get( + &StoreName::from("nonexistent-store-zzz"), + "nonexistent-key-zzz", + ); + assert!(result.is_err(), "should error for missing env var"); + } + + #[test] + fn secret_store_reads_bytes_from_env_var() { + temp_env::with_var( + "TRUSTED_SERVER_SECRET_MY_SECRETS_MY_SECRET", + Some("hello"), + || { + let store = AxumPlatformSecretStore; + let result = store + .get_bytes(&StoreName::from("my-secrets"), "my-secret") + .expect("should read env var as bytes"); + assert_eq!(result, b"hello", "should return raw bytes"); + }, + ); + } + + #[test] + fn backend_predict_name_returns_deterministic_string() { + let backend = AxumPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + let name1 = backend.predict_name(&spec).expect("should return a name"); + let name2 = backend + .predict_name(&spec) + .expect("should return same name"); + assert!(!name1.is_empty(), "should return a non-empty name"); + assert_eq!(name1, name2, "should be deterministic"); + } + + #[test] + fn backend_ensure_returns_same_name_as_predict() { + let backend = AxumPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "example.com".to_string(), + port: None, + certificate_check: true, + first_byte_timeout: Duration::from_secs(15), + }; + assert_eq!( + backend.predict_name(&spec).expect("should return name"), + backend.ensure(&spec).expect("should return name"), + "ensure should equal predict_name" + ); + } + + #[test] + fn geo_always_returns_none() { + let geo = AxumPlatformGeo; + let no_ip = geo.lookup(None).expect("should not error"); + assert!(no_ip.is_none(), "should return None for no IP"); + let with_ip = geo + .lookup(Some("127.0.0.1".parse().expect("should parse IP"))) + .expect("should not error"); + assert!(with_ip.is_none(), "should return None for any IP"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn http_client_strips_hop_by_hop_response_headers() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Transfer-Encoding: chunked\r\n\ + Connection: keep-alive, x-remove-me\r\n\ + Keep-Alive: timeout=5\r\n\ + X-Remove-Me: listed-by-connection\r\n\ + X-Preserve-Me: application-header\r\n\ + \r\n\ + 2\r\n\ + ok\r\n\ + 0\r\n\ + \r\n", + ) + .await; + + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let response = AxumPlatformHttpClient::new() + .send(PlatformHttpRequest::new(request, "test_backend")) + .await + .expect("should proxy raw response") + .response; + + assert!( + response.headers().get(header::TRANSFER_ENCODING).is_none(), + "should strip transfer-encoding" + ); + assert!( + response.headers().get(header::CONNECTION).is_none(), + "should strip connection" + ); + assert!( + response.headers().get("keep-alive").is_none(), + "should strip keep-alive" + ); + assert!( + response.headers().get("x-remove-me").is_none(), + "should strip headers named by connection" + ); + assert_eq!( + response + .headers() + .get("x-preserve-me") + .and_then(|value| value.to_str().ok()), + Some("application-header"), + "should preserve end-to-end headers" + ); + assert_eq!( + response.into_body().into_bytes().as_ref(), + b"ok", + "should preserve decoded response body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn select_attributes_failed_backend_name() { + // Bind and immediately drop a listener so the port is closed — the + // request fails with connection refused. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind probe listener"); + let addr = listener.local_addr().expect("should read local address"); + drop(listener); + + let request = edgezero_core::http::request_builder() + .uri(format!("http://{addr}/")) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let client = AxumPlatformHttpClient::new(); + let pending = client + .send_async(PlatformHttpRequest::new(request, "failing_backend")) + .await + .expect("should spawn async request"); + + let result = client + .select(vec![pending]) + .await + .expect("select should surface the failure via ready, not a fatal error"); + + assert!( + result.ready.is_err(), + "request to a closed port should fail" + ); + assert_eq!( + result.failed_backend_name.as_deref(), + Some("failing_backend"), + "failed provider must be attributed to its backend so the orchestrator can record BidStatus::Error" + ); + assert!( + result.remaining.is_empty(), + "no remaining requests expected" + ); + } + + async fn serve_raw_response(response: &'static [u8]) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind raw HTTP test server"); + let addr = listener.local_addr().expect("should read local address"); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("should accept request"); + let mut request = [0; 1024]; + let _ = stream + .read(&mut request) + .await + .expect("should read request"); + stream + .write_all(response) + .await + .expect("should write response"); + }); + + format!("http://{addr}/") + } +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs new file mode 100644 index 000000000..f00b694d6 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -0,0 +1,270 @@ +//! Integration tests for the Axum dev server. +//! +//! Uses `EdgeZeroAxumService` directly (no live TCP server) so tests remain fast +//! and self-contained. Each test builds the full `TrustedServerApp` router and +//! drives it through the Tower `Service` interface. + +use axum::body::Body as AxumBody; +use axum::http::Request; +use edgezero_adapter_axum::EdgeZeroAxumService; +use tower::{Service as _, ServiceExt as _}; +use trusted_server_adapter_axum::app::TrustedServerApp; + +fn make_service() -> EdgeZeroAxumService { + // Drive the router with explicit test settings: the settings baked into + // the binary contain placeholder secrets that `get_settings()` rejects + // by design, which would turn every route into a startup error page. + let settings = trusted_server_core::settings::Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse route test settings"); + + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build router from test settings"); + EdgeZeroAxumService::new(router) +} + +// --------------------------------------------------------------------------- +// Route smoke tests — verify routing (not business logic correctness) +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn discovery_endpoint_is_routed() { + // Verifies the route exists — 5xx from missing signing keys is acceptable; + // 404 is not (that would mean the route was not registered). + let mut svc = make_service(); + + let req = Request::builder() + .method("GET") + .uri("/.well-known/trusted-server.json") + .body(AxumBody::empty()) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_ne!( + resp.status().as_u16(), + 404, + "discovery endpoint must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn verify_signature_endpoint_is_routed() { + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/verify-signature") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_ne!( + resp.status().as_u16(), + 404, + "verify-signature must be routed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_is_routed() { + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + // The admin handler is a fixed 501 responder with no I/O, and the test + // settings protect only ^/_ts/admin, so this path reaches the handler. + assert_eq!( + resp.status().as_u16(), + 501, + "admin/keys/rotate must reach the not-supported handler" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_deactivate_key_is_routed() { + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/admin/keys/deactivate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + // Same fixed 501 contract as admin/keys/rotate. + assert_eq!( + resp.status().as_u16(), + 501, + "admin/keys/deactivate must reach the not-supported handler" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_rotate_key_returns_non_5xx() { + // Admin routes return 501 Not Implemented on the Axum dev server (store + // writes are unsupported). Auth middleware may short-circuit with 4xx + // before reaching the handler. Either way, no panic or unhandled 500. + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + + assert_eq!( + status, 501, + "admin/keys/rotate must return the fixed not-supported status" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_prefix_is_handled_not_5xx() { + let mut svc = make_service(); + + // /static/tsjs= is a GET /{*rest} catch-all path. The handler returns 404 + // for an unknown hash, which is correct application behaviour (not a routing 404). + // This test verifies the handler is reached (no 5xx/panic) and that routing works. + let req = Request::builder() + .method("GET") + .uri("/static/tsjs=0000000000000000") + .body(AxumBody::empty()) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + + assert!( + status < 500, + "tsjs catch-all handler must not return 5xx: got {status}" + ); +} + +// --------------------------------------------------------------------------- +// Middleware tests +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn finalize_middleware_sets_geo_unavailable_header() { + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/verify-signature") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_eq!( + resp.headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "finalize middleware should set X-Geo-Info-Available: false on every response" + ); +} + +// --------------------------------------------------------------------------- +// Basic-auth gate test +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_route_returns_non_404_non_5xx() { + let mut svc = make_service(); + + let req = Request::builder() + .method("POST") + .uri("/admin/keys/rotate") + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + let status = resp.status().as_u16(); + + assert_ne!(status, 404, "admin route must be routed"); + // 501 Not Implemented is the designed dev-server response for admin key + // routes; only an unhandled 500 indicates a panic or missing handler. + assert_ne!(status, 500, "admin route must not panic: got {status}"); +} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index c101746c0..2a34e2e57 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -6,10 +6,13 @@ //! [`startup_error_router`] returns a bare router without middleware. //! Builds the [`AppState`] once per Wasm instance. //! -//! `EdgeZero`'s current Fastly request context exposes client IP but not TLS -//! protocol or cipher metadata. `edgezero_main` injects a trusted `fastly-ssl` -//! header after stripping client-spoofable headers, so [`detect_request_scheme`] -//! in `http_util` can still derive the correct scheme for HTTPS traffic. +//! TLS protocol and cipher metadata is captured from the raw [`fastly::Request`] +//! in `main.rs` before the request is consumed by `dispatch_with_config_handle`, +//! then injected as trusted internal headers (`x-ts-tls-protocol`, +//! `x-ts-tls-cipher`) after stripping client-spoofable forwarded headers. +//! [`build_per_request_services`] reads those internal headers to populate +//! [`ClientInfo`] so that [`crate::http_util::RequestInfo::from_request`] detects +//! HTTPS correctly on the `EdgeZero` path. //! //! # Route inventory //! @@ -78,6 +81,7 @@ use std::sync::Arc; +use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use edgezero_adapter_fastly::FastlyRequestContext; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; @@ -93,7 +97,6 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; -use trusted_server_core::ec::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -104,7 +107,9 @@ use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, }; -use trusted_server_core::publisher::{handle_publisher_request, handle_tsjs_dynamic}; +use trusted_server_core::publisher::{ + buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, +}; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, handle_verify_signature, @@ -193,13 +198,27 @@ pub(crate) fn runtime_services_for_consent_route( /// Construct per-request [`RuntimeServices`] from the `EdgeZero` request context. /// -/// Extracts the client IP address from the [`FastlyRequestContext`] extension -/// inserted by `edgezero_adapter_fastly::dispatch`. TLS metadata is not -/// available through the `EdgeZero` context; scheme detection relies on the -/// trusted `fastly-ssl` header injected by `edgezero_main` after sanitization. +/// Extracts the client IP from the [`FastlyRequestContext`] extension inserted by +/// `edgezero_adapter_fastly::dispatch`. TLS protocol and cipher are read from the +/// trusted internal headers `x-ts-tls-protocol` and `x-ts-tls-cipher` injected by +/// `edgezero_main` in `main.rs` after stripping client-spoofable forwarded headers. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + let tls_protocol = ctx + .request() + .headers() + .get("x-ts-tls-protocol") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + let tls_cipher = ctx + .request() + .headers() + .get("x-ts-tls-cipher") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) @@ -209,8 +228,8 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .geo(Arc::new(FastlyPlatformGeo)) .client_info(ClientInfo { client_ip, - tls_protocol: None, - tls_cipher: None, + tls_protocol, + tls_cipher, }) .build() } @@ -245,7 +264,11 @@ fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { #[derive(Clone)] pub(crate) struct EcFinalizeState { pub(crate) ec_context: EcContext, - pub(crate) finalize_kv_graph: Option, + /// Whether EC finalization may write to the KV identity graph. + /// `KvIdentityGraph` wraps a non-`Sync` `dyn EcKvStore` and cannot ride + /// in response extensions, so `edgezero_main` rebuilds the graph from + /// settings when this is set. + pub(crate) use_finalize_kv: bool, pub(crate) eids_cookie: Option, pub(crate) sharedid_cookie: Option, pub(crate) is_real_browser: bool, @@ -277,7 +300,7 @@ impl EcRequestState { fn into_finalize_state(self) -> EcFinalizeState { EcFinalizeState { ec_context: self.ec_context, - finalize_kv_graph: self.finalize_kv_graph, + use_finalize_kv: self.finalize_kv_graph.is_some(), eids_cookie: self.eids_cookie, sharedid_cookie: self.sharedid_cookie, is_real_browser: self.is_real_browser, @@ -485,7 +508,7 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> // ec_finalize_response with a default EC context and no finalize KV graph. response.extensions_mut().insert(EcFinalizeState { ec_context: EcContext::default(), - finalize_kv_graph: None, + use_finalize_kv: false, eids_cookie, sharedid_cookie, is_real_browser, @@ -519,7 +542,7 @@ async fn dispatch_fallback(state: &AppState, services: &RuntimeServices, req: Re } else if state.registry.has_route(&method, &path) { // Integration-proxy responses are not bounded by publisher.max_buffered_body_bytes. // Only the handle_publisher_request branch below routes through - // resolve_publisher_response_buffered. Integration responses are small in practice + // buffer_publisher_response. Integration responses are small in practice // and the EdgeZero flag is off by default; extend the cap here if that changes. state .registry @@ -559,11 +582,7 @@ async fn dispatch_fallback(state: &AppState, services: &RuntimeServices, req: Re handle_publisher_request(&state.settings, &state.registry, &publisher_services, req) .await .and_then(|pub_response| { - crate::resolve_publisher_response_buffered( - pub_response, - &state.settings, - &state.registry, - ) + buffer_publisher_response(pub_response, &state.settings, &state.registry) }) } Err(e) => Err(e), @@ -815,12 +834,18 @@ impl Hooks for TrustedServerApp { #[cfg(test)] mod tests { - use super::{build_state_from_settings, startup_error_router, AppState, TrustedServerApp}; + use super::{ + build_per_request_services, build_state_from_settings, startup_error_router, AppState, + TrustedServerApp, + }; + use std::collections::HashMap; use std::sync::Arc; use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; use edgezero_core::http::{header, request_builder, Method, StatusCode}; + use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; use error_stack::Report; use futures::executor::block_on; @@ -885,6 +910,113 @@ mod tests { .expect("should build request") } + fn minimal_state() -> Arc { + // Build from explicit test settings: the settings baked into the + // binary contain placeholder secrets that `get_settings()` rejects + // by design. + app_state_for_settings(settings_with_missing_consent_store()) + } + + // --------------------------------------------------------------------------- + // build_per_request_services TLS header tests + // --------------------------------------------------------------------------- + + #[test] + fn build_per_request_services_reads_tls_protocol_from_internal_header() { + let state = minimal_state(); + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-ts-tls-protocol", "TLSv1.3") + .body(Body::empty()) + .expect("should build request with TLS header"); + let ctx = RequestContext::new(req, PathParams::new(HashMap::new())); + + let services = build_per_request_services(&state, &ctx); + + assert_eq!( + services.client_info().tls_protocol.as_deref(), + Some("TLSv1.3"), + "should read TLS protocol from x-ts-tls-protocol header" + ); + } + + #[test] + fn build_per_request_services_reads_tls_cipher_from_internal_header() { + let state = minimal_state(); + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-ts-tls-cipher", "ECDHE-RSA-AES256-GCM-SHA384") + .body(Body::empty()) + .expect("should build request with TLS cipher header"); + let ctx = RequestContext::new(req, PathParams::new(HashMap::new())); + + let services = build_per_request_services(&state, &ctx); + + assert_eq!( + services.client_info().tls_cipher.as_deref(), + Some("ECDHE-RSA-AES256-GCM-SHA384"), + "should read TLS cipher from x-ts-tls-cipher header" + ); + } + + #[test] + fn build_per_request_services_tls_none_when_headers_absent() { + let state = minimal_state(); + let req = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("should build request without TLS headers"); + let ctx = RequestContext::new(req, PathParams::new(HashMap::new())); + + let services = build_per_request_services(&state, &ctx); + + assert!( + services.client_info().tls_protocol.is_none(), + "tls_protocol should be None when x-ts-tls-protocol header is absent" + ); + assert!( + services.client_info().tls_cipher.is_none(), + "tls_cipher should be None when x-ts-tls-cipher header is absent" + ); + } + + #[test] + fn build_per_request_services_does_not_trust_client_supplied_tls_headers() { + // Regression: edgezero_main must strip x-ts-tls-* before dispatch so a + // plain-HTTP client cannot spoof HTTPS scheme detection by injecting these + // headers. After the strip+set logic runs, build_per_request_services + // should see no TLS data on a request where the Fastly SDK returned None. + // + // This test validates the contract at the build_per_request_services + // boundary: if the header is absent (because edgezero_main stripped it), + // tls_protocol is None. + let state = minimal_state(); + // Simulate a request that arrived with NO internal header + // (edgezero_main already stripped and did not re-inject because + // req.get_tls_protocol() returned None on a plain-HTTP connection). + let req = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("should build plain-HTTP request"); + let ctx = RequestContext::new(req, PathParams::new(HashMap::new())); + + let services = build_per_request_services(&state, &ctx); + + assert!( + services.client_info().tls_protocol.is_none(), + "tls_protocol must be None after edgezero_main strips client-supplied header \ + on a plain-HTTP connection — spoofed HTTPS would affect scheme detection" + ); + assert!( + services.client_info().tls_cipher.is_none(), + "tls_cipher must be None for the same reason" + ); + } + fn test_router() -> RouterService { let settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-fastly/src/ec_kv.rs b/crates/trusted-server-adapter-fastly/src/ec_kv.rs new file mode 100644 index 000000000..842fa393b --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/ec_kv.rs @@ -0,0 +1,140 @@ +//! Fastly KV Store implementation of the core [`EcKvStore`] primitives. +//! +//! Maps the platform-neutral identity-graph store operations onto the +//! Fastly KV Store API, including generation markers for compare-and-swap +//! writes (`if_generation_match`). + +use error_stack::{Report, ResultExt}; +use fastly::kv_store::{InsertMode, KVStore}; +use trusted_server_core::ec::kv_backend::{ + EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; +use trusted_server_core::error::TrustedServerError; + +/// Fastly KV Store backend for the EC identity graph. +#[derive(Debug, Clone)] +pub struct FastlyEcKvStore { + store_name: String, +} + +impl FastlyEcKvStore { + /// Creates a backend for the named Fastly KV store. + #[must_use] + pub fn new(store_name: impl Into) -> Self { + Self { + store_name: store_name.into(), + } + } + + /// Opens the underlying Fastly KV store. + fn open_store(&self) -> Result> { + KVStore::open(&self.store_name) + .change_context(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: "Failed to open KV store".to_owned(), + })? + .ok_or_else(|| { + Report::new(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: "KV store not found".to_owned(), + }) + }) + } +} + +impl EcKvStore for FastlyEcKvStore { + fn store_name(&self) -> &str { + &self.store_name + } + + fn lookup(&self, key: &str) -> Result, Report> { + let store = self.open_store()?; + let mut response = match store.lookup(key) { + Ok(resp) => resp, + Err(fastly::kv_store::KVStoreError::ItemNotFound) => return Ok(None), + Err(err) => { + return Err( + Report::new(err).change_context(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: format!("Failed to read key '{key}'"), + }), + ); + } + }; + + let generation = response.current_generation(); + let metadata = response.metadata().map(|bytes| bytes.to_vec()); + let body = response.take_body_bytes(); + + Ok(Some(EcKvLookup { + body, + metadata, + generation, + })) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let store = self.open_store()?; + let mut builder = store + .build_insert() + .metadata(write.metadata) + .time_to_live(write.ttl); + + builder = match write.mode { + EcKvWriteMode::Add => builder.mode(InsertMode::Add), + EcKvWriteMode::Overwrite => builder, + EcKvWriteMode::IfGenerationMatch(generation) => builder.if_generation_match(generation), + }; + + match builder.execute(key, write.body) { + Ok(()) => Ok(EcKvWriteOutcome::Written), + Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + Ok(EcKvWriteOutcome::PreconditionFailed) + } + Err(err) => Err( + Report::new(err).change_context(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: format!("Failed to write entry for key '{key}'"), + }), + ), + } + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + let store = self.open_store()?; + let page = store + .build_list() + .prefix(prefix) + .limit(limit) + .execute() + .change_context(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: format!( + "Failed to list keys with prefix '{}'", + &prefix[..prefix.len().min(8)], + ), + })?; + + #[allow(clippy::cast_possible_truncation)] + let count = page.keys().len() as u32; + Ok(count) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let store = self.open_store()?; + store + .delete(key) + .change_context(TrustedServerError::KvStore { + store_name: self.store_name.clone(), + message: format!("Failed to delete key '{key}'"), + }) + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index aa8d0ac79..43dda51e7 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,5 +1,3 @@ -use std::io::Write; - use std::sync::Arc; use edgezero_adapter_fastly::{into_core_request, FastlyConfigStore}; @@ -26,7 +24,6 @@ use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ build_pull_sync_context, dispatch_pull_sync, PullSyncContext, }; -use trusted_server_core::ec::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; @@ -53,18 +50,22 @@ use trusted_server_core::settings_data::get_settings; mod app; mod backend; mod compat; +mod ec_kv; mod error; mod logging; mod management_api; mod middleware; mod platform; +mod rate_limiter; #[cfg(test)] mod route_tests; use crate::app::{build_state, TrustedServerApp}; +use crate::ec_kv::FastlyEcKvStore; use crate::error::to_error_response; use crate::middleware::{apply_finalize_headers, resolve_geo_for_response, HEADER_X_TS_FINALIZED}; use crate::platform::{build_runtime_services, FastlyPlatformGeo}; +use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; const EDGEZERO_ENABLED_KEY: &str = "edgezero_enabled"; @@ -231,6 +232,20 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { // Capture client IP before the request is consumed by dispatch. let client_ip = req.get_client_ip_addr(); + // Strip any client-supplied x-ts-tls-* headers before injecting the + // trusted values from the Fastly SDK. Without this, a plain-HTTP request + // carrying X-TS-TLS-Protocol: TLSv1.3 would sail through and cause + // detect_request_scheme to return "https", spoofing cookie Secure and + // URL rewriting. Must run after sanitize_fastly_forwarded_headers. + req.remove_header("x-ts-tls-protocol"); + req.remove_header("x-ts-tls-cipher"); + if let Some(proto) = req.get_tls_protocol() { + req.set_header("x-ts-tls-protocol", proto); + } + if let Some(cipher) = req.get_tls_cipher_openssl_name() { + req.set_header("x-ts-tls-cipher", cipher); + } + // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. The standard dispatch helpers // (dispatch_with_config_handle, etc.) convert through fastly::Response using @@ -293,10 +308,18 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { match get_settings() { Ok(settings) => match PartnerRegistry::from_config(&settings.ec.partners) { Ok(partner_registry) => { + // KvIdentityGraph cannot ride in response extensions + // (non-Sync dyn EcKvStore), so rebuild it from settings + // when the handler enabled the KV write path. + let finalize_kv_graph = if ec_state.use_finalize_kv { + maybe_identity_graph(&settings) + } else { + None + }; ec_finalize_response( &settings, &ec_state.ec_context, - ec_state.finalize_kv_graph.as_ref(), + finalize_kv_graph.as_ref(), &partner_registry, ec_state.eids_cookie.as_deref(), ec_state.sharedid_cookie.as_deref(), @@ -852,7 +875,11 @@ async fn route_request( } pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option { - settings.ec.ec_store.as_ref().map(KvIdentityGraph::new) + settings + .ec + .ec_store + .as_ref() + .map(|store_name| KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) } fn run_pull_sync_after_send( @@ -873,69 +900,6 @@ fn run_pull_sync_after_send( dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context, services); } -struct BoundedWriter { - inner: Vec, - limit: usize, -} - -impl BoundedWriter { - fn new(limit: usize) -> Self { - Self { - inner: Vec::new(), - limit, - } - } - - fn into_inner(self) -> Vec { - self.inner - } -} - -impl Write for BoundedWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if self.inner.len() + buf.len() > self.limit { - return Err(std::io::Error::other( - "publisher body exceeded maximum buffered size", - )); - } - self.inner.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -pub(crate) fn resolve_publisher_response_buffered( - publisher_response: PublisherResponse, - settings: &Settings, - integration_registry: &IntegrationRegistry, -) -> Result> { - match publisher_response { - PublisherResponse::Buffered(response) => Ok(response), - PublisherResponse::Stream { - mut response, - body, - params, - } => { - let mut output = BoundedWriter::new(settings.publisher.max_buffered_body_bytes); - stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; - let bytes = output.into_inner(); - response.headers_mut().insert( - header::CONTENT_LENGTH, - HeaderValue::from(bytes.len() as u64), - ); - *response.body_mut() = EdgeBody::from(bytes); - Ok(response) - } - PublisherResponse::PassThrough { mut response, body } => { - *response.body_mut() = body; - Ok(response) - } - } -} - /// Applies all standard response headers: geo, version, staging, and configured headers. /// /// Called from every response path (including auth early-returns) so that all @@ -973,7 +937,7 @@ pub(crate) fn require_identity_graph( message: "ec.ec_store is not configured".to_owned(), }) })?; - Ok(KvIdentityGraph::new(store_name)) + Ok(KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) } /// Extracts a named cookie value from the request's `Cookie` header. diff --git a/crates/trusted-server-adapter-fastly/src/rate_limiter.rs b/crates/trusted-server-adapter-fastly/src/rate_limiter.rs new file mode 100644 index 000000000..67520925e --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/rate_limiter.rs @@ -0,0 +1,132 @@ +//! Fastly Edge Rate Limiting implementation of the core [`RateLimiter`] trait. + +use error_stack::Report; +use fastly::erl::{CounterDuration, RateCounter}; +use trusted_server_core::ec::rate_limiter::RateLimiter; +use trusted_server_core::error::TrustedServerError; + +/// Name of the Fastly rate counter resource used by sync rate limiting. +pub const RATE_COUNTER_NAME: &str = "counter_store"; + +fn hourly_limit_to_per_minute_limit(hourly_limit: u32) -> u32 { + if hourly_limit == 0 { + return 0; + } + + let per_minute_limit = hourly_limit.saturating_add(59) / 60; + per_minute_limit.max(1) +} + +#[cfg(test)] +fn effective_hourly_limit(hourly_limit: u32) -> u32 { + hourly_limit_to_per_minute_limit(hourly_limit).saturating_mul(60) +} + +/// Fastly Edge Rate Limiting implementation of [`RateLimiter`]. +pub struct FastlyRateLimiter { + counter: RateCounter, +} + +impl FastlyRateLimiter { + /// Creates a new rate limiter backed by the named Fastly rate counter. + #[must_use] + pub fn new(counter_name: &str) -> Self { + Self { + counter: RateCounter::open(counter_name), + } + } +} + +impl RateLimiter for FastlyRateLimiter { + fn exceeded(&self, key: &str, hourly_limit: u32) -> Result> { + // Fastly's public rate-counter API currently exposes windows up to 60s. + // Approximate the story's 1h limit by converting to a per-minute budget. + // + // Follow-up: move to exact 1-hour enforcement once platform counters + // expose longer windows or we add a dedicated KV-backed hour bucket. + let per_minute_limit = hourly_limit_to_per_minute_limit(hourly_limit); + if per_minute_limit == 0 { + return Ok(true); + } + + let current = self + .counter + .lookup_count(key, CounterDuration::SixtySecs) + .map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: RATE_COUNTER_NAME.to_owned(), + message: format!("Failed to read sync rate counter: {e}"), + }) + })?; + + if current >= per_minute_limit { + return Ok(true); + } + + self.counter.increment(key, 1).map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: RATE_COUNTER_NAME.to_owned(), + message: format!("Failed to increment sync rate counter: {e}"), + }) + })?; + + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_hourly_limit_denies_all() { + assert_eq!( + hourly_limit_to_per_minute_limit(0), + 0, + "should preserve deny-all zero limit" + ); + assert_eq!( + effective_hourly_limit(0), + 0, + "should preserve effective zero limit" + ); + } + + #[test] + fn hourly_limit_rounds_up_to_whole_requests_per_minute() { + assert_eq!( + hourly_limit_to_per_minute_limit(65), + 2, + "should round 65/hr up to 2/min" + ); + assert_eq!( + effective_hourly_limit(65), + 120, + "should expose the resulting effective hourly budget" + ); + } + + #[test] + fn small_positive_hourly_limits_round_up_to_sixty_per_hour() { + assert_eq!( + hourly_limit_to_per_minute_limit(1), + 1, + "should round any positive sub-60 hourly limit up to 1/min" + ); + assert_eq!( + effective_hourly_limit(1), + 60, + "should enforce a 60/hr effective minimum with the current counter window" + ); + } + + #[test] + fn effective_hourly_limit_stays_within_hourly_plus_fifty_nine() { + for hourly_limit in [1, 10, 59, 60, 61, 65, 119, 120, 121, 600] { + assert!( + effective_hourly_limit(hourly_limit) <= hourly_limit.saturating_add(59), + "effective hourly limit should never overshoot by more than 59 requests" + ); + } + } +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 4036ffbb4..2c8303ec5 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -22,11 +22,6 @@ config = { workspace = true } cookie = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } -# Still required by ec/kv.rs (KV Store compare-and-swap via InsertMode + -# generation preconditions, which the EdgeZero KvStore trait does not expose -# yet) and ec/rate_limiter.rs (fastly::erl Edge Rate Limiting). Removal is -# deferred until EdgeZero grows equivalent APIs — tracked in issue #495. -fastly = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } hex = { workspace = true } @@ -74,9 +69,6 @@ toml = { workspace = true } url = { workspace = true } validator = { workspace = true } -[features] -default = [] - [dev-dependencies] criterion = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 945ad7888..16f7e0046 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -422,7 +422,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_without_registry() { - let kv = KvIdentityGraph::new("test_store"); + let kv = KvIdentityGraph::failing("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); @@ -435,7 +435,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_when_consent_denied() { - let kv = KvIdentityGraph::new("test_store"); + let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::Unknown, Some(&ec_id)); @@ -449,7 +449,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_none_when_no_ec() { - let kv = KvIdentityGraph::new("test_store"); + let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); @@ -462,7 +462,7 @@ mod tests { #[test] fn resolve_auction_eids_returns_empty_on_kv_miss() { - let kv = KvIdentityGraph::new("nonexistent_store"); + let kv = KvIdentityGraph::failing("nonexistent_store"); let registry = PartnerRegistry::empty(); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 0dec1daec..dd9681b23 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -66,6 +66,11 @@ pub const INTERNAL_HEADERS: &[&str] = &[ "x-request-id", "x-compress-hint", "x-debug-fastly-pop", + // Trusted TLS metadata injected by the Fastly EdgeZero entry point. + // Injected after stripping spoofable forwarded headers so they cannot be + // client-supplied. Must not be forwarded to downstream origins. + "x-ts-tls-protocol", + "x-ts-tls-cipher", ]; // Consent-related cookie names diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 8888a8472..ca24f5ba2 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -509,7 +509,7 @@ mod tests { #[test] fn handle_batch_sync_rejects_missing_auth() { - let kv = KvIdentityGraph::new("test_store"); + let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let limiter = MockRateLimiter { should_exceed: false, diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 44c9f0fbc..2a3a9f91d 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -465,7 +465,7 @@ mod tests { #[test] fn handle_identify_rejects_missing_bearer_token() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let registry = PartnerRegistry::empty(); let req = Request::builder() .method("GET") @@ -503,7 +503,7 @@ mod tests { #[test] fn handle_identify_rejects_invalid_bearer_token() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() @@ -528,7 +528,7 @@ mod tests { #[test] fn handle_identify_denied_consent_returns_403() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() @@ -560,7 +560,7 @@ mod tests { #[test] fn handle_identify_without_ec_returns_204() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() @@ -585,7 +585,7 @@ mod tests { #[test] fn handle_identify_kv_failure_sets_degraded_true() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() @@ -636,7 +636,7 @@ mod tests { #[test] fn handle_identify_denies_mismatched_browser_origin() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() @@ -662,7 +662,7 @@ mod tests { #[test] fn handle_identify_allows_browser_origin_and_reflects_cors_headers() { let settings = create_test_settings(); - let kv = KvIdentityGraph::new("missing_store"); + let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let req = Request::builder() diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index ccd6b95a4..d6e3b6f4a 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -1,22 +1,27 @@ //! KV identity graph operations. //! -//! This module provides [`KvIdentityGraph`] which wraps a Fastly KV Store -//! and implements the read-modify-write operations for the EC identity graph. +//! This module provides [`KvIdentityGraph`] which implements the +//! read-modify-write operations for the EC identity graph on top of the +//! platform-neutral [`EcKvStore`] primitives. The platform adapter supplies +//! the concrete store backend (e.g. the Fastly KV Store implementation in +//! `trusted-server-adapter-fastly`). //! //! All methods return `Result` — callers decide whether to swallow errors //! (organic request paths) or propagate them (sync endpoints). See the //! per-operation error handling policy in the spec §7.5. use std::collections::BTreeMap; +use std::fmt; +use std::sync::Arc; use std::time::Duration; use error_stack::{Report, ResultExt}; -use fastly::kv_store::{InsertMode, KVStore}; use crate::error::TrustedServerError; use super::current_timestamp; use super::generation::ec_hash; +use super::kv_backend::{EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; use super::kv_types::{KvEntry, KvMetadata, KvNetwork}; use super::log_id; @@ -98,46 +103,46 @@ fn apply_partner_id_updates(entry: &mut KvEntry, updates: &[PartnerIdUpdate]) -> changed } -/// Wraps a Fastly KV Store for EC identity graph operations. +/// EC identity graph on top of the platform KV store primitives. /// /// Each EC ID (`{64hex}.{6alnum}`) maps to a JSON-encoded [`KvEntry`] /// containing consent state, geo location, and accumulated partner IDs. /// /// Methods use optimistic concurrency (generation markers) for safe /// read-modify-write operations on concurrent requests. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct KvIdentityGraph { - store_name: String, + store: Arc, +} + +impl fmt::Debug for KvIdentityGraph { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KvIdentityGraph") + .field("store_name", &self.store.store_name()) + .finish() + } } impl KvIdentityGraph { - /// Creates a new identity graph backed by the named KV store. + /// Creates a new identity graph backed by the given store primitives. #[must_use] - pub fn new(store_name: impl Into) -> Self { + pub fn new(store: impl EcKvStore + 'static) -> Self { Self { - store_name: store_name.into(), + store: Arc::new(store), } } /// Returns the configured store name. #[must_use] pub fn store_name(&self) -> &str { - &self.store_name + self.store.store_name() } - /// Opens the underlying Fastly KV store. - fn open_store(&self) -> Result> { - KVStore::open(&self.store_name) - .change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: "Failed to open KV store".to_owned(), - })? - .ok_or_else(|| { - Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: "KV store not found".to_owned(), - }) - }) + fn kv_error(&self, message: String) -> Report { + Report::new(TrustedServerError::KvStore { + store_name: self.store_name().to_owned(), + message, + }) } /// Serializes an entry body and metadata for insertion. @@ -173,33 +178,12 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store open or read failure. pub fn get(&self, ec_id: &str) -> Result, Report> { - let store = self.open_store()?; - Self::lookup_entry(&store, &self.store_name, ec_id) - } - - fn lookup_entry( - store: &KVStore, - store_name: &str, - ec_id: &str, - ) -> Result, Report> { - let mut response = match store.lookup(ec_id) { - Ok(resp) => resp, - Err(fastly::kv_store::KVStoreError::ItemNotFound) => return Ok(None), - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: store_name.to_owned(), - message: format!("Failed to read key '{ec_id}'"), - }), - ); - } + let Some(lookup) = self.store.lookup(ec_id)? else { + return Ok(None); }; - let generation = response.current_generation(); - let body_bytes = response.take_body_bytes(); - let entry = Self::deserialize_entry(store_name, ec_id, &body_bytes)?; - - Ok(Some((entry, generation))) + let entry = Self::deserialize_entry(self.store_name(), ec_id, &lookup.body)?; + Ok(Some((entry, lookup.generation))) } fn deserialize_entry( @@ -223,7 +207,7 @@ impl KvIdentityGraph { Ok(entry) } - /// Reads only the metadata for an EC ID key (no body streaming). + /// Reads only the metadata for an EC ID key. /// /// Returns `Ok(None)` when the key does not exist or has no metadata. /// @@ -234,28 +218,17 @@ impl KvIdentityGraph { &self, ec_id: &str, ) -> Result, Report> { - let store = self.open_store()?; - let response = match store.lookup(ec_id) { - Ok(resp) => resp, - Err(fastly::kv_store::KVStoreError::ItemNotFound) => return Ok(None), - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!("Failed to read metadata for key '{ec_id}'"), - }), - ); - } + let Some(lookup) = self.store.lookup(ec_id)? else { + return Ok(None); }; - let meta_bytes = match response.metadata() { - Some(bytes) => bytes, - None => return Ok(None), + let Some(meta_bytes) = lookup.metadata else { + return Ok(None); }; let meta: KvMetadata = serde_json::from_slice(&meta_bytes).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), + store_name: self.store_name().to_owned(), message: format!("Failed to deserialize metadata for key '{ec_id}'"), })?; @@ -264,54 +237,41 @@ impl KvIdentityGraph { /// Creates a new entry. Fails if the key already exists. /// - /// Uses `InsertMode::Add` so concurrent creates for the same EC ID + /// Uses [`EcKvWriteMode::Add`] so concurrent creates for the same EC ID /// are safely rejected (only one wins). /// /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store error or if the - /// key already exists (`ItemPreconditionFailed`). + /// key already exists. pub fn create(&self, ec_id: &str, entry: &KvEntry) -> Result<(), Report> { - let store = self.open_store()?; - let (body, meta_str) = Self::serialize_entry(entry, &self.store_name)?; - let created = Self::try_insert_add(&store, ec_id, &body, &meta_str, &self.store_name)?; - if created { - Ok(()) - } else { - Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!("Key '{ec_id}' already exists"), - })) + let (body, meta_str) = Self::serialize_entry(entry, self.store_name())?; + match self.write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add)? { + EcKvWriteOutcome::Written => Ok(()), + EcKvWriteOutcome::PreconditionFailed => { + Err(self.kv_error(format!("Key '{ec_id}' already exists"))) + } } } - /// Low-level create using a pre-opened store and pre-serialized data. - /// - /// Returns `true` if the entry was created, `false` if the key already - /// exists (`ItemPreconditionFailed`). Other errors are propagated. - fn try_insert_add( - store: &KVStore, + /// Low-level write with shared error context. + fn write_entry( + &self, ec_id: &str, body: &str, meta_str: &str, - store_name: &str, - ) -> Result> { - match store - .build_insert() - .mode(InsertMode::Add) - .metadata(meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body) - { - Ok(()) => Ok(true), - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => Ok(false), - Err(err) => Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: store_name.to_owned(), - message: format!("Failed to create entry for key '{ec_id}'"), - }), - ), - } + ttl: Duration, + mode: EcKvWriteMode, + ) -> Result> { + self.store.insert( + ec_id, + EcKvWrite { + body, + metadata: meta_str, + ttl, + mode, + }, + ) } /// Creates a new entry, or overwrites an existing tombstone on re-consent. @@ -336,11 +296,12 @@ impl KvIdentityGraph { entry: &KvEntry, ) -> Result<(), Report> { // Serialize once and reuse across the fast path and CAS loop. - let store = self.open_store()?; - let (body, meta_str) = Self::serialize_entry(entry, &self.store_name)?; + let (body, meta_str) = Self::serialize_entry(entry, self.store_name())?; // Try create first — fast path for new entries. - if Self::try_insert_add(&store, ec_id, &body, &meta_str, &self.store_name)? { + if self.write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add)? + == EcKvWriteOutcome::Written + { return Ok(()); } @@ -368,22 +329,22 @@ impl KvIdentityGraph { let mut current_gen = generation; for attempt in 0..MAX_CAS_RETRIES { - match store - .build_insert() - .if_generation_match(current_gen) - .metadata(&meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body.as_str()) - { - Ok(()) => return Ok(()), - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(current_gen), + )? { + EcKvWriteOutcome::Written => return Ok(()), + EcKvWriteOutcome::PreconditionFailed => { log::debug!( "create_or_revive: CAS conflict on attempt {}/{MAX_CAS_RETRIES} for '{}'", attempt + 1, log_id(ec_id), ); // Re-read immediately to get a fresh generation. Sleeping in - // the CAS loop would block the Fastly Compute request worker. + // the CAS loop would block the edge compute request worker. match self.get(ec_id)? { Some((refreshed, gen)) => { if refreshed.consent.ok { @@ -395,26 +356,12 @@ impl KvIdentityGraph { None => return self.create(ec_id, entry), } } - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Failed to revive tombstone for key '{ec_id}' on attempt {}", - attempt + 1, - ), - }), - ); - } } } - Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "CAS conflict after {MAX_CAS_RETRIES} retries reviving tombstone for '{ec_id}'" - ), - })) + Err(self.kv_error(format!( + "CAS conflict after {MAX_CAS_RETRIES} retries reviving tombstone for '{ec_id}'" + ))) } /// Atomically merges multiple partner IDs into the existing entry. @@ -437,11 +384,8 @@ impl KvIdentityGraph { return Ok(()); } - let store = self.open_store()?; - for attempt in 0..MAX_CAS_RETRIES { - let (mut entry, generation) = match Self::lookup_entry(&store, &self.store_name, ec_id)? - { + let (mut entry, generation) = match self.get(ec_id)? { Some(pair) => pair, None => { log::info!( @@ -449,13 +393,10 @@ impl KvIdentityGraph { log_id(ec_id), updates.len(), ); - return Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Cannot upsert {} partner IDs for missing key '{ec_id}'", - updates.len(), - ), - })); + return Err(self.kv_error(format!( + "Cannot upsert {} partner IDs for missing key '{ec_id}'", + updates.len(), + ))); } }; @@ -467,30 +408,27 @@ impl KvIdentityGraph { log_id(ec_id), updates.len(), ); - return Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Cannot upsert {} partner IDs for withdrawn key '{ec_id}'", - updates.len(), - ), - })); + return Err(self.kv_error(format!( + "Cannot upsert {} partner IDs for withdrawn key '{ec_id}'", + updates.len(), + ))); } if !apply_partner_id_updates(&mut entry, updates) { return Ok(()); } - let (body, meta_str) = Self::serialize_entry(&entry, &self.store_name)?; - - match store - .build_insert() - .if_generation_match(generation) - .metadata(&meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body.as_str()) - { - Ok(()) => return Ok(()), - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; + + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + )? { + EcKvWriteOutcome::Written => return Ok(()), + EcKvWriteOutcome::PreconditionFailed => { log::debug!( "upsert_partner_ids: CAS conflict on attempt {}/{MAX_CAS_RETRIES} for '{}'", attempt + 1, @@ -498,27 +436,13 @@ impl KvIdentityGraph { ); // Retry immediately; sleeping here blocks the edge worker. } - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Failed to upsert {} partner IDs for key '{ec_id}'", - updates.len(), - ), - }), - ); - } } } - Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "CAS conflict after {MAX_CAS_RETRIES} retries upserting {} partner IDs for '{ec_id}'", - updates.len(), - ), - })) + Err(self.kv_error(format!( + "CAS conflict after {MAX_CAS_RETRIES} retries upserting {} partner IDs for '{ec_id}'", + updates.len(), + ))) } /// Atomically merges a partner ID into the existing entry. @@ -539,12 +463,6 @@ impl KvIdentityGraph { partner_id: &str, uid: &str, ) -> Result<(), Report> { - // Open store once for write operations. Note: `self.get()` opens - // its own handle internally — this is intentional since `KVStore::open` - // is a cheap name lookup, and keeping the read/write APIs independent - // simplifies the method signatures. - let store = self.open_store()?; - for attempt in 0..MAX_CAS_RETRIES { let (mut entry, generation) = match self.get(ec_id)? { Some(pair) => pair, @@ -553,12 +471,9 @@ impl KvIdentityGraph { "upsert_partner_id: no entry for '{}', rejecting partner upsert", log_id(ec_id) ); - return Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Cannot upsert partner '{partner_id}' for missing key '{ec_id}'" - ), - })); + return Err(self.kv_error(format!( + "Cannot upsert partner '{partner_id}' for missing key '{ec_id}'" + ))); } }; @@ -569,12 +484,9 @@ impl KvIdentityGraph { "upsert_partner_id: entry for '{}' is a tombstone, rejecting upsert", log_id(ec_id), ); - return Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Cannot upsert partner '{partner_id}' for withdrawn key '{ec_id}'" - ), - })); + return Err(self.kv_error(format!( + "Cannot upsert partner '{partner_id}' for withdrawn key '{ec_id}'" + ))); } if entry @@ -593,17 +505,17 @@ impl KvIdentityGraph { }, ); - let (body, meta_str) = Self::serialize_entry(&entry, &self.store_name)?; - - match store - .build_insert() - .if_generation_match(generation) - .metadata(&meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body.as_str()) - { - Ok(()) => return Ok(()), - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; + + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + )? { + EcKvWriteOutcome::Written => return Ok(()), + EcKvWriteOutcome::PreconditionFailed => { log::debug!( "upsert_partner_id: CAS conflict on attempt {}/{MAX_CAS_RETRIES} for '{}'", attempt + 1, @@ -612,25 +524,12 @@ impl KvIdentityGraph { // Loop will re-read on next iteration. Do not sleep here: // blocking sleeps burn edge compute while holding the request worker. } - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Failed to upsert partner '{partner_id}' for key '{ec_id}'" - ), - }), - ); - } } } - Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "CAS conflict after {MAX_CAS_RETRIES} retries upserting partner '{partner_id}' for '{ec_id}'" - ), - })) + Err(self.kv_error(format!( + "CAS conflict after {MAX_CAS_RETRIES} retries upserting partner '{partner_id}' for '{ec_id}'" + ))) } /// Upserts a partner ID only if the KV entry already exists. @@ -652,8 +551,6 @@ impl KvIdentityGraph { partner_id: &str, uid: &str, ) -> Result> { - let store = self.open_store()?; - for attempt in 0..MAX_CAS_RETRIES { let (mut entry, generation) = match self.get(ec_id)? { Some(pair) => pair, @@ -679,17 +576,17 @@ impl KvIdentityGraph { }, ); - let (body, meta_str) = Self::serialize_entry(&entry, &self.store_name)?; - - match store - .build_insert() - .if_generation_match(generation) - .metadata(&meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body.as_str()) - { - Ok(()) => return Ok(UpsertResult::Written), - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; + + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + )? { + EcKvWriteOutcome::Written => return Ok(UpsertResult::Written), + EcKvWriteOutcome::PreconditionFailed => { log::debug!( "upsert_partner_id_if_exists: CAS conflict on attempt {}/{MAX_CAS_RETRIES} for '{}'", attempt + 1, @@ -697,25 +594,12 @@ impl KvIdentityGraph { ); // Retry immediately; sleeping here blocks the edge worker. } - Err(err) => { - return Err( - Report::new(err).change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Failed to upsert partner '{partner_id}' for key '{ec_id}'" - ), - }), - ); - } } } - Err(Report::new(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "CAS conflict after {MAX_CAS_RETRIES} retries upserting partner '{partner_id}' for '{ec_id}'" - ), - })) + Err(self.kv_error(format!( + "CAS conflict after {MAX_CAS_RETRIES} retries upserting partner '{partner_id}' for '{ec_id}'" + ))) } /// Writes a withdrawal tombstone for consent enforcement. @@ -736,24 +620,27 @@ impl KvIdentityGraph { &self, ec_id: &str, ) -> Result<(), Report> { - let store = self.open_store()?; let entry = KvEntry::tombstone(current_timestamp()); - let (body, meta_str) = Self::serialize_entry(&entry, &self.store_name)?; - - store - .build_insert() - .metadata(&meta_str) - .time_to_live(TOMBSTONE_TTL) - .execute(ec_id, body) - .change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), + let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; + + match self.write_entry( + ec_id, + &body, + &meta_str, + TOMBSTONE_TTL, + EcKvWriteMode::Overwrite, + ) { + Ok(_) => Ok(()), + Err(report) => Err(report.change_context(TrustedServerError::KvStore { + store_name: self.store_name().to_owned(), message: format!("Failed to write tombstone for key '{ec_id}'"), - }) + })), + } } /// Counts the number of keys sharing the same EC hash prefix. /// - /// Uses the Fastly KV list API with a prefix filter, limited to + /// Uses the platform KV list API with a prefix filter, limited to /// [`CLUSTER_LIST_LIMIT`] keys. If the limit is reached, the count /// is capped — the exact number beyond the limit is not meaningful /// for disambiguation. @@ -765,27 +652,11 @@ impl KvIdentityGraph { &self, hash_prefix: &str, ) -> Result> { - let store = self.open_store()?; - - // Request a single page of up to CLUSTER_LIST_LIMIT keys. // The prefix ensures we only match EC IDs derived from the same - // IP+passphrase (i.e. same 64-hex hash). - let page = store - .build_list() - .prefix(hash_prefix) - .limit(CLUSTER_LIST_LIMIT) - .execute() - .change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!( - "Failed to list keys with prefix '{}'", - &hash_prefix[..hash_prefix.len().min(8)], - ), - })?; - - #[allow(clippy::cast_possible_truncation)] - let count = page.keys().len() as u32; - Ok(count) + // IP+passphrase (i.e. same 64-hex hash). The backend already attaches + // store context to list failures, so propagate without re-wrapping. + self.store + .count_keys_with_prefix(hash_prefix, CLUSTER_LIST_LIMIT) } /// Evaluates the cluster size for an EC entry. @@ -795,8 +666,8 @@ impl KvIdentityGraph { /// their 24-hour withdrawal TTL is not extended. Otherwise, counts the /// number of keys sharing the same hash prefix via /// [`count_hash_prefix_keys`](Self::count_hash_prefix_keys) and writes the - /// result back to the entry. The CAS write is best-effort — on conflict, - /// the computed value is still returned. + /// result back to the entry. The CAS write is best-effort — on conflict + /// or write failure, the computed value is still returned. /// /// # Errors /// @@ -839,28 +710,27 @@ impl KvIdentityGraph { network.cluster_size = Some(cluster_size); updated_entry.network = Some(network); - let store = self.open_store()?; - let (body, meta_str) = Self::serialize_entry(&updated_entry, &self.store_name)?; - - match store - .build_insert() - .if_generation_match(generation) - .metadata(&meta_str) - .time_to_live(ENTRY_TTL) - .execute(ec_id, body.as_str()) - { - Ok(()) => {} - Err(fastly::kv_store::KVStoreError::ItemPreconditionFailed) => { + let (body, meta_str) = Self::serialize_entry(&updated_entry, self.store_name())?; + + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) { + Ok(EcKvWriteOutcome::Written) => {} + Ok(EcKvWriteOutcome::PreconditionFailed) => { log::debug!( "evaluate_cluster: CAS conflict writing cluster_size for '{}', \ returning computed value anyway", log_id(ec_id), ); } - Err(err) => { + Err(report) => { // Log but don't fail — the computed value is still valid. log::warn!( - "evaluate_cluster: failed to write cluster_size for '{}': {err}", + "evaluate_cluster: failed to write cluster_size for '{}': {report}", log_id(ec_id) ); } @@ -878,13 +748,28 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store error. pub fn delete(&self, ec_id: &str) -> Result<(), Report> { - let store = self.open_store()?; - store - .delete(ec_id) - .change_context(TrustedServerError::KvStore { - store_name: self.store_name.clone(), - message: format!("Failed to delete key '{ec_id}'"), - }) + // The backend's delete already attaches store context, so propagate + // without re-wrapping the same message. + self.store.delete(ec_id) + } +} + +#[cfg(test)] +impl KvIdentityGraph { + /// Test helper: a graph whose every store operation fails, mimicking a + /// missing or unreachable platform store. + pub(crate) fn failing(store_name: impl Into) -> Self { + Self::new(super::kv_backend::test_support::FailingEcKv::new( + store_name, + )) + } + + /// Test helper: a graph backed by an in-memory store with generation + /// tracking. + pub(crate) fn in_memory(store_name: impl Into) -> Self { + Self::new(super::kv_backend::test_support::InMemoryEcKv::new( + store_name, + )) } } @@ -981,6 +866,160 @@ mod tests { entry } + // ----------------------------------------------------------------------- + // CAS-conflict injection tests + // ----------------------------------------------------------------------- + + use crate::ec::kv_backend::test_support::InMemoryEcKv; + use crate::ec::kv_backend::EcKvLookup; + + /// [`EcKvStore`] wrapper that injects generation conflicts: the first + /// `conflicts_remaining` `IfGenerationMatch` inserts return + /// [`EcKvWriteOutcome::PreconditionFailed`] without writing, optionally + /// reviving the underlying entry to simulate a concurrent writer. + struct ConflictInjectingEcKv { + inner: InMemoryEcKv, + conflicts_remaining: std::sync::Mutex, + revive_on_conflict: bool, + } + + impl ConflictInjectingEcKv { + fn new(conflicts: u32, revive_on_conflict: bool) -> Self { + Self { + inner: InMemoryEcKv::new("conflict-store"), + conflicts_remaining: std::sync::Mutex::new(conflicts), + revive_on_conflict, + } + } + + fn seed_tombstone(&self, ec_id: &str) { + let (body, meta) = KvIdentityGraph::serialize_entry( + &KvEntry::tombstone(1000), + self.inner.store_name(), + ) + .expect("should serialize tombstone"); + self.inner + .insert( + ec_id, + EcKvWrite { + body: &body, + metadata: &meta, + ttl: TOMBSTONE_TTL, + mode: EcKvWriteMode::Add, + }, + ) + .expect("should seed tombstone"); + } + } + + impl EcKvStore for ConflictInjectingEcKv { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + fn lookup(&self, key: &str) -> Result, Report> { + self.inner.lookup(key) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + if matches!(write.mode, EcKvWriteMode::IfGenerationMatch(_)) { + let mut remaining = self + .conflicts_remaining + .lock() + .expect("should lock conflict counter"); + if *remaining > 0 { + *remaining -= 1; + if self.revive_on_conflict { + // Simulate a concurrent writer reviving the entry + // between this writer's read and its CAS write. + let (body, meta) = KvIdentityGraph::serialize_entry( + &live_entry(), + self.inner.store_name(), + ) + .expect("should serialize concurrent live entry"); + self.inner + .insert( + key, + EcKvWrite { + body: &body, + metadata: &meta, + ttl: ENTRY_TTL, + mode: EcKvWriteMode::Overwrite, + }, + ) + .expect("should apply concurrent revive"); + } + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + } + self.inner.insert(key, write) + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + self.inner.count_keys_with_prefix(prefix, limit) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key) + } + } + + #[test] + fn create_or_revive_retries_cas_conflict_and_succeeds() { + let store = ConflictInjectingEcKv::new(2, false); + store.seed_tombstone("ec-1"); + let graph = KvIdentityGraph::new(store); + + graph + .create_or_revive("ec-1", &live_entry()) + .expect("should revive after re-reading a fresh generation"); + + let (entry, _) = graph + .get("ec-1") + .expect("should read entry") + .expect("entry should exist"); + assert!( + entry.consent.ok, + "tombstone should be revived after CAS retries" + ); + } + + #[test] + fn create_or_revive_short_circuits_on_concurrent_revive() { + // Inject more conflicts than MAX_CAS_RETRIES so the only way the call + // can succeed is the concurrent-revive short-circuit on re-read. + let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, true); + store.seed_tombstone("ec-2"); + let graph = KvIdentityGraph::new(store); + + graph + .create_or_revive("ec-2", &live_entry()) + .expect("should return Ok when a concurrent writer already revived the entry"); + } + + #[test] + fn create_or_revive_errors_after_cas_exhaustion() { + let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); + store.seed_tombstone("ec-3"); + let graph = KvIdentityGraph::new(store); + + let err = graph + .create_or_revive("ec-3", &live_entry()) + .expect_err("should fail after exhausting CAS retries"); + assert!( + format!("{err}").contains("CAS conflict after"), + "should report CAS exhaustion as the terminal error" + ); + } + #[test] fn apply_partner_id_updates_returns_unchanged_for_empty_updates() { let mut entry = live_entry(); @@ -1076,7 +1115,7 @@ mod tests { #[test] fn evaluate_cluster_returns_stored_value_without_store_io() { - let kv = KvIdentityGraph::new("nonexistent_store_for_cluster_cache_test"); + let kv = KvIdentityGraph::failing("nonexistent_store_for_cluster_cache_test"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut entry = live_entry(); entry.network = Some(KvNetwork { @@ -1096,7 +1135,7 @@ mod tests { #[test] fn evaluate_cluster_skips_tombstone_without_store_io() { - let kv = KvIdentityGraph::new("nonexistent_store_for_tombstone_cluster_test"); + let kv = KvIdentityGraph::failing("nonexistent_store_for_tombstone_cluster_test"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let entry = KvEntry::tombstone(1000); @@ -1109,4 +1148,110 @@ mod tests { "should not evaluate or write cluster_size for tombstones" ); } + + #[test] + fn create_then_get_roundtrips_entry() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let entry = live_entry(); + + kv.create(&ec_id, &entry).expect("should create new entry"); + let (loaded, generation) = kv + .get(&ec_id) + .expect("should read entry back") + .expect("should find created entry"); + + assert!(loaded.consent.ok, "should preserve consent state"); + assert!(generation > 0, "should expose a generation marker"); + } + + #[test] + fn create_rejects_existing_key() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + let entry = live_entry(); + + kv.create(&ec_id, &entry).expect("should create new entry"); + let err = kv + .create(&ec_id, &entry) + .expect_err("should reject duplicate create"); + assert!( + format!("{err}").contains("already exists"), + "should report duplicate key" + ); + } + + #[test] + fn create_or_revive_revives_tombstone() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + + kv.create(&ec_id, &KvEntry::tombstone(1000)) + .expect("should create tombstone"); + kv.create_or_revive(&ec_id, &live_entry()) + .expect("should revive tombstone"); + + let (loaded, _) = kv + .get(&ec_id) + .expect("should read entry back") + .expect("should find revived entry"); + assert!(loaded.consent.ok, "should be live after revive"); + } + + #[test] + fn upsert_partner_id_if_exists_reports_missing_key() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + + let result = kv + .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .expect("should not error on missing key"); + assert_eq!(result, UpsertResult::NotFound); + } + + #[test] + fn upsert_partner_id_if_exists_writes_and_detects_unchanged() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + kv.create(&ec_id, &live_entry()).expect("should create"); + + let first = kv + .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .expect("should write partner id"); + assert_eq!(first, UpsertResult::Written); + + let second = kv + .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .expect("should detect unchanged uid"); + assert_eq!(second, UpsertResult::Unchanged); + } + + #[test] + fn upsert_partner_id_if_exists_rejects_tombstone() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + kv.create(&ec_id, &KvEntry::tombstone(1000)) + .expect("should create tombstone"); + + let result = kv + .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .expect("should not error on tombstone"); + assert_eq!(result, UpsertResult::ConsentWithdrawn); + } + + #[test] + fn write_withdrawal_tombstone_overwrites_live_entry() { + let kv = KvIdentityGraph::in_memory("test_store"); + let ec_id = format!("{}.ABC123", "a".repeat(64)); + kv.create(&ec_id, &live_entry()).expect("should create"); + + kv.write_withdrawal_tombstone(&ec_id) + .expect("should write tombstone"); + + let (loaded, _) = kv + .get(&ec_id) + .expect("should read entry back") + .expect("should find tombstone entry"); + assert!(!loaded.consent.ok, "should be withdrawn after tombstone"); + } } diff --git a/crates/trusted-server-core/src/ec/kv_backend.rs b/crates/trusted-server-core/src/ec/kv_backend.rs new file mode 100644 index 000000000..60f938291 --- /dev/null +++ b/crates/trusted-server-core/src/ec/kv_backend.rs @@ -0,0 +1,263 @@ +//! Platform-neutral KV primitives for the EC identity graph. +//! +//! [`super::kv::KvIdentityGraph`] owns all identity-graph business logic +//! (CAS retry loops, consent tombstone semantics, entry validation) and +//! delegates raw store access to an [`EcKvStore`] implementation provided +//! by the adapter crate (e.g. the Fastly KV Store backend in +//! `trusted-server-adapter-fastly`). +//! +//! This trait is intentionally narrow: lookup with a generation marker, +//! conditional insert, prefix counting, and delete. Conditional writes are +//! expressed through [`EcKvWriteMode`] so compare-and-swap loops stay in +//! core while the platform supplies the actual precondition mechanics. + +use std::time::Duration; + +use error_stack::Report; + +use crate::error::TrustedServerError; + +/// Result of a successful [`EcKvStore::lookup`] for an existing key. +#[derive(Debug, Clone)] +pub struct EcKvLookup { + /// Raw entry body bytes. + pub body: Vec, + /// Raw metadata bytes, when the platform stored any. + pub metadata: Option>, + /// Generation marker for subsequent compare-and-swap writes. + pub generation: u64, +} + +/// Write request passed to [`EcKvStore::insert`]. +#[derive(Debug, Clone, Copy)] +pub struct EcKvWrite<'a> { + /// Serialized entry body. + pub body: &'a str, + /// Serialized entry metadata. + pub metadata: &'a str, + /// Time-to-live for the written entry. + pub ttl: Duration, + /// Precondition mode for the write. + pub mode: EcKvWriteMode, +} + +/// Precondition mode for an [`EcKvStore::insert`] call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EcKvWriteMode { + /// Create the key; fail with [`EcKvWriteOutcome::PreconditionFailed`] + /// when the key already exists. + Add, + /// Unconditionally overwrite any existing value. + Overwrite, + /// Write only when the stored generation matches the provided marker. + IfGenerationMatch(u64), +} + +/// Outcome of an [`EcKvStore::insert`] call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EcKvWriteOutcome { + /// The write was applied. + Written, + /// The write precondition failed (key exists for + /// [`EcKvWriteMode::Add`], or generation mismatch for + /// [`EcKvWriteMode::IfGenerationMatch`]). + PreconditionFailed, +} + +/// Raw KV store primitives backing the EC identity graph. +/// +/// Implementations map these operations onto the platform KV API. +/// Infrastructure failures are reported as [`TrustedServerError::KvStore`]; +/// write precondition failures are part of the normal control flow and are +/// returned as [`EcKvWriteOutcome::PreconditionFailed`] instead of errors. +pub trait EcKvStore { + /// Returns the platform store name, used in log and error messages. + fn store_name(&self) -> &str; + + /// Reads the body, metadata, and generation marker for a key. + /// + /// Returns `Ok(None)` when the key does not exist. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::KvStore`] on store open or read failure. + fn lookup(&self, key: &str) -> Result, Report>; + + /// Writes an entry according to the requested precondition mode. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::KvStore`] on store open or write + /// failure. Precondition failures are reported through the + /// [`EcKvWriteOutcome`] instead. + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result>; + + /// Counts keys sharing the given prefix, up to `limit`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::KvStore`] on store open or list failure. + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result>; + + /// Hard-deletes a key. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::KvStore`] on store open or delete failure. + fn delete(&self, key: &str) -> Result<(), Report>; +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::collections::BTreeMap; + use std::sync::Mutex; + + use super::*; + + /// In-memory [`EcKvStore`] with generation tracking for CAS tests. + pub(crate) struct InMemoryEcKv { + name: String, + entries: Mutex>, + } + + struct StoredEntry { + body: Vec, + metadata: Option>, + generation: u64, + } + + impl InMemoryEcKv { + pub(crate) fn new(name: impl Into) -> Self { + Self { + name: name.into(), + entries: Mutex::new(BTreeMap::new()), + } + } + } + + impl EcKvStore for InMemoryEcKv { + fn store_name(&self) -> &str { + &self.name + } + + fn lookup(&self, key: &str) -> Result, Report> { + let entries = self.entries.lock().expect("should lock in-memory store"); + Ok(entries.get(key).map(|stored| EcKvLookup { + body: stored.body.clone(), + metadata: stored.metadata.clone(), + generation: stored.generation, + })) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let mut entries = self.entries.lock().expect("should lock in-memory store"); + let existing_generation = entries.get(key).map(|stored| stored.generation); + + match write.mode { + EcKvWriteMode::Add if existing_generation.is_some() => { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + EcKvWriteMode::IfGenerationMatch(expected) + if existing_generation != Some(expected) => + { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + EcKvWriteMode::Add + | EcKvWriteMode::Overwrite + | EcKvWriteMode::IfGenerationMatch(_) => {} + } + + entries.insert( + key.to_owned(), + StoredEntry { + body: write.body.as_bytes().to_vec(), + metadata: Some(write.metadata.as_bytes().to_vec()), + generation: existing_generation.unwrap_or(0) + 1, + }, + ); + Ok(EcKvWriteOutcome::Written) + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + let entries = self.entries.lock().expect("should lock in-memory store"); + let count = entries + .keys() + .filter(|key| key.starts_with(prefix)) + .take(limit as usize) + .count(); + #[allow(clippy::cast_possible_truncation)] + Ok(count as u32) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let mut entries = self.entries.lock().expect("should lock in-memory store"); + entries.remove(key); + Ok(()) + } + } + + /// [`EcKvStore`] that fails every operation, mimicking a missing or + /// unreachable platform store. + pub(crate) struct FailingEcKv { + name: String, + } + + impl FailingEcKv { + pub(crate) fn new(name: impl Into) -> Self { + Self { name: name.into() } + } + + fn error(&self, operation: &str) -> Report { + Report::new(TrustedServerError::KvStore { + store_name: self.name.clone(), + message: format!("KV store not found (failing test store, {operation})"), + }) + } + } + + impl EcKvStore for FailingEcKv { + fn store_name(&self) -> &str { + &self.name + } + + fn lookup(&self, _key: &str) -> Result, Report> { + Err(self.error("lookup")) + } + + fn insert( + &self, + _key: &str, + _write: EcKvWrite<'_>, + ) -> Result> { + Err(self.error("insert")) + } + + fn count_keys_with_prefix( + &self, + _prefix: &str, + _limit: u32, + ) -> Result> { + Err(self.error("list")) + } + + fn delete(&self, _key: &str) -> Result<(), Report> { + Err(self.error("delete")) + } + } +} diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index f554bec7f..5d6669f98 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -18,11 +18,12 @@ //! - [`consent`] — EC-specific consent gating wrapper //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers //! - [`kv`] — KV Store identity graph operations (CAS, tombstones, debounce) +//! - [`kv_backend`] — Platform-neutral KV primitives implemented by adapters //! - [`kv_types`] — Schema types for KV identity graph entries //! - [`device`] — Device signal derivation (UA, JA4, H2 fingerprinting) //! - [`partner`] — Partner validation helpers (ID format, pull sync config) //! - [`registry`] — In-memory partner registry built from config -//! - [`rate_limiter`] — Rate limiting abstraction (Fastly Edge Rate Limiting) +//! - [`rate_limiter`] — Rate limiting abstraction (implemented by adapters) //! - [`identify`] — Identity read endpoint (`GET /_ts/api/v1/identify`) //! - [`eids`] — Shared EID resolution and formatting helpers //! - [`batch_sync`] — S2S batch sync endpoint (`POST /_ts/api/v1/batch-sync`) @@ -39,6 +40,7 @@ pub mod finalize; pub mod generation; pub mod identify; pub mod kv; +pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; diff --git a/crates/trusted-server-core/src/ec/rate_limiter.rs b/crates/trusted-server-core/src/ec/rate_limiter.rs index 6157d3c44..5a5dac27e 100644 --- a/crates/trusted-server-core/src/ec/rate_limiter.rs +++ b/crates/trusted-server-core/src/ec/rate_limiter.rs @@ -1,17 +1,14 @@ //! Rate limiting abstraction for EC sync endpoints. //! -//! Provides a [`RateLimiter`] trait and its Fastly Edge Rate Limiting -//! implementation [`FastlyRateLimiter`]. Used by batch sync and pull sync -//! for per-partner request rate enforcement. +//! Provides the [`RateLimiter`] trait used by batch sync and pull sync for +//! per-partner request rate enforcement. Platform-specific implementations +//! live in the adapter crates (e.g. the Fastly Edge Rate Limiting +//! implementation in `trusted-server-adapter-fastly`). use error_stack::Report; -use fastly::erl::{CounterDuration, RateCounter}; use crate::error::TrustedServerError; -/// Name of the Fastly rate counter resource used by sync rate limiting. -pub const RATE_COUNTER_NAME: &str = "counter_store"; - /// Rate limiter abstraction for sync endpoints. /// /// Used by batch sync (`/_ts/api/v1/batch-sync`) and pull sync for @@ -19,7 +16,7 @@ pub const RATE_COUNTER_NAME: &str = "counter_store"; pub trait RateLimiter { /// Returns `true` when the rate limit has been exceeded for the given key. /// - /// `hourly_limit` is currently approximated via a 60-second Fastly counter + /// `hourly_limit` is currently approximated via a 60-second counter /// window, so the effective budget rounds up to the next whole request per /// minute. For example, `65/hr` becomes `2/min` (`120/hr` effective), and /// any positive limit below `60/hr` rounds up to `1/min` (`60/hr` @@ -49,126 +46,3 @@ pub trait RateLimiter { self.exceeded(key, per_minute_limit.saturating_mul(60)) } } - -fn hourly_limit_to_per_minute_limit(hourly_limit: u32) -> u32 { - if hourly_limit == 0 { - return 0; - } - - let per_minute_limit = hourly_limit.saturating_add(59) / 60; - per_minute_limit.max(1) -} - -#[cfg(test)] -fn effective_hourly_limit(hourly_limit: u32) -> u32 { - hourly_limit_to_per_minute_limit(hourly_limit).saturating_mul(60) -} - -/// Fastly Edge Rate Limiting implementation of [`RateLimiter`]. -pub struct FastlyRateLimiter { - counter: RateCounter, -} - -impl FastlyRateLimiter { - /// Creates a new rate limiter backed by the named Fastly rate counter. - #[must_use] - pub fn new(counter_name: &str) -> Self { - Self { - counter: RateCounter::open(counter_name), - } - } -} - -impl RateLimiter for FastlyRateLimiter { - fn exceeded(&self, key: &str, hourly_limit: u32) -> Result> { - // Fastly's public rate-counter API currently exposes windows up to 60s. - // Approximate the story's 1h limit by converting to a per-minute budget. - // - // Follow-up: move to exact 1-hour enforcement once platform counters - // expose longer windows or we add a dedicated KV-backed hour bucket. - let per_minute_limit = hourly_limit_to_per_minute_limit(hourly_limit); - if per_minute_limit == 0 { - return Ok(true); - } - - let current = self - .counter - .lookup_count(key, CounterDuration::SixtySecs) - .map_err(|e| { - Report::new(TrustedServerError::KvStore { - store_name: RATE_COUNTER_NAME.to_owned(), - message: format!("Failed to read sync rate counter: {e}"), - }) - })?; - - if current >= per_minute_limit { - return Ok(true); - } - - self.counter.increment(key, 1).map_err(|e| { - Report::new(TrustedServerError::KvStore { - store_name: RATE_COUNTER_NAME.to_owned(), - message: format!("Failed to increment sync rate counter: {e}"), - }) - })?; - - Ok(false) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn zero_hourly_limit_denies_all() { - assert_eq!( - hourly_limit_to_per_minute_limit(0), - 0, - "should preserve deny-all zero limit" - ); - assert_eq!( - effective_hourly_limit(0), - 0, - "should preserve effective zero limit" - ); - } - - #[test] - fn hourly_limit_rounds_up_to_whole_requests_per_minute() { - assert_eq!( - hourly_limit_to_per_minute_limit(65), - 2, - "should round 65/hr up to 2/min" - ); - assert_eq!( - effective_hourly_limit(65), - 120, - "should expose the resulting effective hourly budget" - ); - } - - #[test] - fn small_positive_hourly_limits_round_up_to_sixty_per_hour() { - assert_eq!( - hourly_limit_to_per_minute_limit(1), - 1, - "should round any positive sub-60 hourly limit up to 1/min" - ); - assert_eq!( - effective_hourly_limit(1), - 60, - "should enforce a 60/hr effective minimum with the current counter window" - ); - } - - #[test] - fn effective_hourly_limit_stays_within_hourly_plus_fifty_nine() { - for hourly_limit in [1, 10, 59, 60, 61, 65, 119, 120, 121, 600] { - assert!( - effective_hourly_limit(hourly_limit) <= hourly_limit.saturating_add(59), - "effective hourly limit should never overshoot by more than 59 requests" - ); - } - } -} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 327a49317..3938ffd81 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -197,13 +197,23 @@ fn process_response_with_pipeline( )) } -fn finalize_proxied_response( - settings: &Settings, +/// Extracted content-type and content-encoding from an origin response. +struct OriginResponseMeta { + ct_raw: String, + content_encoding: String, +} + +/// Extract content-type and content-encoding and log origin response metadata. +/// +/// When `log_encoding` is `true`, the `ce=` field is included in the log line +/// (used by the buffered finalizer which performs content-encoding processing). +/// The streaming finalizer omits it since it never decodes the body. +fn origin_response_metadata( req: &Request, + beresp: &Response, target_url: &str, - mut beresp: Response, -) -> Result, Report> { - // Determine content-type and content-encoding from response headers + log_encoding: bool, +) -> OriginResponseMeta { let status_code = beresp.status().as_u16(); let ct_raw = beresp .headers() @@ -229,23 +239,109 @@ fn finalize_proxied_response( .unwrap_or("-"); let ct_for_log: &str = if ct_raw.is_empty() { "-" } else { &ct_raw }; - let ce_for_log: &str = if content_encoding.is_empty() { - "-" + + if log_encoding { + let ce_for_log: &str = if content_encoding.is_empty() { + "-" + } else { + &content_encoding + }; + log::info!( + "origin response status={} ct={} ce={} cl={} accept={} url={}", + status_code, + ct_for_log, + ce_for_log, + cl_raw, + accept_raw, + target_url + ); } else { - &content_encoding - }; - log::info!( - "origin response status={} ct={} ce={} cl={} accept={} url={}", - status_code, - ct_for_log, - ce_for_log, - cl_raw, - accept_raw, - target_url - ); + log::info!( + "origin response status={} ct={} cl={} accept={} url={}", + status_code, + ct_for_log, + cl_raw, + accept_raw, + target_url + ); + } + + OriginResponseMeta { + ct_raw, + content_encoding, + } +} + +/// Apply image content-type header and log pixel heuristics. +/// +/// Sets a generic `image/*` content-type when the response has none, then logs +/// a warning if size or path heuristics suggest a tracking pixel. Both call +/// sites pass the response through unchanged afterwards, so this returns +/// nothing. +fn apply_image_passthrough_metadata( + req: &Request, + target_url: &str, + ct: &str, + beresp: &mut Response, + log_prefix: &str, +) { + let req_accept_images = req + .headers() + .get(HEADER_ACCEPT) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_ascii_lowercase().contains("image/")) + .unwrap_or(false); - let ct = ct_raw.to_ascii_lowercase(); - let compression = Compression::from_content_encoding(&content_encoding); + if !ct.starts_with("image/") && !req_accept_images { + return; + } + + if beresp.headers().get(header::CONTENT_TYPE).is_none() { + beresp + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/*")); + } + + let mut is_pixel = false; + if let Some(cl) = beresp + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()) + { + if cl <= 256 { + is_pixel = true; + } + } + if !is_pixel { + let lower = target_url.to_ascii_lowercase(); + if lower.contains("/pixel") + || lower.ends_with("/p.gif") + || lower.contains("1x1") + || lower.contains("/track") + { + is_pixel = true; + } + } + if is_pixel { + log::info!( + "{}likely pixel image fetched: {} ct={}", + log_prefix, + target_url, + ct + ); + } +} + +fn finalize_proxied_response( + settings: &Settings, + req: &Request, + target_url: &str, + mut beresp: Response, +) -> Result, Report> { + let meta = origin_response_metadata(req, &beresp, target_url, true); + let ct = meta.ct_raw.to_ascii_lowercase(); + let compression = Compression::from_content_encoding(&meta.content_encoding); if ct.contains("text/html") { let processor = CreativeHtmlProcessor::new(settings); @@ -269,55 +365,7 @@ fn finalize_proxied_response( ); } - // Image handling: set generic content-type if missing and log pixel heuristics - let req_accept_images = req - .headers() - .get(HEADER_ACCEPT) - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_ascii_lowercase().contains("image/")) - .unwrap_or(false); - - if ct.starts_with("image/") || req_accept_images { - if beresp.headers().get(header::CONTENT_TYPE).is_none() { - beresp - .headers_mut() - .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/*")); - } - - // Heuristics to log likely tracking pixels without altering response - let mut is_pixel = false; - if let Some(cl) = beresp - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.parse::().ok()) - { - if cl <= 256 { - // typical 1x1 PNG/GIF are very small - is_pixel = true; - } - } - - // Path heuristics: common pixel patterns - if !is_pixel { - let lower = target_url.to_ascii_lowercase(); - if lower.contains("/pixel") - || lower.ends_with("/p.gif") - || lower.contains("1x1") - || lower.contains("/track") - { - is_pixel = true; - } - } - - if is_pixel { - log::info!("likely pixel image fetched: {} ct={}", target_url, ct); - } - - return Ok(beresp); - } - - // Passthrough for non-text, non-image responses + apply_image_passthrough_metadata(req, target_url, &ct, &mut beresp, ""); Ok(beresp) } @@ -326,82 +374,9 @@ fn finalize_proxied_response_streaming( target_url: &str, mut beresp: Response, ) -> Response { - let status_code = beresp.status().as_u16(); - let ct_raw = beresp - .headers() - .get(header::CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .unwrap_or("") - .to_string(); - let cl_raw = beresp - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|h| h.to_str().ok()) - .unwrap_or("-"); - let accept_raw = req - .headers() - .get(HEADER_ACCEPT) - .and_then(|h| h.to_str().ok()) - .unwrap_or("-"); - - let ct_for_log: &str = if ct_raw.is_empty() { "-" } else { &ct_raw }; - log::info!( - "origin response status={} ct={} cl={} accept={} url={}", - status_code, - ct_for_log, - cl_raw, - accept_raw, - target_url - ); - - let ct = ct_raw.to_ascii_lowercase(); - - let req_accept_images = req - .headers() - .get(HEADER_ACCEPT) - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_ascii_lowercase().contains("image/")) - .unwrap_or(false); - - if ct.starts_with("image/") || req_accept_images { - if beresp.headers().get(header::CONTENT_TYPE).is_none() { - beresp - .headers_mut() - .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/*")); - } - - let mut is_pixel = false; - if let Some(cl) = beresp - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.parse::().ok()) - { - if cl <= 256 { - is_pixel = true; - } - } - - if !is_pixel { - let lower = target_url.to_ascii_lowercase(); - if lower.contains("/pixel") - || lower.ends_with("/p.gif") - || lower.contains("1x1") - || lower.contains("/track") - { - is_pixel = true; - } - } - - if is_pixel { - log::info!( - "stream: likely pixel image fetched: {} ct={}", - target_url, - ct - ); - } - } - + let meta = origin_response_metadata(req, &beresp, target_url, false); + let ct = meta.ct_raw.to_ascii_lowercase(); + apply_image_passthrough_metadata(req, target_url, &ct, &mut beresp, "stream: "); beresp } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index dc80c517f..62e564e70 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -396,18 +396,98 @@ pub struct OwnedProcessResponseParams { pub(crate) content_type: String, } +/// Growable byte buffer that rejects writes past a configured limit. +/// +/// Used by [`buffer_publisher_response`] to enforce +/// `settings.publisher.max_buffered_body_bytes` so a large processable +/// origin response fails safely instead of exhausting the Wasm heap. +struct BoundedWriter { + inner: Vec, + limit: usize, +} + +impl BoundedWriter { + fn new(limit: usize) -> Self { + Self { + inner: Vec::new(), + limit, + } + } + + fn into_inner(self) -> Vec { + self.inner + } +} + +impl Write for BoundedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.inner.len() + buf.len() > self.limit { + return Err(std::io::Error::other( + "publisher body exceeded maximum buffered size", + )); + } + self.inner.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Buffer a [`PublisherResponse`] into a single [`Response`]. +/// +/// Handles all three variants: returns [`PublisherResponse::Buffered`] unchanged, +/// pipes [`PublisherResponse::Stream`] through the streaming pipeline into memory, +/// and reattaches [`PublisherResponse::PassThrough`] bodies directly. +/// +/// The buffered size is capped by `settings.publisher.max_buffered_body_bytes` +/// (16 MiB by default), so processable origin responses cannot grow the +/// buffer without bound and exhaust the Wasm heap. +/// +/// # Errors +/// +/// Returns an error if the streaming pipeline fails to process the response +/// body, or if the processed body exceeds the configured buffer cap. +pub fn buffer_publisher_response( + publisher_response: PublisherResponse, + settings: &Settings, + integration_registry: &IntegrationRegistry, +) -> Result, Report> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + let mut output = BoundedWriter::new(settings.publisher.max_buffered_body_bytes); + stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; + let bytes = output.into_inner(); + response.headers_mut().insert( + http::header::CONTENT_LENGTH, + http::HeaderValue::from(bytes.len() as u64), + ); + *response.body_mut() = EdgeBody::from(bytes); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + Ok(response) + } + } +} + /// Stream the publisher response body through the processing pipeline. /// /// Called by the adapter after `stream_to_client()` has committed the response -/// headers. Runs synchronously against an already-materialised body; the async -/// I/O happened upstream in [`handle_publisher_request`]. Writes processed -/// chunks directly to `output`. +/// headers. Writes processed chunks directly to `output`. /// /// # Errors /// -/// Returns an error if processing fails mid-stream. Since headers are -/// already committed, the caller should log the error and drop the -/// `StreamingBody` (client sees a truncated response). +/// Returns an error if processing fails mid-stream. Since headers are already +/// committed, the caller should log the error and drop the `StreamingBody` +/// (client sees a truncated response). pub fn stream_publisher_body( body: EdgeBody, output: &mut W, @@ -1839,4 +1919,39 @@ mod tests { "origin host must not leak. Got: {processed}" ); } + + #[test] + fn bounded_writer_accepts_writes_within_limit() { + let mut writer = BoundedWriter::new(10); + + writer + .write_all(b"12345") + .expect("should accept write within limit"); + writer + .write_all(b"67890") + .expect("should accept write up to exact limit"); + + assert_eq!( + writer.into_inner(), + b"1234567890", + "should preserve all written bytes" + ); + } + + #[test] + fn bounded_writer_rejects_writes_exceeding_limit() { + let mut writer = BoundedWriter::new(8); + + writer + .write_all(b"12345") + .expect("should accept write within limit"); + let err = writer + .write_all(b"6789") + .expect_err("should reject write that exceeds the limit"); + + assert!( + err.to_string().contains("maximum buffered size"), + "should report the buffer cap in the error message" + ); + } } diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 501e577c1..aa7eb6d1b 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -4,7 +4,7 @@ Understanding the architecture of Trusted Server. ## High-Level Overview -Trusted Server is built as a Rust-based edge computing application that runs on Fastly Compute platform. +Trusted Server is built as a Rust-based edge computing application. The core logic lives in a platform-agnostic library; platform-specific adapters target different runtimes (Fastly Compute, native Axum). ```mermaid flowchart TD @@ -37,12 +37,29 @@ Core library containing shared functionality: ### trusted-server-adapter-fastly -Fastly-specific implementation: +Fastly Compute adapter (WASM binary, `wasm32-wasip1` target): -- Main application entry point -- Fastly SDK integration -- Request/response handling -- KV store access +- Main application entry point for production Fastly deployment +- Fastly SDK integration (KV stores, secret stores, geo lookup) +- Compiled to WebAssembly and run via Viceroy locally or on Fastly's edge + +### trusted-server-adapter-axum + +Native Axum dev/test adapter (native binary): + +- Local development and integration-test adapter — not a production-equivalent runtime +- Platform implementations backed by environment variables instead of Fastly stores +- Listens on `http://localhost:8787` by default + +**Current limitations compared to the Fastly adapter:** + +| Feature | Axum dev server | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | +| Geo lookup | Always returns `None` | +| Config/secret-store writes | Return an error (read-only via env vars) | +| Admin key management (`/admin/keys/*`) | Returns 501 Not Implemented | +| Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | ## Design Patterns @@ -105,13 +122,14 @@ User data is not persisted in storage - only processed in-flight at the edge. - **Request Signing** - Optional request authentication - **Content Security** - Creative scanning and modification -## WebAssembly Target +## Runtime Targets -Compiled to `wasm32-wasip1` for Fastly Compute: +| Adapter | Target | Use case | +| ------------------------------- | --------------- | ----------------------------------------------------------------- | +| `trusted-server-adapter-fastly` | `wasm32-wasip1` | Production on Fastly Compute | +| `trusted-server-adapter-axum` | native | Local development and integration testing (see limitations above) | -- Sandboxed execution -- Fast cold starts -- Efficient resource usage +The Fastly adapter compiles to WebAssembly for sandboxed, low-cold-start edge execution. The Axum adapter is a standard native binary — no WASM toolchain required for local development. ## Next Steps diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a4ed418cc..7f8b810ad 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,9 +7,12 @@ Get up and running with Trusted Server quickly. Before you begin, ensure you have: - Rust 1.91.1 (see `.tool-versions`) +- Basic familiarity with Rust and WebAssembly + +**For Fastly deployment** (optional for local dev): + - Fastly CLI installed - A Fastly account and API key -- Basic familiarity with WebAssembly ## Installation @@ -20,37 +23,82 @@ git clone https://github.com/IABTechLab/trusted-server.git cd trusted-server ``` -### Fastly CLI Setup +## Local Development + +Trusted Server supports two local development modes: -Install and configure the Fastly CLI using the [Fastly setup guide](/guide/fastly). +### Option A — Fastly Compute via Viceroy -### Install Viceroy (Test Runtime) +Simulates the full Fastly production environment locally. + +Install and configure the Fastly CLI using the [Fastly setup guide](/guide/fastly), then install Viceroy: ```bash cargo install viceroy ``` -## Local Development - -### Build the Project +Start the local Fastly simulator: ```bash -cargo build +fastly compute serve ``` -### Run Tests +The server will be available at `http://localhost:7676`. + +### Option B — Axum dev server + +No Fastly account, CLI, or Viceroy needed. Runs natively on your machine. + +The Axum adapter reads configuration from environment variables — it does **not** +auto-load `.env` files. You must export the variables into your shell before starting +the server. ```bash -cargo test +# Copy and edit the environment file +cp .env.dev .env + +# Export the variables into your current shell session +set -a && source .env && set +a + +# Build and start the dev server +cargo run -p trusted-server-adapter-axum ``` -### Start Local Server +The server will be available at `http://localhost:8787`. Set `PORT=` before +`cargo run` to bind the dev server to a different local port. + +**Environment variable conventions used by the Axum adapter:** + +| Purpose | Pattern | Example | +| ------------------ | ------------------------------------- | -------------------------------------------------------- | +| Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_SETTINGS_AD_SERVER_URL=https://…` | +| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_KEYS_SIGNING_KEY=abc123` | + +Store names and key names are uppercased with hyphens and dots replaced by underscores. + +> **Dev server limitations:** The Axum adapter does not support KV store, +> geo lookup, config/secret-store writes, or admin key-management routes. +> See [Architecture](/guide/architecture) for the full list. + +### Build the Project ```bash -fastly compute serve +# Axum dev server (native) +cargo build -p trusted-server-adapter-axum + +# Fastly adapter (WASM) +cargo build -p trusted-server-adapter-fastly --target wasm32-wasip1 ``` -The server will be available at `http://localhost:7676`. +### Run Tests + +```bash +# Fastly/WASM crates (requires Viceroy) +cargo test-fastly + +# Axum native adapter +cargo test-axum +``` ## Configuration diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 79576b8bd..bb49db11d 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -256,7 +256,7 @@ Integrations that ship additional JS (such as Testlight) typically expose a `shi ### 8. Test Locally 1. Add minimal config (`trusted-server.toml` + `.env.*` overrides). -2. Run `cargo fmt --all -- --check` and `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +2. Run `cargo fmt --all -- --check` and `cargo clippy-fastly && cargo clippy-axum`. 3. Execute targeted tests, e.g. `cargo test -p trusted-server-core html_processor`. 4. Use `fastly compute serve` (with Viceroy installed) to hit `/integrations//…` and fetch HTML from your origin to confirm rewrites are applied. diff --git a/docs/guide/testing.md b/docs/guide/testing.md index fb12ac775..868a6cbb6 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -6,14 +6,22 @@ Learn how to test Trusted Server locally and in CI/CD. ### Viceroy -Viceroy is the local test runtime for Fastly Compute applications. It simulates the Fastly environment locally. +Viceroy is the local test runtime for Fastly Compute applications. It simulates the Fastly environment locally and is required for running the WASM crate tests. ```bash # Install viceroy cargo install viceroy -# Run tests (viceroy is invoked automatically) -cargo test +# Run Fastly/WASM crate tests (viceroy is invoked automatically via .cargo/config.toml runner) +cargo test-fastly +``` + +### Axum adapter tests + +The Axum adapter runs as a native binary — no Viceroy or WASM toolchain needed: + +```bash +cargo test-axum ``` ### Test Organization @@ -47,40 +55,57 @@ mod tests { ### Unit Tests ```bash -# Run all tests -cargo test +# Run Fastly/WASM crate tests (requires Viceroy) +cargo test-fastly -# Run specific test by name -cargo test test_generate_ec_id +# Run Axum adapter tests (native) +cargo test-axum -# Run tests with output visible -cargo test -- --nocapture +# Run a specific test by name (Fastly/WASM) +cargo test-fastly test_generate_ec_id -# Run tests for specific crate +# Run a specific test by name (Axum native) +cargo test-axum test_generate_ec_id + +# Run tests for a specific crate (native) cargo test -p trusted-server-core -# Run tests matching a pattern -cargo test ec +# Run tests matching a pattern (Fastly/WASM) +cargo test-fastly ec ``` ### Integration Tests +The integration test suite runs the full pipeline against Docker containers using both the Fastly (Viceroy) and Axum runtimes: + ```bash -# Run all integration tests -cargo test --test '*' +# Build both runtimes and run all integration tests +./scripts/integration-tests.sh -# Run with single thread (useful for debugging) -cargo test -- --test-threads=1 +# Run a single test +./scripts/integration-tests.sh test_wordpress_axum +./scripts/integration-tests.sh test_wordpress_fastly ``` ### Local Server Testing +**Axum dev server** (no Fastly CLI required): + ```bash -# Start local server +# Start the Axum dev server +cargo run -p trusted-server-adapter-axum + +# Test endpoints with curl +curl http://localhost:8787/.well-known/trusted-server.json +``` + +**Fastly Viceroy** (requires Fastly CLI): + +```bash +# Start local Fastly simulator fastly compute serve # Test endpoints with curl -curl http://localhost:7676/health curl http://localhost:7676/.well-known/trusted-server.json ``` @@ -239,11 +264,14 @@ cargo fmt ### Linting ```bash -# Run clippy with all checks -cargo clippy --workspace --all-targets --all-features -- -D warnings +# Run clippy for Fastly/WASM adapter +cargo clippy-fastly + +# Run clippy for Axum native adapter +cargo clippy-axum -# Fix clippy warnings automatically -cargo clippy --fix --allow-dirty +# Fix clippy warnings automatically (Axum) +cargo clippy-axum --fix --allow-dirty ``` ### Code Coverage @@ -261,20 +289,29 @@ cargo tarpaulin --out Html Tests run automatically on pull requests and main branch commits. See `.github/workflows/` for the complete CI configuration. ```yaml -# Example workflow +# Example workflow (see .github/workflows/test.yml for the full version) name: Test on: [push, pull_request] jobs: - test: + test-rust: # Fastly/WASM crates — requires Viceroy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-action@stable - name: Run tests - run: cargo test + run: cargo test-fastly - name: Run clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy-fastly + + test-axum: # Axum native adapter — no Viceroy needed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build Axum adapter + run: cargo build -p trusted-server-adapter-axum + - name: Run Axum adapter tests + run: cargo test-axum ``` ## Debugging Tests @@ -282,13 +319,13 @@ jobs: ### Enable Debug Logging ```bash -RUST_LOG=debug cargo test -- --nocapture +RUST_LOG=debug cargo test-axum -- --nocapture ``` ### Run Single Test with Full Output ```bash -cargo test test_name -- --nocapture --test-threads=1 +cargo test-axum test_name -- --nocapture --test-threads=1 ``` ### Viceroy Limitations diff --git a/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md index 683d3db6d..f4ebae47b 100644 --- a/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md +++ b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md @@ -16,23 +16,24 @@ Read these before starting — do not guess: -| What to read | Path | Why | -|---|---|---| -| Core Cargo.toml | `crates/trusted-server-core/Cargo.toml` | Exact dep names to remove | -| Core lib.rs | `crates/trusted-server-core/src/lib.rs` | Module declarations to remove | -| Adapter main.rs | `crates/trusted-server-adapter-fastly/src/main.rs` | `compat::` call sites (lines 12, 159, 169, 182) | -| Core compat.rs | `crates/trusted-server-core/src/compat.rs` | Functions to port | -| Core geo.rs | `crates/trusted-server-core/src/geo.rs` | `geo_from_fastly` impl (lines 25–35) | -| Core backend.rs | `crates/trusted-server-core/src/backend.rs` | Entire module to port | -| Adapter platform.rs | `crates/trusted-server-adapter-fastly/src/platform.rs` | Import lines to update (17, 18, 362) | -| Adapter management_api.rs | `crates/trusted-server-adapter-fastly/src/management_api.rs` | `BackendConfig` import (line 55) | -| Core consent/kv.rs | `crates/trusted-server-core/src/consent/kv.rs` | Verify any `fastly::kv_store` usage | +| What to read | Path | Why | +| ------------------------- | ------------------------------------------------------------ | ----------------------------------------------- | +| Core Cargo.toml | `crates/trusted-server-core/Cargo.toml` | Exact dep names to remove | +| Core lib.rs | `crates/trusted-server-core/src/lib.rs` | Module declarations to remove | +| Adapter main.rs | `crates/trusted-server-adapter-fastly/src/main.rs` | `compat::` call sites (lines 12, 159, 169, 182) | +| Core compat.rs | `crates/trusted-server-core/src/compat.rs` | Functions to port | +| Core geo.rs | `crates/trusted-server-core/src/geo.rs` | `geo_from_fastly` impl (lines 25–35) | +| Core backend.rs | `crates/trusted-server-core/src/backend.rs` | Entire module to port | +| Adapter platform.rs | `crates/trusted-server-adapter-fastly/src/platform.rs` | Import lines to update (17, 18, 362) | +| Adapter management_api.rs | `crates/trusted-server-adapter-fastly/src/management_api.rs` | `BackendConfig` import (line 55) | +| Core consent/kv.rs | `crates/trusted-server-core/src/consent/kv.rs` | Verify any `fastly::kv_store` usage | --- ## File Map ### Files to **delete** from `crates/trusted-server-core/src/` + - `compat.rs` — Fastly conversion scaffolding, scheduled for deletion in PR 15 - `backend.rs` — Fastly-coupled backend builder, moved to adapter - `storage/config_store.rs` — Legacy `FastlyConfigStore` (call sites migrated to platform traits) @@ -40,19 +41,23 @@ Read these before starting — do not guess: - `storage/mod.rs` — Empty after above deletions ### Files to **modify** in `crates/trusted-server-core/src/` + - `lib.rs` — Remove `pub mod compat;`, `pub mod backend;`, `pub mod storage;` - `geo.rs` — Remove `use fastly::geo::Geo;` and `pub fn geo_from_fastly` ### Files to **create** in `crates/trusted-server-adapter-fastly/src/` + - `compat.rs` — The 3 conversion functions that adapter's `main.rs` needs - `backend.rs` — Full `BackendConfig` moved from core ### Files to **modify** in `crates/trusted-server-adapter-fastly/src/` + - `main.rs` — Add `mod compat;`, update import from `trusted_server_core::compat` to `crate::compat` - `platform.rs` — Remove `use trusted_server_core::geo::geo_from_fastly;`, add inline private function; remove `use trusted_server_core::backend::BackendConfig;`, add `use crate::backend::BackendConfig;` - `management_api.rs` — Update `use trusted_server_core::backend::BackendConfig` → `use crate::backend::BackendConfig` ### Files to **modify** (Cargo.toml) + - `crates/trusted-server-core/Cargo.toml` — Remove `fastly`, move `tokio` → `[dev-dependencies]` --- @@ -89,6 +94,7 @@ Expected: `Finished` with no errors. **Context:** Adapter's `main.rs` uses `trusted_server_core::compat` for 3 functions in `legacy_main()`: `sanitize_fastly_forwarded_headers`, `from_fastly_request`, and `to_fastly_response`. All three deal with `fastly::Request` / `fastly::Response` — they belong in the adapter. The remaining ~8 functions in core's `compat.rs` are unused by the adapter and can be dropped entirely. **Files:** + - Create: `crates/trusted-server-adapter-fastly/src/compat.rs` - Modify: `crates/trusted-server-adapter-fastly/src/main.rs` - Delete: `crates/trusted-server-core/src/compat.rs` @@ -97,6 +103,7 @@ Expected: `Finished` with no errors. - [ ] **Step 2.1: Read core's `compat.rs` fully** Read `crates/trusted-server-core/src/compat.rs` lines 1–560. You need the exact implementations of: + - `sanitize_fastly_forwarded_headers` — strips spoofable forwarded headers from a `fastly::Request` - `from_fastly_request` — converts owned `fastly::Request` → `http::Request` - `to_fastly_response` — converts `http::Response` → `fastly::Response` @@ -186,6 +193,7 @@ git commit -m "Move compat conversion fns to adapter, delete core compat.rs" **Context:** Core's `geo.rs` imports `fastly::geo::Geo` solely for `geo_from_fastly`. The adapter's `platform.rs` (line 18) imports this function from core and calls it at line 362. Moving it inline into `platform.rs` as a `pub(crate)` or private function is the minimal change — no new file required. **Files:** + - Modify: `crates/trusted-server-core/src/geo.rs` - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` @@ -232,6 +240,7 @@ Then remove the import line `use trusted_server_core::geo::geo_from_fastly;` (li - [ ] **Step 3.3: Remove `geo_from_fastly` and the fastly import from core's `geo.rs`** In `crates/trusted-server-core/src/geo.rs`: + - Remove: `use fastly::geo::Geo;` - Remove: the entire `pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { ... }` function and its doc comment @@ -260,6 +269,7 @@ git commit -m "Move geo_from_fastly from core to adapter platform" **Context:** Core's `backend.rs` exists solely to create dynamic Fastly backends (`fastly::backend::Backend`). Both `platform.rs` (line 17) and `management_api.rs` (line 55) in the adapter import `BackendConfig` from core. Moving the entire module to the adapter is a clean cut with minimal ripple. **Files:** + - Create: `crates/trusted-server-adapter-fastly/src/backend.rs` - Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (add `mod backend;`) - Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` @@ -291,6 +301,7 @@ Add `mod backend;` to `crates/trusted-server-adapter-fastly/src/main.rs`. - [ ] **Step 4.4: Update imports in `platform.rs` and `management_api.rs`** In `crates/trusted-server-adapter-fastly/src/platform.rs` (line 17): + ```rust // Remove: use trusted_server_core::backend::BackendConfig; @@ -299,6 +310,7 @@ use crate::backend::BackendConfig; ``` In `crates/trusted-server-adapter-fastly/src/management_api.rs` (line 55): + ```rust // Remove: use trusted_server_core::backend::BackendConfig; @@ -349,6 +361,7 @@ git commit -m "Move BackendConfig from core to adapter backend module" **Context:** `crates/trusted-server-core/src/storage/` exports `FastlyConfigStore` and `FastlySecretStore`. The adapter does not import either — it uses the platform traits (`PlatformConfigStore`, `PlatformSecretStore`) directly. Core's `platform/mod.rs` is also trait-only and has no dependency on these legacy types. The storage doc comment confirms: "will be removed once all call sites have migrated to platform traits." **Files:** + - Delete: `crates/trusted-server-core/src/storage/config_store.rs` - Delete: `crates/trusted-server-core/src/storage/secret_store.rs` - Delete: `crates/trusted-server-core/src/storage/mod.rs` @@ -399,6 +412,7 @@ git commit -m "Delete legacy FastlyConfigStore and FastlySecretStore from core" **Context:** The initial audit flagged possible `fastly::kv_store::KVStore` usage at line 230 of `consent/kv.rs`. The top of the file (lines 1–50) shows no fastly imports — the reference may be via fully-qualified path or may have been a hallucination. Verify before removing `fastly` from Cargo.toml. **Files:** + - Inspect: `crates/trusted-server-core/src/consent/kv.rs` - Possibly modify: same file @@ -413,6 +427,7 @@ grep -n "fastly" crates/trusted-server-core/src/consent/kv.rs - [ ] **Step 6.2b (if fastly:: appears): Investigate and move** Read the lines around each match. The KV store usage in consent likely goes through the `PlatformKvStore` trait (from `edgezero-core`). If raw `fastly::kv_store::KVStore` calls exist: + - Understand what function uses it (likely `open_store` or `fingerprint_unchanged`) - Move that function to adapter's consent integration or abstract via a trait closure / callback passed in from the adapter - The goal is zero `fastly::` references in core @@ -437,6 +452,7 @@ git commit -m "Remove fastly::kv_store usage from core consent module" **Context:** `tokio` appears in `[dependencies]` (line 45 of core's `Cargo.toml`). The audit found zero tokio usage in production code — all 30 uses are `#[tokio::test]` attributes in test modules. Moving it to `[dev-dependencies]` removes it from the production dependency graph for wasm builds. **Files:** + - Modify: `crates/trusted-server-core/Cargo.toml` - [ ] **Step 7.1: Confirm no production tokio usage** @@ -454,11 +470,13 @@ Expected: no results. If any appear, investigate and refactor before proceeding. In `crates/trusted-server-core/Cargo.toml`: Remove from `[dependencies]`: + ```toml tokio = { workspace = true } ``` Add to `[dev-dependencies]` (alongside `tokio-test`): + ```toml tokio = { workspace = true } ``` @@ -493,6 +511,7 @@ git commit -m "Move tokio to dev-dependencies in core (test-only usage)" **Context:** After Tasks 2–6, core should have zero `fastly::` references. Now remove the dependency. **Files:** + - Modify: `crates/trusted-server-core/Cargo.toml` - [ ] **Step 8.1: Confirm zero remaining fastly references in core** @@ -514,6 +533,7 @@ If `log-fastly` appears, remove it alongside `fastly` in the next step. - [ ] **Step 8.2: Remove `fastly` (and `log-fastly` if present) from core's `Cargo.toml`** In `crates/trusted-server-core/Cargo.toml`, remove: + ```toml fastly = { workspace = true } # Also remove if present: diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 01ab7654f..d8bde44f2 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -50,7 +50,7 @@ if [ -z "$TARGET" ]; then exit 1 fi -echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." +echo "==> Building Fastly WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret-padded-32" \ @@ -58,6 +58,13 @@ TRUSTED_SERVER__EC__PARTNERS='[{"name":"Integration Test Partner","source_domain TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 +echo "==> Building Axum native binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." +TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ +TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ +TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY="integration-test-secret-key" \ +TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ + cargo build -p trusted-server-adapter-axum + echo "==> Building WordPress test container..." docker build -t test-wordpress:latest \ crates/integration-tests/fixtures/frameworks/wordpress/ @@ -70,6 +77,7 @@ docker build \ echo "==> Running integration tests (target: $TARGET, origin port: $ORIGIN_PORT)..." WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" \ +AXUM_BINARY_PATH="$REPO_ROOT/target/debug/trusted-server-axum" \ INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" \ RUST_LOG=info \ cargo test \