diff --git a/.cargo/config.toml b/.cargo/config.toml index e0e3f5be..168f5884 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,8 +1,23 @@ +# 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 -[build] -target = "wasm32-wasip1" +# 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"] [target.'cfg(all(target_arch = "wasm32"))'] runner = "viceroy run -C ../../fastly.toml -- " diff --git a/.claude/agents/pr-creator.md b/.claude/agents/pr-creator.md index 182f7aad..eb13c94a 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 e260ac27..ffdddec1 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 5d26c679..bf340297 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/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 87c59d2f..fe6f25b9 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 @@ -84,6 +88,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/workflows/format.yml b/.github/workflows/format.yml index b6aba137..f4334f47 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 da467583..2c570ef3 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 186569da..d27ded91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,30 @@ 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 }} + 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 test-typescript: name: vitest diff --git a/.gitignore b/.gitignore index af70c452..0e58da93 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 bcbcd179..8aaf5cf7 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 ec76ee46..c7a59c2a 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 ``` @@ -49,19 +50,27 @@ 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 @@ -268,8 +277,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`) @@ -282,7 +291,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 1bffca16..cc6eb77f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,12 +126,92 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +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 = "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" version = "0.2.0" @@ -239,11 +319,13 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" 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" @@ -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" @@ -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", ] @@ -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.3", + "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", @@ -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", ] @@ -1179,21 +1364,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - [[package]] name = "hashlink" version = "0.10.0" @@ -1250,6 +1429,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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +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 +1677,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 +1692,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 +1706,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 +1720,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1477,6 +1747,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 +1844,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1595,16 +1926,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", "cfg-if", "cssparser", "encoding_rs", "foldhash 0.2.0", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "memchr", "mime", "precomputed-hash", @@ -1612,6 +1943,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" @@ -1640,6 +1983,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1648,9 +2002,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,7 +2020,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1718,6 +2072,15 @@ dependencies = [ "libm", ] +[[package]] +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" @@ -1736,6 +2099,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 +2266,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2015,6 +2404,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 +2469,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 +2488,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 +2509,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,6 +2531,24 @@ 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" @@ -2098,6 +2587,93 @@ 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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +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" @@ -2125,7 +2701,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2157,6 +2733,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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +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 +2830,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,11 +2858,34 @@ 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.11.1", + "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", "cssparser", @@ -2277,6 +2961,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" @@ -2348,6 +3043,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -2355,7 +3060,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 +3069,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 +3115,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2450,6 +3193,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 +3270,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2570,15 +3324,35 @@ 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.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -2592,6 +3366,16 @@ dependencies = [ "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" @@ -2631,6 +3415,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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "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" @@ -2643,6 +3482,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", @@ -2668,6 +3508,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" @@ -2718,9 +3577,9 @@ dependencies = [ "jose-jwk", "log", "lol_html", - "matchit", + "matchit 0.9.2", "mime", - "rand", + "rand 0.8.6", "regex", "serde", "serde_json", @@ -2756,6 +3615,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" @@ -2802,6 +3667,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" @@ -2883,6 +3754,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" @@ -2909,9 +3789,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2920,11 +3800,21 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2932,9 +3822,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2945,9 +3835,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2986,6 +3876,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2996,6 +3896,24 @@ 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" @@ -3011,7 +3929,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]] @@ -3073,6 +3991,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" @@ -3082,6 +4018,135 @@ 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" @@ -3259,9 +4324,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 05a0eaf7..60032d1e 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", ] @@ -76,8 +80,10 @@ lol_html = "2.7.2" matchit = "0.9" mime = "0.3" rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } regex = "1.12.3" serde = { version = "1.0", features = ["derive"] } +simple_logger = "5" serde_json = "1.0.149" sha2 = "0.10.9" subtle = "2.6" diff --git a/README.md b/README.md index 82dfe7b5..a844db84 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,16 @@ See the [Getting Started guide](https://iabtechlab.github.io/trusted-server/guid # Build cargo build -# Run tests -cargo test +# Run tests (Fastly/WASM crates — requires Viceroy) +cargo test-fastly -# Start local server +# 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 +45,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/tests/environments/axum.rs b/crates/integration-tests/tests/environments/axum.rs new file mode 100644 index 00000000..b6bfdc4d --- /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 b53fa4bc..c3797e20 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 e52d0944..288f1685 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -134,3 +134,19 @@ fn test_nextjs_fastly() { let framework = frameworks::nextjs::NextJs; 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"); +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml new file mode 100644 index 00000000..163530fb --- /dev/null +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -0,0 +1,37 @@ +[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 = "0.8" +temp-env = { workspace = true } +edgezero-adapter-axum = { workspace = true, features = ["axum"] } +edgezero-core = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tower = { version = "0.4", 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 00000000..48224aa7 --- /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 00000000..7e3fb70c --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -0,0 +1,378 @@ +use std::sync::Arc; + +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Method, 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::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::integrations::IntegrationRegistry; +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::{ + PublisherResponse, handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, +}; +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 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()?; + 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), + })) +} + +// --------------------------------------------------------------------------- +// Publisher response helper +// --------------------------------------------------------------------------- + +/// Collapse a [`PublisherResponse`] into a plain [`Response`]. +/// +/// Mirrors the Fastly adapter's `resolve_publisher_response`: buffers streaming +/// and pass-through variants in memory (acceptable for a dev server) so the +/// Axum handler can return a single `Response`. +fn resolve_publisher_response( + publisher_response: PublisherResponse, + settings: &Settings, + registry: &IntegrationRegistry, +) -> Result> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + let mut output = Vec::new(); + stream_publisher_body(body, &mut output, ¶ms, settings, registry)?; + response.headers_mut().insert( + header::CONTENT_LENGTH, + edgezero_core::http::HeaderValue::from(output.len() as u64), + ); + *response.body_mut() = edgezero_core::body::Body::from(output); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + Ok(response) + } + } +} + +// --------------------------------------------------------------------------- +// 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 +} + +// --------------------------------------------------------------------------- +// 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); + } + }; + + // /.well-known/trusted-server.json + let s = Arc::clone(&state); + let discovery_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_trusted_server_discovery(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /verify-signature + let s = Arc::clone(&state); + let verify_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_verify_signature(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /admin/keys/rotate and /admin/keys/deactivate + // + // Config/secret-store writes are not supported on the Axum dev server + // (backed by read-only env vars). Exposing these routes and returning 500 + // on the first store write is misleading, so we explicitly return 501. + let admin_not_supported = |_ctx: RequestContext| async { + 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) + }; + // /auction + let s = Arc::clone(&state); + let auction_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_auction(&s.settings, &s.orchestrator, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /first-party/proxy + let s = Arc::clone(&state); + let fp_proxy_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /first-party/click + let s = Arc::clone(&state); + let fp_click_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_click(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // GET /first-party/sign + let s = Arc::clone(&state); + let fp_sign_get_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // POST /first-party/sign + let s = Arc::clone(&state); + let fp_sign_post_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) + } + }; + + // /first-party/proxy-rebuild + let s = Arc::clone(&state); + let fp_rebuild_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + Ok( + handle_first_party_proxy_rebuild(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + + // GET /static/tsjs= → JS bundle; all other paths → integration proxy or publisher fallback. + // A single handler covers all HTTP methods so HEAD/OPTIONS/PUT/PATCH/DELETE reach the + // publisher origin on both wildcard paths and non-primary methods on named routes, + // matching the Fastly adapter's publisher_fallback_methods parity. + let s = Arc::clone(&state); + let general_fallback = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + let result = if method == Method::GET && path.starts_with("/static/tsjs=") { + handle_tsjs_dynamic(&req, &s.registry) + } else if s.registry.has_route(&method, &path) { + s.registry + .handle_proxy(&method, &path, &s.settings, &services, req) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + } else { + handle_publisher_request(&s.settings, &s.registry, &services, req) + .await + .and_then(|pr| resolve_publisher_response(pr, &s.settings, &s.registry)) + }; + + Ok(result.unwrap_or_else(|e| http_error(&e))) + } + }; + + // Named routes paired with their primary methods. Non-primary methods are + // registered below as publisher fallback to match the Fastly adapter. + let named_routes: &[(&str, &[Method])] = &[ + ("/.well-known/trusted-server.json", &[Method::GET]), + ("/verify-signature", &[Method::POST]), + ("/admin/keys/rotate", &[Method::POST]), + ("/admin/keys/deactivate", &[Method::POST]), + ("/auction", &[Method::POST]), + ("/first-party/proxy", &[Method::GET]), + ("/first-party/click", &[Method::GET]), + ("/first-party/sign", &[Method::GET, Method::POST]), + ("/first-party/proxy-rebuild", &[Method::POST]), + ]; + + let mut router = RouterService::builder() + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) + .get("/.well-known/trusted-server.json", discovery_handler) + .post("/verify-signature", verify_handler) + .post("/admin/keys/rotate", admin_not_supported) + .post("/admin/keys/deactivate", admin_not_supported) + .post("/auction", auction_handler) + .get("/first-party/proxy", fp_proxy_handler) + .get("/first-party/click", fp_click_handler) + .get("/first-party/sign", fp_sign_get_handler) + .post("/first-party/sign", fp_sign_post_handler) + .post("/first-party/proxy-rebuild", fp_rebuild_handler); + + for (path, primary) in named_routes { + for method in publisher_fallback_methods() { + if !primary.contains(&method) { + router = router.route(path, method, general_fallback.clone()); + } + } + } + + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), general_fallback.clone()); + router = router.route("/{*rest}", method, general_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 00000000..825e0542 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod middleware; +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 00000000..eba867b4 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -0,0 +1,52 @@ +use edgezero_core::app::Hooks as _; +use trusted_server_adapter_axum::app::TrustedServerApp; + +fn main() { + // When PORT is set, use a dynamic address so integration tests can allocate + // a fresh OS port each run and avoid TIME_WAIT flakiness. The standard + // `run_app` path is kept for normal development (reads config from axum.toml). + if let Some(port) = port_from_env() { + let _ = simple_logger::SimpleLogger::new().init(); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + log::info!("Listening on http://{addr}"); + let config = edgezero_adapter_axum::AxumDevServerConfig { + addr, + enable_ctrl_c: true, + }; + 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); + } + } else { + let addr = edgezero_adapter_axum::AxumDevServerConfig::default().addr; + let _ = simple_logger::SimpleLogger::new().init(); + log::info!("Listening on http://{addr}"); + if let Err(err) = + edgezero_adapter_axum::run_app::(include_str!("../axum.toml")) + { + 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 or cannot be parsed as `u16`. +fn port_from_env() -> Option { + let raw = std::env::var("PORT").ok()?; + match raw.parse() { + Ok(port) => Some(port), + Err(e) => { + log::warn!("PORT env var '{raw}' is not a valid u16: {e}; falling back to axum.toml"); + None + } + } +} + +#[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 00000000..1bb9d860 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -0,0 +1,191 @@ +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 = + trusted_server_core::settings_data::get_settings().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 00000000..ad2027a4 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -0,0 +1,679 @@ +use std::net::IpAddr; +use std::sync::Arc; +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, +} + +/// 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)) + .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)?; + 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(); + 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 mut handles: Vec = pending_requests + .into_iter() + .map(|pr| { + pr.downcast::().map_err(|_| { + Report::new(PlatformError::HttpClient) + .attach("unexpected inner type in AxumPlatformHttpClient::select") + }) + }) + .collect::, _>>()?; + + let backend_names: Vec = handles.iter().map(|h| h.backend_name.clone()).collect(); + let join_handles: Vec<_> = handles.drain(..).map(|h| h.handle).collect(); + + let (result, ready_idx, remaining_handles) = + futures::future::select_all(join_handles).await; + + let remaining: Vec = remaining_handles + .into_iter() + .enumerate() + .map(|(i, handle)| { + let original_idx = if i < ready_idx { i } else { i + 1 }; + let bn = backend_names[original_idx].clone(); + PlatformPendingRequest::new(AxumPendingHandle { + backend_name: bn.clone(), + handle, + }) + .with_backend_name(bn) + }) + .collect(); + + let (status, headers, body) = result.map_err(|e| { + Report::new(PlatformError::HttpClient) + .attach(format!("auction request task failed: {e}")) + })??; + + let backend_name = backend_names[ready_idx].clone(); + let mut builder = edgezero_core::http::response_builder().status(status); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_slice()); + } + let edge_resp = builder + .body(edgezero_core::body::Body::from(body)) + .change_context(PlatformError::HttpClient)?; + + let ready = Ok(PlatformResponse::new(edge_resp).with_backend_name(backend_name)); + Ok(PlatformSelectResult { ready, remaining }) + } +} + +// --------------------------------------------------------------------------- +// 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()); + + RuntimeServices::builder() + .config_store(Arc::new(AxumPlatformConfigStore)) + .secret_store(Arc::new(AxumPlatformSecretStore)) + .kv_store(Arc::new(trusted_server_core::platform::UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + // 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::new(AxumPlatformGeo)) + .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" + ); + } + + 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 00000000..6d3275ea --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -0,0 +1,246 @@ +//! 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 edgezero_core::app::Hooks as _; +use tower::{Service as _, ServiceExt as _}; +use trusted_server_adapter_axum::app::TrustedServerApp; + +fn make_service() -> EdgeZeroAxumService { + EdgeZeroAxumService::new(TrustedServerApp::routes()) +} + +// --------------------------------------------------------------------------- +// 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"); + + assert_ne!( + resp.status().as_u16(), + 404, + "admin/keys/rotate must be routed" + ); +} + +#[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"); + + assert_ne!( + resp.status().as_u16(), + 404, + "admin/keys/deactivate must be routed" + ); +} + +#[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_ne!(status, 404, "admin/keys/rotate must be routed"); + assert_ne!( + status, 500, + "admin/keys/rotate must not panic: got {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"); + assert!( + status < 500, + "admin route should not return 5xx: got {status}" + ); +} diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 501e577c..aa7eb6d1 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 ce4b328d..8315c6dc 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,77 @@ 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`. + +**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 +cargo build ``` -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/testing.md b/docs/guide/testing.md index cfa7ea51..ec315476 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,16 +55,16 @@ mod tests { ### Unit Tests ```bash -# Run all tests -cargo test +# Run Fastly/WASM crate tests (requires Viceroy) +cargo test-fastly + +# Run Axum adapter tests (native) +cargo test-axum # Run specific test by name cargo test test_generate_ec_id -# Run tests with output visible -cargo test -- --nocapture - -# Run tests for specific crate +# Run tests for a specific crate cargo test -p trusted-server-core # Run tests matching a pattern @@ -65,22 +73,36 @@ cargo test 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 ``` @@ -261,20 +283,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 --workspace --exclude trusted-server-adapter-axum --target wasm32-wasip1 -- -D warnings + + 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 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 683d3db6..f4ebae47 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 3b9ec974..52c39e2f 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -50,13 +50,20 @@ 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__EDGE_COOKIE__SECRET_KEY="integration-test-secret-key" \ 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/ @@ -69,6 +76,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 \