diff --git a/.claude/agents/code-architect.md b/.claude/agents/code-architect.md index 624316d1..fd46bd41 100644 --- a/.claude/agents/code-architect.md +++ b/.claude/agents/code-architect.md @@ -16,7 +16,7 @@ architectural principles: 3. **Thin adapters**: each adapter translates platform types to/from core types. Business logic never lives in adapters. The adapter file structure is: - `context.rs`, `request.rs`, `response.rs`, `proxy.rs`, `logger.rs`, `cli.rs`. + `context.rs`, `request.rs`, `response.rs`, `outbound.rs`, `logger.rs`, `cli.rs`. 4. **Contract testing**: every adapter has `tests/contract.rs` that validates request/response mapping. New adapters must follow this pattern. diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ee9d851f..f2cd3056 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -34,7 +34,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up rust toolchain @@ -114,7 +114,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -144,7 +144,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js @@ -162,3 +162,10 @@ jobs: - name: Run Prettier (check) run: npm run format + + - name: Check outbound documentation contract + working-directory: . + run: node scripts/check_outbound_docs_contract.mjs + + - name: Build documentation + run: npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e629d17a..09b5cd47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,12 +35,13 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain uses: actions-rust-lang/setup-rust-toolchain@v1 with: + components: "clippy, rustfmt" toolchain: ${{ steps.rust-version.outputs.rust-version }} - name: Add wasm targets @@ -55,6 +56,14 @@ jobs: - name: No legacy typed reads run: ./scripts/check_no_legacy_typed_reads.sh + # These roots are the active API surface. Historical design and plan + # documents under docs/superpowers intentionally remain immutable. + - name: No legacy outbound API + run: bash scripts/check_outbound_legacy_api.sh + + - name: Outbound documentation contract + run: node scripts/check_outbound_docs_contract.mjs + - name: Nested AppConfig audit run: cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates @@ -68,6 +77,13 @@ jobs: - name: Run workspace tests run: cargo test --workspace --all-targets + - name: Run native outbound adapter contracts + run: | + scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract + scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract + scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract + scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract + # The adapter CLI dispatch (build/deploy/stage/healthcheck/rollback) and # its tests live behind the `cli` feature, which the unfeatured # `cargo test --workspace` step above does not enable — so none of those @@ -75,11 +91,24 @@ jobs: - name: Adapter CLI dispatch tests run: cargo test -p edgezero-adapter-fastly --all-targets --features cli + - name: Verify exact outbound capability matrices + run: | + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" + - name: Check native adapter feature matrices + run: | + for adapter in axum cloudflare fastly spin; do + bash scripts/check_adapter_feature_matrix.sh "${adapter}" native + done + - name: Verify a generated project compiles - run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored + run: scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds # `examples/app-demo` is excluded from the root workspace, so # `cargo test --workspace` above does not cover it. Run its own @@ -101,6 +130,13 @@ jobs: working-directory: examples/app-demo run: cargo test --locked --workspace --all-targets + - name: Check app-demo wasm targets + working-directory: examples/app-demo + run: | + cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare + cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly + cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin + adapter-wasm-tests: name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest @@ -112,14 +148,17 @@ jobs: target: wasm32-unknown-unknown runner_env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER runner_value: wasm-bindgen-test-runner + sentinel: dispatch_runs_router_and_returns_response - adapter: fastly target: wasm32-wasip1 runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER runner_value: viceroy run + sentinel: dispatch_runs_router_and_returns_response - adapter: spin target: wasm32-wasip2 runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER - runner_value: wasmtime run + runner_value: wasmtime run -W component-model-async=y -S p3=y -S http=y + sentinel: router_dispatches_get_and_returns_response steps: - uses: actions/checkout@v6 @@ -139,7 +178,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -235,7 +274,25 @@ jobs: - name: Run ${{ matrix.adapter }} wasm tests env: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} - run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract + run: scripts/run_test_nonzero.sh ${{ matrix.sentinel }} cargo test --offline --locked -p edgezero-adapter-${{ matrix.adapter }} --no-default-features --features ${{ matrix.adapter }},test-utils --target ${{ matrix.target }} --test contract + + - name: Run Fastly outbound concurrency wasm sentinel + if: matrix.adapter == 'fastly' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract + + - name: Run Spin WASI HTTP SDK resource tests + if: matrix.adapter == 'spin' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources + + - name: Run Cloudflare outbound runtime unit tests + if: matrix.adapter == 'cloudflare' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib # The colocated runtime unit tests (config-store resolver, chunk # remediation) need the `fastly` feature and only RUN under Viceroy — the @@ -245,7 +302,8 @@ jobs: if: matrix.adapter == 'fastly' env: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + working-directory: crates/edgezero-adapter-fastly + run: ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib - - name: Check ${{ matrix.adapter }} wasm target - run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} + - name: Check ${{ matrix.adapter }} wasm feature matrix + run: bash scripts/check_adapter_feature_matrix.sh ${{ matrix.adapter }} ${{ matrix.target }} diff --git a/CLAUDE.md b/CLAUDE.md index eb649651..0b318356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ CI workflows under `.github/workflows/`. ``` crates/ - edgezero-core/ # Core: routing, extractors, middleware, proxy, body, errors + edgezero-core/ # Core: routing, extractors, middleware, outbound HTTP, body, errors edgezero-macros/ # Proc macros: #[action], #[app] edgezero-adapter/ # Adapter registry and traits edgezero-adapter-fastly/ # Fastly Compute bridge (wasm32-wasip1) @@ -134,11 +134,13 @@ impl Middleware for MyMiddleware { } ``` -### Proxy +### Outbound HTTP -Use `ProxyService` with adapter-specific clients (`FastlyProxyClient`, -`CloudflareProxyClient`, `SpinProxyClient`). Keep proxy logic provider-agnostic -in core. +Handlers obtain `HttpClient` from `RequestContext::http_client()`, construct +`OutboundRequest` values, and consume `OutboundResponse` or per-slot +`OutboundSlotResult` outcomes. Keep provider transport logic in adapter-specific +`*OutboundClient` implementations and use the capability matrix for behavioral +differences. ### Logging @@ -168,7 +170,7 @@ Each adapter follows the same structure: - `context.rs` — platform-specific request context - `request.rs` — platform request → core request conversion - `response.rs` — core response → platform response conversion -- `proxy.rs` — platform-specific proxy client +- `outbound.rs` — platform-specific outbound HTTP client - `logger.rs` — platform-specific logging init - `cli.rs` — adapter dispatch behind the `cli` feature: `build` / `deploy` / `serve` (legacy) plus `Adapter::execute` for `auth` (login/logout/status) and dedicated trait methods `provision` (Stage 6 — platform-resource creation) and `push_config_entries` (Stage 7 — `config push` writeback). Self-registers via `#[ctor]` into the `edgezero-adapter` registry. diff --git a/Cargo.lock b/Cargo.lock index 92ca9374..6ca99657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -100,7 +100,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -448,7 +448,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -664,6 +664,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ + "edgezero-core", "tempfile", "toml", ] @@ -673,12 +674,14 @@ name = "edgezero-adapter-axum" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", "ctor", "edgezero-adapter", "edgezero-core", + "flate2", "futures", "futures-util", "http", @@ -702,6 +705,7 @@ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -735,6 +739,7 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", "fern", "flate2", "futures", @@ -756,6 +761,7 @@ name = "edgezero-adapter-spin" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -777,6 +783,7 @@ dependencies = [ "toml", "toml_edit", "walkdir", + "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", ] [[package]] @@ -817,6 +824,7 @@ dependencies = [ "async-stream", "async-trait", "brotli", + "brotli-decompressor", "bytes", "edgezero-macros", "flate2", @@ -837,6 +845,7 @@ dependencies = [ "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -889,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1735,7 +1744,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2179,7 +2188,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2236,7 +2245,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2513,7 +2522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2652,7 +2661,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3312,7 +3321,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 48519d07..c5600e58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" -async-compression = { version = "0.4", features = [ +async-compression = { version = "=0.4.43", features = [ "futures-io", "gzip", "brotli", @@ -36,7 +36,8 @@ async-compression = { version = "0.4", features = [ async-stream = "0.3" async-trait = "0.1" axum = { version = "0.8", default-features = true } -brotli = "8" +brotli = "=8.0.4" +brotli-decompressor = "=5.0.1" bytes = "1" chrono = "0.4" ctor = "1.0" @@ -47,7 +48,8 @@ edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-fea edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } edgezero-cli = { path = "crates/edgezero-cli", default-features = false } -fastly = "0.12" +fastly = "=0.12.1" +fastly-shared = "=0.12.1" fern = "0.7" flate2 = { version = "1", features = ["rust_backend"] } futures = { version = "0.3", features = ["std", "executor"] } @@ -61,7 +63,7 @@ log-fastly = "0.12" matchit = "0.9" once_cell = "1" redb = "4.1.0" -reqwest = { version = "0.13", default-features = false, features = ["rustls", "blocking", "json"] } +reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "blocking", "json"] } # `bundled` ships SQLite source so operators don't need a system # `libsqlite3-sys` install. Used by `edgezero-adapter-spin`'s CLI-only # `config push --adapter spin` writer to write into Spin's local KV @@ -80,25 +82,25 @@ proc-macro2 = { version = "1", features = ["span-locations"] } quote = "1" syn = { version = "3", features = ["full", "extra-traits", "visit"] } subtle = "2" -# Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor -# bump that touches `key_value::Store::open`'s async signature or the -# wasi-http import surface fails at build time rather than at `spin -# up` (where a runtime mismatch surfaces as opaque WIT linker errors). -spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } +# The Spin outbound classifier exhaustively matches this SDK's WASI HTTP +# snapshot, so both dependencies move only through an explicit review. +spin-sdk = { version = "=6.0.0", default-features = false, features = ["http", "key-value", "variables"] } tempfile = "3" toml_edit = "0.25" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] } trybuild = "1" toml = { version = "1.1" } tower = { version = "0.5", features = ["util"] } tower-layer = "0.3" tower-service = "0.3" tracing = "0.1" +url = "=2.5.8" validator = { version = "0.20", features = ["derive"] } walkdir = { version = "2" } +wasip3 = "=0.6.0" web-time = "1" -worker = { version = "0.8", features = ["http"] } +worker = { version = "=0.8.3", default-features = false, features = ["http"] } [workspace.lints.clippy] # Same strict gate as the demo workspace. Allow-list mirrors the demo's @@ -135,11 +137,10 @@ separated_literal_suffix = "allow" # decompress_body, and one extra in fastly/request.rs. pub_with_shorthand = "allow" # `module_name_repetitions` was attempted: 39 sites in edgezero-core, -# centred on three concrete blockers that surfaced during the rename: -# 1. `proxy::Request`/`proxy::Response` would collide with the -# `http::Request`/`http::Response` already imported by every -# consumer; the only viable alternative names (`OutboundRequest`, -# `Outbound`) are strictly more verbose than `ProxyRequest`. +# centred on three concrete blockers that surfaced during the API review: +# 1. Outbound request/response types intentionally retain the `Outbound*` +# prefix because they are re-exported beside inbound `http::Request` and +# `http::Response`; shorter names would collide in normal consumers. # 2. `manifest.rs` has 17 `Manifest*` types; consumers in adapters, # cli, demos, scaffold templates, and the macro-generated app # code use these names directly. Stripping the prefix would force @@ -175,4 +176,5 @@ std_instead_of_alloc = "allow" std_instead_of_core = "allow" [workspace.lints.rust] -unsafe_code = "deny" \ No newline at end of file +unsafe_code = "deny" +unexpected_cfgs = "deny" diff --git a/README.md b/README.md index aacee78a..eafe908c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Full documentation is available at **[stackpop.github.io/edgezero](https://stack - [Getting Started](https://stackpop.github.io/edgezero/guide/getting-started) - Project setup and first steps - [Architecture](https://stackpop.github.io/edgezero/guide/architecture) - How EdgeZero works - [Configuration](https://stackpop.github.io/edgezero/guide/configuration) - `edgezero.toml` reference +- [Outbound HTTP](https://stackpop.github.io/edgezero/guide/proxying) - Portable requests, limits, deadlines, and batching +- [Capabilities](https://stackpop.github.io/edgezero/guide/capabilities) - Exact adapter support matrix and deployment caveats - [CLI Reference](https://stackpop.github.io/edgezero/guide/cli-reference) - All CLI commands - [Blob App-Config Migration](https://stackpop.github.io/edgezero/guide/blob-app-config-migration) - Typed `AppConfig` extractor + `config push` / `config diff` workflow diff --git a/TODO.md b/TODO.md index 19bf99e0..392db764 100644 --- a/TODO.md +++ b/TODO.md @@ -7,9 +7,9 @@ High-level backlog and decisions to drive the next milestones. ### High Priority - [ ] Core: `Response::json` behind `serde` feature -- [ ] Fastly proxy: add tests for backend send + header/body mapping +- [x] Fastly outbound HTTP: add tests for backend send + header/body mapping - [ ] Cloudflare streaming: map `Response::with_chunks` to `ReadableStream` with backpressure -- [ ] Core proxy: add async fetch facade (feature `async-client`) and implement Cloudflare proxy async fetch facade (feature `async-client`) and implement Cloudflare proxy +- [x] Core outbound HTTP: add the portable async client and Cloudflare fetch implementation ### Medium Priority @@ -31,7 +31,7 @@ High-level backlog and decisions to drive the next milestones. ## Test Coverage Plan (2025-09-18) -- [ ] Adapters: introduce Fastly/Cloudflare mapping tests (headers, streaming, proxy failure) to catch glue regressions. +- [x] Adapters: introduce Fastly/Cloudflare outbound mapping tests (headers, streaming, transport failure) to catch glue regressions. - [ ] Adapters: assert error-path mapping for Fastly/Cloudflare request conversion and re-enable the ignored Cloudflare response header test. - [ ] CLI: add integration tests for `edgezero new` scaffolding, feature-flag builds, and `dev` fallback app. - [ ] CLI: cover `dev_server`, generator, and template scaffolding flows with tempdir-based integration tests to guard manual HTTP parsing and shell commands. @@ -84,7 +84,7 @@ High-level backlog and decisions to drive the next milestones. ## Roadmap (2025-09-24) -- [x] Adapter stability: formalise the provider adapter contract (request/response mapping, streaming guarantees, proxy hooks) and capture it in shared docs + integration tests so new targets plug in safely. (`docs/adapter-contract.md`, Fastly contract tests under `crates/edgezero-adapter-fastly/tests/contract.rs`, Cloudflare contract tests under `crates/edgezero-adapter-cloudflare/tests/contract.rs`, manifest schema in `docs/manifest.md`) +- [x] Adapter stability: formalise the provider adapter contract (request/response mapping, streaming guarantees, outbound hooks) and capture it in shared docs + integration tests so new targets plug in safely. (`docs/adapter-contract.md`, Fastly contract tests under `crates/edgezero-adapter-fastly/tests/contract.rs`, Cloudflare contract tests under `crates/edgezero-adapter-cloudflare/tests/contract.rs`, manifest schema in `docs/manifest.md`) - [ ] Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) using the stabilized adapter API to validate cross-provider abstractions. - [x] Manifest ergonomics: design an `edgezero.toml` schema that mirrors Spin’s manifest convenience (route triggers, env/secrets, build targets) while remaining provider-agnostic; update CLI scaffolding accordingly. (`crates/edgezero-cli/src/manifest.rs`, templates in `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`, doc `docs/manifest.md`, app-demo manifest `examples/app-demo/edgezero.toml`) - [ ] Tooling parity: extend `edgezero-cli` with template/plugin style commands (similar to Spin templates) to streamline new app scaffolds and provider-specific wiring. @@ -112,9 +112,9 @@ High-level backlog and decisions to drive the next milestones. ### Familiarization Summary -- EdgeZero centres around `edgezero-core`, which provides provider-neutral HTTP primitives, routing, middleware, logging, and proxy abstractions; adapters reuse these types to stay DRY. +- EdgeZero centres around `edgezero-core`, which provides provider-neutral HTTP primitives, routing, middleware, logging, and outbound abstractions; adapters reuse these types to stay DRY. - Controller ergonomics live in `edgezero-controller` plus `edgezero-macros`, offering `#[action]` functions that extract typed inputs and return `Responder`s. -- Provider adapters (`edgezero-adapter-fastly`, `edgezero-adapter-cloudflare`) are feature-gated; each exposes `handle` plus logging/proxy helpers while delegating behaviour to the core crate. +- Provider adapters (`edgezero-adapter-fastly`, `edgezero-adapter-cloudflare`) are feature-gated; each exposes `handle` plus logging/outbound helpers while delegating behavior to the core crate. - Supporting crates include `edgezero-std` for stdout logging, `edgezero-cli` for dev server + scaffolding, and demo workspaces under `examples/app-demo` to validate provider flows. - Workspace `Cargo.toml` keeps default members lean (core only) to support offline builds; additional crates are opt-in via features when targeting specific adapters. @@ -267,7 +267,7 @@ High-level backlog and decisions to drive the next milestones. - [ ] Update the Fastly adapter to compile against `fastly` 0.11 APIs (request building, async streaming, response conversion). - [ ] Adjust logging helper to the new log-fastly builder API. -- [ ] Ensure proxy tests/builds pass for streaming + compression paths. +- [x] Ensure outbound tests/builds pass for streaming + compression paths. - [ ] Verify the app demos compile for `wasm32-wasip1` with the updated SDK. ## Review (2025-09-19 01:28 UTC) @@ -311,7 +311,7 @@ High-level backlog and decisions to drive the next milestones. ## Review (2025-09-19 02:35 UTC) - Temporary stopgap: adapter builds against Fastly 0.11 by buffering request/response bodies and wiring a new logging helper; wasm demo (`cargo build -p app-demo-adapter-fastly --target wasm32-wasip1`) and `cargo test` now succeed. -- Regression: streaming proxy behaviour (and streaming decompression) is currently disabled because bodies are buffered; follow-up work is required to restore async streaming under the new SDK. +- Outbound streaming and decompression are implemented with adapter-specific capability declarations; Axum, Fastly, and Spin retain the documented 16 MiB downstream conversion fallback. ## Review (2025-09-19 07:35 UTC) @@ -505,14 +505,14 @@ High-level backlog and decisions to drive the next milestones. ## Review (2026-01-27 00:50:51 UTC) -- Summary: Aligned guide content with current EdgeZero APIs (App::build_app, adapter entrypoints, middleware signature, proxy clients), corrected routing/streaming/handlers/CLI details, and refreshed manifest/logging docs; added the missing CLI/dev features list to the roadmap. +- Summary: Aligned guide content with the then-current EdgeZero APIs, corrected routing/streaming/handlers/CLI details, and refreshed manifest/logging docs; this was superseded by the outbound HTTP migration. - Assumptions: Docs now reflect current behavior for Fastly/Cloudflare/Axum adapters and the CLI; future features are captured explicitly in the roadmap rather than implied in guides. - Outstanding: None (docs-only updates). ## Review (2026-01-27 01:02:05 UTC) -- Summary: Condensed the proxying guide into a single end-to-end example that uses adapter proxy handles, and added short logging-status callouts to the Fastly, Cloudflare, and Axum adapter docs. -- Assumptions: The proxy handle approach is the preferred public pattern; adapter logging notes should stay concise and match current defaults. +- Summary: Condensed the upstream-forwarding guide into one example and added logging-status callouts; the client example was superseded by `HttpClient`. +- Assumptions: Adapter logging notes should stay concise and match current defaults. - Outstanding: None (docs-only updates). ## Review (2026-01-27 01:05:18 UTC) @@ -589,14 +589,14 @@ High-level backlog and decisions to drive the next milestones. - [x] Add the key doc/CLI gaps found during the review to the roadmap page. - [x] Add an explicit roadmap item for Spin support (define scope at the doc level). -## Codex Plan (2026-01-27 - Proxying Snippet + Adapter Logging Callout) +## Codex Plan (2026-01-27 - Upstream Snippet + Adapter Logging Callout) -- [x] Condense proxying guide into a single end-to-end example using adapter proxy handles. +- [x] Condense the upstream guide into a single end-to-end example (later migrated to `HttpClient`). - [x] Add a short logging status callout to the adapter docs (Axum/Cloudflare/Fastly). ## Codex Plan (2026-01-27 - Docs Alignment + Roadmap Additions) -- [x] Update guides to reflect current APIs (App::build_app, adapter entrypoints, middleware signature, proxy client usage). +- [x] Update guides to reflect current APIs (App::build_app, adapter entrypoints, middleware signature, outbound client usage). - [x] Correct routing, streaming, handlers, and CLI reference docs to match current behavior. - [x] Refresh configuration docs to align with manifest schema and loader APIs. - [x] Add missing-feature backlog (list-adapters, exit codes, manifest search-up, RUST_LOG, hot reload) to the roadmap section. diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 9979e39a..a6851cf7 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [features] default = ["axum"] axum = [ + "dep:async-stream", "dep:axum", "dep:tokio", "dep:tower", @@ -27,6 +28,7 @@ cli = [ "dep:toml", "dep:walkdir", ] +test-utils = [] [dependencies] edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ @@ -35,6 +37,7 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ edgezero-core = { path = "../edgezero-core" } anyhow = { workspace = true } async-trait = { workspace = true } +async-stream = { workspace = true, optional = true } axum = { workspace = true, optional = true } bytes = { workspace = true } ctor = { workspace = true, optional = true } @@ -57,6 +60,7 @@ walkdir = { workspace = true, optional = true } async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +flate2 = { workspace = true } serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index bfe5781d..07ded77b 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -20,6 +20,7 @@ use edgezero_adapter::scaffold::{ }; use edgezero_core::addr; use edgezero_core::manifest::ManifestLoader; +use edgezero_core::{Capability, CapabilitySupport}; use toml::Value; use walkdir::WalkDir; @@ -133,6 +134,31 @@ struct EdgezeroAxumConfig { reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for AxumCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::ConfigReadDeadlines | Capability::LazyStreamedResponsePassthrough => { + CapabilitySupport::BestEffort + } + Capability::InboundReadDeadlines + | Capability::IngressAdmission + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHeaderFidelity + | Capability::OutboundHttp + | Capability::SendAllSlotIsolation + | Capability::StreamedUploadDeadlines => CapabilitySupport::Native, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // The axum adapter is the in-process native dev server — @@ -158,6 +184,29 @@ impl Adapter for AxumCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + match action { + AdapterAction::Build => build_target(target, args), + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve_target(target, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "axum adapter does not support pinned target action {action:?}" + )), + } + } + fn name(&self) -> &'static str { "axum" } @@ -374,11 +423,34 @@ fn build(extra_args: &[String]) -> Result<(), String> { run_cargo(&project, "build", extra_args) } +fn build_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { + let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; + run_cargo(&project, "build", extra_args) +} + fn serve(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; run_cargo(&project, "run", extra_args) } +fn serve_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { + let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; + run_cargo(&project, "run", extra_args) +} + +fn target_manifest(target: &AdapterExecutionTarget, name: &str) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join(name), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned axum manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + fn deploy(_extra_args: &[String]) -> Result<(), String> { Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } @@ -695,6 +767,77 @@ mod tests { use std::net::Ipv6Addr; use tempfile::tempdir; + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::InboundReadDeadlines, CapabilitySupport::Native), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::Unsupported, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::Native), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::Native, + ), + (Capability::SendAllSlotIsolation, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::Native, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::BestEffort, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + AXUM_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + #[test] fn read_axum_project_loads_defaults() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 19b7ddfd..632fede7 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -17,9 +17,12 @@ use std::env; use std::fs; use std::io::ErrorKind; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::{BoundedStoreRead, Deadline}; /// Local-file config store used by the Axum dev server. /// @@ -28,12 +31,16 @@ use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; /// state, not an error. pub struct AxumConfigStore { data: HashMap, + #[cfg(test)] + unbounded_get_calls: AtomicUsize, } impl AxumConfigStore { fn empty() -> Self { Self { data: HashMap::new(), + #[cfg(test)] + unbounded_get_calls: AtomicUsize::new(0), } } @@ -62,6 +69,8 @@ impl AxumConfigStore { { Self { data: entries.into_iter().collect(), + #[cfg(test)] + unbounded_get_calls: AtomicUsize::new(0), } } @@ -115,7 +124,11 @@ impl AxumConfigStore { path.display() )) })?; - Ok(Self { data }) + Ok(Self { + data, + #[cfg(test)] + unbounded_get_calls: AtomicUsize::new(0), + }) } /// Resolve the on-disk path for the given logical config id. @@ -158,8 +171,47 @@ impl AxumConfigStore { impl ConfigStore for AxumConfigStore { #[inline] async fn get(&self, key: &str) -> Result, ConfigStoreError> { + #[cfg(test)] + self.unbounded_get_calls.fetch_add(1, Ordering::Relaxed); Ok(self.data.get(key).cloned()) } + + #[inline] + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + + // Values are already resident after startup JSON parsing. This bounds + // request-time materialization; it does not bound startup file allocation. + let stored_value = self.data.get(key); + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + + let backend_bytes = stored_value.map_or(Ok(0_u64), |value| { + u64::try_from(value.len()).map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(ConfigStoreError::ValueTooLarge); + } + + let value = stored_value.cloned(); + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } /// Walk up from the process cwd looking for an ancestor that @@ -199,9 +251,54 @@ mod tests { }); use super::*; + use edgezero_core::{Deadline, MonotonicInstant}; use futures::executor::block_on; + use std::sync::atomic::Ordering; + use std::time::Duration; use tempfile::tempdir; + #[test] + fn bounded_get_accepts_exact_caps_and_reports_exact_backend_bytes() { + let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + + let read = + block_on(cs.get_bounded("greeting", Deadline::after(Duration::from_secs(1)), 5, 5)) + .expect("exact cap must succeed"); + + assert_eq!(read.backend_bytes, 5); + assert_eq!(read.value.as_deref(), Some("hello")); + } + + #[test] + fn bounded_get_rejects_either_cap_without_using_cloning_get() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + + let error = block_on(cs.get_bounded( + "greeting", + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("over-cap value must fail"); + + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(cs.unbounded_get_calls.load(Ordering::Relaxed), 0); + } + } + + #[test] + fn bounded_get_rejects_an_expired_deadline_before_reading() { + let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let expired = Deadline::at_instant(MonotonicInstant::now()); + + let error = block_on(cs.get_bounded("greeting", expired, 5, 5)) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert_eq!(cs.unbounded_get_calls.load(Ordering::Relaxed), 0); + } + #[test] fn axum_config_store_from_map_returns_values() { let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1a5405c0..10e38316 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -14,7 +14,7 @@ use tokio::signal; use tower::{Service as _, service_fn}; use edgezero_core::addr; -use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; @@ -116,7 +116,7 @@ impl AxumDevServer { let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores(App::new(router), listener, config.enable_ctrl_c, stores).await } #[cfg(test)] @@ -126,7 +126,7 @@ impl AxumDevServer { config, stores, } = self; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores(App::new(router), listener, config.enable_ctrl_c, stores).await } #[must_use] @@ -272,13 +272,13 @@ fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { } async fn serve_with_stores( - router: RouterService, + app: App, listener: TokioTcpListener, enable_ctrl_c: bool, stores: Stores, ) -> anyhow::Result<()> { let service = { - let mut service = EdgeZeroAxumService::new(router); + let mut service = EdgeZeroAxumService::from_app(app); if let Some(registry) = stores.config_registry { service = service.with_config_registry(registry); } @@ -350,7 +350,6 @@ pub fn run_app() -> anyhow::Result<()> { } let addr = resolution.addr; let app = A::build_app(); - let router = app.router().clone(); log::info!("[edgezero] starting axum server on http://{addr}"); @@ -378,7 +377,7 @@ pub fn run_app() -> anyhow::Result<()> { secret_registry, ..Stores::default() }; - serve_with_stores(router, listener, true, request_stores).await + serve_with_stores(app, listener, true, request_stores).await }) } @@ -859,7 +858,6 @@ mod integration_tests { async fn server_forwards_headers() { async fn handler(ctx: RequestContext) -> Result { let value = ctx - .request() .headers() .get("x-custom") .and_then(|val| val.to_str().ok()) @@ -1194,7 +1192,8 @@ mod integration_tests { ); let body = response.text().await.unwrap(); assert!(!body.contains("API_KEY")); - assert!(body.contains("required secret is not configured")); + assert!(body.contains("internal server error")); + assert!(!body.contains("required secret is not configured")); server.handle.abort(); } @@ -1215,7 +1214,8 @@ mod integration_tests { reqwest::StatusCode::INTERNAL_SERVER_ERROR ); let body = response.text().await.unwrap(); - assert!(body.contains( + assert!(body.contains("internal server error")); + assert!(!body.contains( "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" )); diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index d4cedf97..248c394d 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -9,7 +9,7 @@ pub mod dev_server; #[cfg(feature = "axum")] pub mod key_value_store; #[cfg(feature = "axum")] -pub mod proxy; +pub mod outbound; #[cfg(feature = "axum")] pub mod request; #[cfg(feature = "axum")] @@ -19,7 +19,7 @@ pub mod secret_store; #[cfg(feature = "axum")] pub mod service; -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; #[cfg(test)] diff --git a/crates/edgezero-adapter-axum/src/outbound.rs b/crates/edgezero-adapter-axum/src/outbound.rs new file mode 100644 index 00000000..df9e89a8 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/outbound.rs @@ -0,0 +1,632 @@ +use async_stream::stream; +use async_trait::async_trait; +use bytes::Bytes; +use core::num::NonZeroU64; +use core::time::Duration; +use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_gzip_stream, +}; +use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; +use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; +use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode}; +use edgezero_core::outbound::{ + OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseHeaderLimiter, ResponseMode, + collect_response_stream, enforce_payload_content_length, limit_decoded_stream, + limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, rechunk_stream, + validate_for_dispatch, +}; +use edgezero_core::time::{DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget}; +use futures_util::StreamExt as _; +use futures_util::future::join_all; +use reqwest::header::HeaderMap as ReqwestHeaderMap; +use reqwest::redirect::Policy; +use std::sync::Arc; +use tokio::time::timeout; + +/// Native outbound HTTP implementation used by the Axum adapter. +pub struct AxumOutboundClient { + client: Arc, + clock: MonotonicClock, +} + +struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, +} + +enum PreparedSlot { + Finished(OutboundSlotResult), + Pending(Box), +} + +impl AxumOutboundClient { + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + mut headers, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + let request_body = + collect_request_body(body, max_request_body_bytes, budget, &self.clock).await?; + let remaining = budget_remaining(budget, &self.clock)?; + let request = self + .client + .request(reqwest_method(&method)?, uri.to_string()) + .headers(headers) + .body(request_body) + .timeout(remaining); + let response = request + .send() + .await + .map_err(|error| classify_send_error(&error, budget, &self.clock))?; + + process_response( + response, + method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + Self::prepare_validated(request, started_at) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses; use send for a streamed response", + )); + } + Self::prepare_validated(request, started_at) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + let budget = dispatch_budget(&request, started_at)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + /// Builds a client with redirects and automatic content decoding disabled. + /// + /// # Errors + /// Returns the underlying client-construction error when TLS initialization fails. + #[inline] + pub fn try_new() -> Result { + Self::try_with_clock(MonotonicClock::default()) + } + + pub(crate) fn try_transport() -> Result, reqwest::Error> { + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .no_brotli() + .no_deflate() + .no_gzip() + .no_zstd() + .build()?; + Ok(Arc::new(client)) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + /// + /// # Errors + /// Returns the underlying client-construction error when TLS initialization fails. + #[inline] + pub fn try_with_clock(clock: MonotonicClock) -> Result { + Self::try_transport().map(|client| Self { client, clock }) + } + + pub(crate) fn with_transport_and_clock( + client: Arc, + clock: MonotonicClock, + ) -> Self { + Self { client, clock } + } +} + +#[async_trait(?Send)] +impl OutboundHttpClient for AxumOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + async fn send_all(&self, requests: Vec) -> Vec { + let batch_started_at = self.clock.now(); + let preflight: Vec = requests + .into_iter() + .map(|request| { + Self::prepare_batch(request, batch_started_at).map_or_else( + |error| { + PreparedSlot::Finished(finish_slot( + batch_started_at, + Err(error), + &self.clock, + )) + }, + |prepared| PreparedSlot::Pending(Box::new(prepared)), + ) + }) + .collect(); + + join_all(preflight.into_iter().map(|slot| async move { + match slot { + PreparedSlot::Pending(prepared) => { + let outcome = self.execute(*prepared).await; + finish_slot(batch_started_at, outcome, &self.clock) + } + PreparedSlot::Finished(done) => done, + } + })) + .await + } +} + +async fn collect_request_body( + body: Body, + maximum: u64, + budget: DispatchBudget, + clock: &MonotonicClock, +) -> Result { + match body { + Body::Once(bytes) => { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + budget_remaining(budget, clock)?; + Ok(bytes) + } + Body::Stream(mut source) => { + let mut collected = Vec::new(); + let mut total = 0_u64; + loop { + let remaining = budget_remaining(budget, clock)?; + let next_item = timeout(remaining, source.next()) + .await + .map_err(|_elapsed| timeout_error(budget.cause))?; + budget_remaining(budget, clock)?; + let Some(item) = next_item else { + return Ok(Bytes::from(collected)); + }; + let bytes = item?; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + collected.extend_from_slice(&bytes); + total = next_total; + } + } + } +} + +fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, +) -> BodyStream { + stream! { + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next_item = match timeout(remaining, source.next()).await { + Ok(item) => item, + Err(_elapsed) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() +} + +fn finish_slot( + started_at: MonotonicInstant, + outcome: Result, + clock: &MonotonicClock, +) -> OutboundSlotResult { + let completed_at = clock.now(); + match completed_at.checked_duration_since(started_at) { + Some(elapsed) => OutboundSlotResult::new(elapsed, outcome), + None => OutboundSlotResult::new( + Duration::ZERO, + Err(EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards during outbound dispatch" + ))), + ), + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the adapter consumes the independent request policy fields without hiding them" +)] +async fn process_response( + response: reqwest::Response, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, +) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; + let mut headers = copy_headers(response.headers()); + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let disposition = normalize_response_headers(&request_method, status, &mut headers)?; + headers.insert(PROXY_HEADER, HeaderValue::from_static("axum")); + + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + let native = response_stream(response, budget, clock.clone()); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + if !declared_reset_body { + let mut reset_stream = deadline_stream(native, budget, clock); + if let Some(item) = reset_stream.next().await { + item?; + } + } + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let encoding = classify_content_encoding(&headers); + let max_buffered = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + &headers, + encoding, + max_buffered, + max_decoded_response_bytes, + max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, max_encoded_response_bytes); + let decoded = match encoding { + ContentEncoding::Brotli => { + decode_brotli_stream(encoded, max_brotli_window_bits, max_brotli_decoder_bytes) + } + ContentEncoding::Gzip => decode_gzip_stream(encoded), + ContentEncoding::Identity | ContentEncoding::Passthrough => encoded, + }; + if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match encoding { + ContentEncoding::Brotli | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough => decoded, + }; + let shaped = rechunk_stream(output, max_chunk_bytes); + let deadline_bound = deadline_stream(shaped, budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + body, + response_clock, + )) +} + +fn budget_remaining(budget: DispatchBudget, clock: &MonotonicClock) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) +} + +fn classify_send_error( + error: &reqwest::Error, + budget: DispatchBudget, + clock: &MonotonicClock, +) -> EdgeError { + if budget.deadline.is_expired_at(clock.now()) { + return timeout_error(budget.cause); + } + if error.is_timeout() { + return timeout_error(budget.cause); + } + let reason = if error.is_connect() { + BadGatewayReason::Unreachable + } else if error.is_builder() { + BadGatewayReason::Protocol + } else { + BadGatewayReason::Transport + }; + EdgeError::bad_gateway_with_reason("upstream request failed", reason) +} + +fn copy_headers(headers: &ReqwestHeaderMap) -> HeaderMap { + let mut copied = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + copied.append(name.clone(), value.clone()); + } + copied +} + +fn reqwest_method(method: &Method) -> Result { + reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(EdgeError::internal) +} + +fn response_stream( + mut response: reqwest::Response, + budget: DispatchBudget, + clock: MonotonicClock, +) -> BodyStream { + stream! { + loop { + match response.chunk().await { + Ok(Some(bytes)) => yield Ok(bytes), + Ok(None) => return, + Err(error) => { + let mapped = if budget.deadline.is_expired_at(clock.now()) || error.is_timeout() { + timeout_error(budget.cause) + } else { + EdgeError::bad_gateway_with_reason( + "upstream response body failed", + BadGatewayReason::Transport, + ) + }; + yield Err(mapped); + return; + } + } + } + } + .boxed_local() +} + +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(test)] +mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use edgezero_core::time::Deadline; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[tokio::test] + async fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = AxumOutboundClient::try_with_clock(scripted_clock(vec![start, completed])) + .expect("client"); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client.send_all(vec![request]).await; + + assert_eq!(results[0].elapsed, Duration::from_millis(9)); + assert!(matches!( + results[0].outcome, + Err(EdgeError::BadRequest { .. }) + )); + } + + #[tokio::test] + async fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = AxumOutboundClient::try_with_clock(scripted_clock(vec![start, earlier])) + .expect("client"); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client.send_all(vec![request]).await; + + assert_eq!(results[0].elapsed, Duration::ZERO); + assert!(matches!( + results[0].outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[tokio::test] + async fn buffered_request_preparation_reduces_the_remaining_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let clock = scripted_clock(vec![observed, observed]); + + let body = collect_request_body(Body::from("body"), 16, budget, &clock) + .await + .expect("prepared body"); + let remaining = budget_remaining(budget, &clock).expect("remaining budget"); + + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!(remaining, Duration::from_millis(7)); + } + + #[tokio::test] + async fn streamed_upload_checks_injected_clock_after_ready_item() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let body = Body::from_stream(stream::once(async { Ok(Bytes::from_static(b"body")) })); + + let error = collect_request_body(body, 16, budget, &clock) + .await + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[tokio::test] + async fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } +} diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/proxy.rs deleted file mode 100644 index 1dd3e504..00000000 --- a/crates/edgezero-adapter-axum/src/proxy.rs +++ /dev/null @@ -1,334 +0,0 @@ -use std::time::Duration; - -use async_trait::async_trait; -use edgezero_core::body::Body; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Method, StatusCode}; -use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::StreamExt as _; -use reqwest::{Client, header}; - -pub struct AxumProxyClient { - client: Client, -} - -impl AxumProxyClient { - /// Construct a proxy client with the workspace-default 30-second timeout. - /// - /// **Breaking change (pre-1.0):** previously `AxumProxyClient` implemented - /// `Default` and panicked if reqwest's TLS backend could not be initialised. - /// Construction is now fallible so callers can decide how to handle a - /// missing or misconfigured TLS backend. - /// - /// # Errors - /// Returns the underlying [`reqwest::Error`] if `reqwest::Client::builder().build()` - /// fails — typically because the TLS backend cannot be initialised on this target. - #[inline] - pub fn try_new() -> Result { - let client = Client::builder().timeout(Duration::from_secs(30)).build()?; - Ok(Self { client }) - } -} - -#[async_trait(?Send)] -impl ProxyClient for AxumProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _extensions) = request.into_parts(); - let reqwest_method = reqwest_method(&method)?; - let mut builder = self.client.request(reqwest_method, uri.to_string()); - - for (name, value) in &headers { - let header_name = header::HeaderName::from_bytes(name.as_str().as_bytes()) - .map_err(EdgeError::internal)?; - let header_value = - header::HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; - builder = builder.header(header_name, header_value); - } - - builder = match body { - Body::Once(bytes) => builder.body(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut buf = Vec::new(); - while let Some(result) = stream.next().await { - let chunk = result.map_err(EdgeError::internal)?; - buf.extend_from_slice(&chunk); - } - builder.body(buf) - } - }; - - let response = builder.send().await.map_err(EdgeError::internal)?; - let status = - StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - for (name, value) in response.headers() { - let header_name = - HeaderName::from_bytes(name.as_str().as_bytes()).map_err(EdgeError::internal)?; - let header_value = - HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; - proxy_response - .headers_mut() - .insert(header_name, header_value); - } - - let bytes = response.bytes().await.map_err(EdgeError::internal)?; - *proxy_response.body_mut() = Body::from(bytes.to_vec()); - - Ok(proxy_response) - } -} - -fn reqwest_method(method: &Method) -> Result { - reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(EdgeError::internal) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::mem; - - #[test] - fn converts_method_to_reqwest() { - let method = Method::POST; - let req = reqwest_method(&method).expect("reqwest method"); - assert_eq!(req, reqwest::Method::POST); - } - - #[test] - fn converts_all_methods_to_reqwest() { - let cases = [ - (Method::GET, reqwest::Method::GET), - (Method::POST, reqwest::Method::POST), - (Method::PUT, reqwest::Method::PUT), - (Method::DELETE, reqwest::Method::DELETE), - (Method::PATCH, reqwest::Method::PATCH), - (Method::HEAD, reqwest::Method::HEAD), - (Method::OPTIONS, reqwest::Method::OPTIONS), - ]; - for (input, expected) in cases { - let result = reqwest_method(&input).expect("method conversion"); - assert_eq!(result, expected); - } - } - - #[test] - fn default_client_creates_successfully() { - let client = AxumProxyClient::try_new().expect("reqwest client init"); - // Just verify it builds without panicking - assert!(mem::size_of_val(&client) > 0); - } -} - -#[cfg(test)] -mod integration_tests { - use super::*; - use axum::Router; - use axum::body::Bytes as AxumBytes; - use axum::http::header::CONTENT_TYPE; - use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; - use axum::routing::{delete, get, patch, post, put}; - use edgezero_core::http::Uri; - use tokio::net::TcpListener; - - async fn start_test_server(router: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - format!("http://{addr}") - } - - #[tokio::test] - async fn proxy_client_sends_get_request() { - let app = Router::new().route("/test", get(|| async { "hello from server" })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/test").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"hello from server"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_sends_post_with_body() { - let app = Router::new().route("/echo", post(|body: AxumBytes| async move { body })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/echo").parse().unwrap(); - let mut request = ProxyRequest::new(Method::POST, uri); - *request.body_mut() = Body::from("request body data"); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"request body data"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_forwards_request_headers() { - let app = Router::new().route( - "/headers", - get(|headers: AxumHeaderMap| async move { - headers - .get("x-custom-header") - .and_then(|val| val.to_str().ok()) - .unwrap_or("missing") - .to_owned() - }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/headers").parse().unwrap(); - let mut request = ProxyRequest::new(Method::GET, uri); - request - .headers_mut() - .insert("x-custom-header", HeaderValue::from_static("custom-value")); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"custom-value"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_receives_response_headers() { - let app = Router::new().route( - "/with-headers", - get(|| async { ([(CONTENT_TYPE, "application/json")], "{}") }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/with-headers").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let content_type = response - .headers() - .get("content-type") - .and_then(|val| val.to_str().ok()); - assert_eq!(content_type, Some("application/json")); - } - - #[tokio::test] - async fn proxy_client_handles_404() { - let app = Router::new(); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/nonexistent").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn proxy_client_handles_500() { - let app = Router::new().route( - "/error", - get(|| async { (AxumStatusCode::INTERNAL_SERVER_ERROR, "error") }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/error").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[tokio::test] - async fn proxy_client_handles_various_methods() { - let app = Router::new() - .route("/method", get(|| async { "GET" })) - .route("/method", post(|| async { "POST" })) - .route("/method", put(|| async { "PUT" })) - .route("/method", delete(|| async { "DELETE" })) - .route("/method", patch(|| async { "PATCH" })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - - for (method, expected_body) in [ - (Method::GET, "GET"), - (Method::POST, "POST"), - (Method::PUT, "PUT"), - (Method::DELETE, "DELETE"), - (Method::PATCH, "PATCH"), - ] { - let uri: Uri = format!("{base_url}/method").parse().unwrap(); - let request = ProxyRequest::new(method, uri); - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), expected_body.as_bytes()), - Body::Stream(_) => panic!("expected buffered body"), - } - } - } - - #[tokio::test] - async fn proxy_client_handles_connection_refused() { - let client = AxumProxyClient::try_new().expect("reqwest client init"); - // Use a port that's unlikely to have anything running - let uri: Uri = "http://127.0.0.1:1".parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - client - .send(request) - .await - .expect_err("expected connection refused"); - } - - #[tokio::test] - async fn proxy_client_sends_streaming_body() { - use bytes::Bytes; - use futures::stream; - - let app = Router::new().route("/stream-echo", post(|body: AxumBytes| async move { body })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/stream-echo").parse().unwrap(); - let mut request = ProxyRequest::new(Method::POST, uri); - - // Create a streaming body - Body::stream expects Stream - let chunks = vec![ - Bytes::from("chunk1"), - Bytes::from("chunk2"), - Bytes::from("chunk3"), - ]; - let stream = stream::iter(chunks); - *request.body_mut() = Body::stream(stream); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"chunk1chunk2chunk3"), - Body::Stream(_) => panic!("expected buffered body"), - } - } -} diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index 9e3f5976..3112bf5f 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -1,37 +1,50 @@ use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; -use axum::body::{Body as AxumBody, to_bytes}; +use axum::body::{Body as AxumBody, BodyDataStream}; use axum::extract::connect_info::ConnectInfo; -use axum::http::Request; +use axum::http::{Request, request::Parts}; use edgezero_core::body::Body; -use edgezero_core::http::HeaderValue; +use edgezero_core::error::EdgeError; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::CONTENT_TYPE; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::{StreamExt as _, stream}; +use tokio::time::timeout; use crate::context::AxumRequestContext; -use crate::proxy::AxumProxyClient; +use crate::outbound::AxumOutboundClient; /// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. /// /// # Errors -/// Returns an error if a buffered (`application/json`) body cannot be read into memory. +/// Returns an error if the outbound client cannot be initialized. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request(request: Request) -> Result { let (parts, axum_body) = request.into_parts(); + into_core_request_parts(parts, axum_body, None, None) +} - let body = match parts.headers.get(CONTENT_TYPE) { - Some(value) if is_json_content_type(value) => { - let bytes = to_bytes(axum_body, usize::MAX) - .await - .map_err(|err| format!("Failed to convert body into bytes: {err}"))?; - Body::from_bytes(bytes) - } - _ => { - let stream = axum_body.into_data_stream(); - Body::from_stream(stream) +pub(crate) fn into_core_request_parts( + parts: Parts, + axum_body: AxumBody, + read_lifetime: Option<(Deadline, MonotonicClock)>, + outbound_transport: Option>, +) -> Result { + let outbound_clock = read_lifetime + .as_ref() + .map_or_else(MonotonicClock::default, |(_, clock)| clock.clone()); + let body = match read_lifetime { + Some((deadline, monotonic_clock)) => { + deadline_body(axum_body.into_data_stream(), deadline, monotonic_clock) } + None => Body::from_external_stream(axum_body.into_data_stream()), }; let mut core_request = CoreRequest::from_parts(parts, body); @@ -52,47 +65,112 @@ pub async fn into_core_request(request: Request) -> Result AxumOutboundClient::with_transport_and_clock(transport, outbound_clock), + None => AxumOutboundClient::try_with_clock(outbound_clock) + .map_err(|_error| "failed to build outbound HTTP client".to_owned())?, + }; core_request .extensions_mut() - .insert(ProxyHandle::with_client(proxy_client)); + .insert(HttpClient::with_client(outbound_client)); Ok(core_request) } -fn is_json_content_type(value: &HeaderValue) -> bool { - let Ok(raw) = value.to_str() else { - return false; - }; - - let media_type = raw.split(';').next().map_or("", str::trim); - if media_type.eq_ignore_ascii_case("application/json") { - return true; - } - - let Some((ty, raw_subtype)) = media_type.split_once('/') else { - return false; - }; - - if !ty.eq_ignore_ascii_case("application") { - return false; - } - - let subtype = raw_subtype.trim(); - let Some(suffix_start) = subtype.len().checked_sub(5) else { - return false; - }; - subtype - .get(suffix_start..) - .is_some_and(|suffix| suffix.eq_ignore_ascii_case("+json")) +fn deadline_body( + body_stream: BodyDataStream, + deadline: Deadline, + monotonic_clock: MonotonicClock, +) -> Body { + let deadline_stream = stream::unfold( + Some(Box::pin(body_stream)), + move |stream_state: Option>>| { + let clock = monotonic_clock.clone(); + async move { + let mut state_stream = stream_state?; + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + }; + let item = match timeout(remaining, state_stream.next()).await { + Ok(item) => item, + Err(_elapsed) => { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + }; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(state_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }, + ); + Body::from_stream(deadline_stream) } #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::http::Method; + use edgezero_core::http::{Method, StatusCode}; + use edgezero_core::time::MonotonicInstant; + use std::io; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn deadline_body_releases_source_when_timeout_is_emitted() { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = stream::poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = deadline_body( + AxumBody::from_stream(source).into_data_stream(), + Deadline::at_instant(start), + clock, + ); + let mut body_stream = body.into_stream().expect("stream"); + + let error = body_stream + .next() + .await + .expect("terminal timeout item") + .expect_err("timeout"); + assert_eq!(error.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } #[tokio::test] async fn converts_request_and_records_connect_info() { @@ -125,6 +203,25 @@ mod tests { .get::>() .is_none() ); + assert!(core_request.extensions().get::().is_some()); + } + + #[tokio::test] + async fn supplied_outbound_transport_is_retained_by_the_core_request() { + let transport = AxumOutboundClient::try_transport().expect("transport"); + let request = Request::builder() + .method(Method::GET) + .uri("/demo") + .body(AxumBody::empty()) + .expect("request"); + let (parts, body) = request.into_parts(); + + let core_request = into_core_request_parts(parts, body, None, Some(Arc::clone(&transport))) + .expect("request conversion"); + + assert_eq!(Arc::strong_count(&transport), 2); + drop(core_request); + assert_eq!(Arc::strong_count(&transport), 1); } #[tokio::test] @@ -142,7 +239,7 @@ mod tests { } #[tokio::test] - async fn json_content_type_buffers_body() { + async fn json_content_type_stays_streaming() { let json_payload = r#"{"name":"test"}"#; let request = Request::builder() .method(Method::POST) @@ -156,12 +253,7 @@ mod tests { .expect("request conversion"); assert_eq!(core_request.method(), &Method::POST); - match core_request.body() { - Body::Once(bytes) => { - assert_eq!(bytes.as_ref(), json_payload.as_bytes()); - } - Body::Stream(_) => panic!("JSON body should be buffered, not streaming"), - } + assert!(matches!(core_request.body(), Body::Stream(_))); } #[tokio::test] @@ -179,27 +271,4 @@ mod tests { assert!(matches!(core_request.body(), Body::Stream(_))); } - - #[test] - fn json_content_type_detection() { - assert!(is_json_content_type(&HeaderValue::from_static( - "application/json" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "application/json; charset=utf-8" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "application/vnd.api+json" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "APPLICATION/VND.CUSTOM+JSON; CHARSET=UTF-8" - ))); - - assert!(!is_json_content_type(&HeaderValue::from_static( - "text/json" - ))); - assert!(!is_json_content_type(&HeaderValue::from_static( - "application/json+xml" - ))); - } } diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index cf3bb6c6..964e751b 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -1,80 +1,58 @@ use axum::body::Body as AxumBody; -use axum::http::header::CONTENT_TYPE; -use axum::http::{HeaderValue, Response, StatusCode}; -use futures::executor::block_on; -use futures_util::{StreamExt as _, pin_mut}; -use tracing::error; +use axum::http::Response; use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; use edgezero_core::http::Response as CoreResponse; +use edgezero_core::outbound::collect_response_stream; + +pub const AXUM_RESPONSE_STREAM_BUFFER_BYTES: u64 = 0x0100_0000; /// Convert an `EdgeZero` response into one consumable by Axum/Hyper. /// -/// Streaming responses are collected into an in-memory buffer. While this sacrifices -/// incremental flushing, it keeps the adapter compatible with the non-`Send` streaming type used by -/// `edgezero_core::Body` and works well for local development. +/// Streaming responses are collected under a fixed adapter boundary because Axum requires a +/// `Send` response body while the portable core stream is intentionally local. /// +/// # Errors +/// Returns the original typed stream failure, or a typed response-limit error when the adapter +/// conversion boundary exceeds [`AXUM_RESPONSE_STREAM_BUFFER_BYTES`]. #[inline] -pub fn into_axum_response(response: CoreResponse) -> Response { +pub async fn into_axum_response(response: CoreResponse) -> Result, EdgeError> { let (parts, core_body) = response.into_parts(); let body = match core_body { Body::Once(bytes) => AxumBody::from(bytes), - Body::Stream(stream) => { - let result = block_on(async { - let mut buf = Vec::new(); - pin_mut!(stream); - while let Some(chunk) = stream.next().await { - let bytes = chunk?; - buf.extend_from_slice(&bytes); - } - Ok::, anyhow::Error>(buf) - }); - match result { - Ok(buf) => AxumBody::from(buf), - Err(err) => { - error!("streaming response error: {err}"); - return error_response_500("streaming response error"); - } - } - } + Body::Stream(stream) => AxumBody::from( + collect_response_stream(stream, AXUM_RESPONSE_STREAM_BUFFER_BYTES).await?, + ), }; - Response::from_parts(parts, body) -} - -/// Build a minimal 500 response without any builder steps that could fail. -/// Used as a fallback on the request path so we never panic on synthesis. -fn error_response_500(message: &'static str) -> Response { - let mut response = Response::new(AxumBody::from(message)); - *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response + Ok(Response::from_parts(parts, body)) } #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; use edgezero_core::body::Body; + use edgezero_core::error::ResponseLimitReason; use edgezero_core::http::{StatusCode, response_builder}; use futures::stream; + use futures_util::StreamExt as _; - #[test] - fn converts_core_response_stream_into_axum_body() { + #[tokio::test] + async fn converts_core_response_stream_into_axum_body() { let stream = stream::iter(vec![ Ok::<_, anyhow::Error>(bytes::Bytes::from_static(b"hel")), Ok(bytes::Bytes::from_static(b"lo")), ]); - let body = Body::from_stream(stream); + let body = Body::from_external_stream(stream); let response = response_builder() .status(StatusCode::OK) .header("content-type", "text/plain") .body(body) .expect("response"); - let axum_response = into_axum_response(response); + let axum_response = into_axum_response(response).await.expect("conversion"); assert_eq!(axum_response.status(), StatusCode::OK); assert_eq!( axum_response @@ -86,16 +64,44 @@ mod tests { "text/plain" ); - let collected = block_on(async { - let mut data = Vec::new(); - let mut body_stream = axum_response.into_body().into_data_stream(); - while let Some(result) = body_stream.next().await { - let chunk = result.expect("chunk"); - data.extend_from_slice(&chunk); - } - data - }); + let mut data = Vec::new(); + let mut body_stream = axum_response.into_body().into_data_stream(); + while let Some(result) = body_stream.next().await { + let chunk = result.expect("chunk"); + data.extend_from_slice(&chunk); + } - assert_eq!(collected, b"hello"); + assert_eq!(data, b"hello"); + } + + #[tokio::test] + async fn response_stream_conversion_enforces_fixed_cap() { + let cap = usize::try_from(AXUM_RESPONSE_STREAM_BUFFER_BYTES).expect("cap fits usize"); + let exact = response_builder() + .status(StatusCode::OK) + .body(Body::from_stream(stream::iter([Ok(Bytes::from(vec![ + 0; + cap + ]))]))) + .expect("response"); + into_axum_response(exact).await.expect("exact cap"); + + let over = response_builder() + .status(StatusCode::OK) + .body(Body::from_stream(stream::iter([Ok(Bytes::from(vec![ + 0; + cap + 1 + ]))]))) + .expect("response"); + let error = into_axum_response(over) + .await + .expect_err("one byte over cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); } } diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index 89f77287..61947885 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -12,6 +12,7 @@ use std::env; use async_trait::async_trait; use bytes::Bytes; use edgezero_core::secret_store::{SecretError, SecretStore}; +use edgezero_core::{BoundedStoreRead, Deadline}; /// Secret store for local development that reads secrets from environment variables. /// @@ -61,6 +62,79 @@ impl SecretStore for EnvSecretStore { } } } + + #[inline] + async fn get_bytes_bounded( + &self, + _store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + + #[cfg(unix)] + let stored_value = env::var_os(key); + + #[cfg(not(unix))] + let stored_value = env::var(key); + + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + + #[cfg(not(unix))] + let stored_value = match stored_value { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + return Err(SecretError::Internal(anyhow::anyhow!( + "secret store returned an invalid Unicode value" + ))); + } + }; + + #[cfg(unix)] + let backend_bytes = { + use std::os::unix::ffi::OsStrExt as _; + + stored_value.as_ref().map_or(Ok(0_u64), |value| { + u64::try_from(value.as_os_str().as_bytes().len()) + .map_err(|_length_error| SecretError::ValueTooLarge) + })? + }; + + #[cfg(not(unix))] + let backend_bytes = stored_value.as_ref().map_or(Ok(0_u64), |value| { + u64::try_from(value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + + #[cfg(unix)] + let value = { + use std::os::unix::ffi::OsStringExt as _; + + stored_value.map(|value| Bytes::from(value.into_vec())) + }; + + #[cfg(not(unix))] + let value = stored_value.map(|value| Bytes::from(value.into_bytes())); + + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } #[cfg(test)] @@ -78,11 +152,84 @@ mod tests { use super::*; use crate::test_utils::env_guard; use bytes::Bytes; - use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use edgezero_core::secret_store_contract_tests; use edgezero_core::test_env::EnvOverride; + use edgezero_core::{Deadline, MonotonicInstant}; #[cfg(unix)] use std::ffi::OsString; + use std::sync::Arc; + use std::time::Duration; + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_accepts_exact_caps_and_reports_exact_backend_bytes() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_TEST_BOUNDED_SECRET__", "hello"); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + + let read = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_BOUNDED_SECRET__", + Deadline::after(Duration::from_secs(1)), + 5, + 5, + ) + .await + .expect("exact cap must succeed"); + + assert_eq!(read.backend_bytes, 5); + assert_eq!(read.value, Some(Bytes::from_static(b"hello"))); + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_rejects_either_cap() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_TEST_OVERSIZE_SECRET__", "hello"); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_OVERSIZE_SECRET__", + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + ) + .await + .expect_err("over-cap secret must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_rejects_an_expired_deadline() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set("__EDGEZERO_TEST_EXPIRED_SECRET__", "hello"); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let expired = Deadline::at_instant(MonotonicInstant::now()); + + let error = handle + .get_bytes_bounded("env", "__EDGEZERO_TEST_EXPIRED_SECRET__", expired, 5, 5) + .await + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_preserves_handle_name_validation() { + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + + let error = handle + .get_bytes_bounded("", "key", Deadline::after(Duration::from_secs(1)), 5, 5) + .await + .expect_err("empty store name must be rejected"); + + assert!(matches!(error, SecretError::Validation(_))); + } #[cfg(unix)] #[tokio::test(flavor = "current_thread")] diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index ecaf9b1c..3d0e029b 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -1,51 +1,71 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use axum::body::Body as AxumBody; use axum::http::{Request, Response}; +use edgezero_core::app::App; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::error::EdgeError; use edgezero_core::http::StatusCode; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressFraming, IngressHeadAccounting, IngressHeadParts, + validate_normalized_ingress_parts, +}; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::response::IntoResponse as _; +use edgezero_core::response_egress::{ResponseEgressEnvelope, ResponseEgressOutcome}; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, }; +use tokio::time::timeout; use tokio::{runtime::Handle, task}; use tower::Service; -use crate::request::into_core_request; +use crate::outbound::AxumOutboundClient; +use crate::request::into_core_request_parts; use crate::response::into_axum_response; /// Tower service that adapts `EdgeZero` router requests to Axum/Hyper compatible responses. #[derive(Clone)] pub struct EdgeZeroAxumService { + app: Arc, config_registry: Option, config_store_handle: Option, kv_handle: Option, kv_registry: Option, - router: RouterService, + outbound_transport: Option>, secret_handle: Option, secret_registry: Option, } impl EdgeZeroAxumService { + /// Creates a service that preserves all policies configured on `App`. #[must_use] #[inline] - pub fn new(router: RouterService) -> Self { + pub fn from_app(app: App) -> Self { Self { + app: Arc::new(app), config_registry: None, config_store_handle: None, kv_handle: None, kv_registry: None, - router, + outbound_transport: AxumOutboundClient::try_transport().ok(), secret_handle: None, secret_registry: None, } } + #[must_use] + #[inline] + pub fn new(router: RouterService) -> Self { + Self::from_app(App::new(router)) + } + /// Attach an id-keyed config-store registry to this service. #[must_use] #[inline] @@ -130,7 +150,9 @@ impl Service> for EdgeZeroAxumService { #[inline] fn call(&mut self, req: Request) -> Self::Future { - let router = self.router.clone(); + let request_start = self.app.monotonic_now(); + let app = Arc::clone(&self.app); + let outbound_transport_handle = self.outbound_transport.clone(); // Hard-cutoff: legacy bare `KvHandle` / // `ConfigStoreHandle` / `SecretHandle` entries are NO // LONGER inserted into request extensions. The legacy @@ -170,38 +192,65 @@ impl Service> for EdgeZeroAxumService { }) }); Box::pin(async move { - let mut core_request = match into_core_request(req).await { - Ok(converted) => converted, - Err(err) => { - let mut err_response = Response::new(AxumBody::from(err.clone())); - *err_response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - - return Ok(err_response); - } - }; - - if let Some(registry) = config_registry { - core_request.extensions_mut().insert(registry); - } - if let Some(registry) = kv_registry { - core_request.extensions_mut().insert(registry); - } - if let Some(registry) = secret_registry { - core_request.extensions_mut().insert(registry); - } - - let core_response = task::block_in_place(move || { - Handle::current().block_on(router.oneshot(core_request)) + let response = task::block_in_place(move || { + Handle::current().block_on(async move { + let (parts, native_body) = req.into_parts(); + if let Err(error) = + validate_normalized_ingress_parts(&parts, app.ingress_head_limits()) + { + return convert_error_response(error).await; + } + let head_parts = IngressHeadParts::from_parts( + &parts, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let prepared = match app.begin_ingress(head_parts, request_start) { + Ok(IngressBeginOutcome::Admitted(prepared)) => prepared, + Ok(IngressBeginOutcome::Refused(response)) => { + return convert_egress_envelope(response).await; + } + Err(error) => return convert_error_response(error).await, + Ok(_) => { + return minimal_error_response( + "unsupported ingress admission outcome".to_owned(), + ); + } + }; + let read_deadline = prepared.read_deadline(); + let monotonic_clock = prepared.monotonic_clock(); + let Some(outbound_transport) = outbound_transport_handle else { + return minimal_error_response( + "failed to initialize outbound HTTP transport".to_owned(), + ); + }; + let mut core_request = match into_core_request_parts( + parts, + native_body, + Some((read_deadline, monotonic_clock)), + Some(outbound_transport), + ) { + Ok(converted) => converted, + Err(err) => return minimal_error_response(err), + }; + + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); + } + + let egress = match app.dispatch_admitted(prepared, core_request).await { + Ok(egress) => egress, + Err(error) => return convert_error_response(error).await, + }; + convert_egress_envelope(egress).await + }) }); - let response = match core_response { - Ok(response) => into_axum_response(response), - Err(err) => { - let body = AxumBody::from(format!("internal error: {err}")); - let mut fallback = Response::new(body); - *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - fallback - } - }; Ok(response) }) } @@ -212,22 +261,144 @@ impl Service> for EdgeZeroAxumService { } } +async fn convert_egress_envelope(egress: ResponseEgressEnvelope) -> Response { + let Ok((response, policy, mut attempt, clock)) = egress.begin() else { + return minimal_error_response("response-egress policy failed".to_owned()); + }; + let Some(remaining) = policy.write_deadline.remaining_at(clock.now()) else { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return minimal_status_response( + StatusCode::GATEWAY_TIMEOUT, + "response write deadline exceeded".to_owned(), + ); + }; + + let result = match timeout(remaining, into_axum_response(response)).await { + Ok(result) => result, + Err(_elapsed) => { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return minimal_status_response( + StatusCode::GATEWAY_TIMEOUT, + "response write deadline exceeded".to_owned(), + ); + } + }; + let observed_at = clock.now(); + if policy.write_deadline.is_expired_at(observed_at) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, observed_at); + return minimal_status_response( + StatusCode::GATEWAY_TIMEOUT, + "response write deadline exceeded".to_owned(), + ); + } + match result { + Ok(converted) => { + attempt.terminate(ResponseEgressOutcome::ResponseReturned, observed_at); + converted + } + Err(error) => { + let outcome = if matches!(error, EdgeError::ResponseTooLarge { .. }) { + ResponseEgressOutcome::ConversionError + } else { + ResponseEgressOutcome::SourceError + }; + attempt.terminate(outcome, observed_at); + convert_error_response(error).await + } + } +} + +async fn convert_error_response(error: EdgeError) -> Response { + match error.into_response() { + Ok(error_response) => match into_axum_response(error_response).await { + Ok(converted) => converted, + Err(fallback_error) => minimal_error_response(format!( + "internal response conversion error: {fallback_error}" + )), + }, + Err(fallback_error) => { + minimal_error_response(format!("internal error response error: {fallback_error}")) + } + } +} + +fn minimal_error_response(message: String) -> Response { + minimal_status_response(StatusCode::INTERNAL_SERVER_ERROR, message) +} + +fn minimal_status_response(status: StatusCode, message: String) -> Response { + let mut response = Response::new(AxumBody::from(message)); + *response.status_mut() = status; + response +} + #[cfg(test)] mod tests { use super::*; use axum::body::to_bytes; + use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{StatusCode, response_builder}; + use edgezero_core::http::{ + HeaderMap, HeaderValue, Response as CoreResponse, StatusCode, response_builder, + }; + use edgezero_core::ingress::{AdmissionDecision, BufferedIngressResponse, IngressGrant}; use edgezero_core::key_value_store::KvStore; - use std::sync::Arc; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::outbound::OutboundRequest; + use edgezero_core::response_egress::{ + ResponseEgressObserver, ResponseEgressPolicy, ResponseEgressReport, + }; + use edgezero_core::router::{RouteMetadata, RouteResolution}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures_util::stream::poll_fn; + use std::io; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Poll, Waker}; + use std::time::Duration; use tower::ServiceExt as _; struct FixedConfigStore(String); + struct CountingMiddleware(Arc); + + struct DropSignal(Arc); + + #[derive(Clone)] + struct RecordingEgressObserver(Arc>>); + + impl ResponseEgressObserver for RecordingEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the legacy test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for FixedConfigStore { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.clone())) @@ -252,6 +423,558 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn standard_service_installs_the_exact_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client.send_all(vec![request]).await; + Ok(results[0].elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let router = RouterService::builder().get("/clock", elapsed).build(); + let mut app = App::new(router); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let request = Request::builder() + .uri("/clock") + .body(AxumBody::empty()) + .expect("request"); + + let response = EdgeZeroAxumService::from_app(app) + .oneshot(request) + .await + .expect("response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + + assert_eq!(body, "7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn configured_admission_refuses_before_native_body_poll() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let observed_calls = Arc::clone(&admission_calls); + let observed_during_admission = Arc::clone(&body_polls); + + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| { + observed_calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(observed_during_admission.load(Ordering::SeqCst), 0); + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("refusal"), + ) + }); + let mut service = EdgeZeroAxumService::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/upload") + .body(native_body) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(admission_calls.load(Ordering::SeqCst), 1); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_call_count = Arc::new(AtomicUsize::new(0)); + let observed_handler_calls = Arc::clone(&handler_call_count); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&observed_handler_calls); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_call_count, middleware_calls) + } + + fn fallback_body_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drop_count: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drop_count); + app.set_ingress_admission_policy(move |head| match head.route_resolution() { + RouteResolution::Matched(_) => AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(read_budget), + }, + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound | _ => { + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: buffered_terminal_response( + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"configured overflow\0response", + ), + on_timeout: buffered_terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"configured timeout\0response", + ), + } + } + }); + (app, handler_calls, middleware_calls) + } + + fn buffered_terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new( + status, + terminal_response_headers(marker), + Bytes::from_static(body), + ) + } + + fn terminal_response_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("x-fallback-terminal", HeaderValue::from_static(marker)); + headers + } + + fn tracked_lengthless_request( + path: &str, + body_chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> Request { + let observed_grant_drops = Arc::clone(grant_drops); + let source_drop = DropSignal(Arc::clone(source_drops)); + let observed_body_polls = Arc::clone(body_polls); + let mut pending_chunks = body_chunks.into_iter(); + let stream = poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + observed_body_polls.fetch_add(1, Ordering::SeqCst); + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + Poll::Ready(pending_chunks.next().map(Ok::)) + }); + let request = Request::builder() + .method("POST") + .uri(path) + .body(AxumBody::from_stream(stream)) + .expect("request"); + assert!(request.headers().get("content-length").is_none()); + request + } + + fn assert_no_fallback_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + async fn assert_terminal_response( + response: Response, + status: StatusCode, + marker: &'static str, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!(response.headers(), &terminal_response_headers(marker)); + assert_eq!( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"), + body + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_preserves_404_and_405_at_exact_cap() { + for (path, expected) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4, Duration::from_secs(1), &grant_drops); + let request = tracked_lengthless_request( + path, + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + ); + let response = EdgeZeroAxumService::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + assert_eq!(response.status(), expected); + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_overflow_precedes_404_and_405() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4, Duration::from_secs(1), &grant_drops); + let request = tracked_lengthless_request( + path, + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + ); + let response = EdgeZeroAxumService::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + assert_terminal_response( + response, + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"configured overflow\0response", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_deadline_interrupts_pending_native_body() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4_096, Duration::from_millis(500), &grant_drops); + let mut service = EdgeZeroAxumService::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/missing") + .body(native_body) + .expect("request"); + + let response_task = + tokio::spawn(async move { service.ready().await.expect("ready").call(request).await }); + timeout(Duration::from_secs(2), async { + while body_polls.load(Ordering::SeqCst) == 0 { + task::yield_now().await; + } + }) + .await + .expect("native body must be polled before its read deadline"); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + + let response = timeout(Duration::from_secs(5), response_task) + .await + .expect("fallback deadline must preempt the pending native body") + .expect("service task") + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"configured timeout\0response", + ) + .await; + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn abort_requested_service_continues_until_the_fallback_drain_finishes() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body_ready = Arc::new(AtomicBool::new(false)); + let observed_ready = Arc::clone(&body_ready); + let body_waker: Arc>> = Arc::new(Mutex::new(None)); + let observed_waker = Arc::clone(&body_waker); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |context| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + if observed_ready.load(Ordering::SeqCst) { + observed_polls.fetch_add(1, Ordering::SeqCst); + return Poll::>>::Ready(None); + } + *observed_waker.lock().expect("body waker") = Some(context.waker().clone()); + observed_polls.fetch_add(1, Ordering::SeqCst); + if observed_ready.load(Ordering::SeqCst) { + Poll::Ready(None) + } else { + Poll::Pending + } + })); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4_096, Duration::from_secs(5), &grant_drops); + let mut service = EdgeZeroAxumService::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/missing") + .body(native_body) + .expect("request"); + + let response_task = + tokio::spawn(async move { service.ready().await.expect("ready").call(request).await }); + timeout(Duration::from_secs(2), async { + while body_polls.load(Ordering::SeqCst) == 0 { + task::yield_now().await; + } + }) + .await + .expect("native body must be polled before cancellation"); + + response_task.abort(); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + body_ready.store(true, Ordering::SeqCst); + body_waker + .lock() + .expect("body waker") + .take() + .expect("pending body waker") + .wake(); + + let response = timeout(Duration::from_secs(2), response_task) + .await + .expect("completed fallback drain must finish the blocking bridge") + .expect("blocking bridge completes despite the abort request") + .expect("response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fallback_refusal_returns_503_without_polling_native_body() { + for path in ["/missing", "/known"] { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from("fallback unavailable\n")) + .expect("refusal response"), + ) + }); + let request = Request::builder() + .method("POST") + .uri(path) + .body(native_body) + .expect("request"); + + let response = EdgeZeroAxumService::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"), + b"fallback unavailable\n".as_slice() + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn returned_response_reports_once_without_claiming_host_handoff() { + let router = RouterService::builder() + .get("/observed", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("ok") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(router); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let mut service = EdgeZeroAxumService::from_app(app); + let request = Request::builder() + .method("GET") + .uri("/observed") + .body(AxumBody::empty()) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].outcome, ResponseEgressOutcome::ResponseReturned); + assert_eq!(observed[0].bytes_written, 0); + assert_eq!( + observed[0].route.as_ref().map(RouteMetadata::pattern), + Some("/observed") + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn injected_clock_controls_response_write_deadline() { + let router = RouterService::builder() + .get("/deadline", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("too late") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let start = MonotonicInstant::now(); + let observed_now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed_now); + let policy_now = Arc::clone(&observed_now); + let mut app = App::new(router); + app.set_monotonic_clock(MonotonicClock::new(move || { + *clock_now.lock().expect("clock lock") + })); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + app.set_response_egress_policy(move |_head, started_at| { + let deadline = started_at + .checked_add(Duration::from_secs(1)) + .expect("write deadline"); + *policy_now.lock().expect("clock lock") = deadline; + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + } + }); + let request = Request::builder() + .method("GET") + .uri("/deadline") + .body(AxumBody::empty()) + .expect("request"); + + let response = EdgeZeroAxumService::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(observed[0].elapsed, Duration::from_secs(1)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admitted_deadline_reaches_body_cell_as_request_timeout() { + let router = RouterService::builder() + .post("/upload", |ctx: RequestContext| async move { + let _bytes = ctx.body_bytes(1024).await?; + Ok::<_, EdgeError>("unexpected success") + }) + .build(); + let mut app = App::new(router); + let request_start = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || request_start)); + app.set_ingress_admission_policy(move |head| { + assert_eq!(head.request_start(), request_start); + AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: Deadline::at_instant(head.request_start()), + } + }); + let mut service = EdgeZeroAxumService::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/upload") + .body(AxumBody::from("data")) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_config_store_handle_injects_into_request() { // Hard-cutoff: legacy `ctx.config_handle()` is diff --git a/crates/edgezero-adapter-axum/tests/contract.rs b/crates/edgezero-adapter-axum/tests/contract.rs new file mode 100644 index 00000000..d9ed5290 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/contract.rs @@ -0,0 +1,347 @@ +#![cfg(all(test, feature = "axum"))] +#![expect( + clippy::expect_used, + clippy::tests_outside_test_module, + reason = "this integration-test crate uses explicit fixture diagnostics and consists only of contract tests" +)] + +use core::convert::Infallible; +use std::io::Write as _; +use std::time::Duration; + +use async_stream::stream as async_body_stream; +use axum::Router; +use axum::body::Body; +use axum::http::HeaderValue; +use axum::http::header::{CONTENT_ENCODING, SET_COOKIE}; +use axum::response::Response; +use axum::routing::get; +use bytes::Bytes; +use edgezero_adapter_axum::outbound::AxumOutboundClient; +use edgezero_core::body::Body as CoreBody; +use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason}; +use edgezero_core::http::{Method, StatusCode}; +use edgezero_core::time::Deadline; +use edgezero_core::{OutboundHttpClient as _, OutboundRequest, PROXY_HEADER}; +use flate2::Compression; +use flate2::write::GzEncoder; +use futures_util::stream; +use tokio::net::TcpListener; +use tokio::time::sleep; + +fn gzip(input: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).expect("gzip input"); + encoder.finish().expect("gzip output") +} + +async fn start_origin(router: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind origin"); + let address = listener.local_addr().expect("origin address"); + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve origin"); + }); + format!("http://{address}") +} + +#[tokio::test] +async fn redirect_response_is_not_followed() { + let origin = start_origin( + Router::new() + .route("/target", get(|| async { "followed" })) + .route( + "/redirect", + get(|| async { (StatusCode::FOUND, [("location", "/target")]) }), + ), + ) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/redirect")).expect("request"); + + let response = client.send(request).await.expect("response"); + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response + .headers() + .get(PROXY_HEADER) + .and_then(|value| value.to_str().ok()), + Some("axum") + ); +} + +#[tokio::test] +async fn send_all_reports_per_slot_elapsed() { + let origin = start_origin(Router::new().route("/", get(|| async { "ok" }))).await; + let client = AxumOutboundClient::try_new().expect("client"); + let requests = vec![ + OutboundRequest::get(format!("{origin}/")).expect("reachable request"), + OutboundRequest::new( + Method::GET, + "http://127.0.0.1:1/unreachable" + .parse() + .expect("unreachable URI"), + ) + .expect("unreachable request"), + ]; + + let results = client.send_all(requests).await; + + assert_eq!(results.len(), 2); + results[0] + .outcome + .as_ref() + .expect("reachable slot succeeds"); + assert!(matches!( + results[1].outcome, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + }) + )); +} + +#[tokio::test] +async fn send_all_preflight_precedence_and_indices() { + let client = AxumOutboundClient::try_new().expect("client"); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(CoreBody::stream(stream::iter([bytes::Bytes::from_static( + b"body", + )]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(CoreBody::stream(stream::iter([bytes::Bytes::new()]))); + + let results = client + .send_all(vec![streamed_upload, streamed_response, method_error]) + .await; + + let messages: Vec<_> = results + .iter() + .map(|slot| match &slot.outcome { + Err(EdgeError::BadRequest { message }) => message.as_str(), + other => panic!("expected preflight rejection, got {other:?}"), + }) + .collect(); + assert_eq!( + messages, + [ + "send_all requires buffered request bodies; use send for a streamed upload", + "send_all requires buffered responses; use send for a streamed response", + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + ] + ); +} + +#[tokio::test] +async fn buffered_response_deadline_covers_body_completion() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let body = async_body_stream! { + yield Ok::<_, Infallible>(Bytes::from_static(b"first")); + sleep(Duration::from_millis(100)).await; + yield Ok::<_, Infallible>(Bytes::from_static(b"second")); + }; + Response::new(Body::from_stream(body)) + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .timeout(Duration::from_millis(10)); + + let error = client + .send(request) + .await + .expect_err("body completion must share the absolute request deadline"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); +} + +#[tokio::test] +async fn send_all_preserves_absolute_deadline_provenance() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + sleep(Duration::from_millis(100)).await; + "late" + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .deadline(Deadline::after(Duration::from_millis(10))); + + let results = client.send_all(vec![request]).await; + + assert!(matches!( + results[0].outcome, + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + }) + )); +} + +#[tokio::test] +async fn streamed_response_deadline_preserves_timeout_provenance() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let body = async_body_stream! { + sleep(Duration::from_secs(2)).await; + yield Ok::<_, Infallible>(Bytes::from_static(b"late")); + }; + Response::new(Body::from_stream(body)) + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .stream_response() + .timeout(Duration::from_millis(500)); + + let response = client.send(request).await.expect("response headers"); + let error = response + .into_body() + .into_bytes_bounded(1_024) + .await + .expect_err("stream body must retain the request deadline"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); +} + +#[tokio::test] +async fn encoded_and_decoded_limits_report_independent_origins() { + let decoded = vec![b'x'; 4_096]; + let encoded = gzip(&decoded); + let origin_body = encoded.clone(); + let origin = start_origin(Router::new().route( + "/", + get(move || { + let bytes = origin_body.clone(); + async move { + Response::builder() + .header(CONTENT_ENCODING, "gzip") + .body(Body::from(bytes)) + .expect("origin response") + } + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + + let decoded_error = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("decoded-limit request") + .max_encoded_response_bytes(u64::try_from(encoded.len()).expect("encoded length")) + .max_decoded_response_bytes(128), + ) + .await + .expect_err("decoded response must exceed its independent limit"); + assert!(matches!( + decoded_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + + let encoded_error = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("encoded-limit request") + .max_encoded_response_bytes( + u64::try_from(encoded.len()) + .expect("encoded length") + .saturating_sub(1), + ) + .max_decoded_response_bytes(u64::try_from(decoded.len()).expect("decoded length")), + ) + .await + .expect_err("encoded response must exceed its independent limit"); + assert!(matches!( + encoded_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); +} + +#[tokio::test] +async fn passthrough_coding_is_not_charged_to_decoded_limit() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + Response::builder() + .header(CONTENT_ENCODING, "zstd") + .body(Body::from("opaque")) + .expect("origin response") + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .max_encoded_response_bytes(6) + .max_decoded_response_bytes(1); + + let response = client.send(request).await.expect("response"); + let bytes = response.into_bytes_bounded(6).await.expect("response body"); + + assert_eq!(bytes, "opaque"); +} + +#[tokio::test] +async fn repeated_response_headers_are_preserved() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let mut response = Response::new(Body::empty()); + response + .headers_mut() + .append(SET_COOKIE, HeaderValue::from_static("a=1")); + response + .headers_mut() + .append(SET_COOKIE, HeaderValue::from_static("b=2")); + response + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + + let response = client + .send(OutboundRequest::get(format!("{origin}/")).expect("request")) + .await + .expect("response"); + let values: Vec<_> = response + .headers() + .get_all(SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("header value")) + .collect(); + + assert_eq!(values, ["a=1", "b=2"]); +} diff --git a/crates/edgezero-adapter-cloudflare/.cargo/config.toml b/crates/edgezero-adapter-cloudflare/.cargo/config.toml index 6d90049f..e3359302 100644 --- a/crates/edgezero-adapter-cloudflare/.cargo/config.toml +++ b/crates/edgezero-adapter-cloudflare/.cargo/config.toml @@ -1,7 +1,5 @@ [build] -target = "wasm32-wasip1" +target = "wasm32-unknown-unknown" -[target.'cfg(target_arch = "wasm32")'] -# Use Viceroy to run wasm built from binaries in this crate or dependents. -# Points to the demo service config by default. -runner = "wasmtime run --dir=. -- " +[target.wasm32-unknown-unknown] +runner = "wasm-bindgen-test-runner" diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 5dbb80fc..d630dd21 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [features] default = [] -cloudflare = ["dep:worker", "dep:serde_json"] +cloudflare = ["dep:async-stream", "dep:worker", "dep:serde_json"] cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", @@ -22,6 +22,7 @@ cli = [ "dep:toml_edit", "dep:walkdir", ] +test-utils = [] [dependencies] anyhow = { workspace = true } @@ -30,6 +31,7 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ "cli", ] } async-trait = { workspace = true } +async-stream = { workspace = true, optional = true } brotli = { workspace = true } bytes = { workspace = true } flate2 = { workspace = true } @@ -40,7 +42,7 @@ ctor = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } toml_edit = { workspace = true, optional = true } -worker = { version = "0.8", default-features = false, features = ["http"], optional = true } +worker = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index bd6cdcc8..90e16b07 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -10,13 +10,14 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::{Capability, CapabilitySupport}; use walkdir::WalkDir; static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; @@ -134,6 +135,30 @@ struct CloudflareCliAdapter; reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for CloudflareCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::OutboundHeaderFidelity => CapabilitySupport::BestEffort, + Capability::IngressAdmission + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHttp + | Capability::SendAllSlotIsolation + | Capability::StreamedUploadDeadlines => CapabilitySupport::Native, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `wrangler` is the native sign-in surface for Cloudflare @@ -167,6 +192,30 @@ impl Adapter for CloudflareCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + match action { + AdapterAction::Build => build_from_manifest(&manifest, args).map(|_artifact| ()), + AdapterAction::Deploy => deploy_from_manifest(&manifest, args), + AdapterAction::Serve => serve_from_manifest(&manifest, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "cloudflare adapter does not support pinned target action {action:?}" + )), + } + } + fn merged_id_kinds(&self) -> &'static [&'static str] { // Both KV and Config back to Worker KV namespaces via the // same `[[kv_namespaces]] binding = ` @@ -929,6 +978,10 @@ fn read_wrangler_kv_key( pub fn build(extra_args: &[String]) -> Result { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -971,6 +1024,10 @@ pub fn build(extra_args: &[String]) -> Result { pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + deploy_from_manifest(&manifest, extra_args) +} + +fn deploy_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -1112,6 +1169,10 @@ fn register_ctor() { pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + serve_from_manifest(&manifest, extra_args) +} + +fn serve_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -1132,6 +1193,20 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target.platform_manifest().map_or_else( + || target.app_root().join("wrangler.toml"), + Path::to_path_buf, + ); + if !manifest.is_file() { + return Err(format!( + "pinned Cloudflare manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + #[cfg(test)] mod tests { use super::*; @@ -1154,6 +1229,80 @@ mod tests { const TEST_CONFIG_ID: &str = "app_config"; const TEST_SECRET_ID: &str = "default"; + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::Unsupported, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundDeadlines, CapabilitySupport::Native), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::Native, + ), + (Capability::SendAllSlotIsolation, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::Native, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::Native, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + CLOUDFLARE_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + // ---------- extract_namespace_id ---------- #[test] diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index 6fea0ae7..f2673707 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -16,12 +16,13 @@ //! arbitrary dotted keys had to be JSON-packed inside one variable. The KV //! backing has no such restriction. +use std::future::Future; + use async_trait::async_trait; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; +use edgezero_core::time::Deadline; #[cfg(test)] use std::collections::HashMap; -#[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] -use std::convert::Infallible; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::Env; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -40,9 +41,6 @@ enum CloudflareConfigBackend { InMemory(HashMap), #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] Kv(WorkerKvStore), - /// Never constructed; keeps the enum inhabited off production/test cfgs. - #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] - _Uninhabited(Infallible), } impl CloudflareConfigStore { @@ -83,18 +81,66 @@ impl ConfigStore for CloudflareConfigStore { }), #[cfg(test)] CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), - #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] - CloudflareConfigBackend::_Uninhabited(never) => { - let _: &str = key; - match *never {} - } } } + + #[inline] + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + bounded_config_read(self.get(key), deadline, max_backend_bytes, max_value_bytes).await + } +} + +// Workers KV returns a complete string, so these bounds are cooperative and +// apply immediately after host materialization rather than during allocation. +async fn bounded_config_read( + read: F, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, ConfigStoreError> +where + F: Future, ConfigStoreError>>, +{ + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + + let result = read.await; + if deadline.is_expired() { + drop(result); + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = result?; + + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + drop(value); + return Err(ConfigStoreError::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + use std::thread; + use std::time::Duration; + + use edgezero_core::time::Deadline; + use futures::executor::block_on; edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, { CloudflareConfigStore::from_entries([ @@ -102,4 +148,83 @@ mod tests { ("contract.key.b".to_owned(), "value_b".to_owned()), ]) }); + + #[test] + fn bounded_read_reports_exact_bytes_and_accepts_exact_caps() { + let result = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value.as_deref(), Some("value")); + } + + #[test] + fn bounded_read_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + } + + #[test] + fn bounded_read_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let error = block_on(bounded_config_read( + async { + polled.set(true); + Ok(None) + }, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_read_checks_deadline_after_host_call() { + let error = block_on(bounded_config_read( + async { + thread::sleep(Duration::from_millis(10)); + Ok(None) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_read_deadline_wins_over_late_host_error() { + let error = block_on(bounded_config_read( + async { + thread::sleep(Duration::from_millis(10)); + Err(ConfigStoreError::unavailable("late host error")) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } } diff --git a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs index 65178003..9c2b4b1e 100644 --- a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs @@ -7,26 +7,19 @@ //! This module is only compiled when the `cloudflare` feature is enabled //! and the target is `wasm32`. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use async_trait::async_trait; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use bytes::Bytes; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use std::time::Duration; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::kv::KvStore as WorkerKvStore; /// KV store backed by Cloudflare Workers KV. /// /// Wraps a `worker::kv::KvStore` handle obtained via the environment binding. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub struct CloudflareKvStore { store: WorkerKvStore, } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl CloudflareKvStore { /// Create a new Cloudflare KV store from the environment binding name. /// @@ -45,7 +38,6 @@ impl CloudflareKvStore { } } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl KvStore for CloudflareKvStore { #[inline] diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index edd9224f..567ed1d5 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -1,6 +1,6 @@ //! Adapter helpers for Cloudflare Workers. -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; // `config_store` compiles on host for its `InMemory` test backend; the @@ -11,8 +11,12 @@ pub mod config_store; pub mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod key_value_store; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub mod proxy; +#[cfg(any( + test, + feature = "test-utils", + all(feature = "cloudflare", target_arch = "wasm32") +))] +pub mod outbound; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod request; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-cloudflare/src/outbound.rs b/crates/edgezero-adapter-cloudflare/src/outbound.rs new file mode 100644 index 00000000..7523d0e2 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/outbound.rs @@ -0,0 +1,1412 @@ +#![cfg_attr( + any(test, all(feature = "cloudflare", target_arch = "wasm32")), + expect( + clippy::arbitrary_source_item_ordering, + reason = "target and test helper modules stay adjacent to the imports that define their boundaries" + ) +)] +#![cfg_attr( + all(feature = "cloudflare", target_arch = "wasm32"), + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Workers imports out of native builds" + ) +)] + +use edgezero_core::error::EdgeError; +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; + +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +mod header_bridge { + use std::str; + + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderMap, HeaderName}; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum ResetContentAction { + Abort, + Disarm, + InspectStream, + } + + pub(crate) trait HeaderSink { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError>; + } + + pub(crate) fn copy_header_values( + source: &HeaderMap, + sink: &mut Sink, + invalid_value: InvalidValue, + ) -> Result<(), EdgeError> + where + Sink: HeaderSink, + InvalidValue: Fn(&HeaderName) -> EdgeError, + { + for (name, value) in source { + let header_value = + str::from_utf8(value.as_bytes()).map_err(|_encoding_error| invalid_value(name))?; + sink.append(name.as_str(), header_value)?; + } + Ok(()) + } + + pub(super) fn reset_content_action( + declared_body: bool, + native_body_is_empty: bool, + ) -> ResetContentAction { + if declared_body { + ResetContentAction::Abort + } else if native_body_is_empty { + ResetContentAction::Disarm + } else { + ResetContentAction::InspectStream + } + } +} + +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +pub(crate) use header_bridge::copy_header_values; +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +use header_bridge::{HeaderSink, ResetContentAction, reset_content_action}; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl HeaderSink for worker::Headers { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError> { + worker::Headers::append(self, name, value).map_err(EdgeError::internal) + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod worker_impl { + use std::cell::Cell; + use std::future::Future; + use std::num::NonZeroU64; + use std::pin::Pin; + use std::rc::Rc; + use std::task::{Context, Poll}; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_gzip_stream, + }; + #[cfg(feature = "test-utils")] + use edgezero_core::error::BudgetSource; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; + use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; + use edgezero_core::outbound::{ + OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseHeaderLimiter, + ResponseMode, collect_response_stream, enforce_payload_content_length, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + normalize_response_headers, rechunk_stream, validate_for_dispatch, + }; + #[cfg(feature = "test-utils")] + use edgezero_core::time::Deadline; + use edgezero_core::time::{DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget}; + use futures_util::StreamExt as _; + use futures_util::future::{Either, join_all, select}; + use worker::js_sys::{Function, Reflect, Uint8Array, global}; + use worker::wasm_bindgen::closure::Closure; + use worker::wasm_bindgen::{JsCast as _, JsValue}; + use worker::wasm_bindgen_futures::JsFuture; + use worker::web_sys; + use worker::{ + Delay, Headers, Method as WorkerMethod, Request as WorkerRequest, RequestInit, + RequestRedirect, Response as WorkerResponse, ResponseBody as WorkerResponseBody, + }; + + use super::{ResetContentAction, reset_content_action, timeout_error}; + + const READY_ITEM_YIELD_QUOTA: u32 = 64; + + struct HostEventYield { + awoken: Rc>, + callback: Option>, + timeout_handle: Option, + } + + impl HostEventYield { + fn new() -> Self { + Self { + awoken: Rc::new(Cell::new(false)), + callback: None, + timeout_handle: None, + } + } + } + + impl Future for HostEventYield { + type Output = Result<(), EdgeError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.awoken.get() { + return Poll::Ready(Ok(())); + } + if this.callback.is_some() { + return Poll::Pending; + } + + let awoken = Rc::clone(&this.awoken); + let waker = cx.waker().clone(); + let callback = Closure::::new(move || { + awoken.set(true); + waker.wake_by_ref(); + }); + let global_scope = global(); + let Some(set_timeout) = Reflect::get(&global_scope, &JsValue::from_str("setTimeout")) + .ok() + .and_then(|value| value.dyn_into::().ok()) + else { + return Poll::Ready(Err(host_event_schedule_error())); + }; + let timeout_handle = match set_timeout.call2( + &global_scope, + callback.as_ref(), + &JsValue::from_f64(0.0), + ) { + Ok(handle) => handle, + Err(_error) => return Poll::Ready(Err(host_event_schedule_error())), + }; + this.callback = Some(callback); + this.timeout_handle = Some(timeout_handle); + Poll::Pending + } + } + + impl Drop for HostEventYield { + fn drop(&mut self) { + if self.awoken.get() { + return; + } + let Some(timeout_handle) = self.timeout_handle.take() else { + return; + }; + let global_scope = global(); + let Ok(clear_timeout) = Reflect::get(&global_scope, &JsValue::from_str("clearTimeout")) + else { + return; + }; + let Some(clear_timeout_function) = clear_timeout.dyn_ref::() else { + return; + }; + let _ignored = clear_timeout_function.call1(&global_scope, &timeout_handle); + } + } + + fn host_event_schedule_error() -> EdgeError { + EdgeError::internal(anyhow::anyhow!("Cloudflare host-event scheduling failed")) + } + + /// Native outbound HTTP implementation for Cloudflare Workers. + pub struct CloudflareOutboundClient { + clock: MonotonicClock, + } + + struct AbortGuard { + controller: Option, + } + + struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, + } + + enum PreparedSlot { + Finished(OutboundSlotResult), + Pending(Box), + } + + impl AbortGuard { + fn disarm(&mut self) { + self.controller = None; + } + + fn new(controller: web_sys::AbortController) -> Self { + Self { + controller: Some(controller), + } + } + } + + impl CloudflareOutboundClient { + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + mut headers, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + let worker_body = + request_body(body, max_request_body_bytes, budget, self.clock.clone()).await?; + let url = uri.to_string(); + let request = build_worker_request(&method, &url, &headers, worker_body)?; + let (response, abort_guard) = raw_fetch(request, budget, &self.clock).await?; + + process_response( + response, + abort_guard, + method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + Self::prepare_validated(request, started_at) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + super::validate_batch_request(&request)?; + Self::prepare_validated(request, started_at) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + let budget = dispatch_budget(&request, started_at)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + /// Builds a client using the process-default monotonic clock. + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { clock } + } + } + + impl Default for CloudflareOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + impl Drop for AbortGuard { + fn drop(&mut self) { + if let Some(controller) = self.controller.take() { + controller.abort(); + } + } + } + + #[async_trait(?Send)] + impl OutboundHttpClient for CloudflareOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + async fn send_all(&self, requests: Vec) -> Vec { + let batch_started_at = self.clock.now(); + let preflight: Vec = requests + .into_iter() + .map(|request| { + Self::prepare_batch(request, batch_started_at).map_or_else( + |error| { + PreparedSlot::Finished(finish_slot( + batch_started_at, + Err(error), + &self.clock, + )) + }, + |prepared| PreparedSlot::Pending(Box::new(prepared)), + ) + }) + .collect(); + + join_all(preflight.into_iter().map(|slot| async move { + match slot { + PreparedSlot::Pending(prepared) => { + let outcome = self.execute(*prepared).await; + finish_slot(batch_started_at, outcome, &self.clock) + } + PreparedSlot::Finished(done) => done, + } + })) + .await + } + } + + fn build_headers(headers: &HeaderMap) -> Result { + let mut worker_headers = Headers::new(); + super::copy_header_values(headers, &mut worker_headers, |name| { + EdgeError::bad_request(format!("header value is not valid UTF-8: {name}")) + })?; + Ok(worker_headers) + } + + fn build_worker_request( + method: &Method, + url: &str, + headers: &HeaderMap, + request_body: Option, + ) -> Result { + let mut init = RequestInit::new(); + init.with_headers(build_headers(headers)?) + .with_method(worker_method(method)) + .with_redirect(RequestRedirect::Manual); + if let Some(worker_body) = request_body { + init.with_body(Some(worker_body)); + } + WorkerRequest::new_with_init(url, &init).map_err(EdgeError::internal) + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + mut abort_guard: AbortGuard, + ) -> BodyStream { + stream! { + let mut ready_items = 0_u32; + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next = source.next(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let next_item = match select(next, timer).await { + Either::Left((item, _timer)) => item, + Either::Right(((), _next)) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + None => { + if let Err(error) = yield_host_event(budget, &clock).await { + yield Err(error); + return; + } + abort_guard.disarm(); + return; + } + } + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + let checkpoint = yield_host_event(budget, &clock).await; + ready_items = 0; + if let Err(error) = checkpoint { + yield Err(error); + return; + } + } + } + } + .boxed_local() + } + + fn finish_slot( + started_at: MonotonicInstant, + outcome: Result, + clock: &MonotonicClock, + ) -> OutboundSlotResult { + let completed_at = clock.now(); + match completed_at.checked_duration_since(started_at) { + Some(elapsed) => OutboundSlotResult::new(elapsed, outcome), + None => OutboundSlotResult::new( + Duration::ZERO, + Err(EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards during outbound dispatch" + ))), + ), + } + } + + #[expect( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "the adapter consumes the independent request policy fields without hiding them" + )] + async fn process_response( + mut response: WorkerResponse, + mut abort_guard: AbortGuard, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.status_code()).map_err(EdgeError::internal)?; + let mut headers = response_headers(&response)?; + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let disposition = normalize_response_headers(&request_method, status, &mut headers)?; + headers.insert(PROXY_HEADER, HeaderValue::from_static("cloudflare")); + + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + if let ResponseBodyDisposition::ResetContent { declared_body } = disposition { + match reset_content_action( + declared_body, + matches!(response.body(), WorkerResponseBody::Empty), + ) { + ResetContentAction::Abort => {} + ResetContentAction::Disarm => abort_guard.disarm(), + ResetContentAction::InspectStream => { + let native = response_stream(&mut response)?; + let remaining = budget_remaining(budget, &clock)?; + let next = native.into_future(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let ready = match select(next, timer).await { + Either::Left((ready, _timer)) => ready, + Either::Right(((), _next)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, &clock)?; + yield_host_event(budget, &clock).await?; + match ready { + (Some(item), _rest) => { + item?; + } + (None, _rest) => abort_guard.disarm(), + } + } + } + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let native = response_stream(&mut response)?; + let encoding = classify_content_encoding(&headers); + let max_buffered = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + &headers, + encoding, + max_buffered, + max_decoded_response_bytes, + max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, max_encoded_response_bytes); + let decoded = match encoding { + ContentEncoding::Brotli => { + decode_brotli_stream(encoded, max_brotli_window_bits, max_brotli_decoder_bytes) + } + ContentEncoding::Gzip => decode_gzip_stream(encoded), + ContentEncoding::Identity | ContentEncoding::Passthrough => encoded, + }; + if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match encoding { + ContentEncoding::Brotli | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough => decoded, + }; + let shaped = rechunk_stream(output, max_chunk_bytes); + let deadline_bound = deadline_stream(shaped, budget, clock, abort_guard); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + body, + response_clock, + )) + } + + async fn raw_fetch( + request: WorkerRequest, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(WorkerResponse, AbortGuard), EdgeError> { + let remaining = budget_remaining(budget, clock)?; + let controller = web_sys::AbortController::new().map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("failed to construct abort controller")) + })?; + let signal = controller.signal(); + let abort_guard = AbortGuard::new(controller); + let fetch_init = web_sys::RequestInit::new(); + fetch_init.set_signal(Some(&signal)); + let set = Reflect::set( + fetch_init.as_ref(), + &JsValue::from_str("encodeResponseBody"), + &JsValue::from_str("manual"), + ) + .map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("failed to set manual response encoding")) + })?; + if !set { + return Err(EdgeError::internal(anyhow::anyhow!( + "runtime refused manual response encoding" + ))); + } + + let global: web_sys::WorkerGlobalScope = global().unchecked_into(); + let promise = global.fetch_with_request_and_init(request.inner(), &fetch_init); + let fetch = JsFuture::from(promise); + let timer = Delay::from(remaining); + futures_util::pin_mut!(fetch, timer); + let result = match select(fetch, timer).await { + Either::Left((result, _timer)) => result, + Either::Right(((), _fetch)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, clock)?; + let value = result.map_err(|_error| super::generic_fetch_failure())?; + let web_response: web_sys::Response = value.dyn_into().map_err(|_non_response| { + EdgeError::internal(anyhow::anyhow!("fetch returned a non-response value")) + })?; + Ok((WorkerResponse::from(web_response), abort_guard)) + } + + async fn request_body( + body: Body, + maximum: u64, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> Result, EdgeError> { + match body { + Body::Once(bytes) => { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + budget_remaining(budget, &clock)?; + if bytes.is_empty() { + Ok(None) + } else { + Ok(Some(Uint8Array::from(bytes.as_ref()).into())) + } + } + Body::Stream(source) => { + let mut bounded = upload_stream(source, maximum, budget, clock); + let mut collected = Vec::new(); + while let Some(item) = bounded.next().await { + collected.extend_from_slice(&item?); + } + if collected.is_empty() { + Ok(None) + } else { + Ok(Some(Uint8Array::from(collected.as_slice()).into())) + } + } + } + } + + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn response_abort_lifecycle_holds_for_test() -> bool { + async fn run_case( + status: StatusCode, + content_length: Option<&str>, + start: MonotonicInstant, + ) -> Option { + let headers = Headers::new(); + if let Some(value) = content_length { + headers.set("content-length", value).ok()?; + } + let response = WorkerResponse::empty() + .ok()? + .with_status(status.as_u16()) + .with_headers(headers); + let controller = web_sys::AbortController::new().ok()?; + let signal = controller.signal(); + let deadline = start.checked_add(Duration::from_secs(1))?; + let clock = MonotonicClock::new(move || start); + process_response( + response, + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(deadline), + duration: Duration::from_secs(1), + }, + 1024, + 24, + None, + None, + None, + None, + None, + clock, + ) + .await + .ok()?; + Some(signal.aborted()) + } + + let start = MonotonicInstant::now(); + matches!( + run_case(StatusCode::RESET_CONTENT, None, start).await, + Some(false) + ) && matches!( + run_case(StatusCode::RESET_CONTENT, Some("7"), start).await, + Some(true) + ) && matches!( + run_case(StatusCode::NO_CONTENT, None, start).await, + Some(true) + ) + } + + fn response_headers(response: &WorkerResponse) -> Result { + let mut headers = HeaderMap::new(); + for (raw_name, raw_value) in response.headers().entries() { + let parsed_name = HeaderName::from_bytes(raw_name.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response contains an invalid header name", + BadGatewayReason::Protocol, + ) + })?; + let parsed_value = HeaderValue::from_bytes(raw_value.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response contains an invalid header value", + BadGatewayReason::Protocol, + ) + })?; + headers.append(parsed_name, parsed_value); + } + Ok(headers) + } + + fn response_stream(response: &mut WorkerResponse) -> Result { + let source = response.stream().map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response body is unavailable", + BadGatewayReason::Transport, + ) + })?; + Ok(source + .map(|result| { + result.map(Bytes::from).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response body failed", + BadGatewayReason::Transport, + ) + }) + }) + .boxed_local()) + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + async fn yield_host_event( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(), EdgeError> { + HostEventYield::new().await?; + budget_remaining(budget, clock).map(drop) + } + + async fn terminal_error_after_host_event( + error: EdgeError, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> EdgeError { + yield_host_event(budget, clock).await.err().unwrap_or(error) + } + + fn upload_stream( + mut source: BodyStream, + maximum: u64, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + let mut ready_items = 0_u32; + let mut total = 0_u64; + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next = source.next(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let next_item = match select(next, timer).await { + Either::Left((item, _timer)) => item, + Either::Right(((), _next)) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + let Some(item) = next_item else { + if let Err(error) = yield_host_event(budget, &clock).await { + yield Err(error); + } + return; + }; + let bytes = match item { + Ok(bytes) => bytes, + Err(error) => { + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + }; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + let error = EdgeError::bad_request( + "outbound request body size accounting overflow", + ); + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + }; + if next_total > maximum { + let error = EdgeError::bad_request( + "outbound request body exceeded configured limit", + ); + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + total = next_total; + yield Ok(bytes); + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + let checkpoint = yield_host_event(budget, &clock).await; + ready_items = 0; + if let Err(error) = checkpoint { + yield Err(error); + return; + } + } + } + } + .boxed_local() + } + + fn worker_method(method: &Method) -> WorkerMethod { + match *method { + Method::DELETE => WorkerMethod::Delete, + Method::HEAD => WorkerMethod::Head, + Method::OPTIONS => WorkerMethod::Options, + Method::PATCH => WorkerMethod::Patch, + Method::POST => WorkerMethod::Post, + Method::PUT => WorkerMethod::Put, + _ => WorkerMethod::Get, + } + } + + /// Runs deferred upload and response stream clock checks in the hosted contract binary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn deferred_clock_paths_hold_for_test() -> bool { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use futures_util::stream::once; + + fn expiring_clock(start: MonotonicInstant, deadline: MonotonicInstant) -> MonotonicClock { + let observations = Arc::new(AtomicUsize::new(0)); + MonotonicClock::new(move || { + if observations.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + deadline + } + }) + } + + let start = MonotonicInstant::now(); + let Ok(request) = OutboundRequest::get("https://example.com/") else { + return false; + }; + let Ok(budget) = dispatch_budget(&request.timeout(Duration::from_millis(10)), start) else { + return false; + }; + let upload_source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut upload = upload_stream( + upload_source, + 16, + budget, + expiring_clock(start, budget.deadline.instant()), + ); + let upload_holds = matches!( + upload.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ); + + let Ok(controller) = web_sys::AbortController::new() else { + return false; + }; + let response_source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut response = deadline_stream( + response_source, + budget, + expiring_clock(start, budget.deadline.instant()), + AbortGuard::new(controller), + ); + let response_holds = matches!( + response.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ); + + upload_holds && response_holds + } + + #[cfg(test)] + mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + + use edgezero_core::error::BudgetSource; + use edgezero_core::time::Deadline; + use futures_util::stream; + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn constant_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) + } + + fn empty_worker_response( + status: StatusCode, + content_length: Option<&str>, + ) -> WorkerResponse { + let headers = Headers::new(); + if let Some(value) = content_length { + headers + .set("content-length", value) + .expect("content-length"); + } + WorkerResponse::empty() + .expect("empty response") + .with_status(status.as_u16()) + .with_headers(headers) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[wasm_bindgen_test] + async fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client.send_all(vec![request]).await; + + assert_eq!(results[0].elapsed, Duration::from_millis(9)); + assert!(matches!( + results[0].outcome, + Err(EdgeError::BadRequest { .. }) + )); + } + + #[wasm_bindgen_test] + async fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = CloudflareOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client.send_all(vec![request]).await; + + assert_eq!(results[0].elapsed, Duration::ZERO); + assert!(matches!( + results[0].outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[wasm_bindgen_test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[wasm_bindgen_test] + async fn buffered_request_preparation_reduces_the_remaining_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let clock = scripted_clock(vec![observed, observed]); + let body = request_body(Body::from("body"), 16, budget, clock.clone()) + .await + .expect("prepared body"); + let remaining = budget_remaining(budget, &clock).expect("remaining budget"); + + assert!(body.is_some()); + assert_eq!(remaining, Duration::from_millis(7)); + } + + #[wasm_bindgen_test] + async fn streamed_request_body_is_drained_before_fetch_construction() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&polls); + let source = stream::poll_fn(move |_context| { + let poll = observed_polls.fetch_add(1, Ordering::SeqCst); + if poll == 0 { + Poll::Ready(Some(Bytes::from_static(b"body"))) + } else { + Poll::Ready(None) + } + }) + .boxed_local(); + + let body = request_body(Body::stream(source), 16, budget, constant_clock(start)) + .await + .expect("prepared body"); + + assert!(body.is_some()); + assert_eq!(polls.load(Ordering::SeqCst), 2); + } + + #[wasm_bindgen_test] + async fn streamed_upload_checks_injected_clock_after_ready_item() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = upload_stream(source, 16, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn streamed_upload_cap_error_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"too large")) }).boxed_local(); + let mut body = upload_stream(source, 1, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("host-event deadline wins over cap error"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn streamed_upload_eof_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let mut body = upload_stream(stream::empty().boxed_local(), 1, budget, clock); + + let error = body + .next() + .await + .expect("deadline after host-event yield") + .expect_err("host-event deadline wins over EOF"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + fn streamed_upload_fairness_quota_is_at_most_sixty_four() { + const { assert!(READY_ITEM_YIELD_QUOTA <= 64) }; + } + + #[wasm_bindgen_test] + async fn streamed_upload_source_error_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let source = + stream::once(async { Err(EdgeError::bad_gateway("source failed")) }).boxed_local(); + let mut body = upload_stream(source, 1, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("host-event deadline wins over source error"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let controller = web_sys::AbortController::new().expect("abort controller"); + let mut body = deadline_stream(source, budget, clock, AbortGuard::new(controller)); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn null_reset_content_disarms_only_without_a_declared_body() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + for (content_length, expected_aborted) in + [(None, false), (Some("0"), false), (Some("7"), true)] + { + let controller = web_sys::AbortController::new().expect("abort controller"); + let signal = controller.signal(); + let response = process_response( + empty_worker_response(StatusCode::RESET_CONTENT, content_length), + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + budget, + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + ) + .await + .expect("empty reset response"); + + assert_eq!(response.status(), StatusCode::RESET_CONTENT); + assert_eq!(response.body().as_bytes(), Some(&[][..])); + assert_eq!(signal.aborted(), expected_aborted); + } + } + + #[wasm_bindgen_test] + async fn framing_bodyless_response_aborts_its_unread_native_body() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + let controller = web_sys::AbortController::new().expect("abort controller"); + let signal = controller.signal(); + + let response = process_response( + empty_worker_response(StatusCode::NO_CONTENT, None), + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + budget, + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + ) + .await + .expect("bodyless response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(signal.aborted()); + } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub use worker_impl::CloudflareOutboundClient; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32", feature = "test-utils"))] +pub use worker_impl::deferred_clock_paths_hold_for_test; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32", feature = "test-utils"))] +pub use worker_impl::response_abort_lifecycle_holds_for_test; + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn generic_fetch_failure() -> EdgeError { + // Workers exposes a rejected Fetch promise as an opaque JS exception, without a stable phase + // or connection-failure discriminator. Preserve that limitation as `Unspecified` instead of + // guessing that every rejection happened before a response was reachable. + EdgeError::bad_gateway_with_reason("outbound fetch failed", BadGatewayReason::Unspecified) +} + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +/// Runs the target-neutral Cloudflare batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +/// Returns the target-neutral classification for an opaque Workers fetch rejection. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn generic_fetch_failure_for_test() -> EdgeError { + generic_fetch_failure() +} + +/// Returns the same attributed timeout emitted by Workers budget timers. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn timeout_error_for_test(cause: BudgetSource) -> EdgeError { + timeout_error(cause) +} + +#[cfg(test)] +mod header_bridge_tests { + use std::collections::BTreeMap; + + use edgezero_core::http::{HeaderMap, HeaderValue}; + + use super::*; + + #[derive(Default)] + struct AppendSink(BTreeMap>); + + impl HeaderSink for AppendSink { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError> { + self.0 + .entry(name.to_owned()) + .or_default() + .push(value.to_owned()); + Ok(()) + } + } + + #[test] + fn response_header_copy_preserves_duplicate_set_cookie_values() { + let mut source = HeaderMap::new(); + source.append("set-cookie", HeaderValue::from_static("first=1")); + source.append("set-cookie", HeaderValue::from_static("second=2")); + let mut sink = AppendSink::default(); + + copy_header_values(&source, &mut sink, |_name| { + EdgeError::internal(anyhow::anyhow!("invalid response header")) + }) + .expect("copy response headers"); + + assert_eq!( + sink.0.get("set-cookie"), + Some(&vec!["first=1".to_owned(), "second=2".to_owned()]) + ); + } + + #[test] + fn response_header_copy_accepts_non_ascii_utf8() { + let mut source = HeaderMap::new(); + source.append( + "x-label", + HeaderValue::from_bytes("caf\u{e9}".as_bytes()).expect("valid header bytes"), + ); + let mut sink = AppendSink::default(); + + copy_header_values(&source, &mut sink, |_name| { + EdgeError::internal(anyhow::anyhow!("invalid response header")) + }) + .expect("copy UTF-8 response header"); + + assert_eq!(sink.0.get("x-label"), Some(&vec!["caf\u{e9}".to_owned()])); + } + + #[test] + fn reset_content_null_body_selects_abort_or_disarm_without_streaming() { + assert_eq!( + reset_content_action(false, true), + ResetContentAction::Disarm + ); + assert_eq!(reset_content_action(true, true), ResetContentAction::Abort); + assert_eq!( + reset_content_action(false, false), + ResetContentAction::InspectStream + ); + assert_eq!(reset_content_action(true, false), ResetContentAction::Abort); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/proxy.rs b/crates/edgezero-adapter-cloudflare/src/proxy.rs deleted file mode 100644 index 211d00e4..00000000 --- a/crates/edgezero-adapter-cloudflare/src/proxy.rs +++ /dev/null @@ -1,204 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::TryStreamExt as _; -use futures_util::stream::{self, LocalBoxStream, StreamExt as _}; -use std::io; -use worker::{ - Body as WorkerBody, Fetch, Headers, Method as CfMethod, Request as CfRequest, RequestInit, - Response as CfResponse, wasm_bindgen::JsValue, -}; - -type ChunkStream = LocalBoxStream<'static, Result, io::Error>>; - -pub struct CloudflareProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for CloudflareProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _ext) = request.into_parts(); - let cf_request = build_cf_request(&method, &uri, &headers, body)?; - let mut cf_response = Fetch::Request(cf_request) - .send() - .await - .map_err(EdgeError::internal)?; - - let mut proxy_response = convert_response(&mut cf_response)?; - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("cloudflare")); - Ok(proxy_response) - } -} - -fn build_cf_request( - method: &Method, - uri: &Uri, - headers: &HeaderMap, - body: Body, -) -> Result { - let mut init = RequestInit::new(); - init.with_method(http_method_to_cf(method)); - - let cf_headers = Headers::from(headers); - init.with_headers(cf_headers); - - attach_body(&mut init, body)?; - - let request = CfRequest::new_with_init(&uri.to_string(), &init).map_err(EdgeError::internal)?; - Ok(request) -} - -fn attach_body(init: &mut RequestInit, body: Body) -> Result<(), EdgeError> { - match body { - Body::Once(bytes) => { - if bytes.is_empty() { - return Ok(()); - } - let chunk = bytes.to_vec(); - let stream = stream::once(async move { Ok::, JsValue>(chunk) }).boxed_local(); - let worker_body = WorkerBody::from_stream(stream).map_err(EdgeError::internal)?; - if let Some(readable) = worker_body.into_inner() { - init.with_body(Some(JsValue::from(readable))); - } - } - Body::Stream(stream) => { - let mapped = stream - .map(|res| match res { - Ok(bytes) => Ok::, JsValue>(bytes.to_vec()), - Err(err) => Err(JsValue::from_str(&err.to_string())), - }) - .boxed_local(); - let worker_body = WorkerBody::from_stream(mapped).map_err(EdgeError::internal)?; - if let Some(readable) = worker_body.into_inner() { - init.with_body(Some(JsValue::from(readable))); - } - } - } - - Ok(()) -} - -fn convert_response(cf_response: &mut CfResponse) -> Result { - let status = StatusCode::from_u16(cf_response.status_code()).map_err(EdgeError::internal)?; - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - let mut encoding = None; - for (name, value) in cf_response.headers().entries() { - if name.eq_ignore_ascii_case(header::CONTENT_ENCODING.as_str()) { - encoding = Some(value.to_ascii_lowercase()); - } - if let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(&value) - { - proxy_response - .headers_mut() - .insert(header_name, header_value); - } - } - - let worker_stream = cf_response.stream().map_err(EdgeError::internal)?; - - let chunk_stream: ChunkStream = worker_stream - .map_err(|err| worker_error_to_io(&err)) - .boxed_local(); - let body_stream = transform_stream(chunk_stream, encoding.as_deref()); - *proxy_response.body_mut() = Body::from_stream(body_stream); - - if encoding.is_some() { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - Ok(proxy_response) -} - -fn http_method_to_cf(method: &Method) -> CfMethod { - match *method { - Method::POST => CfMethod::Post, - Method::PUT => CfMethod::Put, - Method::PATCH => CfMethod::Patch, - Method::DELETE => CfMethod::Delete, - Method::HEAD => CfMethod::Head, - Method::OPTIONS => CfMethod::Options, - Method::CONNECT => CfMethod::Connect, - Method::TRACE => CfMethod::Trace, - _ => CfMethod::Get, - } -} - -fn transform_stream( - stream: ChunkStream, - encoding: Option<&str>, -) -> LocalBoxStream<'static, Result> { - match encoding { - Some("gzip") => decode_gzip_stream(stream).boxed_local(), - Some("br") => decode_brotli_stream(stream).boxed_local(), - _ => stream.map(|res| res.map(Bytes::from)).boxed_local(), - } -} - -fn worker_error_to_io(err: &worker::Error) -> io::Error { - io::Error::other(err.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use brotli::CompressorWriter; - use flate2::{Compression, write::GzEncoder}; - use futures::executor::block_on; - use futures_util::stream; - use std::io::Write as _; - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut out = Vec::new(); - while let Some(item) = stream.next().await { - let chunk = item.expect("chunk"); - out.extend_from_slice(&chunk); - } - out - }), - } - } - - #[test] - fn streaming_identity_preserves_body() { - let chunks = vec![ - Ok::, io::Error>(b"hello".to_vec()), - Ok(b" world".to_vec()), - ]; - let chunk_stream: ChunkStream = Box::pin(stream::iter(chunks)); - let body = Body::from_stream(transform_stream(chunk_stream, None)); - assert_eq!(collect_body(body), b"hello world"); - } - - #[test] - fn streaming_handles_gzip_and_brotli() { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"gzip payload").unwrap(); - let gzip = encoder.finish().unwrap(); - let gzip_stream: ChunkStream = Box::pin(stream::iter(vec![Ok::, io::Error>(gzip)])); - let body = Body::from_stream(transform_stream(gzip_stream, Some("gzip"))); - assert_eq!(collect_body(body), b"gzip payload"); - - let mut brotli_data = Vec::new(); - let mut compressor = CompressorWriter::new(&mut brotli_data, 4096, 5, 21); - compressor.write_all(b"brotli payload").unwrap(); - drop(compressor); - let brotli_stream: ChunkStream = - Box::pin(stream::iter(vec![Ok::, io::Error>(brotli_data)])); - let brotli_body = Body::from_stream(transform_stream(brotli_stream, Some("br"))); - assert_eq!(collect_body(brotli_body), b"brotli payload"); - } -} diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 21c5e1cd..2e5e5f90 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,28 +1,48 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; +#[cfg(feature = "test-utils")] +use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + task::Poll, +}; +#[cfg(feature = "test-utils")] +use bytes::Bytes; use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{Method as CoreMethod, Request, Uri, request_builder}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressFraming, IngressHeadAccounting, IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +#[cfg(feature = "test-utils")] +use futures::executor::block_on; +use futures_util::future::{Either, select}; +#[cfg(feature = "test-utils")] +use futures_util::stream::poll_fn; +use futures_util::stream::{LocalBoxStream, once, unfold}; +use futures_util::{StreamExt as _, TryStreamExt as _}; use worker::{ - Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, + Context, Delay, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; use crate::config_store::CloudflareConfigStore; use crate::context::CloudflareRequestContext; use crate::key_value_store::CloudflareKvStore; -use crate::proxy::CloudflareProxyClient; -use crate::response::from_core_response; +use crate::outbound::CloudflareOutboundClient; +use crate::response::from_egress_response; use crate::secret_store::CloudflareSecretStore; /// Groups the optional per-request store handles injected at dispatch time. @@ -106,6 +126,7 @@ impl<'app> CloudflareService<'app> { env: Env, ctx: Context, ) -> Result { + let request_start = self.app.monotonic_now(); let config_store = match self.config { ConfigSource::Binding(binding) => open_config_or_warn(&env, &binding), ConfigSource::Handle(handle) => Some(handle), @@ -134,6 +155,7 @@ impl<'app> CloudflareService<'app> { secrets, ..Default::default() }, + request_start, ) .await } @@ -233,6 +255,16 @@ pub(crate) struct RegistryInputs<'env> { pub secret_meta: Option, } +#[cfg(feature = "test-utils")] +struct DropSignal(Arc); + +#[cfg(feature = "test-utils")] +impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + /// Convert a Cloudflare Worker request into an `EdgeZero` core request. /// /// # Errors @@ -240,10 +272,24 @@ pub(crate) struct RegistryInputs<'env> { /// and [`EdgeError::internal`] if the body cannot be read or the core /// request cannot be built. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request( - mut req: CfRequest, + req: CfRequest, + env: Env, + ctx: Context, +) -> Result { + let request = into_core_request_head(&req, env, ctx, MonotonicClock::default())?; + attach_core_body(req, request, None) +} + +fn into_core_request_head( + req: &CfRequest, env: Env, ctx: Context, + outbound_clock: MonotonicClock, ) -> Result { let method = into_core_method(&req.method()); let url = req @@ -260,30 +306,227 @@ pub async fn into_core_request( builder = builder.header(name.as_str(), value); } - let bytes = req.bytes().await.map_err(EdgeError::internal)?; - - let mut request = builder - .body(Body::from(bytes)) - .map_err(EdgeError::internal)?; + let mut request = builder.body(Body::empty()).map_err(EdgeError::internal)?; CloudflareRequestContext::insert(&mut request, env, ctx); request .extensions_mut() - .insert(ProxyHandle::with_client(CloudflareProxyClient)); + .insert(outbound_client(outbound_clock)); Ok(request) } +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(CloudflareOutboundClient::with_clock(clock)) +} + +fn attach_core_body( + req: CfRequest, + mut request: Request, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Result { + let stream = cloudflare_body_stream(req)?; + *request.body_mut() = match read_lifetime { + Some((deadline, monotonic_clock)) => { + cloudflare_deadline_body(stream, deadline, monotonic_clock) + } + None => Body::from_external_stream(stream), + }; + Ok(request) +} + +fn cloudflare_body_stream( + mut req: CfRequest, +) -> Result>, EdgeError> { + if req.inner().body().is_some() { + return Ok(req + .stream() + .map_err(EdgeError::internal)? + .map_ok(bytes::Bytes::from) + .boxed_local()); + } + + // The Workers test runtime and some host-created requests expose an + // ArrayBuffer but no ReadableStream. Keep the fallback read lazy so + // admission still runs before the first body byte is requested. + Ok(once(async move { req.bytes().await.map(bytes::Bytes::from) }).boxed_local()) +} + +fn cloudflare_deadline_body( + source: Source, + deadline: Deadline, + monotonic_clock: MonotonicClock, +) -> Body +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let boxed_stream = source.map_err(Into::into).boxed_local(); + let stream = unfold(Some(boxed_stream), move |stream_state| { + let clock = monotonic_clock.clone(); + async move { + let mut body_stream = stream_state?; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + + Delay::from(Duration::ZERO).await; + + let item = { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + }; + let timer = Delay::from(remaining); + let next = body_stream.next(); + match select(timer, next).await { + Either::Left(((), _)) => { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + Either::Right((item, _)) => item, + } + }; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(body_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }); + Body::from_stream(stream) +} + +/// Runs the terminal source-release probe used by the WASM contract suite. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn deadline_body_releases_source_for_test() -> bool { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = cloudflare_deadline_body(source, Deadline::at_instant(start), clock); + let Some(mut body_stream) = body.into_stream() else { + return false; + }; + let Some(Err(error)) = block_on(body_stream.next()) else { + return false; + }; + matches!(error, EdgeError::RequestTimeout { .. }) && dropped.load(Ordering::SeqCst) == 1 +} + +/// Dispatches an observable source through the production ingress body wrapper. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_stream_for_test( + app: &App, + method: CoreMethod, + uri: Uri, + source: Source, +) -> Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| edge_error_to_worker(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + dispatch_ingress_stream( + app, + core_request, + Stores::default(), + request_start, + move || Ok(source), + ) + .await +} + pub(crate) async fn dispatch_with_handles( app: &App, req: CfRequest, env: Env, ctx: Context, stores: Stores, + request_start: MonotonicInstant, ) -> Result { - let core_request = into_core_request(req, env, ctx) - .await - .map_err(|err| edge_error_to_worker(&err))?; - dispatch_core_request(app, core_request, stores).await + let head_request = into_core_request_head(&req, env, ctx, app.monotonic_clock()) + .map_err(|error| edge_error_to_worker(&error))?; + dispatch_ingress_stream(app, head_request, stores, request_start, move || { + cloudflare_body_stream(req) + }) + .await +} + +async fn dispatch_ingress_stream( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, +) -> Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, +{ + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + head_parts + .validate_normalized(app.ingress_head_limits()) + .map_err(|error| edge_error_to_worker(&error))?; + let prepared = match app + .begin_ingress(head_parts, request_start) + .map_err(|error| edge_error_to_worker(&error))? + { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return from_egress_response(response).map_err(|error| edge_error_to_worker(&error)); + } + _ => { + return Err(WorkerError::RustError( + "unsupported ingress admission outcome".to_owned(), + )); + } + }; + let source = make_source().map_err(|error| edge_error_to_worker(&error))?; + *head_request.body_mut() = + cloudflare_deadline_body(source, prepared.read_deadline(), prepared.monotonic_clock()); + dispatch_core_request(app, head_request, stores, prepared).await } /// Dispatch with per-id store registries built from baked metadata. @@ -302,6 +545,7 @@ pub(crate) async fn dispatch_with_registries( ctx: Context, inputs: RegistryInputs<'_>, ) -> Result { + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(&env, inputs.kv_meta, inputs.env_config)?; let config_registry = build_config_registry(&env, inputs.config_meta, inputs.env_config); let secret_registry = build_secret_registry(&env, inputs.secret_meta, inputs.env_config); @@ -316,6 +560,7 @@ pub(crate) async fn dispatch_with_registries( secret_registry, ..Default::default() }, + request_start, ) .await } @@ -442,6 +687,7 @@ async fn dispatch_core_request( app: &App, mut core_request: Request, stores: Stores, + prepared: PreparedIngress, ) -> Result { // Hard-cutoff: see fastly's `dispatch_core_request` // for the rationale. Only registries go into extensions — @@ -457,12 +703,11 @@ async fn dispatch_core_request( if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let svc = app.router().clone(); - let response = svc - .oneshot(core_request) + let response = app + .dispatch_admitted(prepared, core_request) .await .map_err(|err| edge_error_to_worker(&err))?; - from_core_response(response).map_err(|err| edge_error_to_worker(&err)) + from_egress_response(response).map_err(|err| edge_error_to_worker(&err)) } fn edge_error_to_worker(err: &EdgeError) -> WorkerError { @@ -570,8 +815,18 @@ fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + use super::*; + use edgezero_core::context::RequestContext; + use edgezero_core::outbound::OutboundRequest; + use edgezero_core::router::RouterService; use wasm_bindgen_test::wasm_bindgen_test; + use worker::js_sys::Object; + use worker::wasm_bindgen::JsCast as _; + use worker::worker_sys::Context as WorkerSysContext; #[wasm_bindgen_test] fn into_http_method_defaults_unknown_to_get() { @@ -586,6 +841,46 @@ mod tests { assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); } + + #[wasm_bindgen_test] + async fn standard_service_installs_the_exact_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client.send_all(vec![request]).await; + Ok(results[0].elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let init = worker::RequestInit::new(); + let request = CfRequest::new_with_init("https://example.com/clock", &init) + .expect("Cloudflare request"); + let env = Object::new().unchecked_into::(); + let js_context = Object::new().unchecked_into::(); + + let mut response = CloudflareService::new(&app) + .dispatch(request, env, Context::new(js_context)) + .await + .expect("Cloudflare response"); + + assert_eq!(response.text().await.expect("response body"), "7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } } #[cfg(test)] @@ -601,6 +896,10 @@ mod synthesis_tests { struct StubConfig; #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for StubConfig { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(None) diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 7843b899..9d86c9f3 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -1,8 +1,28 @@ -use edgezero_core::body::Body; +use std::time::Duration; + +#[cfg(feature = "test-utils")] +use bytes::Bytes; +use edgezero_core::body::{Body, BodyStream}; use edgezero_core::error::EdgeError; -use edgezero_core::http::Response; +#[cfg(feature = "test-utils")] +use edgezero_core::http::Version; +use edgezero_core::http::{HeaderMap, Response, StatusCode}; +use edgezero_core::response_egress::{ + ResponseEgressAttempt, ResponseEgressEnvelope, ResponseEgressOutcome, +}; +#[cfg(feature = "test-utils")] +use edgezero_core::response_egress::{ResponseEgressHead, ResponseEgressObserverHandle}; +#[cfg(feature = "test-utils")] +use edgezero_core::time::MonotonicInstant; +use edgezero_core::time::{Deadline, MonotonicClock}; use futures_util::StreamExt as _; -use worker::{Error as WorkerError, Response as CfResponse}; +use futures_util::future::{Either, select}; +#[cfg(feature = "test-utils")] +use futures_util::stream::once; +use futures_util::stream::{LocalBoxStream, unfold}; +use worker::{Delay, Error as WorkerError, Response as CfResponse}; + +use crate::outbound::copy_header_values; /// Convert an `EdgeZero` `Response` into a Cloudflare Worker `Response`. /// @@ -31,16 +51,204 @@ pub fn from_core_response(response: Response) -> Result { } }; - let mut cf_response = body_response.with_status(parts.status.as_u16()); - let headers = cf_response.headers_mut(); - for (name, value) in &parts.headers { - if let Ok(value_str) = value.to_str() { - headers - .set(name.as_str(), value_str) - .map_err(EdgeError::internal)?; + apply_response_head(body_response, parts.status, &parts.headers) +} + +pub(crate) fn from_egress_response( + egress: ResponseEgressEnvelope, +) -> Result { + let (core_response, policy, mut attempt, clock) = egress.begin().map_err(|_policy_error| { + EdgeError::internal(anyhow::anyhow!("response-egress policy failed")) + })?; + if policy.write_deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return deadline_response(); + } + + let (parts, body) = core_response.into_parts(); + match body { + Body::Once(bytes) => { + let body_response_result = if bytes.is_empty() { + CfResponse::empty().map_err(EdgeError::internal) + } else { + CfResponse::from_bytes(bytes.to_vec()).map_err(EdgeError::internal) + }; + let body_response = match body_response_result { + Ok(converted_response) => converted_response, + Err(error) => { + attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); + return Err(error); + } + }; + let converted_response = + match apply_response_head(body_response, parts.status, &parts.headers) { + Ok(converted_response) => converted_response, + Err(error) => { + attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); + return Err(error); + } + }; + if policy.write_deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return deadline_response(); + } + attempt.terminate(ResponseEgressOutcome::ResponseReturned, clock.now()); + Ok(converted_response) + } + Body::Stream(stream) => { + let worker_stream = + response_egress_stream(stream, policy.write_deadline, attempt, clock); + let converted_response = + CfResponse::from_stream(worker_stream).map_err(EdgeError::internal)?; + apply_response_head(converted_response, parts.status, &parts.headers) } } - Ok(cf_response) +} + +fn apply_response_head( + body_response: CfResponse, + status: StatusCode, + source_headers: &HeaderMap, +) -> Result { + let mut response = body_response.with_status(status.as_u16()); + let headers = response.headers_mut(); + // Workers adds this default while constructing byte and stream bodies. + headers + .delete("content-type") + .map_err(EdgeError::internal)?; + copy_header_values(source_headers, headers, |_name| { + EdgeError::internal(anyhow::anyhow!( + "response header cannot be represented by Workers" + )) + })?; + Ok(response) +} + +fn deadline_response() -> Result { + CfResponse::error("response write deadline exceeded", 504).map_err(EdgeError::internal) +} + +fn response_egress_stream( + source_stream: BodyStream, + deadline: Deadline, + egress_attempt: ResponseEgressAttempt, + egress_clock: MonotonicClock, +) -> LocalBoxStream<'static, Result, WorkerError>> { + unfold( + (source_stream, egress_attempt, egress_clock, false, false), + move |(mut body_stream, mut attempt, clock, writing, terminal)| async move { + if terminal { + return None; + } + if deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return Some(( + Err(WorkerError::RustError( + "response write deadline exceeded".to_owned(), + )), + (body_stream, attempt, clock, writing, true), + )); + } + + Delay::from(Duration::ZERO).await; + + let item = { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return Some(( + Err(WorkerError::RustError( + "response write deadline exceeded".to_owned(), + )), + (body_stream, attempt, clock, writing, true), + )); + }; + let timer = Delay::from(remaining); + let next = body_stream.next(); + match select(timer, next).await { + Either::Left(((), _)) => { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return Some(( + Err(WorkerError::RustError( + "response write deadline exceeded".to_owned(), + )), + (body_stream, attempt, clock, writing, true), + )); + } + Either::Right((item, _)) => item, + } + }; + + if deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return Some(( + Err(WorkerError::RustError( + "response write deadline exceeded".to_owned(), + )), + (body_stream, attempt, clock, writing, true), + )); + } + match item { + Some(Ok(bytes)) => { + let has_started_writing = writing || attempt.begin_writing(); + let Ok(len) = u64::try_from(bytes.len()) else { + attempt.terminate(ResponseEgressOutcome::TransportError, clock.now()); + return Some(( + Err(WorkerError::RustError( + "response byte accounting overflow".to_owned(), + )), + (body_stream, attempt, clock, has_started_writing, true), + )); + }; + if !attempt.account_bytes(len, clock.now()) { + return Some(( + Err(WorkerError::RustError( + "response byte accounting failed".to_owned(), + )), + (body_stream, attempt, clock, has_started_writing, true), + )); + } + Some(( + Ok(bytes.to_vec()), + (body_stream, attempt, clock, has_started_writing, false), + )) + } + Some(Err(_error)) => { + attempt.terminate(ResponseEgressOutcome::SourceError, clock.now()); + Some(( + Err(WorkerError::RustError("response source failed".to_owned())), + (body_stream, attempt, clock, writing, true), + )) + } + None => { + if !writing { + attempt.begin_writing(); + } + attempt.terminate(ResponseEgressOutcome::HostHandoff, clock.now()); + None + } + } + }, + ) + .boxed_local() +} + +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn response_write_deadline_uses_injected_clock_for_test() -> bool { + let start = MonotonicInstant::now(); + let Some(deadline) = start.checked_add(Duration::from_secs(1)) else { + return false; + }; + let clock = MonotonicClock::new(move || deadline); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new(StatusCode::OK, Version::HTTP_11, &headers, start, None); + let attempt = ResponseEgressAttempt::new(&head, start, ResponseEgressObserverHandle::default()); + let source = once(async { Ok(Bytes::from_static(b"too late")) }).boxed_local(); + let mut converted = + response_egress_stream(source, Deadline::at_instant(deadline), attempt, clock); + + matches!(converted.next().await, Some(Err(_))) } #[cfg(test)] @@ -48,9 +256,15 @@ mod tests { use super::*; use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::http::{HeaderMap, StatusCode, Version, response_builder}; + use edgezero_core::response_egress::{ + ResponseEgressAttempt, ResponseEgressHead, ResponseEgressObserverHandle, + }; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures::executor::block_on; use futures_util::stream; + use std::time::Duration; + use wasm_bindgen_test::wasm_bindgen_test; #[test] #[ignore = "requires worker runtime — worker::Response cannot be constructed in unit tests"] @@ -87,4 +301,30 @@ mod tests { assert_eq!(collected, b"foobar"); } + + #[wasm_bindgen_test] + async fn injected_clock_controls_response_write_deadline() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let clock = MonotonicClock::new(move || deadline); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new(StatusCode::OK, Version::HTTP_11, &headers, start, None); + let attempt = + ResponseEgressAttempt::new(&head, start, ResponseEgressObserverHandle::default()); + let source = stream::once(async { Ok(Bytes::from_static(b"too late")) }).boxed_local(); + let mut converted = + response_egress_stream(source, Deadline::at_instant(deadline), attempt, clock); + + let error = converted + .next() + .await + .expect("deadline item") + .expect_err("deadline must win"); + + assert!( + error + .to_string() + .contains("response write deadline exceeded") + ); + } } diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs index bfe5c2b1..aabbb927 100644 --- a/crates/edgezero-adapter-cloudflare/src/secret_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -9,25 +9,24 @@ //! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; //! Cloudflare accesses all secrets via this adapter regardless of name. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use std::future::Future; + use async_trait::async_trait; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use bytes::Bytes; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::secret_store::{SecretError, SecretStore}; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::config_store::BoundedStoreRead; +use edgezero_core::secret_store::SecretError; +use edgezero_core::secret_store::SecretStore; +use edgezero_core::time::Deadline; use worker::Error as WorkerError; /// Secret store backed by Cloudflare Workers `Env`. /// /// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch /// time so secrets remain accessible throughout the request lifetime. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub struct CloudflareSecretStore { env: worker::Env, } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl CloudflareSecretStore { /// Create a secret store from a cloned `Env`. #[inline] @@ -37,7 +36,6 @@ impl CloudflareSecretStore { } } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for CloudflareSecretStore { #[inline] @@ -59,4 +57,58 @@ impl SecretStore for CloudflareSecretStore { ))), } } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + bounded_secret_read( + self.get_bytes(store_name, key), + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } +} + +// Workers Secrets returns a complete value, so these bounds are cooperative +// and apply immediately after host materialization rather than during allocation. +pub(crate) async fn bounded_secret_read( + read: F, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, SecretError> +where + F: Future, SecretError>>, +{ + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + + let result = read.await; + if deadline.is_expired() { + drop(result); + return Err(SecretError::DeadlineExceeded); + } + let value = result?; + + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + drop(value); + return Err(SecretError::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 249ccefb..51166dfc 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,6 +1,12 @@ -#![cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#![allow( + clippy::expect_used, + clippy::missing_assert_message, + clippy::unwrap_used, + reason = "wasm_bindgen_test functions are tests, but Clippy does not classify the generated wrappers as tests" +)] // Compile-time check: CloudflareSecretStore implements SecretStore. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod secret_store_compile_check { use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; use edgezero_core::secret_store::SecretStore; @@ -12,21 +18,41 @@ mod secret_store_compile_check { const _: fn() = assert_provider_impl::; } -#[cfg(test)] +#[cfg(all(test, feature = "cloudflare", target_arch = "wasm32"))] +#[cfg_attr( + feature = "test-utils", + expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" + ) +)] mod tests { use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; use bytes::Bytes; use edgezero_adapter_cloudflare::context::CloudflareRequestContext; + use edgezero_adapter_cloudflare::outbound::CloudflareOutboundClient; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::outbound::{ + deferred_clock_paths_hold_for_test, response_abort_lifecycle_holds_for_test, + }; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::request::deadline_body_releases_source_for_test; use edgezero_adapter_cloudflare::request::{CloudflareService, into_core_request}; use edgezero_adapter_cloudflare::response::from_core_response; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::response::response_write_deadline_uses_injected_clock_for_test; use edgezero_core::app::App; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Method, Response, StatusCode, response_builder}; + use edgezero_core::outbound::{OutboundHttpClient as _, OutboundRequest}; use edgezero_core::router::RouterService; + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; use futures::stream; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; use worker::js_sys::Object; @@ -39,6 +65,10 @@ mod tests { struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for FixedConfigStore { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.to_owned())) @@ -47,7 +77,7 @@ mod tests { fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -56,7 +86,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -150,6 +180,12 @@ mod tests { (env, Context::new(js_context)) } + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + fn deadline_body_releases_source_when_timeout_is_emitted() { + assert!(deadline_body_releases_source_for_test()); + } + #[wasm_bindgen_test] async fn dispatch_passes_request_body_to_handlers() { let app = build_test_app(); @@ -239,12 +275,14 @@ mod tests { .and_then(|value| value.to_str().ok()); assert_eq!(header, Some("1")); - assert_eq!( - core_request.body().as_bytes().expect("buffered"), - b"payload" - ); - assert!(CloudflareRequestContext::get(&core_request).is_some()); + + let body = core_request + .into_body() + .into_bytes_bounded(1024) + .await + .expect("request body"); + assert_eq!(body.as_ref(), b"payload"); } #[wasm_bindgen_test] @@ -285,4 +323,663 @@ mod tests { let body = response.text().await.expect("text"); assert_eq!(body, "no"); } + + #[wasm_bindgen_test] + async fn outbound_clock_controls_preflight_elapsed_and_backwards_failure() { + use std::collections::VecDeque; + use std::sync::Mutex; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let forward_client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let forward_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let forward_results = forward_client.send_all(vec![forward_request]).await; + assert_eq!(forward_results.len(), 1); + let forward_result = forward_results.first().expect("single forward result"); + assert_eq!(forward_result.elapsed, Duration::from_millis(9)); + assert!(matches!( + forward_result.outcome, + Err(EdgeError::BadRequest { .. }) + )); + + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let backwards_client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let backwards_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let backwards_results = backwards_client.send_all(vec![backwards_request]).await; + assert_eq!(backwards_results.len(), 1); + let backwards_result = backwards_results.first().expect("single backwards result"); + assert_eq!(backwards_result.elapsed, Duration::ZERO); + assert!(matches!( + backwards_result.outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[wasm_bindgen_test] + async fn send_all_preserves_three_preflight_slots_in_input_order() { + let now = MonotonicInstant::now(); + let client = CloudflareOutboundClient::with_clock(MonotonicClock::new(move || now)); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + let results = client + .send_all(vec![streamed_upload, streamed_response, method_error]) + .await; + let messages: Vec<_> = results + .iter() + .map(|slot| match &slot.outcome { + Err(EdgeError::BadRequest { message }) => Some(message.as_str()), + _ => None, + }) + .collect(); + + assert_eq!( + messages, + [ + Some("send_all requires buffered request bodies; use send for a streamed upload"), + Some("send_all requires buffered responses; use send for a streamed response"), + Some( + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream" + ), + ] + ); + assert!(results.iter().all(|slot| slot.elapsed == Duration::ZERO)); + } + + #[wasm_bindgen_test] + async fn standard_service_installs_the_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client.send_all(vec![request]).await; + let result = results + .first() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing outbound result")))?; + Ok(result.elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let req = cf_request(CfMethod::Get, "/clock", None); + let (env, ctx) = test_env_ctx(); + + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("Cloudflare response"); + + assert_eq!(response.text().await.expect("response body"), "7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + async fn deferred_upload_and_response_paths_retain_the_injected_clock() { + assert!(deferred_clock_paths_hold_for_test().await); + } + + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + async fn response_abort_and_write_deadline_lifecycles_are_enforced() { + assert!(response_abort_lifecycle_holds_for_test().await); + assert!(response_write_deadline_uses_injected_clock_for_test().await); + } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::future::Future as _; + use std::io; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + use std::time::Duration; + + use edgezero_adapter_cloudflare::request::dispatch_ingress_stream_for_test; + use edgezero_core::http::{HeaderMap, HeaderValue}; + use edgezero_core::ingress::{AdmissionDecision, BufferedIngressResponse, IngressGrant}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::router::RouteResolution; + use futures::future::poll_fn as poll_future; + use futures::stream::poll_fn; + use worker::{Delay, Response as CfResponse}; + + use super::*; + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + struct CountingMiddleware(Arc); + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"cloudflare overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"cloudflare timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn tracked_stream( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut pending_chunks = chunks.into_iter(); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(pending_chunks.next().map(Ok)) + }) + } + + fn pending_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }) + } + + fn error_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut emitted = false; + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if emitted { + Poll::Ready(None) + } else { + emitted = true; + Poll::Ready(Some(Err(io::Error::other( + "cloudflare ingress source failure", + )))) + } + }) + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + async fn assert_terminal_response( + mut response: CfResponse, + status: StatusCode, + expected_headers: HeaderMap, + body: &[u8], + ) { + assert_eq!(response.status_code(), status.as_u16()); + let mut actual_headers = response.headers().entries().collect::>(); + actual_headers.sort_unstable(); + let mut expected_provider_headers = expected_headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().expect("UTF-8 test header").to_owned(), + ) + }) + .collect::>(); + expected_provider_headers.sort_unstable(); + assert_eq!(actual_headers, expected_provider_headers); + assert_eq!(response.bytes().await.expect("provider body"), body); + } + + #[wasm_bindgen_test] + async fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + ); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_eq!(response.status_code(), expected_status.as_u16()); + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + ); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + terminal_headers("overflow"), + b"cloudflare overflow\0response", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn standard_service_enforces_fallback_overflow() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let req = cf_request(CfMethod::Post, "/missing", Some(b"abcde")); + let (env, ctx) = test_env_ctx(); + + let response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("Cloudflare response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + terminal_headers("overflow"), + b"cloudflare overflow\0response", + ) + .await; + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[wasm_bindgen_test] + async fn wasm_timer_timeout_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_millis(100), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + terminal_headers("timeout"), + b"cloudflare timeout response\n", + ) + .await; + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn saturated_fallback_refuses_without_polling_body() { + for path in ["/missing", "/known"] { + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("cloudflare unavailable\n")) + .expect("refusal response"), + ) + }); + let unobserved_grant = Arc::new(AtomicUsize::new(0)); + let source = pending_stream(&unobserved_grant, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::SERVICE_UNAVAILABLE, + { + let mut headers = HeaderMap::new(); + headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + headers + }, + b"cloudflare unavailable\n", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(unobserved_grant.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn source_error_uses_worker_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = error_stream(&grant_drops, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + ) + .await + .expect("standard error response"); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + internal_error_headers(), + br#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[wasm_bindgen_test] + async fn poll_then_drop_releases_pending_source_and_grant() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + let mut dispatch = Box::pin(dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + )); + + let first_poll = poll_future(|cx| Poll::Ready(dispatch.as_mut().poll(cx))).await; + assert!(first_poll.is_pending()); + Delay::from(Duration::ZERO).await; + let source_poll = poll_future(|cx| Poll::Ready(dispatch.as_mut().poll(cx))).await; + assert!(source_poll.is_pending()); + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + + drop(dispatch); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +mod native_tests { + #[cfg(all(feature = "test-utils", not(target_arch = "wasm32")))] + mod enabled { + use bytes::Bytes; + use edgezero_adapter_cloudflare::outbound::{ + generic_fetch_failure_for_test, timeout_error_for_test, validate_batch_request_for_test, + }; + use edgezero_core::body::Body; + use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn message(result: Result<(), EdgeError>) -> Option { + if let Err(EdgeError::BadRequest { message }) = result { + Some(message) + } else { + None + } + } + + #[test] + fn send_all_preflight_precedence_and_indices() { + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/response") + .expect("request") + .stream_response(); + let get_stream = OutboundRequest::get("https://example.com/get") + .expect("request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + assert_eq!( + [streamed_upload, streamed_response, get_stream] + .iter() + .map(|request| message(validate_batch_request_for_test(request))) + .collect::>(), + [ + Some("send_all requires buffered request bodies; use send for a streamed upload".to_owned()), + Some("send_all requires buffered responses; use send for a streamed response".to_owned()), + Some("GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream".to_owned()), + ] + ); + } + + #[test] + fn opaque_fetch_rejection_does_not_claim_connection_phase_evidence() { + let error = generic_fetch_failure_for_test(); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )); + } + + #[test] + fn worker_budget_timeouts_preserve_every_selected_source() { + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::BatchDeadline, + BudgetSource::Default, + ] { + assert!(matches!( + timeout_error_for_test(selected), + EdgeError::GatewayTimeout { cause, .. } if cause == selected + )); + } + } + } } diff --git a/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs b/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs new file mode 100644 index 00000000..6afdc6dc --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs @@ -0,0 +1,101 @@ +#![cfg(all(feature = "cloudflare", not(target_arch = "wasm32")))] + +#[path = "../src/secret_store.rs"] +#[expect( + dead_code, + reason = "the source harness exercises the private bounded-read helper without constructing a worker Env" +)] +mod secret_store; + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::thread; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::secret_store::SecretError; + use edgezero_core::time::Deadline; + use futures::executor::block_on; + + use super::secret_store; + + #[test] + fn bounded_secret_reports_exact_bytes_and_accepts_exact_caps() { + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } + + #[test] + fn bounded_secret_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[test] + fn bounded_secret_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let error = block_on(secret_store::bounded_secret_read( + async { + polled.set(true); + Ok(None) + }, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_secret_checks_deadline_after_host_call() { + let error = block_on(secret_store::bounded_secret_read( + async { + thread::sleep(Duration::from_millis(10)); + Ok(None) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_deadline_wins_over_late_host_error() { + let error = block_on(secret_store::bounded_secret_read( + async { + thread::sleep(Duration::from_millis(10)); + Err(SecretError::Unavailable) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } +} diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index 02ae5a83..c9fd0d2e 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -20,6 +20,7 @@ cli = [ "dep:walkdir", ] fastly = ["dep:fastly", "dep:log-fastly"] +test-utils = [] [dependencies] anyhow = { workspace = true } @@ -49,5 +50,6 @@ walkdir = { workspace = true, optional = true } [dev-dependencies] edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +fastly-shared = { workspace = true } handlebars = { workspace = true } tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-fastly/src/chunked_config.rs b/crates/edgezero-adapter-fastly/src/chunked_config.rs index a505bfed..40a0db9a 100644 --- a/crates/edgezero-adapter-fastly/src/chunked_config.rs +++ b/crates/edgezero-adapter-fastly/src/chunked_config.rs @@ -21,6 +21,11 @@ use sha2::{Digest as _, Sha256}; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::config_store::ConfigStoreError; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::{BoundedStoreRead, Deadline, MonotonicInstant}; + /// Per-entry value limit enforced by Fastly Config Store. Used by the CLI writer /// to gate direct-vs-chunked storage, and by the pointer validator (which both /// the CLI and the runtime resolver call) — a chunked envelope is always larger @@ -48,6 +53,17 @@ pub(crate) const CHUNK_KEY_INFIX: &str = ".__edgezero_chunks."; /// (when validating the parsed pointer) -- stays unconditional. pub(crate) const POINTER_KIND: &str = "fastly_config_chunks"; +#[derive(Debug, Eq, PartialEq)] +#[cfg(any(feature = "fastly", test))] +pub(crate) struct FastlyReadTooLarge; + +#[derive(Debug)] +#[cfg(any(feature = "fastly", test))] +pub(crate) enum SyncHostCallError { + Backend(E), + DeadlineExceeded, +} + // --------------------------------------------------------------------------- // Private pointer schema // --------------------------------------------------------------------------- @@ -91,6 +107,7 @@ enum RootValueKind { /// pointer resolution — a v2 envelope reassembled from v1 chunks is only knowable /// AFTER the chunks are fetched — so it cannot be recovered from the raw value /// alone. +#[derive(Debug)] pub(crate) enum ResolveFailure { /// Corrupt or incomplete state a push can repair by overwriting. Carries a /// redacted, human-readable message. @@ -101,6 +118,17 @@ pub(crate) enum ResolveFailure { FutureFormat(String), } +/// A bounded resolver failure that preserves store boundary errors instead of +/// misclassifying them as corrupt pointer state. +#[derive(Debug)] +#[cfg(any(feature = "fastly", test))] +pub(crate) enum BoundedResolveFailure { + Backend(ConfigStoreError), + DeadlineExceeded, + Resolve(ResolveFailure), + ValueTooLarge, +} + impl ResolveFailure { /// The redacted, human-readable message, discarding the category. pub(crate) fn into_message(self) -> String { @@ -111,7 +139,11 @@ impl ResolveFailure { /// Is this a newer format this build must not overwrite (vs. repairable /// corruption)? - #[cfg(any(feature = "cli", feature = "fastly", test))] + #[cfg(any( + feature = "fastly", + test, + all(feature = "cli", not(target_arch = "wasm32")) + ))] pub(crate) fn is_future_format(&self) -> bool { matches!(self, Self::FutureFormat(_)) } @@ -125,7 +157,7 @@ struct FastlyChunkRef { } /// A chunk reference from a validated pointer, for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) struct GcChunkRef { pub key: String, pub len: usize, @@ -133,7 +165,7 @@ pub(crate) struct GcChunkRef { } /// A validated v1 pointer's contents, for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) struct GcPointer { /// Validated: non-empty, one generation, dense `0..n-1` in order. pub chunks: Vec, @@ -142,7 +174,7 @@ pub(crate) struct GcPointer { } /// What a root's value IS, once classified for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) enum GcRootValue { /// A valid v1 chunk pointer. Its METADATA is validated; its CONTENT must /// still be verified against the store -- see [`gc_verify_generation`]. @@ -155,6 +187,62 @@ pub(crate) enum GcRootValue { // Public helpers // --------------------------------------------------------------------------- +/// Account for the exact bytes materialized by a Fastly store operation. +#[cfg(any(feature = "fastly", test))] +pub(crate) fn exact_fastly_read( + value: Option, + max_backend_bytes: u64, +) -> Result, FastlyReadTooLarge> +where + T: AsRef<[u8]>, +{ + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.as_ref().len()).map_err(|_length_error| FastlyReadTooLarge) + })?; + if backend_bytes > max_backend_bytes { + return Err(FastlyReadTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) +} + +/// Run one synchronous Fastly operation under cooperative deadline checks. +/// +/// The host call itself is not preemptible. An overrun is observed immediately +/// after the call returns and its result is discarded. +#[cfg(any(feature = "fastly", test))] +pub(crate) fn run_sync_host_call( + deadline: Deadline, + call: F, +) -> Result> +where + F: FnOnce() -> Result, +{ + run_sync_host_call_at(deadline, MonotonicInstant::now, call) +} + +#[cfg(any(feature = "fastly", test))] +fn run_sync_host_call_at( + deadline: Deadline, + mut now: N, + call: F, +) -> Result> +where + N: FnMut() -> MonotonicInstant, + F: FnOnce() -> Result, +{ + if deadline.instant() <= now() { + return Err(SyncHostCallError::DeadlineExceeded); + } + let result = call(); + if deadline.instant() <= now() { + return Err(SyncHostCallError::DeadlineExceeded); + } + result.map_err(SyncHostCallError::Backend) +} + /// Compute the lowercase-hex SHA-256 of `bytes`. pub(crate) fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) @@ -175,7 +263,7 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> String { /// characters (extremely unlikely in practice; recommends restructuring). /// Reject a physical Config Store key that exceeds the store's key limit, /// before any write is attempted. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] fn check_config_key_len(key: &str) -> Result<(), String> { // CHARACTERS, not bytes: Fastly's limit is a character count, so a non-ASCII // `--key` must be measured by `chars().count()`, not `len()` (UTF-8 bytes). @@ -192,7 +280,7 @@ fn check_config_key_len(key: &str) -> Result<(), String> { Ok(()) } -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn prepare_fastly_config_entries( root_key: &str, envelope_json: &str, @@ -386,7 +474,7 @@ pub(crate) fn verify_writer_split_layout( /// Only the GC path (`cli.rs`) needs it; the runtime resolver inlines the same /// mapping, so this is gated to the `cli` feature to stay dead-code-free in the /// guest build. -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub(crate) fn chunk_lengths(chunks: &[GcChunkRef]) -> Vec { chunks.iter().map(|chunk| chunk.len).collect() } @@ -572,6 +660,112 @@ where finalize_reconstructed_envelope(root_key, reconstructed) } +/// Resolve a Fastly value while accounting for every materialized store byte. +/// +/// `fetch` receives the backend allowance left after the root pointer and all +/// prior chunks. Deadline checks are cooperative: they bracket each callback, +/// but cannot interrupt a synchronous host call already in progress. +#[cfg(any(feature = "fastly", test))] +pub(crate) fn resolve_fastly_config_value_typed_bounded( + root_key: &str, + root_value: String, + root_backend_bytes: u64, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + mut fetch: F, +) -> Result, BoundedResolveFailure> +where + F: FnMut(&str, u64) -> Result, ConfigStoreError>, +{ + if deadline.is_expired() { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + + let root_value_bytes = u64::try_from(root_value.len()) + .map_err(|_length_error| BoundedResolveFailure::ValueTooLarge)?; + if root_backend_bytes != root_value_bytes || root_backend_bytes > max_backend_bytes { + return Err(BoundedResolveFailure::ValueTooLarge); + } + + let mut backend_bytes = root_backend_bytes; + let mut chunk_value_bytes = 0_u64; + let mut boundary_error = None; + let resolve_outcome = resolve_fastly_config_value_typed(root_key, root_value, |chunk_key| { + if deadline.is_expired() { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); + return Err("bounded config read failed".to_owned()); + } + + let Some(remaining_backend_bytes) = max_backend_bytes.checked_sub(backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let read = match fetch(chunk_key, remaining_backend_bytes) { + Ok(read) => read, + Err(error) => { + boundary_error = Some(BoundedResolveFailure::Backend(error)); + return Err("bounded config read failed".to_owned()); + } + }; + + if deadline.is_expired() { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); + return Err("bounded config read failed".to_owned()); + } + + let value_bytes = match read.value.as_ref() { + Some(value) => { + let Ok(value_bytes) = u64::try_from(value.len()) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + value_bytes + } + None => 0, + }; + if read.backend_bytes != value_bytes || read.backend_bytes > remaining_backend_bytes { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } + + let Some(next_backend_bytes) = backend_bytes.checked_add(read.backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let Some(next_chunk_value_bytes) = chunk_value_bytes.checked_add(value_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + if next_backend_bytes > max_backend_bytes || next_chunk_value_bytes > max_value_bytes { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } + + backend_bytes = next_backend_bytes; + chunk_value_bytes = next_chunk_value_bytes; + Ok(read.value) + }); + + if let Some(error) = boundary_error { + return Err(error); + } + let resolved_value = resolve_outcome.map_err(BoundedResolveFailure::Resolve)?; + if deadline.is_expired() { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + let resolved_bytes = u64::try_from(resolved_value.len()) + .map_err(|_length_error| BoundedResolveFailure::ValueTooLarge)?; + if resolved_bytes > max_value_bytes { + return Err(BoundedResolveFailure::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value: Some(resolved_value), + }) +} + /// The final gate the chunked path shares with the direct one: reject a NEWER /// inner envelope, then parse+verify the exact v1 `BlobEnvelope`. /// @@ -647,7 +841,7 @@ where /// but fails validation: malformed, unsupported `version`, or a /// referenced key falls outside this root's chunk prefix. Callers log /// `msg` as a warning and skip GC for this root (delete nothing). -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn prior_chunk_keys(root_key: &str, raw: &str) -> Result, String> { // 1. Parse loosely. Not-JSON, or not our pointer kind => silent. let value: serde_json::Value = match serde_json::from_str(raw) { @@ -885,7 +1079,7 @@ pub(crate) fn value_is_pointer_kind(raw: &str) -> bool { /// a malformed object (a possible truncated/corrupt pointer whose chunk /// references we cannot read), an unknown-kind value, and a pointer: those must /// fail closed, because assuming they reference no chunks could orphan live ones. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn value_is_inert_foreign(raw: &str) -> bool { matches!(classify_root_value(raw), RootValueKind::Foreign) } @@ -898,7 +1092,7 @@ pub(crate) fn value_is_inert_foreign(raw: &str) -> bool { /// envelope fragment that announces nothing, so anything that DOES announce is /// suspicious (a parked pointer, a future-format value) and must be classified, /// not blindly treated as a deletable fragment. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn value_announces_our_kind(raw: &str) -> bool { matches!( classify_root_value(raw), @@ -980,7 +1174,7 @@ pub(crate) fn value_is_future_format(raw: &str) -> bool { /// every metadata check still passes while the dropped chunk silently leaves the /// live set. Callers MUST reconstruct the referenced chunks and put them through /// [`gc_verify_generation`] before trusting the live set. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn gc_classify_root(root_key: &str, raw: &str) -> Result { use edgezero_core::blob_envelope::BlobEnvelope; @@ -1078,7 +1272,7 @@ pub(crate) fn gc_classify_root(root_key: &str, raw: &str) -> Result Result<(), String> { use edgezero_core::blob_envelope::BlobEnvelope; @@ -1119,7 +1313,7 @@ pub(crate) fn gc_verify_generation(generation_sha: &str, assembled: &str) -> Res /// This is how reclamation groups the store's ACTUAL keys into generations. It /// validates the shape (`.__edgezero_chunks..`) rather /// than trusting it: a hand-edited or foreign key never becomes a delete target. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn chunk_key_generation(root_key: &str, key: &str) -> Option { chunk_key_parts(root_key, key).map(|(generation, _)| generation) } @@ -1130,7 +1324,7 @@ pub(crate) fn chunk_key_generation(root_key: &str, key: &str) -> Option /// `config gc` uses this to order a proven generation's chunks by writer index /// before deleting, so preview and failure-recovery order do not depend on the /// remote listing order. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn chunk_key_index(root_key: &str, key: &str) -> Option { chunk_key_parts(root_key, key).map(|(_, index)| index) } @@ -1192,8 +1386,307 @@ fn canonical_index(index: &str) -> Option { mod tests { use std::collections::HashMap; + use std::time::Duration; use super::*; + use edgezero_core::blob_envelope::BlobEnvelope; + use edgezero_core::{BoundedStoreRead, Deadline, MonotonicInstant}; + + fn unexpected_chunk_fetch( + _key: &str, + _remaining_backend_bytes: u64, + ) -> Result, ConfigStoreError> { + Err(ConfigStoreError::unavailable("unexpected chunk fetch")) + } + + #[test] + fn exact_fastly_read_accepts_exact_bytes_and_rejects_one_over() { + let exact = exact_fastly_read(Some(bytes::Bytes::from_static(b"secret")), 6) + .expect("an exact-sized value must succeed"); + assert_eq!(exact.backend_bytes, 6); + assert_eq!(exact.value.as_deref(), Some(b"secret".as_slice())); + + let error = exact_fastly_read(Some(bytes::Bytes::from_static(b"secret")), 5) + .expect_err("one byte over the cap must fail"); + assert_eq!(error, FastlyReadTooLarge); + } + + #[test] + fn synchronous_host_call_checks_deadline_before_and_after_operation() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let after_deadline = start + .checked_add(Duration::from_secs(2)) + .expect("post-deadline instant"); + let mut observations = [start, after_deadline].into_iter(); + let mut calls = 0_usize; + + let error = run_sync_host_call_at( + deadline, + || observations.next().expect("clock observation"), + || { + calls += 1; + Ok::<_, ()>("materialized") + }, + ) + .expect_err("an operation completing after the deadline must fail"); + assert!(matches!(error, SyncHostCallError::DeadlineExceeded)); + assert_eq!( + calls, 1, + "the deadline elapsed while the call was in flight" + ); + + let mut backend_observations = [start, after_deadline].into_iter(); + let backend_error = run_sync_host_call_at( + deadline, + || backend_observations.next().expect("clock observation"), + || Err::<(), _>("backend failed"), + ) + .expect_err("post-call deadline must also be checked after backend failure"); + assert!(matches!(backend_error, SyncHostCallError::DeadlineExceeded)); + + let mut called_after_expiry = false; + let preflight_error = run_sync_host_call_at( + deadline, + || after_deadline, + || { + called_after_expiry = true; + Ok::<_, ()>("unreachable") + }, + ) + .expect_err("an already-expired operation must fail"); + assert!(matches!( + preflight_error, + SyncHostCallError::DeadlineExceeded + )); + assert!(!called_after_expiry, "an expired call must not be started"); + } + + #[test] + fn bounded_failures_preserve_backend_and_resolver_payloads() { + let backend_failure = BoundedResolveFailure::Backend(ConfigStoreError::unavailable( + "temporary store failure", + )); + match backend_failure { + BoundedResolveFailure::Backend(error) => drop(error), + BoundedResolveFailure::DeadlineExceeded + | BoundedResolveFailure::Resolve(_) + | BoundedResolveFailure::ValueTooLarge => { + panic!("backend failure variant changed"); + } + } + + let resolve_failure = + BoundedResolveFailure::Resolve(ResolveFailure::Corrupt("invalid pointer".to_owned())); + match resolve_failure { + BoundedResolveFailure::Resolve(error) => { + assert_eq!(error.into_message(), "invalid pointer"); + } + BoundedResolveFailure::Backend(_) + | BoundedResolveFailure::DeadlineExceeded + | BoundedResolveFailure::ValueTooLarge => { + panic!("resolver failure variant changed"); + } + } + + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let mut observations = [start, start].into_iter(); + let backend_error = run_sync_host_call_at( + deadline, + || observations.next().expect("clock observation"), + || Err::<(), _>("backend failed"), + ) + .expect_err("backend failure must remain inspectable"); + match backend_error { + SyncHostCallError::Backend(message) => assert_eq!(message, "backend failed"), + SyncHostCallError::DeadlineExceeded => panic!("deadline must not win"), + } + + let wrapper_result = run_sync_host_call(Deadline::after(Duration::from_secs(1)), || { + Ok::<_, ()>("materialized") + }) + .expect("wrapper call before its deadline must succeed"); + assert_eq!(wrapper_result, "materialized"); + } + + #[test] + fn bounded_resolver_accepts_exact_direct_caps_and_rejects_over_cap() { + let exact = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + unexpected_chunk_fetch, + ) + .expect("a direct value at both exact caps must succeed"); + + assert_eq!(exact.backend_bytes, 5); + assert_eq!(exact.value.as_deref(), Some("value")); + + let over_backend = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + Deadline::after(Duration::from_secs(1)), + 4, + 5, + unexpected_chunk_fetch, + ) + .expect_err("a direct value over the backend cap must fail"); + assert!(matches!(over_backend, BoundedResolveFailure::ValueTooLarge)); + + let over_value = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + Deadline::after(Duration::from_secs(1)), + 5, + 4, + unexpected_chunk_fetch, + ) + .expect_err("a direct value over the value cap must fail"); + assert!(matches!(over_value, BoundedResolveFailure::ValueTooLarge)); + } + + #[test] + fn bounded_resolver_rejects_expired_deadline_without_fetching() { + let mut fetched = false; + let error = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + Deadline::after(Duration::ZERO), + 5, + 5, + |_key, _remaining_backend_bytes| { + fetched = true; + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + }, + ) + .expect_err("an expired deadline must fail"); + + assert!(matches!(error, BoundedResolveFailure::DeadlineExceeded)); + assert!(!fetched, "an expired read must not fetch chunks"); + } + + #[test] + fn bounded_resolver_counts_pointer_and_chunks_with_decreasing_allowance() { + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("root pointer"); + let chunks: HashMap<_, _> = entries.into_iter().collect(); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer length"); + let maybe_chunk_bytes = chunks.values().try_fold(0_u64, |total, chunk| { + total.checked_add(u64::try_from(chunk.len()).ok()?) + }); + let chunk_bytes = maybe_chunk_bytes.expect("chunk byte sum"); + let total_backend_bytes = pointer_bytes + .checked_add(chunk_bytes) + .expect("backend byte sum"); + let mut remaining_allowances = Vec::new(); + + let resolved = resolve_fastly_config_value_typed_bounded( + root_key, + pointer.clone(), + pointer_bytes, + Deadline::after(Duration::from_secs(1)), + total_backend_bytes, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, remaining_backend_bytes| { + remaining_allowances.push(remaining_backend_bytes); + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: value + .as_ref() + .map_or(0, |chunk| u64::try_from(chunk.len()).expect("chunk length")), + value, + }) + }, + ) + .expect("exact pointer and chunk caps must succeed"); + + assert_eq!(resolved.backend_bytes, total_backend_bytes); + assert_eq!(resolved.value.as_deref(), Some(envelope.as_str())); + assert_eq!(remaining_allowances.first(), Some(&chunk_bytes)); + assert!( + remaining_allowances + .windows(2) + .all(|pair| pair[1] < pair[0]), + "each chunk must receive a smaller backend allowance" + ); + + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer, + pointer_bytes, + Deadline::after(Duration::from_secs(1)), + total_backend_bytes - 1, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, _remaining_backend_bytes| { + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: value + .as_ref() + .map_or(0, |chunk| u64::try_from(chunk.len()).expect("chunk length")), + value, + }) + }, + ) + .expect_err("one byte below the aggregate backend cap must fail"); + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + } + + #[test] + fn bounded_resolver_rejects_inconsistent_chunk_reports() { + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("root pointer"); + let chunks: HashMap<_, _> = entries.into_iter().collect(); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer length"); + + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer, + pointer_bytes, + Deadline::after(Duration::from_secs(1)), + u64::MAX, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, _remaining_backend_bytes| { + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: 0, + value, + }) + }, + ) + .expect_err("a provider report smaller than its result must fail closed"); + + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + } /// the index must be one this writer could emit. The /// chunk loop counts in `usize`, so a digit run that overflows it is not diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index b29a43ec..64e0e56f 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -27,13 +27,14 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::{Capability, CapabilitySupport}; use walkdir::WalkDir; static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; @@ -389,6 +390,31 @@ struct RuntimeStoreNameReconciliation { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::IngressAdmission | Capability::OutboundHeaderFidelity => { + CapabilitySupport::Native + } + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHttp + | Capability::SendAllSlotIsolation + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `fastly profile {create|delete|list}` is the native @@ -419,6 +445,37 @@ impl Adapter for FastlyCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "pinned fastly manifest has no parent directory".to_owned())?; + match action { + AdapterAction::Build => { + let artifact = build_from_manifest(&manifest, args)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy_from_dir(manifest_dir, args), + AdapterAction::DeployStaged => deploy_staged_from_dir(args, manifest_dir), + AdapterAction::Serve => serve_from_dir(manifest_dir, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "fastly adapter cannot execute operational action {action:?} against a pinned runtime target" + )), + } + } + fn gc_config_entries( &self, _manifest_root: &Path, @@ -4115,6 +4172,10 @@ fn no_matching_store_error(name: &str) -> String { pub fn build(extra_args: &[String]) -> Result { let manifest = find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; @@ -4176,20 +4237,18 @@ fn build_compute_deploy_args(extra_args: &[String]) -> Vec { /// # Errors /// Returns an error if the Fastly CLI deploy command fails. /// -/// Honours a CLI-threaded `--manifest-path ` (see -/// [`resolve_manifest_dir`]) so a monorepo with several Fastly apps -/// deploys the one the operator's `edgezero.toml` selected, rather than -/// whichever `fastly.toml` a bare working-directory search finds first. -/// The flag is EdgeZero-internal — `fastly compute deploy` has no such -/// flag — so it is stripped from the forwarded argv. #[inline] pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest_dir = resolve_manifest_dir(extra_args)?; + deploy_from_dir(&manifest_dir, extra_args) +} + +fn deploy_from_dir(manifest_dir: &Path, extra_args: &[String]) -> Result<(), String> { let forwarded = args_without_flag_value(extra_args, "--manifest-path"); let status = Command::new("fastly") .args(build_compute_deploy_args(&forwarded)) - .current_dir(&manifest_dir) + .current_dir(manifest_dir) .status() .map_err(|err| format!("failed to run fastly CLI: {err}"))?; if !status.success() { @@ -4293,7 +4352,10 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + serve_from_dir(manifest_dir, extra_args) +} +fn serve_from_dir(manifest_dir: &Path, extra_args: &[String]) -> Result<(), String> { let status = Command::new("fastly") .args(["compute", "serve"]) .args(extra_args) @@ -5010,18 +5072,25 @@ fn fastly_api_put(path: &str, token: &str) -> Result { } } -/// Resolve the directory containing the Fastly manifest for a deploy -/// (production [`deploy`] or [`deploy_staged`]). +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join("fastly.toml"), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned fastly manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + +/// Resolve the directory containing the Fastly manifest for a legacy +/// direct staged-deploy call. /// -/// The CLI (`edgezero_cli::run_deploy`) resolves the `edgezero.toml` -/// manifest — honouring `EDGEZERO_MANIFEST` — and threads the -/// manifest-configured `[adapters.fastly.adapter].manifest` path in as -/// `--manifest-path `. Prefer that so a monorepo with -/// multiple Fastly apps deploys/stages the app the operator actually -/// selected, rather than whichever `fastly.toml` a bare working-directory -/// search happens to find first. Only when no `--manifest-path` is -/// threaded (e.g. a manifest that declares Fastly commands but no adapter -/// `manifest` key) do we fall back to the working-directory search. +/// Runtime-producing CLI actions use [`Adapter::execute_target`] and never +/// enter this discovery path. The `--manifest-path` token remains accepted for +/// compatibility with callers of this crate-level function. fn resolve_manifest_dir(args: &[String]) -> Result { if let Some(raw) = arg_value(args, "--manifest-path") { let path = PathBuf::from(raw); @@ -5043,6 +5112,11 @@ fn resolve_manifest_dir(args: &[String]) -> Result { /// build, upload to a new draft version (no activation), stage it, and /// emit `version=`. fn deploy_staged(args: &[String]) -> Result<(), String> { + let manifest_dir = resolve_manifest_dir(args)?; + deploy_staged_from_dir(args, &manifest_dir) +} + +fn deploy_staged_from_dir(args: &[String], manifest_dir: &Path) -> Result<(), String> { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast @@ -5050,8 +5124,6 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { // `fastly compute update` error. require_token()?; - let manifest_dir_buf = resolve_manifest_dir(args)?; - let manifest_dir = manifest_dir_buf.as_path(); // The CLI threads the app's declared config-store logical ids as // `--edgezero-staging-config=` (one per store) so the staging relink // knows which selectors to redirect — read from the app manifest, never a @@ -5579,16 +5651,93 @@ mod tests { use std::sync::Mutex; use tempfile::tempdir; - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the fastly adapter operates on, not arbitrary strings. - const TEST_KV_ID: &str = "sessions"; + // Logical store ids shared by setup and assertions. const TEST_CONFIG_ID: &str = "app_config"; + const TEST_KV_ID: &str = "sessions"; const TEST_SECRET_ID: &str = "default"; + #[test] + fn adapter_capability_matrix_matches_contracts() { + adapter_capability_matrix_matches_outbound_spec(); + } + + #[test] + fn adapter_capability_matrix_matches_outbound_spec() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::Unsupported, + ), + (Capability::OutboundHttp, CapabilitySupport::BestEffort), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::BestEffort), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::BestEffort, + ), + ( + Capability::SendAllSlotIsolation, + CapabilitySupport::BestEffort, + ), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::BestEffort, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + FASTLY_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + // `PathPrepend` (RAII $PATH guard) is the shared helper imported above from // `edgezero_core::test_env`; the merge with edition-2024 main replaced our // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index bad34170..f3cf16e2 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -4,9 +4,14 @@ use std::cell::Cell; #[cfg(test)] use std::collections::HashMap; -use crate::chunked_config::resolve_fastly_config_value_typed; +use crate::chunked_config::{ + BoundedResolveFailure, ResolveFailure, SyncHostCallError, exact_fastly_read, + resolve_fastly_config_value_typed, resolve_fastly_config_value_typed_bounded, + run_sync_host_call, +}; use async_trait::async_trait; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::Deadline; +use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; use fastly::ConfigStore as FastlyConfigStoreInner; use fastly::config_store::{LookupError, OpenError}; @@ -111,49 +116,123 @@ impl ConfigStore for FastlyConfigStore { } Ok(got) }); - match outcome { - Ok(resolved) => Ok(Some(resolved)), - Err(err) if err.is_future_format() => { - // A NEWER format in OUR reserved namespace (an unknown - // `edgezero_kind`, or a future pointer/inner-envelope version): - // re-pushing the same config will not help; the deployed build - // must be UPGRADED. - log::warn!( - "Fastly config-store value for `{key}` uses a NEWER format than this build \ - understands: {}. Re-pushing the same config will not help -- redeploy this \ - service with an updated EdgeZero build.", - err.into_message() - ); - Err(ConfigStoreError::internal(anyhow::anyhow!( - "config store value uses a newer format than this build understands; redeploy \ - this service with an updated build (re-pushing will not help)" - ))) - } - Err(err) => { - let message = err.into_message(); - if transient.get() { - log::warn!( - "Fastly config-store chunk lookup for `{key}` was transiently \ - unavailable: {message}" - ); - Err(ConfigStoreError::unavailable( - "config store temporarily unavailable", - )) - } else { - log::warn!( - "Fastly config-store chunk resolution failed for `{key}`: {message}. \ - Re-run ` config push` to repair the store." - ); - Err(ConfigStoreError::internal(anyhow::anyhow!( - "config store entry is corrupt or incomplete; re-run config push to \ - repair: {message}" - ))) + outcome + .map(Some) + .map_err(|error| map_resolve_failure(key, transient.get(), error)) + } + + #[inline] + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + let materialized_root = run_sync_host_call(deadline, || { + Ok(match &self.inner { + FastlyConfigStoreBackend::Fastly(inner) => { + inner.try_get(key).map_err(|err| map_lookup_error(&err))? + } + #[cfg(test)] + FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), + }) + }) + .map_err(map_sync_config_error)?; + let root_read = exact_fastly_read(materialized_root, max_backend_bytes) + .map_err(|_size_error| ConfigStoreError::ValueTooLarge)?; + let Some(root_value) = root_read.value else { + return Ok(root_read); + }; + + let transient = Cell::new(false); + let outcome = resolve_fastly_config_value_typed_bounded( + key, + root_value, + root_read.backend_bytes, + deadline, + max_backend_bytes, + max_value_bytes, + |chunk_key, remaining_backend_bytes| { + let chunk_value = run_sync_host_call(deadline, || { + Ok(match &self.inner { + FastlyConfigStoreBackend::Fastly(inner) => { + inner.try_get(chunk_key).map_err(|err| { + if is_transient_lookup(&err) { + transient.set(true); + ConfigStoreError::unavailable( + "config store temporarily unavailable", + ) + } else { + ConfigStoreError::internal(anyhow::anyhow!( + "config store chunk key is invalid" + )) + } + })? + } + #[cfg(test)] + FastlyConfigStoreBackend::InMemory(data) => data.get(chunk_key).cloned(), + }) + }) + .map_err(map_sync_config_error)?; + if chunk_value.is_none() { + transient.set(true); } + exact_fastly_read(chunk_value, remaining_backend_bytes) + .map_err(|_size_error| ConfigStoreError::ValueTooLarge) + }, + ); + + match outcome { + Ok(read) => Ok(read), + Err(BoundedResolveFailure::Backend(error)) => Err(error), + Err(BoundedResolveFailure::DeadlineExceeded) => Err(ConfigStoreError::DeadlineExceeded), + Err(BoundedResolveFailure::Resolve(error)) => { + Err(map_resolve_failure(key, transient.get(), error)) } + Err(BoundedResolveFailure::ValueTooLarge) => Err(ConfigStoreError::ValueTooLarge), } } } +fn map_sync_config_error(call_error: SyncHostCallError) -> ConfigStoreError { + match call_error { + SyncHostCallError::Backend(backend_error) => backend_error, + SyncHostCallError::DeadlineExceeded => ConfigStoreError::DeadlineExceeded, + } +} + +fn map_resolve_failure(key: &str, transient: bool, error: ResolveFailure) -> ConfigStoreError { + if error.is_future_format() { + log::warn!( + "Fastly config-store value for `{key}` uses a NEWER format than this build \ + understands: {}. Re-pushing the same config will not help -- redeploy this \ + service with an updated EdgeZero build.", + error.into_message() + ); + return ConfigStoreError::internal(anyhow::anyhow!( + "config store value uses a newer format than this build understands; redeploy \ + this service with an updated build (re-pushing will not help)" + )); + } + + let message = error.into_message(); + if transient { + log::warn!( + "Fastly config-store chunk lookup for `{key}` was transiently unavailable: {message}" + ); + ConfigStoreError::unavailable("config store temporarily unavailable") + } else { + log::warn!( + "Fastly config-store chunk resolution failed for `{key}`: {message}. \ + Re-run ` config push` to repair the store." + ); + ConfigStoreError::internal(anyhow::anyhow!( + "config store entry is corrupt or incomplete; re-run config push to repair: {message}" + )) + } +} + /// Is a CHUNK lookup failure environmental (retry) rather than corrupt config /// (`config push` to repair)? Only a bad KEY names corrupt state a re-push /// rewrites; everything else — an invalid store handle, lookup exhaustion, an @@ -189,6 +268,8 @@ fn map_lookup_error(err: &LookupError) -> ConfigStoreError { #[cfg(test)] mod tests { use super::*; + use edgezero_core::Deadline; + use std::time::Duration; edgezero_core::config_store_contract_tests!(fastly_config_store_contract, { FastlyConfigStore::from_entries([ @@ -197,6 +278,37 @@ mod tests { ]) }); + #[test] + fn bounded_chunked_read_counts_root_pointer_and_all_chunks() { + use crate::chunked_config::prepare_fastly_config_entries; + use edgezero_core::blob_envelope::BlobEnvelope; + use futures::executor::block_on; + use serde_json::json; + + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let entries = prepare_fastly_config_entries("app_config", &envelope).expect("entries"); + let maybe_backend_bytes = entries.iter().try_fold(0_u64, |total, (_, value)| { + total.checked_add(u64::try_from(value.len()).ok()?) + }); + let expected_backend_bytes = maybe_backend_bytes.expect("backend byte sum"); + let store = FastlyConfigStore::from_entries(entries); + + let read = block_on(store.get_bounded( + "app_config", + Deadline::after(Duration::from_secs(1)), + expected_backend_bytes, + u64::try_from(envelope.len()).expect("envelope length"), + )) + .expect("exact aggregate cap must succeed"); + + assert_eq!(read.backend_bytes, expected_backend_bytes); + assert_eq!(read.value.as_deref(), Some(envelope.as_str())); + } + #[test] fn key_invalid_maps_to_invalid_key_error() { let err = map_lookup_error(&LookupError::KeyInvalid); diff --git a/crates/edgezero-adapter-fastly/src/key_value_store.rs b/crates/edgezero-adapter-fastly/src/key_value_store.rs index 111d18fd..9ca5f7c3 100644 --- a/crates/edgezero-adapter-fastly/src/key_value_store.rs +++ b/crates/edgezero-adapter-fastly/src/key_value_store.rs @@ -6,26 +6,19 @@ //! //! This module is only compiled when the `fastly` feature is enabled. -#[cfg(feature = "fastly")] use async_trait::async_trait; -#[cfg(feature = "fastly")] use bytes::Bytes; -#[cfg(feature = "fastly")] use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; -#[cfg(feature = "fastly")] use fastly::kv_store::{KVStore, KVStoreError}; -#[cfg(feature = "fastly")] use std::time::Duration; /// KV store backed by Fastly's KV Store API. /// /// Wraps a `fastly::kv_store::KVStore` handle obtained via `KVStore::open(name)`. -#[cfg(feature = "fastly")] pub struct FastlyKvStore { store: KVStore, } -#[cfg(feature = "fastly")] impl FastlyKvStore { /// Open a Fastly KV Store by name. /// @@ -42,7 +35,6 @@ impl FastlyKvStore { } } -#[cfg(feature = "fastly")] #[async_trait(?Send)] impl KvStore for FastlyKvStore { #[inline] diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 36161a35..c8abcc7c 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -4,9 +4,13 @@ // Only compiled where it is actually used (the CLI push/GC path and the Fastly // runtime resolver). Gating it keeps a `--no-default-features` build dead-code // clean instead of dragging in helpers no feature references. -#[cfg(any(feature = "cli", feature = "fastly", test))] +#[cfg(any( + feature = "fastly", + test, + all(feature = "cli", not(target_arch = "wasm32")) +))] pub(crate) mod chunked_config; -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; #[cfg(feature = "fastly")] pub mod config_store; @@ -15,8 +19,8 @@ pub mod context; pub mod key_value_store; #[cfg(feature = "fastly")] pub mod logger; -#[cfg(feature = "fastly")] -pub mod proxy; +#[cfg(any(test, feature = "test-utils", feature = "fastly"))] +pub mod outbound; #[cfg(feature = "fastly")] pub mod request; #[cfg(feature = "fastly")] @@ -37,7 +41,7 @@ use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] use fastly::compute_runtime::service_id; -#[cfg(any(feature = "cli", feature = "fastly", test))] +#[cfg(any(feature = "fastly", all(feature = "cli", not(target_arch = "wasm32"))))] const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__"; /// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` @@ -109,7 +113,7 @@ impl From<&EnvConfig> for FastlyLogging { /// The shared `edgezero_runtime_env` Config Store is account-wide. Service /// scoping prevents two linked services that declare the same logical store id /// from overwriting one another's runtime mappings. -#[cfg(any(feature = "cli", feature = "fastly", test))] +#[cfg(any(feature = "fastly", all(feature = "cli", not(target_arch = "wasm32"))))] fn service_scoped_runtime_env_key(service_id: &str, canonical_key: &str) -> String { let suffix = canonical_key .strip_prefix(RUNTIME_ENV_PREFIX) @@ -239,7 +243,10 @@ pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { EnvConfig::from_vars(vars) } -#[cfg(any(feature = "fastly", test))] +#[cfg(any( + feature = "fastly", + all(feature = "cli", test, not(target_arch = "wasm32")) +))] fn runtime_env_vars_for_service( stores: StoresMetadata, service_id: &str, diff --git a/crates/edgezero-adapter-fastly/src/outbound.rs b/crates/edgezero-adapter-fastly/src/outbound.rs new file mode 100644 index 00000000..ae1f8079 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/outbound.rs @@ -0,0 +1,2307 @@ +#![cfg_attr( + feature = "fastly", + expect( + clippy::arbitrary_source_item_ordering, + reason = "the target-gated implementation module is kept before shared test seams" + ) +)] +#![cfg_attr( + feature = "fastly", + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Fastly imports out of native builds" + ) +)] + +#[cfg(test)] +use std::time::Duration; + +use edgezero_core::error::EdgeError; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(any(feature = "fastly", feature = "test-utils"))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; +#[cfg(test)] +use edgezero_core::time::Deadline; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::time::{DispatchBudget, MonotonicInstant}; + +#[cfg(any(feature = "fastly", test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SendFailure { + BudgetedTimeout, + LocalInvariant, + PlatformInternal, + ProviderTimeout, + Transport, + Unknown, + Unreachable, + UpstreamProtocol, +} + +#[cfg(any(feature = "fastly", test))] +fn classify_send_failure( + failure: SendFailure, + budget: DispatchBudget, + observed_at: MonotonicInstant, +) -> EdgeError { + if budget.deadline.is_expired_at(observed_at) { + return timeout_error(budget.cause); + } + match failure { + SendFailure::BudgetedTimeout => timeout_error(budget.cause), + SendFailure::ProviderTimeout => timeout_error(BudgetSource::Unspecified), + SendFailure::Unreachable => EdgeError::bad_gateway_with_reason( + "outbound destination could not be reached", + BadGatewayReason::Unreachable, + ), + SendFailure::Transport => EdgeError::bad_gateway_with_reason( + "outbound connection failed after dispatch", + BadGatewayReason::Transport, + ), + SendFailure::UpstreamProtocol => EdgeError::bad_gateway_with_reason( + "upstream response violated the HTTP protocol", + BadGatewayReason::Protocol, + ), + SendFailure::Unknown => EdgeError::bad_gateway_with_reason( + "Fastly outbound request failed", + BadGatewayReason::Unspecified, + ), + SendFailure::LocalInvariant | SendFailure::PlatformInternal => EdgeError::internal( + anyhow::anyhow!("Fastly rejected an adapter-owned outbound operation"), + ), + } +} + +#[cfg(test)] +fn test_budget(started_at: MonotonicInstant, cause: BudgetSource) -> DispatchBudget { + let duration = Duration::from_secs(1); + DispatchBudget { + cause, + deadline: Deadline::at_instant(started_at.checked_add(duration).expect("deadline instant")), + duration, + } +} + +#[cfg(test)] +fn assert_send_failure_error(failure: SendFailure, error: &EdgeError, selected: BudgetSource) { + match failure { + SendFailure::BudgetedTimeout => { + if let EdgeError::GatewayTimeout { cause, .. } = error { + assert_eq!(*cause, selected); + } else { + panic!("expected selected timeout"); + } + } + SendFailure::ProviderTimeout => assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )), + SendFailure::LocalInvariant | SendFailure::PlatformInternal => { + assert!(matches!(error, EdgeError::Internal { .. })); + } + SendFailure::Transport => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )), + SendFailure::Unknown => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )), + SendFailure::Unreachable => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )), + SendFailure::UpstreamProtocol => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )), + } +} + +#[cfg(feature = "fastly")] +mod fastly_impl { + #[cfg(feature = "test-utils")] + use std::cell::Cell; + use std::collections::HashMap; + use std::io::{Result as IoResult, Write}; + use std::mem::replace; + use std::num::NonZeroU64; + use std::sync::Mutex; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_gzip_stream, + }; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; + use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; + use edgezero_core::outbound::{ + OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseHeaderLimiter, + ResponseMode, collect_response_stream, enforce_payload_content_length, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + normalize_response_headers, rechunk_stream, validate_for_dispatch, + }; + use edgezero_core::time::{ + BATCH_DISPATCH_SLACK_MAX, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, + }; + use fastly::backend::BackendCreationError; + use fastly::http::body::StreamingBody; + use fastly::http::request::{PendingRequest, PollResult, SendError, SendErrorCause}; + use fastly::{ + Backend, Body as FastlyBody, Request as FastlyRequest, Response as FastlyResponse, + }; + use futures_util::StreamExt as _; + use sha2::{Digest as _, Sha256}; + + use super::{dispatch_all_before_wait, timeout_error, validate_batch_request}; + + pub const DYNAMIC_BACKENDS_DISABLED_MESSAGE: &str = "Fastly dynamic backends are not enabled on this service; enable them in the service configuration"; + const RESPONSE_READ_BYTES: usize = 16 * 1024; + const DISPATCH_SLACK_MESSAGE: &str = "Fastly send_all adapter overhead between batch_now and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration"; + + #[cfg(feature = "test-utils")] + std::thread_local! { + static DISPATCH_SLACK_INJECTION: Cell> = const { Cell::new(None) }; + } + + #[cfg(feature = "test-utils")] + struct DispatchSlackInjection { + previous: Option, + } + + #[cfg(feature = "test-utils")] + impl Drop for DispatchSlackInjection { + fn drop(&mut self) { + DISPATCH_SLACK_INJECTION.set(self.previous); + } + } + + /// Overrides the dispatch-guard clock offset for one target-runtime contract test. + #[cfg(feature = "test-utils")] + #[inline] + #[must_use] + pub fn inject_dispatch_slack_for_test(delay: Duration) -> impl Drop { + let previous = DISPATCH_SLACK_INJECTION.replace(Some(delay)); + DispatchSlackInjection { previous } + } + + #[derive(Clone, Debug, Eq, Hash, PartialEq)] + struct BackendIdentity { + budget_ms: u64, + host: String, + port: u16, + scheme: String, + tls: bool, + } + + struct PreparedRequest { + backend: Backend, + budget: DispatchBudget, + parts: OutboundRequestParts, + started_at: MonotonicInstant, + } + + struct PendingSlot { + budget: DispatchBudget, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + pending: PendingRequest, + request_method: Method, + response_mode: ResponseMode, + } + + enum Slot { + Done(OutboundSlotResult), + Pending(Box), + Taken, + } + + /// Native outbound HTTP implementation for Fastly Compute. + pub struct FastlyOutboundClient { + backends: Mutex>, + clock: MonotonicClock, + } + + impl FastlyOutboundClient { + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { + backends: Mutex::new(HashMap::new()), + clock, + } + } + + fn ensure_backend( + &self, + request: &OutboundRequest, + budget: DispatchBudget, + ) -> Result { + let identity = backend_identity(request, budget)?; + let name = backend_name(&identity); + { + let cache = self.backends.lock().map_err(|_poisoned| { + EdgeError::internal(anyhow::anyhow!("Fastly backend cache was poisoned")) + })?; + if let Some((cached_identity, backend)) = cache.get(&name) { + if cached_identity != &identity { + return Err(EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + ))); + } + return Ok(backend.clone()); + } + } + + let target = request.backend_target(); + let host_override = backend_host_override(request); + let timers = backend_timers(identity.budget_ms); + let mut builder = Backend::builder(&name, &target) + .override_host(host_override) + .connect_timeout(timers.connect) + .first_byte_timeout(timers.first_byte) + .between_bytes_timeout(timers.between_bytes); + if identity.tls { + builder = builder.enable_ssl(); + if let Some(sni) = request.sni_hostname() { + builder = builder.sni_hostname(sni); + } + if let Some(cert_host) = request.cert_host() { + builder = builder.check_certificate(cert_host); + } + } + finish_backend_creation(builder.finish(), budget, &self.clock, |backend| { + let mut cache = self.backends.lock().map_err(|_poisoned| { + EdgeError::internal(anyhow::anyhow!("Fastly backend cache was poisoned")) + })?; + if let Some((cached_identity, cached_backend)) = cache.get(&name) { + if cached_identity != &identity { + return Err(EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + ))); + } + return Ok(cached_backend.clone()); + } + cache.insert(name, (identity, backend.clone())); + Ok(backend) + }) + } + + fn prepare( + &self, + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + self.prepare_validated(request, started_at) + } + + fn prepare_batch( + &self, + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_batch_request(&request)?; + self.prepare_validated(request, started_at) + } + + fn prepare_validated( + &self, + mut request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + let budget = dispatch_budget(&request, started_at)?; + normalize_fastly_request(&mut request)?; + budget_remaining(budget, &self.clock)?; + let backend = self.ensure_backend(&request, budget)?; + budget_remaining(budget, &self.clock)?; + Ok(PreparedRequest { + backend, + budget, + parts: request.into_parts(), + started_at, + }) + } + + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { + backend, + budget, + parts, + started_at, + } = prepared; + let OutboundRequestParts { + body, + headers, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + let fastly_request = build_fastly_request(&method, &uri, &headers); + let response = match body { + Body::Once(bytes) => { + validate_request_body_length(&bytes, max_request_body_bytes)?; + let mut buffered_request = fastly_request; + buffered_request.set_body(bytes.to_vec()); + dispatch_guard(started_at, budget, &self.clock)?; + let pending = buffered_request + .send_async(backend) + .map_err(|error| map_send_error(&error, budget, &self.clock))?; + wait_pending(pending, budget, &self.clock)? + } + Body::Stream(source) => { + send_streamed( + fastly_request, + backend, + source, + max_request_body_bytes, + budget, + started_at, + self.clock.clone(), + ) + .await? + } + }; + process_response( + response, + method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn dispatch_batch_slot(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { + backend, + budget, + parts, + started_at, + } = prepared; + let OutboundRequestParts { + body, + headers, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + let Body::Once(bytes) = body else { + return Err(EdgeError::internal(anyhow::anyhow!( + "Fastly batch preflight admitted a streamed upload" + ))); + }; + validate_request_body_length(&bytes, max_request_body_bytes)?; + let mut request = build_fastly_request(&method, &uri, &headers); + request.set_body(bytes.to_vec()); + dispatch_guard(started_at, budget, &self.clock)?; + let pending = request + .send_async(backend) + .map_err(|error| map_send_error(&error, budget, &self.clock))?; + Ok(PendingSlot { + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + pending, + request_method: method, + response_mode, + }) + } + } + + impl Default for FastlyOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + fn backend_host_override(request: &OutboundRequest) -> String { + request.host_authority() + } + + #[async_trait(?Send)] + impl OutboundHttpClient for FastlyOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = self.prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + async fn send_all(&self, requests: Vec) -> Vec { + let batch_started_at = self.clock.now(); + let mut slots: Vec = dispatch_all_before_wait(requests, |request| { + let prepared = self.prepare_batch(request, batch_started_at)?; + self.dispatch_batch_slot(prepared) + }) + .into_iter() + .map(|result| { + result.map_or_else( + |error| Slot::Done(finish_slot(batch_started_at, Err(error), &self.clock)), + |pending| Slot::Pending(Box::new(pending)), + ) + }) + .collect(); + + for index in 0..slots.len() { + let Some(slot) = slots.get_mut(index) else { + continue; + }; + let current = replace(slot, Slot::Taken); + *slot = if let Slot::Pending(pending) = current { + let outcome = finish_pending(*pending, &self.clock).await; + Slot::Done(finish_slot(batch_started_at, outcome, &self.clock)) + } else { + current + }; + + for later in slots.iter_mut().skip(index.saturating_add(1)) { + poll_slot(later, batch_started_at, &self.clock).await; + } + } + + resolve_slots(slots, batch_started_at, &self.clock) + } + } + + #[derive(Clone, Copy)] + struct BackendTimers { + between_bytes: Duration, + connect: Duration, + first_byte: Duration, + } + + fn backend_identity( + request: &OutboundRequest, + budget: DispatchBudget, + ) -> Result { + let budget_ms = ceil_millis(budget.duration); + let scheme = request + .uri() + .scheme_str() + .ok_or_else(|| EdgeError::bad_request("outbound request URI has no scheme"))?; + let port = request + .uri() + .port_u16() + .unwrap_or(if scheme == "https" { 443 } else { 80 }); + Ok(BackendIdentity { + budget_ms, + host: request.host_name().to_owned(), + port, + scheme: scheme.to_owned(), + tls: scheme == "https", + }) + } + + fn backend_name(identity: &BackendIdentity) -> String { + let tls_mode = if identity.tls { "tls" } else { "plain" }; + let canonical = format!( + "{}:{}:{}:{}:{}", + identity.scheme, identity.host, identity.port, tls_mode, identity.budget_ms + ); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let mut name = String::from("ez_"); + let Some(prefix) = digest.get(..16) else { + return name; + }; + for byte in prefix { + let high = u32::from(*byte >> 4_u8); + let low = u32::from(*byte & 0x0f); + if let Some(character) = char::from_digit(high, 16) { + name.push(character); + } + if let Some(character) = char::from_digit(low, 16) { + name.push(character); + } + } + name + } + + fn backend_timers(total_ms: u64) -> BackendTimers { + let total = Duration::from_millis(total_ms.max(1)); + if total_ms < 4 { + return BackendTimers { + between_bytes: total, + connect: total, + first_byte: total, + }; + } + let connect = total.checked_div(4).unwrap_or(total); + BackendTimers { + between_bytes: total, + connect, + first_byte: total.saturating_sub(connect), + } + } + + fn build_fastly_request(method: &Method, uri: &Uri, headers: &HeaderMap) -> FastlyRequest { + let mut request = FastlyRequest::new(method.clone(), uri.to_string()); + request.set_method(method.clone()); + for (name, value) in headers { + request.append_header(name.as_str(), value.as_bytes()); + } + request + } + + fn normalize_fastly_request(request: &mut OutboundRequest) -> Result<(), EdgeError> { + normalize_for_dispatch(request)?; + if !request.headers().contains_key(ACCEPT_ENCODING) { + request + .headers_mut() + .insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + Ok(()) + } + + fn ceil_millis(duration: Duration) -> u64 { + let rounded = duration + .checked_add(Duration::from_micros(999)) + .unwrap_or(Duration::MAX); + u64::try_from(rounded.as_millis()) + .unwrap_or(u64::MAX) + .max(1) + } + + async fn finish_pending( + slot: PendingSlot, + clock: &MonotonicClock, + ) -> Result { + let PendingSlot { + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + pending, + request_method, + response_mode, + } = slot; + let response = wait_pending(pending, budget, clock)?; + process_response( + response, + request_method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + clock.clone(), + ) + .await + } + + fn finish_slot( + started_at: MonotonicInstant, + outcome: Result, + clock: &MonotonicClock, + ) -> OutboundSlotResult { + let completed_at = clock.now(); + match completed_at.checked_duration_since(started_at) { + Some(elapsed) => OutboundSlotResult::new(elapsed, outcome), + None => OutboundSlotResult::new( + Duration::ZERO, + Err(EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards during outbound dispatch" + ))), + ), + } + } + + fn resolve_slots( + slots: Vec, + started_at: MonotonicInstant, + clock: &MonotonicClock, + ) -> Vec { + slots + .into_iter() + .map(|slot| match slot { + Slot::Done(done) => done, + Slot::Pending(_) | Slot::Taken => finish_slot( + started_at, + Err(EdgeError::internal(anyhow::anyhow!( + "Fastly batch harvest left an unresolved slot" + ))), + clock, + ), + }) + .collect() + } + + fn map_backend_creation_error(error: &BackendCreationError) -> EdgeError { + match error { + BackendCreationError::Disallowed => EdgeError::bad_gateway_with_reason( + DYNAMIC_BACKENDS_DISABLED_MESSAGE, + BadGatewayReason::Unspecified, + ), + BackendCreationError::NameInUse => EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + )), + BackendCreationError::BetweenBytesTimeoutTooLarge(_) + | BackendCreationError::ConnectTimeoutTooLarge(_) + | BackendCreationError::EncodingError(_) + | BackendCreationError::FirstByteTimeoutTooLarge(_) + | BackendCreationError::NameTooLong(_) => EdgeError::internal(anyhow::anyhow!( + "Fastly rejected deterministic dynamic backend configuration" + )), + BackendCreationError::HostError(_) => EdgeError::bad_gateway_with_reason( + "Fastly failed to register a dynamic backend", + BadGatewayReason::Unspecified, + ), + } + } + + fn finish_backend_creation( + result: Result, + budget: DispatchBudget, + clock: &MonotonicClock, + retain_success: RetainSuccess, + ) -> Result + where + RetainSuccess: FnOnce(BackendValue) -> Result, + { + match result { + Ok(backend) => { + let retained = retain_success(backend); + if budget.deadline.is_expired_at(clock.now()) { + Err(timeout_error(budget.cause)) + } else { + retained + } + } + Err(error) => { + if budget.deadline.is_expired_at(clock.now()) { + Err(timeout_error(budget.cause)) + } else { + Err(map_backend_creation_error(&error)) + } + } + } + } + + fn cause_to_failure(cause: &SendErrorCause) -> super::SendFailure { + match cause { + SendErrorCause::ConnectionTimeout | SendErrorCause::HttpResponseTimeout => { + super::SendFailure::BudgetedTimeout + } + SendErrorCause::DnsTimeout => super::SendFailure::ProviderTimeout, + SendErrorCause::ConnectionLimitReached + | SendErrorCause::ConnectionRefused + | SendErrorCause::DestinationIpUnroutable + | SendErrorCause::DestinationNotFound + | SendErrorCause::DestinationUnavailable + | SendErrorCause::DnsError { .. } + | SendErrorCause::TlsAlertReceived { .. } + | SendErrorCause::TlsCertificateError + | SendErrorCause::TlsConfigurationError + | SendErrorCause::TlsProtocolError => super::SendFailure::Unreachable, + SendErrorCause::ConnectionTerminated | SendErrorCause::IoError(_) => { + super::SendFailure::Transport + } + SendErrorCause::Http2StreamError { .. } + | SendErrorCause::HttpIncompleteResponse + | SendErrorCause::HttpProtocolError + | SendErrorCause::HttpResponseBodyTooLarge + | SendErrorCause::HttpResponseHeaderSectionTooLarge + | SendErrorCause::HttpResponseStatusInvalid + | SendErrorCause::HttpUpgradeFailed => super::SendFailure::UpstreamProtocol, + SendErrorCause::HttpCacheApiUnsupported + | SendErrorCause::HttpCacheLimitExceeded + | SendErrorCause::HttpRequestCacheKeyInvalid + | SendErrorCause::HttpRequestUriInvalid => super::SendFailure::LocalInvariant, + SendErrorCause::ImageOptimizerUnsupported | SendErrorCause::Custom(_) => { + super::SendFailure::Unknown + } + SendErrorCause::InternalError(_) => super::SendFailure::PlatformInternal, + _ => super::SendFailure::Unknown, + } + } + + fn map_send_error( + error: &SendError, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> EdgeError { + super::classify_send_failure(cause_to_failure(error.root_cause()), budget, clock.now()) + } + + async fn poll_slot(slot: &mut Slot, started_at: MonotonicInstant, clock: &MonotonicClock) { + let current = replace(slot, Slot::Taken); + let Slot::Pending(pending_slot) = current else { + *slot = current; + return; + }; + let PendingSlot { + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + pending: pending_request, + request_method, + response_mode, + } = *pending_slot; + match pending_request.poll() { + PollResult::Pending(still_pending) => { + *slot = Slot::Pending(Box::new(PendingSlot { + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + pending: still_pending, + request_method, + response_mode, + })); + } + PollResult::Done(result) => { + let outcome = match result { + Ok(response) => { + process_response( + response, + request_method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + clock.clone(), + ) + .await + } + Err(error) => Err(map_send_error(&error, budget, clock)), + }; + *slot = Slot::Done(finish_slot(started_at, outcome, clock)); + } + } + } + + #[expect( + clippy::too_many_arguments, + reason = "the adapter consumes independent response-policy fields" + )] + async fn process_response( + mut response: FastlyResponse, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.get_status().as_u16()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Fastly returned an invalid upstream status code", + BadGatewayReason::Protocol, + ) + })?; + let mut headers = response_headers(&response); + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let disposition = normalize_response_headers(&request_method, status, &mut headers)?; + headers.insert(PROXY_HEADER, HeaderValue::from_static("fastly")); + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + let native = fastly_body_stream(response.take_body(), budget, clock.clone()); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + if !declared_reset_body { + let mut reset_stream = native; + if let Some(item) = reset_stream.next().await { + item?; + } + } + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let encoding = classify_content_encoding(&headers); + let max_buffered = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + &headers, + encoding, + max_buffered, + max_decoded_response_bytes, + max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, max_encoded_response_bytes); + let decoded = match encoding { + ContentEncoding::Brotli => { + decode_brotli_stream(encoded, max_brotli_window_bits, max_brotli_decoder_bytes) + } + ContentEncoding::Gzip => decode_gzip_stream(encoded), + ContentEncoding::Identity | ContentEncoding::Passthrough => encoded, + }; + if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match encoding { + ContentEncoding::Brotli | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough => decoded, + }; + let shaped = rechunk_stream(output, max_chunk_bytes); + let deadline_bound = deadline_stream(shaped, budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + body, + response_clock, + )) + } + + fn response_headers(response: &FastlyResponse) -> HeaderMap { + let mut headers = HeaderMap::new(); + for name in response.get_header_names() { + for value in response.get_header_all(name) { + headers.append(name.clone(), value.clone()); + } + } + headers + } + + trait StreamedUploadWriter { + fn finish(self) -> IoResult<()>; + fn flush(&mut self) -> IoResult<()>; + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()>; + } + + impl StreamedUploadWriter for StreamingBody { + fn finish(self) -> IoResult<()> { + StreamingBody::finish(self) + } + + fn flush(&mut self) -> IoResult<()> { + Write::flush(self) + } + + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()> { + Write::write_all(self, bytes) + } + } + + async fn complete_streamed_exchange( + mut writer: Writer, + mut source: BodyStream, + maximum: u64, + budget: DispatchBudget, + clock: &MonotonicClock, + wait: Wait, + ) -> Result + where + Writer: StreamedUploadWriter, + Wait: FnOnce() -> Result, + { + let mut total = 0_u64; + loop { + budget_remaining(budget, clock)?; + let next_item = source.next().await; + budget_remaining(budget, clock)?; + let Some(item) = next_item else { + break; + }; + let bytes = item?; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + resolve_stream_io_result( + writer.write_all(&bytes), + budget, + clock.now(), + "Fastly outbound request body write failed", + )?; + resolve_stream_io_result( + writer.flush(), + budget, + clock.now(), + "Fastly outbound request body flush failed", + )?; + total = next_total; + } + resolve_stream_io_result( + writer.finish(), + budget, + clock.now(), + "Fastly outbound request completion failed", + )?; + wait() + } + + async fn send_streamed( + request: FastlyRequest, + backend: Backend, + source: BodyStream, + maximum: u64, + budget: DispatchBudget, + started_at: MonotonicInstant, + clock: MonotonicClock, + ) -> Result { + dispatch_guard(started_at, budget, &clock)?; + let (writer, pending) = request + .send_async_streaming(backend) + .map_err(|error| map_send_error(&error, budget, &clock))?; + complete_streamed_exchange(writer, source, maximum, budget, &clock, || { + wait_pending(pending, budget, &clock) + }) + .await + } + + fn resolve_stream_io_result( + result: IoResult<()>, + budget: DispatchBudget, + observed_at: MonotonicInstant, + failure_message: &'static str, + ) -> Result<(), EdgeError> { + if budget.deadline.is_expired_at(observed_at) { + return Err(timeout_error(budget.cause)); + } + result.map_err(|_error| { + EdgeError::bad_gateway_with_reason(failure_message, BadGatewayReason::Transport) + }) + } + + fn fastly_body_stream( + mut body: FastlyBody, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + budget_remaining(budget, &clock)?; + let mut chunks = body.read_chunks(RESPONSE_READ_BYTES); + let item = chunks.next(); + drop(chunks); + match item { + Some(Ok(chunk)) => { + budget_remaining(budget, &clock)?; + yield Ok(Bytes::from(chunk)); + } + Some(Err(_error)) => { + if budget.deadline.is_expired_at(clock.now()) { + yield Err(timeout_error(budget.cause)); + } else { + yield Err(EdgeError::bad_gateway_with_reason( + "Fastly upstream response body failed", + BadGatewayReason::Transport, + )); + } + return; + } + None => { + budget_remaining(budget, &clock)?; + return; + } + } + } + } + .boxed_local() + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + if let Err(error) = budget_remaining(budget, &clock) { + yield Err(error); + return; + } + let next_item = source.next().await; + if let Err(error) = budget_remaining(budget, &clock) { + yield Err(error); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() + } + + fn validate_request_body_length(bytes: &Bytes, maximum: u64) -> Result<(), EdgeError> { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + Ok(()) + } + + fn wait_pending( + pending: PendingRequest, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget_remaining(budget, clock)?; + let outcome = pending + .wait() + .map_err(|error| map_send_error(&error, budget, clock)); + budget_remaining(budget, clock)?; + outcome + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + fn dispatch_guard( + started_at: MonotonicInstant, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(), EdgeError> { + dispatch_guard_at(started_at, budget, dispatch_observed_at(started_at, clock)?) + } + + #[cfg(feature = "test-utils")] + fn dispatch_observed_at( + started_at: MonotonicInstant, + clock: &MonotonicClock, + ) -> Result { + if let Some(delay) = DISPATCH_SLACK_INJECTION.get() { + return started_at.checked_add(delay).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "Fastly dispatch test clock exceeded the monotonic instant range" + )) + }); + } + Ok(clock.now()) + } + + #[cfg(not(feature = "test-utils"))] + #[expect( + clippy::unnecessary_wraps, + reason = "the test-utils build can reject an injected monotonic overflow" + )] + fn dispatch_observed_at( + _started_at: MonotonicInstant, + clock: &MonotonicClock, + ) -> Result { + Ok(clock.now()) + } + + fn dispatch_guard_at( + started_at: MonotonicInstant, + budget: DispatchBudget, + observed_at: MonotonicInstant, + ) -> Result<(), EdgeError> { + if budget.deadline.is_expired_at(observed_at) { + return Err(timeout_error(budget.cause)); + } + let elapsed = observed_at + .checked_duration_since(started_at) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards before Fastly SDK dispatch" + )) + })?; + if elapsed > BATCH_DISPATCH_SLACK_MAX { + return Err(EdgeError::internal(anyhow::anyhow!(DISPATCH_SLACK_MESSAGE))); + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::io::{Error, Write as _}; + use std::num::NonZeroU64; + use std::sync::{Arc, Mutex}; + use std::thread; + + use edgezero_core::error::{BudgetSource, ResponseLimitReason}; + use edgezero_core::http::header::CONNECTION; + use edgezero_core::time::Deadline; + use flate2::{Compression, write::GzEncoder}; + use futures::executor::block_on; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn constant_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) + } + + fn clock_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[test] + fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = FastlyOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![request])); + + assert_eq!(results[0].elapsed, Duration::from_millis(9)); + assert!(matches!( + results[0].outcome, + Err(EdgeError::BadRequest { .. }) + )); + } + + #[test] + fn send_all_reports_per_slot_elapsed() { + let start = MonotonicInstant::now(); + let first = start + .checked_add(Duration::from_millis(3)) + .expect("first completion"); + let second = start + .checked_add(Duration::from_millis(8)) + .expect("second completion"); + let client = + FastlyOutboundClient::with_clock(scripted_clock(vec![start, first, second])); + let requests = vec![ + OutboundRequest::get("https://example.com/") + .expect("request") + .body("invalid GET body"), + OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(), + ]; + + let results = block_on(client.send_all(requests)); + + assert_eq!(results[0].elapsed, Duration::from_millis(3)); + assert_eq!(results[1].elapsed, Duration::from_millis(8)); + assert!( + results + .iter() + .all(|slot| matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))) + ); + } + + #[test] + fn one_slot_send_all_matches_send() { + let now = MonotonicInstant::now(); + let invalid = || { + OutboundRequest::get("https://example.com/") + .expect("request") + .body("invalid GET body") + }; + let single = + block_on(FastlyOutboundClient::with_clock(constant_clock(now)).send(invalid())); + let mut batch = block_on( + FastlyOutboundClient::with_clock(constant_clock(now)).send_all(vec![invalid()]), + ); + let batched = batch.remove(0).outcome; + + assert!(matches!(single, Err(EdgeError::BadRequest { .. }))); + assert!(matches!(batched, Err(EdgeError::BadRequest { .. }))); + } + + #[test] + fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = FastlyOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![request])); + + assert_eq!(results[0].elapsed, Duration::ZERO); + assert!(matches!( + results[0].outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[test] + fn backend_identity_uses_the_method_entry_budget_snapshot() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let request = OutboundRequest::get("https://example.com/").expect("request"); + + let identity = backend_identity(&request, budget).expect("backend identity"); + + assert_eq!(identity.budget_ms, 10); + } + + #[test] + fn adapter_final_dispatch_reapplies_request_normalization() { + let mut request = OutboundRequest::get("https://example.com/").expect("request"); + request + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("accept-encoding")); + request + .headers_mut() + .insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + + normalize_fastly_request(&mut request).expect("normalized request"); + + assert!(!request.headers().contains_key(CONNECTION)); + assert_eq!( + request.headers().get(ACCEPT_ENCODING), + Some(&HeaderValue::from_static("identity")) + ); + } + + #[test] + fn canonical_uri_wire_serialization_table() { + for raw in [ + "https://example.com", + "https://example.com/a/../b?x=%2F", + "http://127.0.0.1:8080/path?empty=", + "https://[::1]:8443/", + "https://xn--bcher-kva.example/catalog", + ] { + let request = OutboundRequest::get(raw).expect("canonical request"); + let native = + build_fastly_request(request.method(), request.uri(), request.headers()); + + assert_eq!(native.get_url_str(), request.uri().to_string(), "{raw}"); + } + } + + #[test] + fn backend_builder_keeps_pooling_for_identical_identity_and_settings() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(100)); + let first = OutboundRequest::get("https://example.com/path").expect("first request"); + let second = OutboundRequest::get("https://example.com/other").expect("second request"); + let first_identity = backend_identity(&first, budget).expect("first identity"); + let second_identity = backend_identity(&second, budget).expect("second identity"); + + assert_eq!(first_identity, second_identity); + assert_eq!( + backend_name(&first_identity), + backend_name(&second_identity) + ); + let first_timers = backend_timers(first_identity.budget_ms); + let second_timers = backend_timers(second_identity.budget_ms); + assert_eq!(first_timers.connect, second_timers.connect); + assert_eq!(first_timers.first_byte, second_timers.first_byte); + assert_eq!(first_timers.between_bytes, second_timers.between_bytes); + } + + #[test] + fn ceil_millis_floors_and_saturates_without_wrapping() { + assert_eq!(ceil_millis(Duration::ZERO), 1); + assert_eq!(ceil_millis(Duration::from_nanos(1)), 1); + assert_eq!(ceil_millis(Duration::from_micros(999)), 1); + assert_eq!(ceil_millis(Duration::from_micros(1_001)), 2); + assert_eq!(ceil_millis(Duration::MAX), u64::MAX); + } + + #[test] + fn constant_clock_can_be_sampled_without_exhaustion() { + let now = MonotonicInstant::now(); + let clock = constant_clock(now); + + assert_eq!(clock.now(), now); + assert_eq!(clock.now(), now); + assert_eq!(clock.now(), now); + } + + #[test] + fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let mut body = fastly_body_stream(FastlyBody::from("body"), budget, clock); + + let error = block_on(body.next()) + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn response_read_checks_deadline_after_eof() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let mut body = fastly_body_stream(FastlyBody::new(), budget, clock); + + assert!(matches!( + block_on(body.next()), + Some(Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + })) + )); + } + + #[test] + fn decoder_stalls_timeout_at_all_completion_boundaries() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_millis(100)); + let mut gzip_encoder = GzEncoder::new(Vec::new(), Compression::default()); + gzip_encoder.write_all(b"ab").expect("gzip input"); + let gzip_bytes = gzip_encoder.finish().expect("gzip output"); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_ENCODING, "gzip"); + native.set_body(gzip_bytes); + + let response = block_on(process_response( + native, + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + NonZeroU64::new(1), + None, + None, + None, + None, + MonotonicClock::default(), + )) + .expect("response head"); + let mut body = response.into_body().into_stream().expect("streamed body"); + + assert_eq!( + block_on(body.next()) + .expect("first item") + .expect("first byte"), + Bytes::from_static(b"a") + ); + thread::sleep(Duration::from_millis(125)); + assert!(matches!( + block_on(body.next()), + Some(Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + })) + )); + } + + #[test] + fn response_content_length_rejects_before_body_poll() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_secs(1)); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_LENGTH, "2"); + native.set_body("xx"); + + let error = block_on(process_response( + native, + Method::GET, + ResponseMode::Buffered { max_bytes: 1 }, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + )) + .expect_err("content length exceeds final buffer cap"); + + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn response_content_length_explicit_identity_rejects_before_body_poll() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_secs(1)); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_ENCODING, "identity"); + native.set_header(CONTENT_LENGTH, "2"); + native.set_body("xx"); + + let error = block_on(process_response( + native, + Method::GET, + ResponseMode::Buffered { max_bytes: 16 }, + budget, + 32 * 1024 * 1024, + 24, + None, + Some(1), + None, + None, + None, + constant_clock(start), + )) + .expect_err("content length exceeds decoded cap"); + + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + } + + #[test] + fn send_all_reverse_completion_preserves_order() { + let started_at = MonotonicInstant::now(); + let clock = constant_clock(started_at); + let mut slots = vec![Slot::Taken, Slot::Taken]; + slots[1] = Slot::Done(OutboundSlotResult::new( + Duration::from_millis(2), + Ok(OutboundResponse::new( + Method::GET, + StatusCode::ACCEPTED, + HeaderMap::new(), + Body::empty(), + )), + )); + slots[0] = Slot::Done(OutboundSlotResult::new( + Duration::from_millis(5), + Ok(OutboundResponse::new( + Method::GET, + StatusCode::CREATED, + HeaderMap::new(), + Body::empty(), + )), + )); + + let results = resolve_slots(slots, started_at, &clock); + + assert_eq!( + results[0].outcome.as_ref().expect("slot 0").status(), + StatusCode::CREATED + ); + assert_eq!( + results[1].outcome.as_ref().expect("slot 1").status(), + StatusCode::ACCEPTED + ); + assert_eq!(results[0].elapsed, Duration::from_millis(5)); + assert_eq!(results[1].elapsed, Duration::from_millis(2)); + } + + #[test] + fn done_error_survives_poll_sweep() { + let started_at = MonotonicInstant::now(); + let clock = constant_clock(started_at); + let mut slot = Slot::Done(OutboundSlotResult::new( + Duration::from_millis(4), + Err(EdgeError::bad_gateway_with_reason( + "retained error", + BadGatewayReason::Transport, + )), + )); + + block_on(poll_slot(&mut slot, started_at, &clock)); + let mut results = resolve_slots(vec![slot], started_at, &clock); + let retained = results.remove(0); + + assert_eq!(retained.elapsed, Duration::from_millis(4)); + assert!(matches!( + retained.outcome, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + } + + #[derive(Default)] + struct UploadEvents { + finishes: usize, + flushes: usize, + waits: usize, + writes: Vec, + } + + struct TestUploadWriter { + events: Arc>, + } + + impl StreamedUploadWriter for TestUploadWriter { + fn finish(self) -> IoResult<()> { + let mut events = self + .events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))?; + events.finishes = events.finishes.saturating_add(1); + Ok(()) + } + + fn flush(&mut self) -> IoResult<()> { + let mut events = self + .events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))?; + events.flushes = events.flushes.saturating_add(1); + Ok(()) + } + + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()> { + self.events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))? + .writes + .push(Bytes::copy_from_slice(bytes)); + Ok(()) + } + } + + #[test] + fn streamed_upload_finishes_exactly_once() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_secs(1)); + let clock = constant_clock(started_at); + let events = Arc::new(Mutex::new(UploadEvents::default())); + let writer = TestUploadWriter { + events: Arc::clone(&events), + }; + let source = stream::iter([ + Ok(Bytes::from_static(b"first")), + Ok(Bytes::from_static(b"second")), + ]) + .boxed_local(); + let wait_events = Arc::clone(&events); + + let outcome = block_on(complete_streamed_exchange( + writer, + source, + 32, + budget, + &clock, + move || { + wait_events.lock().expect("upload events").waits += 1; + Ok(7_u8) + }, + )); + let observed = events.lock().expect("upload events"); + + assert_eq!(outcome.expect("exchange"), 7); + assert_eq!( + observed.writes, + [Bytes::from_static(b"first"), Bytes::from_static(b"second")] + ); + assert_eq!(observed.flushes, 2); + assert_eq!(observed.finishes, 1); + assert_eq!(observed.waits, 1); + } + + #[test] + fn streamed_upload_failure_never_waits() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_secs(1)); + let clock = constant_clock(started_at); + let events = Arc::new(Mutex::new(UploadEvents::default())); + let writer = TestUploadWriter { + events: Arc::clone(&events), + }; + let source = stream::iter([Err(EdgeError::bad_gateway_with_reason( + "source failed", + BadGatewayReason::Transport, + ))]) + .boxed_local(); + let wait_events = Arc::clone(&events); + + let outcome = block_on(complete_streamed_exchange( + writer, + source, + 32, + budget, + &clock, + move || { + wait_events.lock().expect("upload events").waits += 1; + Ok(7_u8) + }, + )); + let observed = events.lock().expect("upload events"); + + assert!(matches!( + outcome, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + assert!(observed.writes.is_empty()); + assert_eq!(observed.finishes, 0); + assert_eq!(observed.waits, 0); + } + + #[test] + fn backend_creation_error_table_is_exhaustive() { + use fastly_shared::FastlyStatus; + + let mapped = map_backend_creation_error(&BackendCreationError::Disallowed); + assert_eq!(mapped.status(), StatusCode::BAD_GATEWAY); + assert!(matches!( + mapped, + EdgeError::BadGateway { + message, + reason: BadGatewayReason::Unspecified, + } if message == DYNAMIC_BACKENDS_DISABLED_MESSAGE + )); + + for local_invariant in [ + BackendCreationError::BetweenBytesTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::ConnectTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::EncodingError( + String::from_utf8(vec![0xff]).expect_err("invalid UTF-8"), + ), + BackendCreationError::FirstByteTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::NameTooLong("x".to_owned()), + BackendCreationError::NameInUse, + ] { + assert!(matches!( + map_backend_creation_error(&local_invariant), + EdgeError::Internal { .. } + )); + } + + assert!(matches!( + map_backend_creation_error(&BackendCreationError::HostError(FastlyStatus::ERROR)), + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )); + } + + #[test] + fn backend_creation_failure_observed_at_deadline_is_a_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchDeadline); + let retained = Cell::new(false); + let clock = scripted_clock(vec![budget.deadline.instant()]); + + let error = finish_backend_creation::<(), _>( + Err(BackendCreationError::Disallowed), + budget, + &clock, + |_backend| { + retained.set(true); + Ok(()) + }, + ) + .expect_err("deadline must outrank backend failure"); + + assert!(!retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + } + )); + } + + #[test] + fn successful_backend_creation_is_retained_before_late_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchDeadline); + let retained = Cell::new(false); + let observed_at = Arc::new(Mutex::new(started_at)); + let clock_observation = Arc::clone(&observed_at); + let clock = + MonotonicClock::new(move || *clock_observation.lock().expect("clock observation")); + + let error = finish_backend_creation(Ok(7_u8), budget, &clock, |backend| { + retained.set(true); + *observed_at.lock().expect("clock observation") = budget.deadline.instant(); + Ok(backend) + }) + .expect_err("late success must still return the attributed timeout"); + + assert!(retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + } + )); + } + + #[test] + fn late_timeout_outranks_retention_error_after_success_is_retained() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchDeadline); + let retained = Cell::new(false); + let observed_at = Arc::new(Mutex::new(started_at)); + let clock_observation = Arc::clone(&observed_at); + let clock = + MonotonicClock::new(move || *clock_observation.lock().expect("clock observation")); + + let error = finish_backend_creation(Ok(7_u8), budget, &clock, |_backend| { + retained.set(true); + *observed_at.lock().expect("clock observation") = budget.deadline.instant(); + Err(EdgeError::internal(anyhow::anyhow!("retention failed"))) + }) + .expect_err("late timeout must outrank retention failure"); + + assert!(retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + } + )); + } + + #[test] + fn streamed_write_failure_observed_at_expiry_is_a_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::PerCallTimeout); + + let error = resolve_stream_io_result( + Err(Error::other("write failed")), + budget, + budget.deadline.instant(), + "Fastly outbound request body write failed", + ) + .expect_err("deadline must outrank transport failure"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); + } + + #[test] + fn backend_identity_uses_every_canonical_property() { + let first = BackendIdentity { + budget_ms: 100, + host: "example.com".to_owned(), + port: 443, + scheme: "https".to_owned(), + tls: true, + }; + assert_eq!(backend_name(&first), "ez_ac691b3a5fb10e3bd0923dbbf2dddbde"); + let mut second = first.clone(); + second.budget_ms = 101; + assert_ne!(backend_name(&first), backend_name(&second)); + second = first.clone(); + second.port = 8443; + assert_ne!(backend_name(&first), backend_name(&second)); + second = first.clone(); + second.host = "other.example".to_owned(); + assert_ne!(backend_name(&first), backend_name(&second)); + } + + #[test] + fn backend_host_override_preserves_explicit_port() { + let mut request = + OutboundRequest::get("https://api.example.com:8443/resource").expect("request"); + normalize_for_dispatch(&mut request).expect("normalize"); + + assert_eq!(backend_host_override(&request), "api.example.com:8443"); + } + + #[test] + fn backend_host_override_preserves_bracketed_ipv6() { + let mut request = OutboundRequest::get("http://[::1]:8443/resource").expect("request"); + normalize_for_dispatch(&mut request).expect("normalize"); + + assert_eq!(backend_host_override(&request), "[::1]:8443"); + } + + #[test] + fn backend_timer_partition_is_bounded() { + let timers = backend_timers(100); + assert_eq!(timers.connect, Duration::from_millis(25)); + assert_eq!(timers.first_byte, Duration::from_millis(75)); + assert_eq!(timers.between_bytes, Duration::from_millis(100)); + let short = backend_timers(1); + assert_eq!(short.connect, Duration::from_millis(1)); + assert_eq!(short.first_byte, Duration::from_millis(1)); + assert_eq!(short.between_bytes, Duration::from_millis(1)); + } + + #[test] + fn dispatch_guard_checks_expiry_before_slack() { + use edgezero_core::time::{BATCH_DISPATCH_SLACK_MAX, Deadline}; + + let started_at = MonotonicInstant::now(); + let duration = Duration::from_secs(1); + let budget = DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant( + started_at.checked_add(duration).expect("deadline instant"), + ), + duration, + }; + let at_limit = started_at + .checked_add(BATCH_DISPATCH_SLACK_MAX) + .expect("slack boundary"); + dispatch_guard_at(started_at, budget, at_limit).expect("dispatch at slack boundary"); + + let over_limit = at_limit + .checked_add(Duration::from_nanos(1)) + .expect("past slack boundary"); + assert!(matches!( + dispatch_guard_at(started_at, budget, over_limit), + Err(EdgeError::Internal { source }) + if source.to_string() == DISPATCH_SLACK_MESSAGE + )); + + let expired_budget = DispatchBudget { + deadline: Deadline::at_instant(over_limit), + ..budget + }; + assert!(matches!( + dispatch_guard_at(started_at, expired_budget, over_limit), + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + }) + )); + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the pinned SDK cause table intentionally constructs every known variant" + )] + fn send_error_cause_table_preserves_timeout_source() { + let cases = [ + ( + SendErrorCause::DnsTimeout, + super::super::SendFailure::ProviderTimeout, + ), + ( + SendErrorCause::DnsError { + rcode: None, + info_code: None, + }, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationNotFound, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationUnavailable, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationIpUnroutable, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::ConnectionRefused, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::ConnectionTerminated, + super::super::SendFailure::Transport, + ), + ( + SendErrorCause::ConnectionTimeout, + super::super::SendFailure::BudgetedTimeout, + ), + ( + SendErrorCause::ConnectionLimitReached, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsProtocolError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsCertificateError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsAlertReceived { alert_id: None }, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsConfigurationError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::HttpIncompleteResponse, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseHeaderSectionTooLarge, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseBodyTooLarge, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseTimeout, + super::super::SendFailure::BudgetedTimeout, + ), + ( + SendErrorCause::HttpResponseStatusInvalid, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpUpgradeFailed, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::Http2StreamError { + frame_type: 0, + error_code: 0, + }, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpProtocolError, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpRequestCacheKeyInvalid, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpRequestUriInvalid, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpCacheLimitExceeded, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpCacheApiUnsupported, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::IoError(Error::other("test")), + super::super::SendFailure::Transport, + ), + ( + SendErrorCause::ImageOptimizerUnsupported, + super::super::SendFailure::Unknown, + ), + ( + SendErrorCause::InternalError(None), + super::super::SendFailure::PlatformInternal, + ), + ( + SendErrorCause::Custom(anyhow::anyhow!("test")), + super::super::SendFailure::Unknown, + ), + ]; + + let observed_at = MonotonicInstant::now(); + let selected = BudgetSource::PerCallTimeout; + let budget = super::super::test_budget(observed_at, selected); + for (cause, expected) in cases { + let failure = cause_to_failure(&cause); + assert_eq!(failure, expected, "{cause:?}"); + super::super::assert_send_failure_error( + failure, + &super::super::classify_send_failure(failure, budget, observed_at), + selected, + ); + } + } + } +} + +#[cfg(all(feature = "fastly", feature = "test-utils"))] +pub use fastly_impl::inject_dispatch_slack_for_test; +#[cfg(feature = "fastly")] +pub use fastly_impl::{DYNAMIC_BACKENDS_DISABLED_MESSAGE, FastlyOutboundClient}; + +#[cfg(any(feature = "fastly", feature = "test-utils"))] +fn dispatch_all_before_wait( + items: impl IntoIterator, + dispatch: impl FnMut(Item) -> Result, +) -> Vec> { + items.into_iter().map(dispatch).collect() +} + +#[cfg(any(feature = "fastly", feature = "test-utils"))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +/// Runs the target-neutral Fastly batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +/// Runs the same eager dispatch phase used by Fastly production batching. +#[cfg(feature = "test-utils")] +#[inline] +pub fn dispatch_all_before_wait_for_test( + items: Items, + dispatch: Dispatch, +) -> Vec> +where + Items: IntoIterator, + Dispatch: FnMut(Item) -> Result, +{ + dispatch_all_before_wait(items, dispatch) +} + +#[cfg(any(feature = "fastly", test))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(test)] +mod send_failure_policy_tests { + use super::*; + + fn failures() -> [SendFailure; 8] { + [ + SendFailure::BudgetedTimeout, + SendFailure::LocalInvariant, + SendFailure::PlatformInternal, + SendFailure::ProviderTimeout, + SendFailure::Transport, + SendFailure::Unknown, + SendFailure::Unreachable, + SendFailure::UpstreamProtocol, + ] + } + + #[test] + fn send_failure_policy_maps_every_known_category() { + let observed_at = MonotonicInstant::now(); + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::BatchDeadline, + BudgetSource::Default, + ] { + let budget = test_budget(observed_at, selected); + for failure in failures() { + assert_send_failure_error( + failure, + &classify_send_failure(failure, budget, observed_at), + selected, + ); + } + } + } + + #[test] + fn absolute_deadline_wins_every_send_failure() { + let started_at = MonotonicInstant::now(); + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::BatchDeadline, + BudgetSource::Default, + ] { + let budget = test_budget(started_at, selected); + let after_deadline = budget + .deadline + .instant() + .checked_add(Duration::from_nanos(1)) + .expect("after deadline"); + for observed_at in [budget.deadline.instant(), after_deadline] { + for failure in failures() { + let error = classify_send_failure(failure, budget, observed_at); + if let EdgeError::GatewayTimeout { cause, .. } = error { + assert_eq!(cause, selected); + } else { + panic!("deadline did not win {failure:?} for {selected:?}"); + } + } + } + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs deleted file mode 100644 index eb7efc99..00000000 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ /dev/null @@ -1,276 +0,0 @@ -use async_stream::try_stream; -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderMap, HeaderValue, Method, Uri, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use fastly::{ - Backend, Request as FastlyRequest, Response as FastlyResponse, error::anyhow, - http::body::StreamingBody, -}; -use futures_util::stream::{BoxStream, StreamExt as _}; -use std::io::{self, Write as _}; -use std::time::Duration; - -const BACKEND_PREFIX: &str = "edgezero-dynamic-"; - -type ChunkStream = BoxStream<'static, Result, io::Error>>; - -pub struct FastlyProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for FastlyProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _ext) = request.into_parts(); - let backend_name = ensure_backend(&uri)?; - let fastly_request = build_fastly_request(method, &uri, &headers); - let (mut streaming_body, pending_request) = fastly_request - .send_async_streaming(&backend_name) - .map_err(EdgeError::internal)?; - forward_request_body(body, &mut streaming_body).await?; - streaming_body.finish().map_err(EdgeError::internal)?; - let mut fastly_response = pending_request.wait().map_err(EdgeError::internal)?; - - let mut proxy_response = convert_response(&mut fastly_response); - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("fastly")); - Ok(proxy_response) - } -} - -fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> FastlyRequest { - let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); - fastly_request.set_method(method); - - // Append (not set) so a multi-value client header survives; `Host` below is - // set explicitly as a single value. - for (name, value) in headers { - if name.as_str().eq_ignore_ascii_case("host") { - continue; - } - fastly_request.append_header(name.as_str(), value.clone()); - } - - // Build `Host` from host + explicit port so a non-default target port is - // preserved (origin-form fidelity) WITHOUT leaking any `user:pass@` userinfo - // that `uri.authority()` would include. (Backend connection + TLS SNI still - // key off `uri.host()` in `ensure_backend`.) - if let Some(host) = uri.host() { - let value = match uri.port() { - Some(port) => format!("{host}:{}", port.as_str()), - None => host.to_owned(), - }; - fastly_request.set_header("Host", value.as_str()); - } - - fastly_request -} - -fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { - let status = fastly_response.get_status(); - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL - // values per name and append, instead of first-value + insert (which - // replaced). `get_header_names()` yields `&HeaderName`, usable for both - // `get_header_all` and `append`. - for name in fastly_response.get_header_names() { - for value in fastly_response.get_header_all(name) { - proxy_response.headers_mut().append(name, value.clone()); - } - } - - let encoding = proxy_response - .headers() - .get(header::CONTENT_ENCODING) - .and_then(|value| value.to_str().ok()) - .map(str::to_ascii_lowercase); - - let body = fastly_response.take_body(); - - let chunk_stream = fastly_body_stream(body); - let body_stream = transform_stream(chunk_stream, encoding.as_deref()); - *proxy_response.body_mut() = Body::from_stream(body_stream); - if encoding.as_deref() == Some("gzip") || encoding.as_deref() == Some("br") { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - proxy_response -} - -fn ensure_backend(uri: &Uri) -> Result { - let host = uri - .host() - .ok_or_else(|| EdgeError::bad_request("proxy target must include host"))?; - - let scheme = uri.scheme_str().unwrap_or("https"); - let is_https = scheme.eq_ignore_ascii_case("https"); - - let target_port = match (uri.port_u16(), is_https) { - (Some(port), _) => port, - (None, true) => 443, - (None, false) => 80, - }; - - let host_with_port = format!("{host}:{target_port}"); - - // Human-readable name: backend_{scheme}_{host}_{port} with dots/colons sanitised - let name_base = format!("{scheme}_{host}_{target_port}"); - let backend_name = format!("{}{}", BACKEND_PREFIX, name_base.replace(['.', ':'], "_")); - - let mut builder = Backend::builder(&backend_name, &host_with_port) - .override_host(host) - .connect_timeout(Duration::from_secs(1)) - .first_byte_timeout(Duration::from_secs(15)) - .between_bytes_timeout(Duration::from_secs(10)); - - if is_https { - builder = builder - .enable_ssl() - .sni_hostname(host) - .check_certificate(host); - log::debug!("enable ssl for backend: {backend_name}"); - } - - match builder.finish() { - Ok(_) => { - log::debug!("created dynamic backend: {backend_name} -> {host_with_port}"); - Ok(backend_name) - } - Err(err) => { - let msg = err.to_string(); - if msg.contains("NameInUse") || msg.contains("already in use") { - log::debug!("reusing existing dynamic backend: {backend_name}"); - Ok(backend_name) - } else { - Err(EdgeError::internal(anyhow!( - "dynamic backend creation failed ({backend_name} -> {host_with_port}): {msg}" - ))) - } - } - } -} - -fn fastly_body_stream(mut body: fastly::Body) -> ChunkStream { - try_stream! { - for result in body.read_chunks(8 * 1024) { - let chunk = result?; - yield chunk; - } - } - .boxed() -} - -async fn forward_request_body( - body: Body, - streaming_body: &mut StreamingBody, -) -> Result<(), EdgeError> { - match body { - Body::Once(bytes) => { - if !bytes.is_empty() { - streaming_body - .write_all(bytes.as_ref()) - .map_err(EdgeError::internal)?; - } - } - Body::Stream(mut stream) => { - while let Some(result) = stream.next().await { - let chunk = result.map_err(EdgeError::internal)?; - streaming_body - .write_all(&chunk) - .map_err(EdgeError::internal)?; - } - } - } - - streaming_body.flush().map_err(EdgeError::internal)?; - - Ok(()) -} - -fn transform_stream( - stream: ChunkStream, - encoding: Option<&str>, -) -> BoxStream<'static, Result> { - match encoding { - Some("gzip") => decode_gzip_stream(stream).boxed(), - Some("br") => decode_brotli_stream(stream).boxed(), - _ => stream.map(|res| res.map(Bytes::from)).boxed(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use brotli::CompressorWriter; - use flate2::{Compression, write::GzEncoder}; - use futures::executor::block_on; - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - out.extend_from_slice(&chunk.expect("chunk")); - } - out - }), - } - } - - #[test] - fn convert_response_preserves_multi_value_set_cookie() { - let mut fastly_response = FastlyResponse::from_status(200); - fastly_response.append_header("set-cookie", "a=1"); - fastly_response.append_header("set-cookie", "b=2"); - - let proxy_response = convert_response(&mut fastly_response); - - let cookies: Vec = proxy_response - .headers() - .get_all("set-cookie") - .into_iter() - .map(|value| value.to_str().expect("utf8").to_owned()) - .collect(); - assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); - } - - #[test] - fn stream_handles_brotli() { - let mut compressed = Vec::new(); - let mut compressor = CompressorWriter::new(&mut compressed, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - drop(compressor); - - let mut br_body = fastly::Body::new(); - br_body.write_all(&compressed).unwrap(); - let body = Body::from_stream(transform_stream(fastly_body_stream(br_body), Some("br"))); - let collected = collect_body(body); - assert_eq!(collected, b"hello brotli"); - } - - #[test] - fn stream_handles_identity_and_gzip() { - let mut plain = fastly::Body::new(); - plain.write_all(b"plain").unwrap(); - let plain_body = Body::from_stream(transform_stream(fastly_body_stream(plain), None)); - assert_eq!(collect_body(plain_body), b"plain"); - - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); - let mut gz_body = fastly::Body::new(); - gz_body.write_all(&compressed).unwrap(); - let gzip_body = - Body::from_stream(transform_stream(fastly_body_stream(gz_body), Some("gzip"))); - assert_eq!(collect_body(gzip_body), b"hello gzip"); - } -} diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index ea1a0077..5b4390cc 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,29 +1,38 @@ use std::collections::{HashSet, VecDeque}; use std::fmt::Display; -use std::io::Read as _; +use std::io::Read; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::task::Poll; +use bytes::Bytes; use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{Extensions, Request, request_builder}; +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +use edgezero_core::http::{Method, Uri}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressFraming, IngressHeadAccounting, IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use futures_util::stream; use std::collections::BTreeMap; use crate::config_store::FastlyConfigStore; use crate::context::FastlyRequestContext; use crate::key_value_store::FastlyKvStore; -use crate::proxy::FastlyProxyClient; -use crate::response::{from_core_response, parse_uri}; +use crate::outbound::FastlyOutboundClient; +use crate::response::{from_egress_response, parse_uri}; use crate::secret_store::FastlySecretStore; const WARNED_STORE_CACHE_LIMIT: usize = 64; @@ -123,6 +132,7 @@ impl<'app> FastlyService<'app> { /// the underlying handler returns an error. #[inline] pub fn dispatch(self, req: FastlyRequest) -> Result { + let request_start = self.app.monotonic_now(); let config_store = match self.config { ConfigSource::Handle(handle) => Some(handle), ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { @@ -151,6 +161,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + request_start, |_req, _extensions| {}, ) } @@ -263,6 +274,7 @@ fn dispatch_core_request( app: &App, mut core_request: Request, stores: Stores, + prepared: PreparedIngress, ) -> Result { // Hard-cutoff: legacy bare handles are no longer // inserted into request extensions. `with_config_handle` @@ -282,9 +294,9 @@ fn dispatch_core_request( if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = executor::block_on(app.router().oneshot(core_request)) + let response = executor::block_on(app.dispatch_admitted(prepared, core_request)) .map_err(|err| map_edge_error(&err))?; - from_core_response(response).map_err(|err| map_edge_error(&err)) + from_egress_response(response).map_err(|err| map_edge_error(&err)) } /// Run an app-provided closure against a scratch `Extensions` populated from the @@ -301,8 +313,9 @@ where fn dispatch_with_handles( app: &App, - req: FastlyRequest, + mut req: FastlyRequest, stores: Stores, + request_start: MonotonicInstant, extend: F, ) -> Result where @@ -310,9 +323,48 @@ where { // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. let scratch = apply_request_extend(&req, extend); - let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; - core_request.extensions_mut().extend(scratch); - dispatch_core_request(app, core_request, stores) + let mut head_request = into_core_request_head(&req, app.monotonic_clock()) + .map_err(|error| map_edge_error(&error))?; + head_request.extensions_mut().extend(scratch); + dispatch_ingress_reader(app, head_request, stores, request_start, move || { + req.take_body() + }) +} + +fn dispatch_ingress_reader( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, +) -> Result +where + Source: Read + 'static, + MakeSource: FnOnce() -> Source, +{ + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + head_parts + .validate_normalized(app.ingress_head_limits()) + .map_err(|error| map_edge_error(&error))?; + let prepared = match app + .begin_ingress(head_parts, request_start) + .map_err(|error| map_edge_error(&error))? + { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return from_egress_response(response).map_err(|error| map_edge_error(&error)); + } + _ => return Err(FastlyError::msg("unsupported ingress admission outcome")), + }; + *head_request.body_mut() = fastly_deadline_body( + make_source(), + Some((prepared.read_deadline(), prepared.monotonic_clock())), + ); + dispatch_core_request(app, head_request, stores, prepared) } /// Dispatch with per-id store registries built from baked metadata — the same @@ -344,6 +396,7 @@ pub fn dispatch_with_registries( where F: FnOnce(&FastlyRequest, &mut Extensions), { + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(stores.kv, env)?; let config_registry = build_config_registry(stores.config, env); let secret_registry = build_secret_registry(stores.secrets, env); @@ -356,6 +409,7 @@ where secret_registry, ..Default::default() }, + request_start, extend, ) } @@ -489,6 +543,14 @@ fn build_secret_registry( /// Returns [`EdgeError::Internal`] if the Fastly request cannot be reconstituted into a core request (e.g., method or URI conversion failure). #[inline] pub fn into_core_request(mut req: FastlyRequest) -> Result { + let request = into_core_request_head(&req, MonotonicClock::default())?; + Ok(attach_core_body(&mut req, request, None)) +} + +fn into_core_request_head( + req: &FastlyRequest, + outbound_clock: MonotonicClock, +) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -497,13 +559,7 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { builder = builder.header(name.as_str(), value.as_bytes()); } - let mut body = req.take_body(); - let mut bytes = Vec::new(); - body.read_to_end(&mut bytes).map_err(EdgeError::internal)?; - - let mut request = builder - .body(Body::from(bytes)) - .map_err(EdgeError::internal)?; + let mut request = builder.body(Body::empty()).map_err(EdgeError::internal)?; let context = FastlyRequestContext { client_ip: req.get_client_ip_addr(), @@ -511,11 +567,115 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { FastlyRequestContext::insert(&mut request, context); request .extensions_mut() - .insert(ProxyHandle::with_client(FastlyProxyClient)); + .insert(outbound_client(outbound_clock)); Ok(request) } +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(FastlyOutboundClient::with_clock(clock)) +} + +fn attach_core_body( + req: &mut FastlyRequest, + mut request: Request, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Request { + *request.body_mut() = fastly_deadline_body(req.take_body(), read_lifetime); + request +} + +fn fastly_deadline_body( + source: Source, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Body +where + Source: Read + 'static, +{ + let mut native_body = Some(source); + let mut terminal = false; + let stream = stream::poll_fn(move |_cx| { + if terminal { + return Poll::Ready(None); + } + if read_lifetime + .as_ref() + .is_some_and(|(deadline, clock)| deadline.is_expired_at(clock.now())) + { + terminal = true; + native_body.take(); + return Poll::Ready(Some(Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )))); + } + + let Some(body) = native_body.as_mut() else { + terminal = true; + return Poll::Ready(None); + }; + let mut chunk = vec![0_u8; 16 * 1024]; + let result = body.read(&mut chunk); + if read_lifetime + .as_ref() + .is_some_and(|(deadline, clock)| deadline.is_expired_at(clock.now())) + { + terminal = true; + native_body.take(); + return Poll::Ready(Some(Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )))); + } + match result { + Ok(0) => { + terminal = true; + native_body.take(); + Poll::Ready(None) + } + Ok(read) => { + chunk.truncate(read); + Poll::Ready(Some(Ok(Bytes::from(chunk)))) + } + Err(error) => { + terminal = true; + native_body.take(); + Poll::Ready(Some(Err(EdgeError::internal(error)))) + } + } + }); + Body::from_stream(stream) +} + +/// Dispatches an observable reader through the production ingress body wrapper. +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn dispatch_ingress_reader_for_test( + app: &App, + method: Method, + uri: Uri, + source: Source, +) -> Result +where + Source: Read + 'static, +{ + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| map_edge_error(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + dispatch_ingress_reader( + app, + core_request, + Stores::default(), + request_start, + move || source, + ) +} + fn map_edge_error(err: &EdgeError) -> FastlyError { FastlyError::msg(err.to_string()) } @@ -592,13 +752,21 @@ fn warn_missing_store_once(store_name: &str, detail: &str) { mod synthesis_tests { use super::*; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::context::RequestContext; use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::router::RouterService; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::collections::BTreeMap; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; struct StubConfig; #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the legacy test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for StubConfig { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(None) @@ -639,6 +807,44 @@ mod synthesis_tests { ); } + #[test] + fn standard_service_installs_the_exact_application_outbound_clock() { + use edgezero_core::http::Method; + + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = + edgezero_core::OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client.send_all(vec![request]).await; + Ok(results[0].elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let request = FastlyRequest::new(Method::GET, "http://example.test/clock"); + + let mut response = FastlyService::new(&app) + .dispatch(request) + .expect("Fastly response"); + + assert_eq!(response.take_body_bytes(), b"7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + #[test] fn extended_request_extensions_are_visible_to_handler() { use edgezero_core::body::Body; @@ -652,7 +858,6 @@ mod synthesis_tests { async fn handler(ctx: RequestContext) -> Result { let ja4 = ctx - .request() .extensions() .get::() .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index f9eb4d02..42fa5a99 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -1,27 +1,88 @@ use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::{Response, Uri}; +use edgezero_core::outbound::{collect_response_stream, collect_response_stream_until_with_clock}; +use edgezero_core::response_egress::{ResponseEgressEnvelope, ResponseEgressOutcome}; +use edgezero_core::time::{Deadline, MonotonicClock}; use fastly::Response as FastlyResponse; use futures::executor; -use futures_util::StreamExt as _; -use std::io::Write as _; + +pub const FASTLY_RESPONSE_STREAM_BUFFER_BYTES: u64 = 0x0100_0000; /// # Errors /// Returns [`EdgeError::Internal`] if the response body cannot be streamed to the Fastly send-channel. #[inline] pub fn from_core_response(response: Response) -> Result { + from_core_response_with_deadline(response, None, &MonotonicClock::default()) +} + +pub(crate) fn from_egress_response( + egress: ResponseEgressEnvelope, +) -> Result { + let (response, policy, mut attempt, clock) = egress.begin().map_err(|_outcome| { + EdgeError::internal(anyhow::anyhow!("response-egress policy failed")) + })?; + if policy.write_deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return Ok(deadline_response()); + } + + let converted = from_core_response_with_deadline(response, Some(policy.write_deadline), &clock); + let observed_at = clock.now(); + if policy.write_deadline.is_expired_at(observed_at) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, observed_at); + return Ok(deadline_response()); + } + match converted { + Ok(converted_response) => { + attempt.terminate(ResponseEgressOutcome::ResponseReturned, observed_at); + Ok(converted_response) + } + Err(error) => { + let outcome = if matches!(error, EdgeError::ResponseTooLarge { .. }) { + ResponseEgressOutcome::ConversionError + } else if matches!(error, EdgeError::GatewayTimeout { .. }) { + ResponseEgressOutcome::DeadlineExceeded + } else { + ResponseEgressOutcome::SourceError + }; + attempt.terminate(outcome, observed_at); + if outcome == ResponseEgressOutcome::DeadlineExceeded { + Ok(deadline_response()) + } else { + Err(error) + } + } + } +} + +fn from_core_response_with_deadline( + response: Response, + deadline: Option, + clock: &MonotonicClock, +) -> Result { + ensure_write_deadline(deadline, clock)?; let (parts, body) = response.into_parts(); let mut fastly_response = FastlyResponse::from_status(parts.status.as_u16()); match body { Body::Once(bytes) => fastly_response.set_body(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut fastly_body = fastly::Body::new(); - while let Some(result) = executor::block_on(stream.next()) { - let chunk = result.map_err(EdgeError::internal)?; - fastly_body.write_all(&chunk).map_err(EdgeError::internal)?; - } - fastly_response.set_body(fastly_body); + Body::Stream(stream) => { + let collected = match deadline { + Some(write_deadline) => { + executor::block_on(collect_response_stream_until_with_clock( + stream, + FASTLY_RESPONSE_STREAM_BUFFER_BYTES, + write_deadline, + clock, + ))? + } + None => executor::block_on(collect_response_stream( + stream, + FASTLY_RESPONSE_STREAM_BUFFER_BYTES, + ))?, + }; + fastly_response.set_body(collected.to_vec()); } } @@ -32,9 +93,28 @@ pub fn from_core_response(response: Response) -> Result FastlyResponse { + let mut response = FastlyResponse::from_status(504); + response.set_body("response write deadline exceeded"); + response +} + +fn ensure_write_deadline( + deadline: Option, + clock: &MonotonicClock, +) -> Result<(), EdgeError> { + if deadline.is_some_and(|candidate| candidate.is_expired_at(clock.now())) { + return Err(EdgeError::gateway_timeout( + "response write deadline exceeded", + )); + } + Ok(()) +} + pub(crate) fn parse_uri(uri: &str) -> Result { uri.parse::() .map_err(|err| EdgeError::bad_request(format!("invalid request URI: {err}"))) @@ -45,8 +125,12 @@ mod tests { use super::*; use bytes::Bytes; use edgezero_core::body::Body; + use edgezero_core::error::ResponseLimitReason; use edgezero_core::http::response_builder; + use edgezero_core::response::IntoResponse as _; + use edgezero_core::time::MonotonicInstant; use futures_util::stream; + use std::time::Duration; #[test] fn parse_valid_uri() { @@ -61,7 +145,7 @@ mod tests { } #[test] - fn multi_value_set_cookie_survives_conversion() { + fn repeated_set_cookie_survives_response_conversion() { // http::response::Builder::header APPENDS, so this is two Set-Cookie values. let response = response_builder() .status(200) @@ -93,4 +177,67 @@ mod tests { let body_bytes = fastly_response.take_body_bytes(); assert_eq!(body_bytes, b"hello world"); } + + #[test] + fn stream_body_conversion_enforces_fixed_cap() { + let cap = usize::try_from(FASTLY_RESPONSE_STREAM_BUFFER_BYTES).expect("cap fits usize"); + let over = response_builder() + .status(200) + .body(Body::from_stream(stream::iter([Ok(Bytes::from(vec![ + 0; + cap + 1 + ]))]))) + .expect("response"); + + let error = from_core_response(over).expect_err("one byte over cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn downstream_fallback_preserves_typed_error_envelope() { + let response = EdgeError::response_too_large_with_reason( + "private upstream detail", + ResponseLimitReason::BufferedBody, + ) + .into_response() + .expect("error response"); + + let mut fastly_response = from_core_response(response).expect("Fastly response"); + let body: serde_json::Value = serde_json::from_slice(&fastly_response.take_body_bytes()) + .expect("JSON error envelope"); + + assert_eq!(fastly_response.get_status().as_u16(), 502); + assert_eq!(body["error"]["kind"], "response_too_large"); + assert_eq!( + body["error"]["message"], + "upstream response exceeded configured limits" + ); + assert!(body["error"].get("reason").is_none()); + } + + #[test] + fn injected_clock_controls_response_write_deadline() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let clock = MonotonicClock::new(move || deadline); + let response = response_builder() + .status(200) + .body(Body::empty()) + .expect("response"); + + let error = from_core_response_with_deadline( + response, + Some(Deadline::at_instant(deadline)), + &clock, + ) + .expect_err("deadline must win"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } } diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index e83b2b33..b30368a6 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -4,23 +4,51 @@ //! `FastlySecretStore`, which opens a named Fastly `SecretStore` on //! each lookup. -#[cfg(feature = "fastly")] +use crate::chunked_config::{SyncHostCallError, exact_fastly_read, run_sync_host_call}; use async_trait::async_trait; -#[cfg(feature = "fastly")] use bytes::Bytes; -#[cfg(feature = "fastly")] +use edgezero_core::Deadline; +use edgezero_core::config_store::BoundedStoreRead; use edgezero_core::secret_store::{SecretError, SecretStore}; -#[cfg(feature = "fastly")] use fastly::secret_store::SecretStore as FastlyNativeSecretStore; /// Internal helper that opens a single named Fastly `SecretStore`. -#[cfg(feature = "fastly")] pub struct FastlyNamedStore { store: FastlyNativeSecretStore, } -#[cfg(feature = "fastly")] impl FastlyNamedStore { + fn get_bytes_bounded_sync( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + let lookup = run_sync_host_call(deadline, || { + self.store.try_get(key).map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret lookup failed: {err}")) + }) + }) + .map_err(map_sync_secret_error)?; + + let Some(secret) = lookup else { + return Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }); + }; + let plaintext = run_sync_host_call(deadline, || { + secret.try_plaintext().map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret decryption failed: {err}")) + }) + }) + .map_err(map_sync_secret_error)?; + + exact_fastly_read(Some(plaintext), max_backend_bytes.min(max_value_bytes)) + .map_err(|_size_error| SecretError::ValueTooLarge) + } + pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { let lookup = self .store @@ -53,16 +81,18 @@ impl FastlyNamedStore { })?; Ok(Self { store }) } + + fn open_bounded(name: &str, deadline: Deadline) -> Result { + run_sync_host_call(deadline, || Self::open(name)).map_err(map_sync_secret_error) + } } /// Multi-store provider backed by Fastly's `SecretStore` API. /// /// Opens the named store per call — `FastlyNamedStore::open` is cheap /// (no network; just a handle) so there is no caching. -#[cfg(feature = "fastly")] pub struct FastlySecretStore; -#[cfg(feature = "fastly")] #[async_trait(?Send)] impl SecretStore for FastlySecretStore { #[inline] @@ -70,6 +100,26 @@ impl SecretStore for FastlySecretStore { let store = FastlyNamedStore::open(store_name)?; store.get_bytes_sync(key) } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + let store = FastlyNamedStore::open_bounded(store_name, deadline)?; + store.get_bytes_bounded_sync(key, deadline, max_backend_bytes, max_value_bytes) + } +} + +fn map_sync_secret_error(call_error: SyncHostCallError) -> SecretError { + match call_error { + SyncHostCallError::Backend(backend_error) => backend_error, + SyncHostCallError::DeadlineExceeded => SecretError::DeadlineExceeded, + } } // TODO: integration tests require the Fastly compute environment. diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index fb953b14..2318dc2b 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -1,6 +1,5 @@ -#![cfg(all(feature = "fastly", target_arch = "wasm32"))] - // Compile-time check: FastlySecretStore implements SecretStore. +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] mod secret_store_compile_check { use edgezero_adapter_fastly::secret_store::FastlySecretStore; use edgezero_core::secret_store::SecretStore; @@ -13,6 +12,14 @@ mod secret_store_compile_check { } #[cfg(test)] +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] +#[cfg_attr( + feature = "test-utils", + expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" + ) +)] mod tests { use bytes::Bytes; use edgezero_adapter_fastly::context::FastlyRequestContext; @@ -27,12 +34,16 @@ mod tests { use edgezero_core::router::RouterService; use fastly::Request as FastlyRequest; use fastly::http::{Method as FastlyMethod, StatusCode as FastlyStatus}; - use futures::stream; + use futures::{executor::block_on, stream}; use std::sync::Arc; struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for FixedConfigStore { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.to_owned())) @@ -41,7 +52,7 @@ mod tests { fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -50,7 +61,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -132,13 +143,12 @@ mod tests { Some("1") ); - assert_eq!( - core_request.body().as_bytes().expect("buffered"), - b"payload" - ); - let context = FastlyRequestContext::get(&core_request).expect("context"); assert_eq!(context.client_ip, expected_ip); + + let body = + block_on(core_request.into_body().into_bytes_bounded(1024)).expect("request body"); + assert_eq!(body.as_ref(), b"payload"); } #[test] @@ -213,4 +223,588 @@ mod tests { assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"hello from fastly test"); } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::collections::VecDeque; + use std::io::{self, Read}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_adapter_fastly::request::dispatch_ingress_reader_for_test; + use edgezero_core::http::{HeaderMap, HeaderValue}; + use edgezero_core::ingress::{AdmissionDecision, BufferedIngressResponse, IngressGrant}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; + + use super::*; + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + struct CountingMiddleware(Arc); + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + struct TrackedReader { + body_reads: Arc, + chunks: VecDeque, + expire_on_read: Option<(Arc>, MonotonicInstant)>, + grant_drops: Arc, + _source_drop: DropSignal, + } + + struct ErrorReader { + body_reads: Arc, + grant_drops: Arc, + _source_drop: DropSignal, + } + + #[expect( + clippy::missing_trait_methods, + reason = "the test source only needs the production wrapper's read method" + )] + impl Read for ErrorReader { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + if self.grant_drops.load(Ordering::SeqCst) != 0 { + return Err(io::Error::other("ingress grant dropped during body read")); + } + self.body_reads.fetch_add(1, Ordering::SeqCst); + Err(io::Error::other("fastly ingress source failure")) + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "the test source only needs the production wrapper's read method" + )] + impl Read for TrackedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.grant_drops.load(Ordering::SeqCst) != 0 { + return Err(io::Error::other("ingress grant dropped during body read")); + } + self.body_reads.fetch_add(1, Ordering::SeqCst); + if let Some((now, deadline)) = &self.expire_on_read { + *now.lock() + .map_err(|_poisoned| io::Error::other("clock lock poisoned"))? = *deadline; + } + let Some(chunk) = self.chunks.pop_front() else { + return Ok(0); + }; + if chunk.len() > buf.len() { + return Err(io::Error::other("test chunk exceeds read buffer")); + } + buf[..chunk.len()].copy_from_slice(&chunk); + Ok(chunk.len()) + } + } + + fn tracked_reader( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_reads: &Arc, + expire_on_read: Option<(Arc>, MonotonicInstant)>, + ) -> TrackedReader { + TrackedReader { + body_reads: Arc::clone(body_reads), + chunks: chunks.into(), + expire_on_read, + grant_drops: Arc::clone(grant_drops), + _source_drop: DropSignal(Arc::clone(source_drops)), + } + } + + fn error_reader( + grant_drops: &Arc, + source_drops: &Arc, + body_reads: &Arc, + ) -> ErrorReader { + ErrorReader { + body_reads: Arc::clone(body_reads), + grant_drops: Arc::clone(grant_drops), + _source_drop: DropSignal(Arc::clone(source_drops)), + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"fastly overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"fastly timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn assert_terminal_response( + mut response: fastly::Response, + status: StatusCode, + expected_headers: &HeaderMap, + body: &[u8], + ) { + assert_eq!(response.get_status().as_u16(), status.as_u16()); + let mut actual_headers = HeaderMap::new(); + for name in response.get_header_names() { + for value in response.get_header_all(name) { + actual_headers.append(name.clone(), value.clone()); + } + } + assert_eq!(&actual_headers, expected_headers); + assert_eq!(response.take_body_bytes(), body); + } + + #[test] + fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = tracked_reader( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("response"); + + assert_eq!(response.get_status().as_u16(), expected_status.as_u16()); + assert_eq!(body_reads.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = tracked_reader( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"fastly overflow\0response", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn standard_service_enforces_fallback_overflow() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let request = fastly_request(FastlyMethod::POST, "/missing", Some(b"abcde")); + + let response = FastlyService::new(&app) + .dispatch(request) + .expect("Fastly response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"fastly overflow\0response", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn cooperative_pre_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::ZERO, &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"fastly timeout response\n", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cooperative_post_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(1), &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + Some((now, deadline)), + ); + + let response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"fastly timeout response\n", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn saturated_fallback_refuses_without_reading_body() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("fastly unavailable\n")) + .expect("refusal response"), + ) + }); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let mut response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("response"); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_eq!( + response.get_status().as_u16(), + StatusCode::SERVICE_UNAVAILABLE.as_u16() + ); + let mut actual_headers = HeaderMap::new(); + for name in response.get_header_names() { + for value in response.get_header_all(name) { + actual_headers.append(name.clone(), value.clone()); + } + } + assert_eq!(actual_headers, expected_headers); + assert_eq!(response.take_body_bytes(), b"fastly unavailable\n"); + assert_eq!(body_reads.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + // Fastly exposes the inbound body as synchronous `Read`; there is no + // pending future boundary at which a poll-then-drop cancellation can be + // represented. Source-error termination is the available lifecycle seam. + #[test] + fn synchronous_source_error_uses_fastly_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = error_reader(&grant_drops, &source_drops, &body_reads); + + let response = dispatch_ingress_reader_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + reader, + ) + .expect("standard error response"); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + &internal_error_headers(), + br#"{"error":{"status":500,"kind":"internal","message":"internal server error"}}"#, + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +#[cfg(feature = "test-utils")] +mod outbound_contract_tests { + use std::cell::RefCell; + + use bytes::Bytes; + use edgezero_adapter_fastly::outbound::{ + dispatch_all_before_wait_for_test, validate_batch_request_for_test, + }; + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Uri}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn request() -> OutboundRequest { + OutboundRequest::new( + Method::POST, + "https://example.com/bid".parse::().expect("URI"), + ) + .expect("request") + } + + #[test] + fn batch_preflight_rejects_streamed_slots_without_poisoning_siblings() { + let requests = [ + request().body(Bytes::from_static(b"first")), + request().body(Body::stream(stream::once(async { + Bytes::from_static(b"streamed") + }))), + request().stream_response(), + request().body(Bytes::from_static(b"last")), + ]; + let outcomes: Vec<_> = requests + .iter() + .map(validate_batch_request_for_test) + .collect(); + + let accepted: Vec<_> = outcomes.iter().map(Result::is_ok).collect(); + assert_eq!(accepted, vec![true, false, false, true]); + } + + #[test] + fn send_all_dispatches_every_slot_before_wait() { + let events = RefCell::new(Vec::new()); + let pending = dispatch_all_before_wait_for_test(0_usize..3_usize, |index| { + events.borrow_mut().push(format!("dispatch:{index}")); + Ok::<_, ()>(index) + }); + + for slot in pending { + let index = slot.expect("dispatch succeeds"); + events.borrow_mut().push(format!("wait:{index}")); + } + + assert_eq!( + events.into_inner(), + [ + "dispatch:0", + "dispatch:1", + "dispatch:2", + "wait:0", + "wait:1", + "wait:2", + ] + ); + } + + #[cfg(all(feature = "fastly", target_arch = "wasm32"))] + mod runtime_tests { + use std::time::Duration; + + use edgezero_adapter_fastly::outbound::{ + FastlyOutboundClient, inject_dispatch_slack_for_test, + }; + use edgezero_core::error::EdgeError; + use edgezero_core::outbound::OutboundHttpClient as _; + use futures::executor::block_on; + + use super::*; + + #[test] + fn request_preparation_consumes_entry_budget() { + let _injection = inject_dispatch_slack_for_test(Duration::from_millis(26)); + let request = request() + .body(Bytes::from_static(b"body")) + .timeout(Duration::from_secs(1)); + + let results = block_on(FastlyOutboundClient::new().send_all(vec![request])); + + let Err(EdgeError::Internal { source }) = &results[0].outcome else { + panic!( + "expected dispatch-slack failure, got {:?}", + results[0].outcome + ); + }; + assert_eq!( + source.to_string(), + "Fastly send_all adapter overhead between batch_now and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration" + ); + } + } } diff --git a/crates/edgezero-adapter-spin/.cargo/config.toml b/crates/edgezero-adapter-spin/.cargo/config.toml index 788dbb50..ee14d5eb 100644 --- a/crates/edgezero-adapter-spin/.cargo/config.toml +++ b/crates/edgezero-adapter-spin/.cargo/config.toml @@ -1,9 +1,8 @@ [build] target = "wasm32-wasip2" -# Wasmtime runs the spin contract tests (no Fastly host imports needed). -# Only applies when cargo is invoked from inside this package directory. -# CI overrides via `CARGO_TARGET_WASM32_WASIP2_RUNNER` env var in -# `.github/workflows/test.yml`. +# Wasmtime 44.0.1 runs the Spin SDK resource tests with Preview 3 async and +# WASI HTTP imports enabled. Only applies when Cargo is invoked from this +# package directory. CI uses the same command through the target runner env. [target.'cfg(target_arch = "wasm32")'] -runner = "wasmtime run" +runner = "wasmtime run -W component-model-async=y -S p3=y -S http=y" diff --git a/crates/edgezero-adapter-spin/Cargo.toml b/crates/edgezero-adapter-spin/Cargo.toml index f1eeef4d..23220d3b 100644 --- a/crates/edgezero-adapter-spin/Cargo.toml +++ b/crates/edgezero-adapter-spin/Cargo.toml @@ -12,13 +12,15 @@ workspace = true [features] default = [] -spin = ["dep:spin-sdk"] +spin = ["dep:spin-sdk", "dep:wasip3"] +test-utils = [] cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:rusqlite", "dep:toml", "dep:toml_edit", "dep:walkdir"] [dependencies] edgezero-core = { path = "../edgezero-core" } edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = ["cli"] } anyhow = { workspace = true } +async-stream = { workspace = true } async-trait = { workspace = true } brotli = { workspace = true } bytes = { workspace = true } @@ -35,6 +37,7 @@ ctor = { workspace = true, optional = true } toml = { workspace = true, optional = true } toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } +wasip3 = { workspace = true, optional = true } # rusqlite is CLI-only and host-only — `bundled` ships SQLite source # (no host libsqlite3 install needed), and the `[target.…]` gate keeps diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 623223d2..d5ecf52d 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -17,13 +17,14 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - TypedSecretEntry, register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::{Capability, CapabilitySupport}; use walkdir::WalkDir; mod push_cloud; @@ -136,6 +137,30 @@ struct SpinCliAdapter; reason = "KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." )] impl Adapter for SpinCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::IngressAdmission + | Capability::OutboundHeaderFidelity + | Capability::OutboundHttp + | Capability::SendAllSlotIsolation => CapabilitySupport::Native, + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `spin cloud {login|logout|info}` is the native sign-in @@ -168,6 +193,34 @@ impl Adapter for SpinCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + match action { + AdapterAction::Build => { + let artifact = build_from_manifest(&manifest, args)?; + log::info!("[edgezero] Spin build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy_from_manifest(&manifest, args), + AdapterAction::Serve => serve_from_manifest(&manifest, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "spin adapter cannot execute operational action {action:?} against a pinned runtime target" + )), + } + } + fn merged_id_kinds(&self) -> &'static [&'static str] { // Both KV and Config back to `spin_sdk::key_value::Store` via // the same `provision` path; declaring the same logical id @@ -1054,6 +1107,10 @@ fn ensure_kv_label_in_component( pub fn build(extra_args: &[String]) -> Result { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1096,6 +1153,10 @@ pub fn build(extra_args: &[String]) -> Result { pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + deploy_from_manifest(&manifest, extra_args) +} + +fn deploy_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1145,6 +1206,19 @@ fn find_spin_manifest(start: &Path) -> Result { Ok(candidates.remove(0)) } +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join("spin.toml"), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned spin manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + fn locate_artifact( workspace_root: &Path, manifest_dir: &Path, @@ -1203,6 +1277,10 @@ fn register_ctor() { pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + serve_from_manifest(&manifest, extra_args) +} + +fn serve_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1238,6 +1316,80 @@ mod tests { const TEST_SECRET_ID: &str = "default"; const TEST_COMPONENT_ID: &str = "demo"; + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::Unsupported, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::BestEffort), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::BestEffort, + ), + (Capability::SendAllSlotIsolation, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::BestEffort, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + SPIN_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + #[test] fn is_valid_spin_key_accepts_lowercase_with_digits_and_underscores() { assert!(is_valid_spin_key("foo")); diff --git a/crates/edgezero-adapter-spin/src/config_store.rs b/crates/edgezero-adapter-spin/src/config_store.rs index af5caf9c..66d611bf 100644 --- a/crates/edgezero-adapter-spin/src/config_store.rs +++ b/crates/edgezero-adapter-spin/src/config_store.rs @@ -7,8 +7,11 @@ //! by [`crate::request::build_config_registry`], which resolves it //! through `EDGEZERO__STORES__CONFIG____NAME`. +use std::future::Future; + use async_trait::async_trait; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; +use edgezero_core::time::Deadline; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use spin_sdk::key_value::Store as SpinSdkKvStore; #[cfg(test)] @@ -27,9 +30,6 @@ enum SpinConfigBackend { label: String, store: SpinSdkKvStore, }, - /// Never constructed; keeps the enum inhabited outside production Spin and tests. - #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] - _Uninhabited(std::convert::Infallible), } impl SpinConfigStore { @@ -100,18 +100,65 @@ impl ConfigStore for SpinConfigStore { "store `{label}`: {err}" ))), }, - #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] - SpinConfigBackend::_Uninhabited(never) => { - let _: &str = key; - match *never {} - } } } + + #[inline] + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + bounded_config_read(self.get(key), deadline, max_backend_bytes, max_value_bytes).await + } +} + +// Spin KV returns a complete value, so these bounds are cooperative and +// apply immediately after host materialization rather than during allocation. +async fn bounded_config_read( + read: F, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, ConfigStoreError> +where + F: Future, ConfigStoreError>>, +{ + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + + let result = read.await; + if deadline.is_expired() { + drop(result); + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = result?; + + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + drop(value); + return Err(ConfigStoreError::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + use std::thread; + use std::time::Duration; + + use edgezero_core::time::Deadline; use futures::executor::block_on; // Contract tests exercise the InMemory backend with bytes-backed values. @@ -183,4 +230,83 @@ mod tests { let store = SpinConfigStore::from_entries([]); assert_eq!(block_on(store.get("absent")).expect("ok"), None); } + + #[test] + fn bounded_read_reports_exact_bytes_and_accepts_exact_caps() { + let result = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value.as_deref(), Some("value")); + } + + #[test] + fn bounded_read_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + } + + #[test] + fn bounded_read_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let error = block_on(bounded_config_read( + async { + polled.set(true); + Ok(None) + }, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_read_checks_deadline_after_host_call() { + let error = block_on(bounded_config_read( + async { + thread::sleep(Duration::from_millis(10)); + Ok(None) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_read_deadline_wins_over_late_host_error() { + let error = block_on(bounded_config_read( + async { + thread::sleep(Duration::from_millis(10)); + Err(ConfigStoreError::unavailable("late host error")) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index db282c05..fd473391 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -1,6 +1,6 @@ //! Adapter helpers for Spin (Fermyon). -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] @@ -13,8 +13,12 @@ pub mod key_value_store; // It is host-compilable so its tests run under `cargo test`, while the wasm32 // `SpinKvStore` is the production consumer. mod kv_pagination; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub mod proxy; +#[cfg(any( + test, + feature = "test-utils", + all(feature = "spin", target_arch = "wasm32") +))] +pub mod outbound; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub mod request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-spin/src/outbound.rs b/crates/edgezero-adapter-spin/src/outbound.rs new file mode 100644 index 00000000..02e743e3 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/outbound.rs @@ -0,0 +1,1562 @@ +#![cfg_attr( + any(test, all(feature = "spin", target_arch = "wasm32")), + expect( + clippy::arbitrary_source_item_ordering, + reason = "target and test helper modules stay adjacent to the imports that define their boundaries" + ) +)] +#![cfg_attr( + all(feature = "spin", target_arch = "wasm32"), + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Spin SDK imports out of native builds" + ) +)] + +use edgezero_core::error::EdgeError; +#[cfg(any(feature = "test-utils", all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use edgezero_core::time::Deadline; +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use spin_sdk::wasip3::http::types::ErrorCode; + +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod exchange { + use std::future::{Future, IntoFuture}; + use std::task::Poll; + use std::time::Duration; + + use async_stream::stream; + use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::error::EdgeError; + use futures_util::{StreamExt as _, future::poll_fn}; + + pub(super) const READY_ITEM_YIELD_QUOTA: usize = 64; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum UploadCompletion { + Complete, + ReaderGone, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) struct RequestTimeouts { + between_bytes: u64, + connect: u64, + first_byte: u64, + } + + impl RequestTimeouts { + pub(super) fn between_bytes(self) -> u64 { + self.between_bytes + } + + pub(super) fn connect(self) -> u64 { + self.connect + } + + pub(super) fn first_byte(self) -> u64 { + self.first_byte + } + } + + pub(super) fn request_timeouts(remaining: Duration) -> RequestTimeouts { + let full_remaining = duration_nanos(remaining); + RequestTimeouts { + between_bytes: full_remaining, + connect: full_remaining, + first_byte: full_remaining, + } + } + + pub(super) fn request_body_limit(_body: &Body, configured: u64) -> u64 { + configured + } + + pub(super) fn should_emit_response_chunk(length: usize) -> bool { + length != 0 + } + + pub(super) fn duration_nanos(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()) + .unwrap_or(u64::MAX) + .max(1) + } + + pub(super) async fn cooperative_yield_once() { + let mut yielded = false; + poll_fn(move |context| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + context.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + + pub(super) async fn cooperate_after_response_read(ready_reads: &mut usize) { + *ready_reads = ready_reads.saturating_add(1); + if *ready_reads >= READY_ITEM_YIELD_QUOTA { + *ready_reads = 0; + cooperative_yield_once().await; + } + } + + pub(super) fn cooperative_stream(mut source: BodyStream) -> BodyStream { + stream! { + let mut ready_items = 0_usize; + while let Some(item) = source.next().await { + let terminal = item.is_err(); + yield item; + if terminal { + return; + } + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + cooperative_yield_once().await; + ready_items = 0; + } + } + } + .boxed_local() + } + + pub(super) async fn run_exchange( + send_future: Send, + pump_future: Pump, + request_done_reader: RequestDone, + map_error: MapError, + ) -> Result + where + Send: Future>, + Pump: Future>, + RequestDone: IntoFuture>, + MapError: Fn(&HostError) -> EdgeError, + { + #[derive(Clone, Copy)] + enum State { + AwaitingRequestDone, + ReaderGone, + RequestComplete, + Uploading, + } + + enum Outcome { + PumpError(EdgeError), + RequestDoneError(HostError), + Send(Result), + } + + let mut state = State::Uploading; + let mut retained_send = None; + let mut send = Box::pin(send_future); + let mut pump = Box::pin(pump_future); + let mut request_done = Box::pin(request_done_reader.into_future()); + + let outcome = poll_fn(|context| { + loop { + match state { + State::Uploading => match pump.as_mut().poll(context) { + Poll::Ready(Err(error)) => return Poll::Ready(Outcome::PumpError(error)), + Poll::Ready(Ok(UploadCompletion::Complete)) => { + state = State::AwaitingRequestDone; + } + Poll::Ready(Ok(UploadCompletion::ReaderGone)) => { + state = State::ReaderGone; + } + Poll::Pending => match send.as_mut().poll(context) { + Poll::Ready(result) => return Poll::Ready(Outcome::Send(result)), + Poll::Pending => return Poll::Pending, + }, + }, + State::AwaitingRequestDone => match request_done.as_mut().poll(context) { + Poll::Ready(Err(error)) => { + return Poll::Ready(Outcome::RequestDoneError(error)); + } + Poll::Ready(Ok(())) => state = State::RequestComplete, + Poll::Pending => { + if retained_send.is_none() + && let Poll::Ready(result) = send.as_mut().poll(context) + { + retained_send = Some(result); + } + return Poll::Pending; + } + }, + State::ReaderGone | State::RequestComplete => { + if let Some(result) = retained_send.take() { + return Poll::Ready(Outcome::Send(result)); + } + match send.as_mut().poll(context) { + Poll::Ready(result) => return Poll::Ready(Outcome::Send(result)), + Poll::Pending => return Poll::Pending, + } + } + } + } + }) + .await; + + drop(request_done); + drop(pump); + drop(send); + match outcome { + Outcome::PumpError(error) => Err(error), + Outcome::RequestDoneError(error) | Outcome::Send(Err(error)) => Err(map_error(&error)), + Outcome::Send(Ok(output)) => Ok(output), + } + } +} + +#[cfg(any( + test, + all(feature = "spin", target_arch = "wasm32", feature = "test-utils") +))] +use exchange::READY_ITEM_YIELD_QUOTA; +#[cfg(test)] +use exchange::duration_nanos; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use exchange::{ + UploadCompletion, cooperate_after_response_read, cooperative_stream, cooperative_yield_once, + request_body_limit, request_timeouts, run_exchange, should_emit_response_chunk, +}; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod spin_impl { + use std::num::NonZeroU64; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_gzip_stream, + }; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH}; + use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; + use edgezero_core::outbound::{ + OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, ResponseHeaderLimiter, + ResponseMode, collect_response_stream, enforce_payload_content_length, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + normalize_response_headers, rechunk_stream, validate_for_dispatch, + }; + use edgezero_core::time::{DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget}; + use futures_util::StreamExt as _; + use futures_util::future::{Either, join_all, select}; + use futures_util::stream::once; + use spin_sdk::time::sleep; + use spin_sdk::wasip3::http::client; + use spin_sdk::wasip3::http::types::{ + ErrorCode, Fields, Method as WasiMethod, Request, RequestOptions, RequestOptionsError, + Response, Scheme, + }; + use spin_sdk::wasip3::http_compat::BodyWriter; + use spin_sdk::wasip3::wit_bindgen::{FutureWriter, StreamResult}; + use spin_sdk::wasip3::wit_future; + + use super::{ + UploadCompletion, cooperate_after_response_read, cooperative_yield_once, + map_spin_send_error, run_exchange, should_emit_response_chunk, timeout_error, + }; + + const RESPONSE_READ_BYTES: usize = 16 * 1024; + + /// Native outbound HTTP implementation for Spin's WASI HTTP 0.3 host. + pub struct SpinOutboundClient { + clock: MonotonicClock, + } + + struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, + } + + enum PreparedSlot { + Finished(OutboundSlotResult), + Pending(Box), + } + + impl SpinOutboundClient { + /// Builds a client using the process-default monotonic clock. + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { clock } + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + Self::prepare_validated(request, started_at) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + super::validate_batch_request(&request)?; + Self::prepare_validated(request, started_at) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + let budget = dispatch_budget(&request, started_at)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + mut headers, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + + let fields = request_fields(&headers)?; + let options = request_options(budget, &self.clock)?; + let (writer, contents, trailers) = BodyWriter::new(); + let (request, request_done) = + Request::new(fields, Some(contents), trailers, Some(options)); + set_request_target(&request, &method, &uri)?; + let request_body_limit = super::request_body_limit(&body, max_request_body_bytes); + + let exchange = async move { + let upload = + pump_request_body(body, request_body_limit, writer, budget, self.clock.clone()); + let send = client::send(request); + run_exchange(send, upload, request_done, |error| { + map_spin_send_error(error, budget.deadline, budget.cause, self.clock.now()) + }) + .await + }; + let response = race_deadline(exchange, budget, &self.clock).await??; + + process_response( + response, + method, + response_mode, + budget, + max_brotli_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + } + + impl Default for SpinOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + #[async_trait(?Send)] + impl OutboundHttpClient for SpinOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + async fn send_all(&self, requests: Vec) -> Vec { + let batch_started_at = self.clock.now(); + let preflight: Vec = requests + .into_iter() + .map(|request| { + Self::prepare_batch(request, batch_started_at).map_or_else( + |error| { + PreparedSlot::Finished(finish_slot( + batch_started_at, + Err(error), + &self.clock, + )) + }, + |prepared| PreparedSlot::Pending(Box::new(prepared)), + ) + }) + .collect(); + + join_all(preflight.into_iter().map(|slot| async move { + match slot { + PreparedSlot::Pending(prepared) => { + let outcome = self.execute(*prepared).await; + finish_slot(batch_started_at, outcome, &self.clock) + } + PreparedSlot::Finished(done) => done, + } + })) + .await + } + } + + fn request_fields(headers: &HeaderMap) -> Result { + let fields = Fields::new(); + for (name, value) in headers { + fields + .append(name.as_str(), value.as_bytes()) + .map_err(|_error| { + EdgeError::bad_request("Spin rejected an outbound request header") + })?; + } + Ok(fields) + } + + fn request_options( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + let options = RequestOptions::new(); + let remaining = budget_remaining(budget, clock)?; + let timeouts = super::request_timeouts(remaining); + set_timeout_option( + "connect", + &options.set_connect_timeout(Some(timeouts.connect())), + )?; + set_timeout_option( + "first-byte", + &options.set_first_byte_timeout(Some(timeouts.first_byte())), + )?; + set_timeout_option( + "between-bytes", + &options.set_between_bytes_timeout(Some(timeouts.between_bytes())), + )?; + Ok(options) + } + + fn set_timeout_option( + name: &str, + outcome: &Result<(), RequestOptionsError>, + ) -> Result<(), EdgeError> { + match outcome { + Ok(()) => Ok(()), + Err(RequestOptionsError::NotSupported) => { + log::debug!("Spin host does not support the outbound {name} timeout option"); + Ok(()) + } + Err(RequestOptionsError::Immutable) => Err(EdgeError::internal(anyhow::anyhow!( + "Spin outbound {name} timeout option is immutable" + ))), + Err(RequestOptionsError::Other(_)) => Err(EdgeError::internal(anyhow::anyhow!( + "Spin host rejected the outbound {name} timeout option" + ))), + } + } + + fn set_request_target(request: &Request, method: &Method, uri: &Uri) -> Result<(), EdgeError> { + request + .set_method(&wasi_method(method)) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request method"))?; + let scheme = match uri.scheme_str() { + Some("http") => Scheme::Http, + Some("https") => Scheme::Https, + Some(other) => Scheme::Other(other.to_owned()), + None => return Err(EdgeError::bad_request("outbound request URI has no scheme")), + }; + request + .set_scheme(Some(&scheme)) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request scheme"))?; + let authority = uri + .authority() + .ok_or_else(|| EdgeError::bad_request("outbound request URI has no authority"))?; + request + .set_authority(Some(authority.as_str())) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request authority"))?; + request + .set_path_with_query(Some( + uri.path_and_query() + .map_or("/", |path_and_query| path_and_query.as_str()), + )) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request path"))?; + Ok(()) + } + + fn wasi_method(method: &Method) -> WasiMethod { + match *method { + Method::GET => WasiMethod::Get, + Method::HEAD => WasiMethod::Head, + Method::POST => WasiMethod::Post, + Method::PUT => WasiMethod::Put, + Method::DELETE => WasiMethod::Delete, + Method::CONNECT => WasiMethod::Connect, + Method::OPTIONS => WasiMethod::Options, + Method::TRACE => WasiMethod::Trace, + Method::PATCH => WasiMethod::Patch, + _ => WasiMethod::Other(method.as_str().to_owned()), + } + } + + async fn pump_request_body( + body: Body, + maximum: u64, + writer: BodyWriter, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> Result { + let BodyWriter { + mut stream_writer, + result_writer, + .. + } = writer; + let mut total = 0_u64; + let mut source = match body { + Body::Once(bytes) => once(async move { Ok(bytes) }).boxed_local(), + Body::Stream(source) => source, + }; + + // `BodyWriter` configures `result_writer` to publish a typed host error on drop. + // Deadline returns can therefore signal failure without awaiting after budget expiry. + while let Some(item) = source.next().await { + budget_remaining(budget, &clock)?; + let bytes = match item { + Ok(bytes) => bytes, + Err(error) => { + drop(stream_writer); + signal_upload_failure(result_writer, ErrorCode::InternalError(None)).await; + return Err(error); + } + }; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + drop(stream_writer); + signal_upload_failure(result_writer, ErrorCode::HttpRequestBodySize(None)).await; + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + drop(stream_writer); + signal_upload_failure( + result_writer, + ErrorCode::HttpRequestBodySize(Some(next_total)), + ) + .await; + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + let unwritten = stream_writer.write_all(bytes.to_vec()).await; + budget_remaining(budget, &clock)?; + if !unwritten.is_empty() { + drop(stream_writer); + drop(result_writer.write(Ok(None)).await); + return Ok(UploadCompletion::ReaderGone); + } + total = next_total; + cooperative_yield_once().await; + } + + drop(stream_writer); + if result_writer.write(Ok(None)).await.is_err() { + return Ok(UploadCompletion::ReaderGone); + } + Ok(UploadCompletion::Complete) + } + + async fn signal_upload_failure( + writer: FutureWriter, ErrorCode>>, + error: ErrorCode, + ) { + match writer.write(Err(error)).await { + Ok(()) | Err(_) => {} + } + } + + #[expect( + clippy::too_many_arguments, + reason = "the adapter consumes independent response-policy fields" + )] + async fn process_response( + response: Response, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.get_status_code()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream status code", + BadGatewayReason::Protocol, + ) + })?; + let mut headers = response_headers(&response)?; + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let disposition = normalize_response_headers(&request_method, status, &mut headers)?; + headers.insert(PROXY_HEADER, HeaderValue::from_static("spin")); + + let native = response_stream(response, budget, clock.clone()); + if disposition == ResponseBodyDisposition::FramingBodyless { + drain_response(native, budget, &clock).await?; + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + if !declared_reset_body { + drain_response(native, budget, &clock).await?; + } + return Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + Body::empty(), + response_clock, + )); + } + + let encoding = classify_content_encoding(&headers); + let max_buffered = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + &headers, + encoding, + max_buffered, + max_decoded_response_bytes, + max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, max_encoded_response_bytes); + let decoded = match encoding { + ContentEncoding::Brotli => { + decode_brotli_stream(encoded, max_brotli_window_bits, max_brotli_decoder_bytes) + } + ContentEncoding::Gzip => decode_gzip_stream(encoded), + ContentEncoding::Identity | ContentEncoding::Passthrough => encoded, + }; + if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match encoding { + ContentEncoding::Brotli | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough => decoded, + }; + let shaped = rechunk_stream(output, max_chunk_bytes); + let deadline_bound = deadline_stream(super::cooperative_stream(shaped), budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new_with_monotonic_clock( + request_method, + status, + headers, + body, + response_clock, + )) + } + + fn response_headers(response: &Response) -> Result { + let fields = response.get_headers(); + let mut headers = HeaderMap::new(); + for (raw_name, raw_value) in fields.copy_all() { + let header_name = HeaderName::from_bytes(raw_name.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream header name", + BadGatewayReason::Protocol, + ) + })?; + let header_value = HeaderValue::from_bytes(&raw_value).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream header value", + BadGatewayReason::Protocol, + ) + })?; + headers.append(header_name, header_value); + } + Ok(headers) + } + + fn default_response_result() -> Result<(), ErrorCode> { + Err(ErrorCode::InternalError(Some( + "response body consumer dropped before completion".to_owned(), + ))) + } + + fn response_stream( + response: Response, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + let (completion_writer, result_reader) = wit_future::new(default_response_result); + let (mut body_reader, trailer_reader) = Response::consume_body(response, result_reader); + stream! { + let mut ready_reads = 0_usize; + loop { + let read = body_reader.read(Vec::with_capacity(RESPONSE_READ_BYTES)); + let (result, chunk) = match race_deadline(read, budget, &clock).await { + Ok(value) => value, + Err(error) => { + yield Err(error); + return; + } + }; + match result { + StreamResult::Complete(length) => { + if should_emit_response_chunk(length) { + yield Ok(Bytes::from(chunk)); + } + cooperate_after_response_read(&mut ready_reads).await; + } + StreamResult::Dropped => { + let trailer_result = race_deadline( + async move { trailer_reader.await }, + budget, + &clock, + ) + .await; + match trailer_result { + Ok(Ok(_trailers)) => {} + Ok(Err(error)) => { + yield Err(map_spin_send_error( + &error, + budget.deadline, + budget.cause, + clock.now(), + )); + return; + } + Err(error) => { + yield Err(error); + return; + } + } + let completion = completion_writer.write(Ok(())); + match race_deadline(completion, budget, &clock).await { + Ok(Ok(())) => return, + Ok(Err(_closed)) => { + yield Err(EdgeError::bad_gateway_with_reason( + "Spin response completion reader closed", + BadGatewayReason::Protocol, + )); + return; + } + Err(error) => { + yield Err(error); + return; + } + } + } + StreamResult::Cancelled => { + yield Err(EdgeError::bad_gateway_with_reason( + "Spin cancelled the upstream response body read", + BadGatewayReason::Transport, + )); + return; + } + } + } + } + .boxed_local() + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + let next_item = match race_deadline(source.next(), budget, &clock).await { + Ok(item) => item, + Err(error) => { + yield Err(error); + return; + } + }; + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() + } + + async fn drain_response( + mut body: BodyStream, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(), EdgeError> { + while let Some(item) = body.next().await { + item?; + budget_remaining(budget, clock)?; + } + Ok(()) + } + + async fn race_deadline( + future: impl Future, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + let remaining = budget_remaining(budget, clock)?; + let timer = sleep(remaining); + futures_util::pin_mut!(future, timer); + let output = match select(future, timer).await { + Either::Left((output, _timer)) => output, + Either::Right(((), _future)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, clock)?; + Ok(output) + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + fn finish_slot( + started_at: MonotonicInstant, + outcome: Result, + clock: &MonotonicClock, + ) -> OutboundSlotResult { + let completed_at = clock.now(); + match completed_at.checked_duration_since(started_at) { + Some(elapsed) => OutboundSlotResult::new(elapsed, outcome), + None => OutboundSlotResult::new( + Duration::ZERO, + Err(EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards during outbound dispatch" + ))), + ), + } + } + + /// Runs request-option, response-clock, and fairness checks in the hosted contract binary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn deferred_clock_paths_hold_for_test() -> bool { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = MonotonicInstant::now(); + let Ok(request) = OutboundRequest::get("https://example.com/") else { + return false; + }; + let Ok(budget) = dispatch_budget(&request.timeout(Duration::from_millis(10)), start) else { + return false; + }; + let observed = start.checked_add(Duration::from_millis(3)).unwrap_or(start); + let option_observations = Arc::new(AtomicUsize::new(0)); + let observed_options = Arc::clone(&option_observations); + let option_clock = MonotonicClock::new(move || { + observed_options.fetch_add(1, Ordering::SeqCst); + observed + }); + if request_options(budget, &option_clock).is_err() + || option_observations.load(Ordering::SeqCst) != 1 + { + return false; + } + + let mut ready_reads = 0_usize; + for _ in 0..super::READY_ITEM_YIELD_QUOTA { + cooperate_after_response_read(&mut ready_reads).await; + } + if ready_reads != 0 { + return false; + } + + let stream_observations = Arc::new(AtomicUsize::new(0)); + let observed_stream = Arc::clone(&stream_observations); + let deadline = budget.deadline.instant(); + let stream_clock = MonotonicClock::new(move || { + if observed_stream.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + deadline + } + }); + let source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, stream_clock); + + matches!( + body.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ) + } + + #[cfg(test)] + mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use edgezero_core::error::BudgetSource; + use edgezero_core::time::Deadline; + use futures::executor::block_on; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[test] + fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = SpinOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![request])); + + assert_eq!(results[0].elapsed, Duration::from_millis(9)); + assert!(matches!( + results[0].outcome, + Err(EdgeError::BadRequest { .. }) + )); + } + + #[test] + fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = SpinOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![request])); + + assert_eq!(results[0].elapsed, Duration::ZERO); + assert!(matches!( + results[0].outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[test] + fn request_option_preparation_consumes_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([observed]))); + let clock_observations = Arc::clone(&observations); + let clock = MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }); + + let _options = request_options(budget, &clock).expect("request options"); + + assert!(observations.lock().expect("clock observations").is_empty()); + } + + #[test] + fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, clock); + + let error = block_on(body.next()) + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use spin_impl::SpinOutboundClient; +#[cfg(all(feature = "spin", target_arch = "wasm32", feature = "test-utils"))] +pub use spin_impl::deferred_clock_paths_hold_for_test; + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +fn map_spin_send_error( + error: &ErrorCode, + deadline: Deadline, + cause: BudgetSource, + observed_at: edgezero_core::MonotonicInstant, +) -> EdgeError { + if deadline.is_expired_at(observed_at) { + return timeout_error(cause); + } + + match error { + ErrorCode::DnsTimeout + | ErrorCode::ConnectionTimeout + | ErrorCode::ConnectionReadTimeout + | ErrorCode::ConnectionWriteTimeout + | ErrorCode::HttpResponseTimeout => timeout_error(BudgetSource::Unspecified), + ErrorCode::HttpRequestDenied + | ErrorCode::HttpRequestBodySize(_) + | ErrorCode::HttpRequestUriTooLong + | ErrorCode::HttpRequestHeaderSectionSize(_) + | ErrorCode::HttpRequestHeaderSize(_) => { + EdgeError::bad_request("outbound request was rejected by the Spin HTTP host") + } + ErrorCode::HttpRequestLengthRequired + | ErrorCode::HttpRequestMethodInvalid + | ErrorCode::HttpRequestUriInvalid + | ErrorCode::HttpRequestTrailerSectionSize(_) + | ErrorCode::HttpRequestTrailerSize(_) + | ErrorCode::ConfigurationError => EdgeError::internal(anyhow::anyhow!( + "Spin rejected an adapter-owned outbound request invariant" + )), + ErrorCode::DnsError(_) + | ErrorCode::DestinationNotFound + | ErrorCode::DestinationUnavailable + | ErrorCode::DestinationIpProhibited + | ErrorCode::DestinationIpUnroutable + | ErrorCode::ConnectionRefused + | ErrorCode::ConnectionLimitReached + | ErrorCode::TlsProtocolError + | ErrorCode::TlsCertificateError + | ErrorCode::TlsAlertReceived(_) => EdgeError::bad_gateway_with_reason( + "outbound destination could not be reached", + BadGatewayReason::Unreachable, + ), + ErrorCode::ConnectionTerminated => EdgeError::bad_gateway_with_reason( + "outbound connection failed after dispatch", + BadGatewayReason::Transport, + ), + ErrorCode::HttpResponseIncomplete + | ErrorCode::HttpResponseHeaderSectionSize(_) + | ErrorCode::HttpResponseHeaderSize(_) + | ErrorCode::HttpResponseBodySize(_) + | ErrorCode::HttpResponseTrailerSectionSize(_) + | ErrorCode::HttpResponseTrailerSize(_) + | ErrorCode::HttpResponseTransferCoding(_) + | ErrorCode::HttpResponseContentCoding(_) + | ErrorCode::HttpUpgradeFailed + | ErrorCode::HttpProtocolError + | ErrorCode::LoopDetected => EdgeError::bad_gateway_with_reason( + "upstream response violated the HTTP protocol", + BadGatewayReason::Protocol, + ), + ErrorCode::InternalError(_) => EdgeError::bad_gateway_with_reason( + "Spin outbound HTTP host failed", + BadGatewayReason::Unspecified, + ), + } +} + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(any(feature = "test-utils", all(feature = "spin", target_arch = "wasm32")))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +/// Runs the target-neutral Spin batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +/// Exposes the real pinned SDK classifier to WASI resource contract tests. +#[cfg(all(feature = "spin", feature = "test-utils"))] +#[must_use] +#[inline] +pub fn map_spin_send_error_for_test( + error: &ErrorCode, + deadline: Deadline, + cause: BudgetSource, + observed_at: edgezero_core::MonotonicInstant, +) -> EdgeError { + map_spin_send_error(error, deadline, cause, observed_at) +} + +#[cfg(test)] +mod exchange_tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::future::Future; + use std::pin::Pin; + use std::rc::Rc; + use std::task::{Context, Poll}; + use std::time::Duration; + + use futures::executor::block_on; + + use super::{ + EdgeError, READY_ITEM_YIELD_QUOTA, UploadCompletion, cooperate_after_response_read, + cooperative_stream, cooperative_yield_once, duration_nanos, request_body_limit, + request_timeouts, run_exchange, should_emit_response_chunk, + }; + + struct ScriptedFuture { + drops: Rc>, + polls: Rc>, + steps: VecDeque>, + } + + impl Unpin for ScriptedFuture {} + + impl ScriptedFuture { + fn new( + steps: impl IntoIterator>, + polls: Rc>, + drops: Rc>, + ) -> Self { + Self { + drops, + polls, + steps: steps.into_iter().collect(), + } + } + } + + impl Future for ScriptedFuture { + type Output = Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.polls.set(self.polls.get().saturating_add(1)); + let step = self.steps.pop_front().expect("scripted future exhausted"); + if step.is_pending() { + cx.waker().wake_by_ref(); + } + step + } + } + + impl Drop for ScriptedFuture { + fn drop(&mut self) { + self.drops.set(self.drops.get().saturating_add(1)); + } + } + + fn counters() -> (Rc>, Rc>) { + (Rc::new(Cell::new(0)), Rc::new(Cell::new(0))) + } + + fn map_host_error(_error: &&'static str) -> EdgeError { + EdgeError::bad_gateway("scripted host failure") + } + + #[test] + fn continuously_ready_chunks_yield_after_each_item() { + use futures_util::StreamExt as _; + use futures_util::stream; + use futures_util::task::noop_waker; + + let processed = Rc::new(Cell::new(0_u32)); + let observed = Rc::clone(&processed); + let mut source = stream::iter([(), (), ()]); + let mut pump = Box::pin(async move { + while source.next().await.is_some() { + observed.set(observed.get().saturating_add(1)); + cooperative_yield_once().await; + } + }); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 1); + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 2); + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 3); + assert!(pump.as_mut().poll(&mut context).is_ready()); + } + + #[test] + fn request_timeouts_use_full_remaining_budget_for_every_phase() { + let timeouts = request_timeouts(Duration::from_millis(40)); + assert_eq!(timeouts.connect(), 40_000_000); + assert_eq!(timeouts.first_byte(), 40_000_000); + assert_eq!(timeouts.between_bytes(), 40_000_000); + } + + #[test] + fn duration_nanos_floors_and_saturates_without_wrapping() { + assert_eq!(duration_nanos(Duration::ZERO), 1); + assert_eq!(duration_nanos(Duration::from_nanos(1)), 1); + assert_eq!(duration_nanos(Duration::from_nanos(999)), 999); + assert_eq!(duration_nanos(Duration::MAX), u64::MAX); + } + + #[test] + fn request_body_limit_applies_to_buffered_and_streamed_bodies() { + use bytes::Bytes; + use edgezero_core::body::Body; + use futures_util::stream; + + assert_eq!( + request_body_limit(&Body::from(Bytes::from_static(b"buffered")), 8), + 8 + ); + assert_eq!(request_body_limit(&Body::stream(stream::empty()), 8), 8); + } + + #[test] + fn cooperative_stream_stops_after_the_first_error() { + use bytes::Bytes; + use futures_util::StreamExt as _; + use futures_util::stream; + + let source = stream::iter([ + Err(EdgeError::bad_gateway("terminal")), + Ok(Bytes::from_static(b"must not escape")), + ]) + .boxed_local(); + let mut cooperative = cooperative_stream(source); + + assert!(matches!( + block_on(cooperative.next()), + Some(Err(EdgeError::BadGateway { .. })) + )); + assert!(block_on(cooperative.next()).is_none()); + } + + #[test] + fn zero_length_complete_does_not_emit_an_empty_response_chunk() { + assert!(!should_emit_response_chunk(0)); + assert!(should_emit_response_chunk(1)); + } + + #[test] + fn zero_length_response_reads_force_a_pending_boundary_at_the_quota() { + use futures_util::task::noop_waker; + + let processed = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&processed); + let mut task = Box::pin(async move { + let mut ready_reads = 0_usize; + for _ in 0..READY_ITEM_YIELD_QUOTA.saturating_add(1) { + observed.set(observed.get().saturating_add(1)); + cooperate_after_response_read(&mut ready_reads).await; + } + }); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + assert!(task.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), READY_ITEM_YIELD_QUOTA); + assert!(task.as_mut().poll(&mut context).is_ready()); + assert_eq!(processed.get(), READY_ITEM_YIELD_QUOTA.saturating_add(1)); + } + + #[test] + fn cooperative_stream_forces_a_pending_boundary_at_its_quota() { + use std::iter::repeat_with; + + use bytes::Bytes; + use futures_util::StreamExt as _; + use futures_util::stream; + use futures_util::task::noop_waker; + + let mut source = cooperative_stream( + stream::iter( + repeat_with(|| Ok::(Bytes::new())) + .take(READY_ITEM_YIELD_QUOTA.saturating_add(1)), + ) + .boxed_local(), + ); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + for _ in 0..READY_ITEM_YIELD_QUOTA { + assert!(matches!( + source.as_mut().poll_next(&mut context), + Poll::Ready(Some(Ok(_))) + )); + } + assert!(source.as_mut().poll_next(&mut context).is_pending()); + assert!(matches!( + source.as_mut().poll_next(&mut context), + Poll::Ready(Some(Ok(_))) + )); + } + + #[test] + fn upload_failure_wins_over_simultaneously_ready_send() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Err(EdgeError::bad_request("upload failed")))], + Rc::clone(&pump_polls), + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert!(matches!(outcome, Err(EdgeError::BadRequest { .. }))); + assert_eq!(pump_polls.get(), 1); + assert_eq!(send_polls.get(), 0); + assert_eq!(done_polls.get(), 0); + assert_eq!(send_drops.get(), 1); + assert_eq!(pump_drops.get(), 1); + assert_eq!(done_drops.get(), 1); + } + + #[test] + fn ready_send_after_pending_upload_is_authoritative() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [ + Poll::Pending, + Poll::Ready(Err(EdgeError::bad_request("late upload failure"))), + ], + Rc::clone(&pump_polls), + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("send result"), 7); + assert_eq!(pump_polls.get(), 1); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 0); + assert_eq!(send_drops.get(), 1); + assert_eq!(pump_drops.get(), 1); + assert_eq!(done_drops.get(), 1); + } + + #[test] + fn request_done_error_wins_over_retained_send() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::Complete))], + pump_polls, + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Err("request completion failed"))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let observed_send_drops = Rc::clone(&send_drops); + let observed_pump_drops = Rc::clone(&pump_drops); + let observed_done_drops = Rc::clone(&done_drops); + let outcome = block_on(run_exchange(send, pump, done, move |_error| { + assert_eq!(observed_send_drops.get(), 1); + assert_eq!(observed_pump_drops.get(), 1); + assert_eq!(observed_done_drops.get(), 1); + EdgeError::bad_gateway("scripted host failure") + })); + + assert!(matches!(outcome, Err(EdgeError::BadGateway { .. }))); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 2); + } + + #[test] + fn request_done_success_releases_retained_send_without_repolling_it() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new([Poll::Ready(Ok(7_u8))], Rc::clone(&send_polls), send_drops); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::Complete))], + pump_polls, + pump_drops, + ); + let done = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + done_drops, + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("retained send result"), 7); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 2); + } + + #[test] + fn reader_gone_never_polls_request_done() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + send_drops, + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::ReaderGone))], + pump_polls, + pump_drops, + ); + let done = ScriptedFuture::new([Poll::Ready(Ok(()))], Rc::clone(&done_polls), done_drops); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("early response"), 7); + assert_eq!(send_polls.get(), 2); + assert_eq!(done_polls.get(), 0); + } +} diff --git a/crates/edgezero-adapter-spin/src/proxy.rs b/crates/edgezero-adapter-spin/src/proxy.rs deleted file mode 100644 index 81754c77..00000000 --- a/crates/edgezero-adapter-spin/src/proxy.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::decompress::decompress_body; -use crate::response::collect_body_bytes; -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use spin_sdk::http::body::IncomingBodyExt as _; -use spin_sdk::http::{FullBody, Request as SpinRequest, send}; - -/// A proxy client that uses Spin's outbound HTTP (`spin_sdk::http::send`) -/// to forward requests to upstream services. -pub struct SpinProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for SpinProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _extensions) = request.into_parts(); - - let mut builder = SpinRequest::builder().method(method).uri(uri.to_string()); - - for (name, value) in &headers { - builder = builder.header(name, value); - } - - let request_body_bytes = collect_body_bytes(body).await?; - - let spin_request = builder - .body(FullBody::new(Bytes::from(request_body_bytes))) - .map_err(|err| { - EdgeError::internal(anyhow::anyhow!("failed to build proxy request: {err}")) - })?; - - let spin_response = send(spin_request).await.map_err(|err| { - EdgeError::internal(anyhow::anyhow!("Spin outbound HTTP error: {err}")) - })?; - - let (response_parts, response_body) = spin_response.into_parts(); - - let encoding = response_parts - .headers - .get(header::CONTENT_ENCODING) - .and_then(|value| value.to_str().ok()) - .map(str::to_ascii_lowercase); - - let response_body_bytes = response_body.bytes().await.map_err(|err| { - EdgeError::internal(anyhow::anyhow!("failed to read proxy response body: {err}")) - })?; - let decompressed = decompress_body(response_body_bytes.to_vec(), encoding.as_deref())?; - let mut proxy_response = - ProxyResponse::new(response_parts.status, Body::from(decompressed)); - - for (name, value) in &response_parts.headers { - proxy_response - .headers_mut() - .insert(name.clone(), value.clone()); - } - - // Strip encoding headers after decompression so downstream - // handlers see plain bytes (consistent with Fastly/Cloudflare). - if matches!(encoding.as_deref(), Some("gzip" | "br")) { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - // `HeaderValue::from_static("spin")` is infallible at compile time so - // it cannot panic at runtime — replaces the previous - // `.parse().expect(...)` which tripped expect_used under restriction. - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("spin")); - - Ok(proxy_response) - } -} diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index bf9543bf..fa3ce126 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -1,30 +1,53 @@ use std::collections::BTreeMap; use std::sync::Arc; +#[cfg(feature = "test-utils")] +use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + task::Poll, +}; use anyhow::Context as _; +#[cfg(feature = "test-utils")] +use bytes::Bytes; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; use crate::context::{SpinRequestContext, parse_client_addr}; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; -use crate::proxy::SpinProxyClient; -use crate::response::from_core_response; +use crate::response::from_egress_response; use crate::secret_store::SpinSecretStore; use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request, request_builder}; +#[cfg(feature = "test-utils")] +use edgezero_core::http::Uri; +#[cfg(any(test, feature = "test-utils"))] +use edgezero_core::http::{Method, request_builder}; +use edgezero_core::http::{Request, RequestParts}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressFraming, IngressHeadAccounting, IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +#[cfg(any(test, feature = "test-utils"))] +use futures::executor::block_on; +#[cfg(feature = "test-utils")] +use futures_util::stream::poll_fn; +use futures_util::stream::unfold; +use futures_util::{StreamExt as _, TryStreamExt as _}; use spin_sdk::http::Request as SpinRequest; use spin_sdk::http::body::IncomingBodyExt as _; +use crate::outbound::SpinOutboundClient; + /// Per-dispatch store wiring assembled before the request enters the router. /// The struct itself is `pub(crate)` because `dispatch_with_handles` takes it /// by value, but fields are constructed only inside this module so they stay @@ -39,18 +62,37 @@ pub(crate) struct Stores { secrets: Option, } +#[cfg(feature = "test-utils")] +struct DropSignal(Arc); + +#[cfg(feature = "test-utils")] +impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + /// Convert a Spin `Request` into an `EdgeZero` core `Request`. /// -/// Reads the full body into a buffered `Body::Once`, inserts -/// `SpinRequestContext` and a `ProxyHandle` into extensions. +/// Preserves the body as a lazy stream, inserts `SpinRequestContext`, and adds +/// an outbound [`HttpClient`] to extensions. /// /// # Errors /// Returns [`EdgeError::bad_request`] if the request body cannot be read or /// the core `Request` cannot be built from the resulting parts. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request(req: SpinRequest) -> Result { let (parts, body) = req.into_parts(); + let mut request = into_core_request_head(parts, MonotonicClock::default()); + *request.body_mut() = Body::from_external_stream(body.stream()); + Ok(request) +} +fn into_core_request_head(parts: RequestParts, outbound_clock: MonotonicClock) -> Request { let client_addr = parts .headers .get("spin-client-addr") @@ -62,23 +104,7 @@ pub async fn into_core_request(req: SpinRequest) -> Result { .and_then(|value| value.to_str().ok()) .map(str::to_owned); - let mut builder = request_builder().method(parts.method).uri(parts.uri); - for (name, value) in &parts.headers { - builder = builder.header(name, value); - } - - // Inbound body size is not capped at the adapter level. The Spin runtime - // enforces its own request body limit (configurable via `spin.toml`), which - // is consistent with how the Fastly and Cloudflare adapters delegate inbound - // size enforcement to their respective platform runtimes. - let body_bytes = body - .bytes() - .await - .map_err(|err| EdgeError::bad_request(format!("failed to read request body: {err}")))?; - - let mut request = builder - .body(Body::from(body_bytes.to_vec())) - .map_err(|err| EdgeError::bad_request(format!("failed to build request: {err}")))?; + let mut request = Request::from_parts(parts, Body::empty()); SpinRequestContext::insert( &mut request, @@ -89,9 +115,125 @@ pub async fn into_core_request(req: SpinRequest) -> Result { ); request .extensions_mut() - .insert(ProxyHandle::with_client(SpinProxyClient)); + .insert(outbound_client(outbound_clock)); - Ok(request) + request +} + +fn into_core_request_head_for_app(parts: RequestParts, app: &App) -> Request { + into_core_request_head(parts, app.monotonic_clock()) +} + +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(SpinOutboundClient::with_clock(clock)) +} + +fn spin_deadline_body( + source: Source, + deadline: Deadline, + monotonic_clock: MonotonicClock, +) -> Body +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let boxed_stream = source.map_err(Into::into).boxed_local(); + let stream = unfold(Some(boxed_stream), move |stream_state| { + let clock = monotonic_clock.clone(); + async move { + let mut body_stream = stream_state?; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + let item = body_stream.next().await; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(body_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }); + Body::from_stream(stream) +} + +/// Runs the terminal source-release probe used by the WASM contract suite. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn deadline_body_releases_source_for_test() -> bool { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = spin_deadline_body(source, Deadline::at_instant(start), clock); + let Some(mut body_stream) = body.into_stream() else { + return false; + }; + let Some(Err(error)) = block_on(body_stream.next()) else { + return false; + }; + matches!(error, EdgeError::RequestTimeout { .. }) && dropped.load(Ordering::SeqCst) == 1 +} + +/// Dispatches an observable source through the production ingress body wrapper. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_stream_for_test( + app: &App, + method: Method, + uri: Uri, + source: Source, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(EdgeError::internal)?; + dispatch_ingress_stream( + app, + core_request, + Stores::default(), + request_start, + move || source, + ) + .await +} + +/// Dispatches a native Spin request through the production outer request seam. +/// +/// # Errors +/// Returns the same request-conversion, routing, or response-conversion error as standard dispatch. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_request_for_test( + app: &App, + req: SpinRequest, +) -> anyhow::Result { + dispatch_with_handles(app, req, Stores::default(), app.monotonic_now()).await } /// Dispatch a Spin request through the `EdgeZero` router using the `"default"` @@ -107,7 +249,8 @@ pub async fn into_core_request(req: SpinRequest) -> Result { /// fails. #[inline] pub async fn dispatch(app: &App, req: SpinRequest) -> anyhow::Result { - dispatch_with_kv_label(app, req, "default").await + let request_start = app.monotonic_now(); + dispatch_with_kv_label_at(app, req, "default", request_start).await } /// Dispatch a Spin request through the `EdgeZero` router and return @@ -135,6 +278,16 @@ pub async fn dispatch_with_kv_label( app: &App, req: SpinRequest, kv_label: &str, +) -> anyhow::Result { + let request_start = app.monotonic_now(); + dispatch_with_kv_label_at(app, req, kv_label, request_start).await +} + +async fn dispatch_with_kv_label_at( + app: &App, + req: SpinRequest, + kv_label: &str, + request_start: MonotonicInstant, ) -> anyhow::Result { let stores = Stores { config_store: resolve_config_handle(kv_label).await?, @@ -142,15 +295,65 @@ pub async fn dispatch_with_kv_label( secrets: resolve_secret_handle(true), ..Default::default() }; - dispatch_with_handles(app, req, stores).await + dispatch_with_handles(app, req, stores, request_start).await } pub(crate) async fn dispatch_with_handles( app: &App, req: SpinRequest, stores: Stores, + request_start: MonotonicInstant, +) -> anyhow::Result { + let (parts, native_body) = req.into_parts(); + let core_request = into_core_request_head_for_app(parts, app); + dispatch_ingress_stream(app, core_request, stores, request_start, move || { + native_body.stream() + }) + .await +} + +async fn dispatch_ingress_stream( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Source, +{ + head_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + head_parts.validate_normalized(app.ingress_head_limits())?; + let prepared = match app.begin_ingress(head_parts, request_start)? { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return Ok(from_egress_response(response).await?); + } + _ => return Err(anyhow::anyhow!("unsupported ingress admission outcome")), + }; + *head_request.body_mut() = spin_deadline_body( + make_source(), + prepared.read_deadline(), + prepared.monotonic_clock(), + ); + dispatch_core_request(app, head_request, stores, prepared).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + stores: Stores, + prepared: PreparedIngress, ) -> anyhow::Result { - let mut core_request = into_core_request(req).await?; // Hard-cutoff: see fastly's `dispatch_core_request` // for the rationale. Only registries go into extensions — // legacy bare handles are synthesised into a one-id registry @@ -165,8 +368,8 @@ pub(crate) async fn dispatch_with_handles( if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = app.router().oneshot(core_request).await?; - Ok(from_core_response(response).await?) + let response = app.dispatch_admitted(prepared, core_request).await?; + Ok(from_egress_response(response).await?) } /// Dispatch with per-id store registries built from baked metadata. @@ -188,6 +391,7 @@ pub(crate) async fn dispatch_with_registries( secret_meta: Option, env: &EnvConfig, ) -> anyhow::Result { + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(kv_meta, env).await?; let config_registry = build_config_registry(config_meta, env).await?; let secret_registry = build_secret_registry(secret_meta, env); @@ -200,6 +404,7 @@ pub(crate) async fn dispatch_with_registries( secret_registry, ..Default::default() }, + request_start, ) .await } @@ -378,12 +583,19 @@ mod synthesis_tests { use super::*; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::router::RouterService; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::collections::BTreeMap; - use std::sync::Arc; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::time::Duration; struct StubConfig; #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for StubConfig { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(None) @@ -424,6 +636,43 @@ mod synthesis_tests { ); } + #[test] + fn production_head_conversion_installs_the_exact_application_outbound_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([start, completed]))); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + })); + let request = request_builder() + .method(Method::GET) + .uri("http://example.test/clock") + .body(Body::empty()) + .expect("request"); + let (parts, _body) = request.into_parts(); + let core_request = into_core_request_head_for_app(parts, &app); + let client = core_request + .extensions() + .get::() + .cloned() + .expect("HTTP client"); + let outbound_request = edgezero_core::OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![outbound_request])); + + assert_eq!(results[0].elapsed, Duration::from_millis(7)); + } + #[test] fn synthesis_registry_wins_over_bare_handle_when_both_wired() { let mut by_id: BTreeMap = BTreeMap::new(); diff --git a/crates/edgezero-adapter-spin/src/response.rs b/crates/edgezero-adapter-spin/src/response.rs index 7a652511..808ae994 100644 --- a/crates/edgezero-adapter-spin/src/response.rs +++ b/crates/edgezero-adapter-spin/src/response.rs @@ -1,8 +1,17 @@ +#[cfg(feature = "test-utils")] +use std::time::Duration; + use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::Response; -use futures_util::StreamExt as _; +#[cfg(feature = "test-utils")] +use edgezero_core::http::{StatusCode, response_builder}; +use edgezero_core::outbound::{collect_response_stream, collect_response_stream_until_with_clock}; +use edgezero_core::response_egress::{ResponseEgressEnvelope, ResponseEgressOutcome}; +#[cfg(feature = "test-utils")] +use edgezero_core::time::MonotonicInstant; +use edgezero_core::time::{Deadline, MonotonicClock}; use spin_sdk::http::{FullBody, Response as SpinResponse}; use crate::SpinFullResponse; @@ -10,39 +19,43 @@ use crate::SpinFullResponse; /// Maximum body size (16 MiB) when collecting a streamed body into a buffer. /// Prevents unbounded memory growth from malicious or misconfigured upstreams. /// -/// Note: this cap only applies to `Body::Stream` variants. `Body::Once` is -/// already materialised in memory and bypasses this check. The proxy module -/// uses a separate, larger limit ([`MAX_DECOMPRESSED_SIZE`](crate::proxy) = -/// 64 MiB) because proxy responses are untrusted external data that may -/// decompress to a much larger size. -const MAX_BODY_SIZE: usize = 16 * 1024 * 1024; +/// `Body::Once` is already materialized and bypasses this converter boundary. +pub const SPIN_RESPONSE_STREAM_BUFFER_BYTES: u64 = 0x0100_0000; /// Collect a `Body` into a `Vec`, consuming streamed chunks if necessary. /// /// Stream bodies are capped at [`MAX_BODY_SIZE`] bytes. If the accumulated /// size exceeds the limit, collection stops and an error is returned. +#[cfg(test)] pub(crate) async fn collect_body_bytes(body: Body) -> Result, EdgeError> { + collect_body_bytes_with_deadline(body, None, &MonotonicClock::default()).await +} + +async fn collect_body_bytes_with_deadline( + body: Body, + deadline: Option, + clock: &MonotonicClock, +) -> Result, EdgeError> { + ensure_write_deadline(deadline, clock)?; match body { - Body::Once(bytes) => Ok(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut collected = Vec::new(); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - // `usize::saturating_add` keeps the bound check - // honest against pathological inputs without - // triggering arithmetic_side_effects. - if collected.len().saturating_add(bytes.len()) > MAX_BODY_SIZE { - return Err(EdgeError::internal(anyhow::anyhow!( - "body exceeds maximum size of {MAX_BODY_SIZE} bytes" - ))); - } - collected.extend_from_slice(&bytes); - } - Err(err) => return Err(EdgeError::internal(err)), + Body::Once(bytes) => { + ensure_write_deadline(deadline, clock)?; + Ok(bytes.to_vec()) + } + Body::Stream(stream) => { + let collected = match deadline { + Some(write_deadline) => { + collect_response_stream_until_with_clock( + stream, + SPIN_RESPONSE_STREAM_BUFFER_BYTES, + write_deadline, + clock, + ) + .await? } - } - Ok(collected) + None => collect_response_stream(stream, SPIN_RESPONSE_STREAM_BUFFER_BYTES).await?, + }; + Ok(collected.to_vec()) } } } @@ -58,6 +71,56 @@ pub(crate) async fn collect_body_bytes(body: Body) -> Result, EdgeError> /// cannot be built from the collected bytes. #[inline] pub async fn from_core_response(response: Response) -> Result { + from_core_response_with_deadline(response, None, &MonotonicClock::default()).await +} + +pub(crate) async fn from_egress_response( + egress: ResponseEgressEnvelope, +) -> Result { + let (core_response, policy, mut attempt, clock) = egress.begin().map_err(|_policy_error| { + EdgeError::internal(anyhow::anyhow!("response-egress policy failed")) + })?; + if policy.write_deadline.is_expired_at(clock.now()) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, clock.now()); + return deadline_response(); + } + + let converted = + from_core_response_with_deadline(core_response, Some(policy.write_deadline), &clock).await; + let observed_at = clock.now(); + if policy.write_deadline.is_expired_at(observed_at) { + attempt.terminate(ResponseEgressOutcome::DeadlineExceeded, observed_at); + return deadline_response(); + } + match converted { + Ok(converted_response) => { + attempt.terminate(ResponseEgressOutcome::ResponseReturned, observed_at); + Ok(converted_response) + } + Err(error) => { + let outcome = if matches!(error, EdgeError::ResponseTooLarge { .. }) { + ResponseEgressOutcome::ConversionError + } else if matches!(error, EdgeError::GatewayTimeout { .. }) { + ResponseEgressOutcome::DeadlineExceeded + } else { + ResponseEgressOutcome::SourceError + }; + attempt.terminate(outcome, observed_at); + if outcome == ResponseEgressOutcome::DeadlineExceeded { + deadline_response() + } else { + Err(error) + } + } + } +} + +async fn from_core_response_with_deadline( + response: Response, + deadline: Option, + clock: &MonotonicClock, +) -> Result { + ensure_write_deadline(deadline, clock)?; let (parts, body) = response.into_parts(); let mut builder = SpinResponse::builder().status(parts.status); @@ -66,9 +129,81 @@ pub async fn from_core_response(response: Response) -> Result Result { + SpinResponse::builder() + .status(504) + .body(FullBody::new(Bytes::from_static( + b"response write deadline exceeded", + ))) + .map_err(|error| { + EdgeError::internal(anyhow::anyhow!( + "failed to build deadline response: {error}" + )) + }) +} + +fn ensure_write_deadline( + deadline: Option, + clock: &MonotonicClock, +) -> Result<(), EdgeError> { + if deadline.is_some_and(|write_deadline| write_deadline.is_expired_at(clock.now())) { + return Err(EdgeError::gateway_timeout( + "response write deadline exceeded", + )); + } + Ok(()) +} + +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn response_write_deadline_uses_injected_clock_for_test() -> bool { + let start = MonotonicInstant::now(); + let Some(deadline) = start.checked_add(Duration::from_secs(1)) else { + return false; + }; + let clock = MonotonicClock::new(move || deadline); + let Ok(response) = response_builder() + .status(StatusCode::OK) + .body(Body::empty()) + else { + return false; + }; + + matches!( + from_core_response_with_deadline(response, Some(Deadline::at_instant(deadline)), &clock,) + .await, + Err(EdgeError::GatewayTimeout { .. }) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::error::ResponseLimitReason; + use futures::executor::block_on; + use futures_util::stream; + + #[test] + fn stream_body_conversion_enforces_fixed_cap() { + let cap = usize::try_from(SPIN_RESPONSE_STREAM_BUFFER_BYTES).expect("cap fits usize"); + let body = Body::from_stream(stream::iter([Ok(Bytes::from(vec![0; cap + 1]))])); + + let error = block_on(collect_body_bytes(body)).expect_err("one byte over cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } +} diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index f160f178..907a31b5 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -4,9 +4,13 @@ //! The `store_name` parameter is intentionally ignored; provision secrets as //! application variables in `spin.toml`. +use std::future::Future; + use async_trait::async_trait; use bytes::Bytes; +use edgezero_core::config_store::BoundedStoreRead; use edgezero_core::secret_store::{SecretError, SecretStore}; +use edgezero_core::time::Deadline; /// Secret store backed by Spin component variables. /// @@ -58,6 +62,60 @@ impl SecretStore for SpinSecretStore { ))), } } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + bounded_secret_read( + self.get_bytes(store_name, key), + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } +} + +// Spin Variables returns a complete value, so these bounds are cooperative +// and apply immediately after host materialization rather than during allocation. +pub(crate) async fn bounded_secret_read( + read: F, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, SecretError> +where + F: Future, SecretError>>, +{ + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + + let result = read.await; + if deadline.is_expired() { + drop(result); + return Err(SecretError::DeadlineExceeded); + } + let value = result?; + + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + drop(value); + return Err(SecretError::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } // TODO: integration tests require the Spin runtime. diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 7e4af0ee..300c9e57 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -1,10 +1,6 @@ -// Adapter contract tests run on the Spin wasm32 target, matching the -// fastly and cloudflare contract suites. Gating the whole file keeps the -// host `cargo test`/`clippy` runs consistent across adapters. -#![cfg(all(feature = "spin", target_arch = "wasm32"))] - // Compile-time check: SpinKvStore and SpinSecretStore implement their // respective core store traits. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] mod store_trait_compile_checks { use edgezero_adapter_spin::key_value_store::SpinKvStore; use edgezero_adapter_spin::secret_store::SpinSecretStore; @@ -21,6 +17,14 @@ mod store_trait_compile_checks { } #[cfg(test)] +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg_attr( + feature = "test-utils", + expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" + ) +)] mod tests { // `from_core_response` tests live in a nested module so they're grouped // together; the `tests_outside_test_module` lint is satisfied by the @@ -105,6 +109,8 @@ mod tests { use bytes::Bytes; use edgezero_adapter_spin::context::SpinRequestContext; + #[cfg(feature = "test-utils")] + use edgezero_adapter_spin::request::deadline_body_releases_source_for_test; use edgezero_core::app::App; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; @@ -129,6 +135,10 @@ mod tests { } #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl ConfigStore for FixedConfigStore { async fn get(&self, key: &str) -> Result, ConfigStoreError> { if key == self.key { @@ -191,6 +201,10 @@ mod tests { } #[async_trait::async_trait(?Send)] + #[expect( + clippy::missing_trait_methods, + reason = "the test provider intentionally exercises the bounded-read compatibility default" + )] impl SecretStore for FixedSecretStore { async fn get_bytes( &self, @@ -207,7 +221,7 @@ mod tests { fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -216,12 +230,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx - .request() - .body() - .as_bytes() - .expect("buffered request body") - .to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -333,6 +342,12 @@ mod tests { let _app = build_test_app(); } + #[cfg(feature = "test-utils")] + #[test] + fn deadline_body_releases_source_when_timeout_is_emitted() { + assert!(deadline_body_releases_source_for_test()); + } + #[test] fn router_dispatches_get_and_returns_response() { let app = build_test_app(); @@ -536,4 +551,684 @@ mod tests { "no secret handle yields the no-handle marker" ); } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::collections::VecDeque; + use std::io; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + + use edgezero_adapter_spin::outbound::{ + SpinOutboundClient, deferred_clock_paths_hold_for_test, + }; + use edgezero_adapter_spin::request::{ + dispatch_ingress_stream_for_test, dispatch_request_for_test, + }; + use edgezero_adapter_spin::response::response_write_deadline_uses_injected_clock_for_test; + use edgezero_core::http::{HeaderMap, HeaderValue, Method}; + use edgezero_core::ingress::{AdmissionDecision, BufferedIngressResponse, IngressGrant}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::outbound::{OutboundHttpClient as _, OutboundRequest}; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; + use futures::FutureExt as _; + use futures::stream::{empty, poll_fn}; + use http_body_util::BodyExt as _; + use spin_sdk::http::{FromRequest as _, IntoRequest as _, Request as SpinRequest}; + + use super::*; + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + #[test] + fn outbound_clock_controls_preflight_elapsed_and_backwards_failure() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let forward_client = + SpinOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let forward_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let forward_results = block_on(forward_client.send_all(vec![forward_request])); + assert_eq!(forward_results.len(), 1); + let forward_result = forward_results.first().expect("single forward result"); + assert_eq!(forward_result.elapsed, Duration::from_millis(9)); + assert!(matches!( + forward_result.outcome, + Err(EdgeError::BadRequest { .. }) + )); + + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let backwards_client = + SpinOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let backwards_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let backwards_results = block_on(backwards_client.send_all(vec![backwards_request])); + assert_eq!(backwards_results.len(), 1); + let backwards_result = backwards_results.first().expect("single backwards result"); + assert_eq!(backwards_result.elapsed, Duration::ZERO); + assert!(matches!( + backwards_result.outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn send_all_preserves_three_preflight_slots_in_input_order() { + let now = MonotonicInstant::now(); + let client = SpinOutboundClient::with_clock(MonotonicClock::new(move || now)); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + let results = + block_on(client.send_all(vec![streamed_upload, streamed_response, method_error])); + let messages: Vec<_> = results + .iter() + .map(|slot| match &slot.outcome { + Err(EdgeError::BadRequest { message }) => message.as_str(), + other => panic!("expected preflight rejection, got {other:?}"), + }) + .collect(); + + assert_eq!( + messages, + [ + "send_all requires buffered request bodies; use send for a streamed upload", + "send_all requires buffered responses; use send for a streamed response", + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + ] + ); + assert!(results.iter().all(|slot| slot.elapsed == Duration::ZERO)); + } + + #[test] + fn deferred_request_and_response_paths_retain_the_injected_clock() { + assert!(block_on(deferred_clock_paths_hold_for_test())); + } + + #[test] + fn response_write_deadline_uses_the_injected_clock() { + assert!(block_on( + response_write_deadline_uses_injected_clock_for_test() + )); + } + + #[test] + fn standard_dispatch_installs_the_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client.send_all(vec![request]).await; + let result = results.first().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("missing outbound result")) + })?; + Ok(result.elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let source = empty::>(); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::GET, + "/clock".parse().expect("URI"), + source, + )) + .expect("Spin response"); + + let bytes = block_on(response.into_body().collect()) + .expect("provider body") + .to_bytes(); + assert_eq!(bytes.as_ref(), b"7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[test] + fn standard_native_request_refuses_before_body_read() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!(head.route_resolution(), RouteResolution::NotFound)); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("spin unavailable\n")) + .expect("refusal response"), + ) + }); + let outgoing_request = SpinRequest::builder() + .method("POST") + .uri("http://example.test/missing") + .body(http_body_util::Full::new(Bytes::from_static(b"abcde"))) + .expect("outgoing-compatible request"); + let wasi_request = outgoing_request.into_request().expect("WASI request"); + let incoming_request = + SpinRequest::from_request(wasi_request).expect("incoming Spin request"); + + let response = + block_on(dispatch_request_for_test(&app, incoming_request)).expect("Spin response"); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_terminal_response( + response, + StatusCode::SERVICE_UNAVAILABLE, + &expected_headers, + b"spin unavailable\n", + ); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + struct CountingMiddleware(Arc); + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"spin overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"spin timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn tracked_stream( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + expire_on_poll: Option<(Arc>, MonotonicInstant)>, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut pending_chunks = chunks.into_iter(); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if let Some((now, deadline)) = &expire_on_poll { + *now.lock().expect("clock lock") = *deadline; + } + Poll::Ready(pending_chunks.next().map(Ok)) + }) + } + + fn error_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut emitted = false; + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if emitted { + Poll::Ready(None) + } else { + emitted = true; + Poll::Ready(Some(Err(io::Error::other("spin ingress source failure")))) + } + }) + } + + fn pending_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }) + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn assert_terminal_response( + response: edgezero_adapter_spin::SpinFullResponse, + status: StatusCode, + expected_headers: &HeaderMap, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!(response.headers(), expected_headers); + let bytes = block_on(response.into_body().collect()) + .expect("provider body") + .to_bytes(); + assert_eq!(bytes.as_ref(), body); + } + + #[test] + fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_eq!(response.status(), expected_status); + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"spin overflow\0response", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn best_effort_pre_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::ZERO, &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"spin timeout response\n", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn best_effort_post_ready_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(1), &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_polls, + Some((now, deadline)), + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"spin timeout response\n", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn saturated_fallback_refuses_without_polling_body() { + for path in ["/missing", "/known"] { + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("spin unavailable\n")) + .expect("refusal response"), + ) + }); + let unobserved_grant = Arc::new(AtomicUsize::new(0)); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &unobserved_grant, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers(), &expected_headers); + let bytes = block_on(response.into_body().collect()) + .expect("provider body") + .to_bytes(); + assert_eq!(bytes.as_ref(), b"spin unavailable\n"); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(unobserved_grant.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn source_error_uses_spin_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = error_stream(&grant_drops, &source_drops, &body_polls); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + )) + .expect("standard error response"); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + &internal_error_headers(), + br#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn poll_then_drop_releases_pending_source_and_grant() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let outcome = dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + ) + .now_or_never(); + + assert!(outcome.is_none()); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +#[cfg(all(feature = "test-utils", not(target_arch = "wasm32")))] +mod outbound_contract_tests { + use bytes::Bytes; + use edgezero_adapter_spin::outbound::validate_batch_request_for_test; + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Uri}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn request() -> OutboundRequest { + OutboundRequest::new( + Method::POST, + "https://example.com/bid".parse::().expect("URI"), + ) + .expect("request") + } + + #[test] + fn send_all_preflight_precedence_and_indices() { + let requests = [ + request().body(Bytes::from_static(b"buffered")), + request().body(Body::stream(stream::once(async { + Bytes::from_static(b"streamed") + }))), + request().stream_response(), + request().body(Bytes::from_static(b"last")), + ]; + + let outcomes: Vec<_> = requests + .iter() + .map(validate_batch_request_for_test) + .collect(); + + outcomes[0].as_ref().expect("buffered slot 0"); + assert!(outcomes[1].is_err(), "streamed upload keeps slot index 1"); + assert!(outcomes[2].is_err(), "streamed response keeps slot index 2"); + outcomes[3].as_ref().expect("buffered slot 3"); + } } diff --git a/crates/edgezero-adapter-spin/tests/sdk_resources.rs b/crates/edgezero-adapter-spin/tests/sdk_resources.rs new file mode 100644 index 00000000..f1f9accc --- /dev/null +++ b/crates/edgezero-adapter-spin/tests/sdk_resources.rs @@ -0,0 +1,340 @@ +#![cfg(all(feature = "spin", feature = "test-utils", target_arch = "wasm32"))] + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "SDK construction checks stay before the exhaustive classifier fixture" +)] +mod tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_adapter_spin::outbound::{SpinOutboundClient, map_spin_send_error_for_test}; + use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; + use edgezero_core::outbound::{OutboundHttpClient, OutboundRequest}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::executor::block_on; + use spin_sdk::wasip3::http::types::{ + DnsErrorPayload, ErrorCode, FieldSizePayload, Fields, Request, RequestOptions, Response, + TlsAlertReceivedPayload, + }; + use spin_sdk::wasip3::http_compat::BodyWriter; + + fn assert_outbound_client() {} + + #[test] + fn spin_outbound_client_implements_portable_contract() { + assert_outbound_client::(); + } + + #[test] + fn injected_clock_controls_method_entry_and_preflight_elapsed() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([start, completed]))); + let clock_observations = Arc::clone(&observations); + let client = SpinOutboundClient::with_clock(MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + })); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all(vec![request])); + + assert_eq!(results[0].elapsed, Duration::from_millis(7)); + assert!(matches!( + results[0].outcome, + Err(EdgeError::BadRequest { .. }) + )); + } + + #[test] + fn sdk_fields_preserve_duplicate_values() { + let fields = Fields::new(); + fields + .append("set-cookie", b"first=1") + .expect("first field append"); + fields + .append("set-cookie", b"second=2") + .expect("second field append"); + + assert_eq!( + fields.get("set-cookie"), + vec![b"first=1".to_vec(), b"second=2".to_vec()] + ); + } + + #[test] + fn sdk_request_options_execute_all_setters() { + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(1_000_000)) + .expect("connect timeout is supported"); + options + .set_first_byte_timeout(Some(2_000_000)) + .expect("first-byte timeout is supported"); + options + .set_between_bytes_timeout(Some(3_000_000)) + .expect("between-bytes timeout is supported"); + } + + #[test] + fn sdk_request_and_response_completion_resources_construct() { + let (request_writer, request_body, request_trailers) = BodyWriter::new(); + let (request, request_done) = Request::new( + Fields::new(), + Some(request_body), + request_trailers, + Some(RequestOptions::new()), + ); + + let (response_writer, response_body, response_trailers) = BodyWriter::new(); + let (response, response_done) = + Response::new(Fields::new(), Some(response_body), response_trailers); + + drop((request, request_done, request_writer)); + drop((response, response_done, response_writer)); + } + + #[derive(Clone, Copy, Debug)] + enum ExpectedError { + BadRequest, + Internal, + Protocol, + Timeout, + Transport, + Unknown, + Unreachable, + } + + #[expect( + clippy::too_many_lines, + reason = "the pinned SDK table intentionally constructs every known ErrorCode variant" + )] + fn known_error_codes() -> Vec<(ErrorCode, ExpectedError)> { + let field = FieldSizePayload { + field_name: Some("x-test".to_owned()), + field_size: Some(1), + }; + vec![ + (ErrorCode::DnsTimeout, ExpectedError::Timeout), + ( + ErrorCode::DnsError(DnsErrorPayload { + rcode: Some("NXDOMAIN".to_owned()), + info_code: Some(3), + }), + ExpectedError::Unreachable, + ), + (ErrorCode::DestinationNotFound, ExpectedError::Unreachable), + ( + ErrorCode::DestinationUnavailable, + ExpectedError::Unreachable, + ), + ( + ErrorCode::DestinationIpProhibited, + ExpectedError::Unreachable, + ), + ( + ErrorCode::DestinationIpUnroutable, + ExpectedError::Unreachable, + ), + (ErrorCode::ConnectionRefused, ExpectedError::Unreachable), + (ErrorCode::ConnectionTerminated, ExpectedError::Transport), + (ErrorCode::ConnectionTimeout, ExpectedError::Timeout), + (ErrorCode::ConnectionReadTimeout, ExpectedError::Timeout), + (ErrorCode::ConnectionWriteTimeout, ExpectedError::Timeout), + ( + ErrorCode::ConnectionLimitReached, + ExpectedError::Unreachable, + ), + (ErrorCode::TlsProtocolError, ExpectedError::Unreachable), + (ErrorCode::TlsCertificateError, ExpectedError::Unreachable), + ( + ErrorCode::TlsAlertReceived(TlsAlertReceivedPayload { + alert_id: Some(42), + alert_message: Some("fixture".to_owned()), + }), + ExpectedError::Unreachable, + ), + (ErrorCode::HttpRequestDenied, ExpectedError::BadRequest), + ( + ErrorCode::HttpRequestLengthRequired, + ExpectedError::Internal, + ), + ( + ErrorCode::HttpRequestBodySize(Some(1)), + ExpectedError::BadRequest, + ), + (ErrorCode::HttpRequestMethodInvalid, ExpectedError::Internal), + (ErrorCode::HttpRequestUriInvalid, ExpectedError::Internal), + (ErrorCode::HttpRequestUriTooLong, ExpectedError::BadRequest), + ( + ErrorCode::HttpRequestHeaderSectionSize(Some(1)), + ExpectedError::BadRequest, + ), + ( + ErrorCode::HttpRequestHeaderSize(Some(field.clone())), + ExpectedError::BadRequest, + ), + ( + ErrorCode::HttpRequestTrailerSectionSize(Some(1)), + ExpectedError::Internal, + ), + ( + ErrorCode::HttpRequestTrailerSize(field.clone()), + ExpectedError::Internal, + ), + (ErrorCode::HttpResponseIncomplete, ExpectedError::Protocol), + ( + ErrorCode::HttpResponseHeaderSectionSize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseHeaderSize(field.clone()), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseBodySize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTrailerSectionSize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTrailerSize(field), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTransferCoding(Some("fixture".to_owned())), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseContentCoding(Some("fixture".to_owned())), + ExpectedError::Protocol, + ), + (ErrorCode::HttpResponseTimeout, ExpectedError::Timeout), + (ErrorCode::HttpUpgradeFailed, ExpectedError::Protocol), + (ErrorCode::HttpProtocolError, ExpectedError::Protocol), + (ErrorCode::LoopDetected, ExpectedError::Protocol), + (ErrorCode::ConfigurationError, ExpectedError::Internal), + ( + ErrorCode::InternalError(Some("fixture".to_owned())), + ExpectedError::Unknown, + ), + ] + } + + fn assert_expected_error(error: &EdgeError, expected: ExpectedError) { + match expected { + ExpectedError::BadRequest => assert!(matches!(error, EdgeError::BadRequest { .. })), + ExpectedError::Internal => assert!(matches!(error, EdgeError::Internal { .. })), + ExpectedError::Protocol => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )), + ExpectedError::Timeout => assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )), + ExpectedError::Transport => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )), + ExpectedError::Unknown => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )), + ExpectedError::Unreachable => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )), + } + } + + #[test] + fn spin_error_code_table_is_exhaustive() { + let now = MonotonicInstant::now(); + let live = Deadline::at_instant( + now.checked_add(Duration::from_secs(30)) + .expect("live deadline"), + ); + for (code, expected) in known_error_codes() { + let mapped = map_spin_send_error_for_test(&code, live, BudgetSource::Default, now); + assert_expected_error(&mapped, expected); + + let expired = map_spin_send_error_for_test( + &code, + Deadline::at_instant(now), + BudgetSource::BatchDeadline, + now, + ); + assert!(matches!( + expired, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + } + )); + } + } + + #[test] + fn spin_timeout_provenance_before_and_after_deadline() { + let now = MonotonicInstant::now(); + let live = Deadline::at_instant( + now.checked_add(Duration::from_secs(30)) + .expect("live deadline"), + ); + let early = map_spin_send_error_for_test( + &ErrorCode::DnsTimeout, + live, + BudgetSource::PerCallTimeout, + now, + ); + assert!(matches!( + early, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )); + + let unreachable = map_spin_send_error_for_test( + &ErrorCode::ConnectionRefused, + live, + BudgetSource::Default, + now, + ); + assert!(matches!( + unreachable, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )); + } +} diff --git a/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs b/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs new file mode 100644 index 00000000..1cdd17c2 --- /dev/null +++ b/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs @@ -0,0 +1,146 @@ +#![cfg(not(target_arch = "wasm32"))] + +extern crate self as spin_sdk; + +mod variables { + use std::fmt; + + use futures::future; + + #[derive(Debug)] + pub(crate) enum Error { + InvalidName(String), + Other, + Undefined(String), + } + + impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName(message) => write!(f, "invalid name: {message}"), + Self::Other => f.write_str("other"), + Self::Undefined(message) => write!(f, "undefined: {message}"), + } + } + } + + pub(crate) async fn get(_key: &str) -> Result { + future::pending().await + } +} + +#[path = "../src/secret_store.rs"] +mod secret_store; + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::mem; + use std::thread; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::secret_store::SecretError; + use edgezero_core::time::Deadline; + use futures::executor::block_on; + + use super::{secret_store, variables}; + + #[test] + fn source_harness_constructs_spin_types() { + let store = secret_store::SpinSecretStore::new(); + assert_eq!(mem::size_of_val(&store), 0); + let errors = [ + variables::Error::Undefined(String::new()), + variables::Error::InvalidName(String::new()), + variables::Error::Other, + ]; + for error in errors { + match error { + variables::Error::Undefined(message) | variables::Error::InvalidName(message) => { + assert!(message.is_empty()); + } + variables::Error::Other => {} + } + } + } + + #[test] + fn bounded_secret_reports_exact_bytes_and_accepts_exact_caps() { + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } + + #[test] + fn bounded_secret_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[test] + fn bounded_secret_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let error = block_on(secret_store::bounded_secret_read( + async { + polled.set(true); + Ok(None) + }, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_secret_checks_deadline_after_host_call() { + let error = block_on(secret_store::bounded_secret_read( + async { + thread::sleep(Duration::from_millis(10)); + Ok(None) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_deadline_wins_over_late_host_error() { + let error = block_on(secret_store::bounded_secret_read( + async { + thread::sleep(Duration::from_millis(10)); + Err(SecretError::Unavailable) + }, + Deadline::after(Duration::from_millis(1)), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } +} diff --git a/crates/edgezero-adapter/Cargo.toml b/crates/edgezero-adapter/Cargo.toml index bc234283..4f552eeb 100644 --- a/crates/edgezero-adapter/Cargo.toml +++ b/crates/edgezero-adapter/Cargo.toml @@ -15,6 +15,7 @@ default = [] cli = ["dep:toml"] [dependencies] +edgezero-core = { workspace = true } toml = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index a2d18c8a..b66414e2 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; +use edgezero_core::{Capability, CapabilitySupport}; + static REGISTRY: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -41,6 +43,53 @@ pub enum AdapterAction { Serve, } +/// Canonical application target selected by the CLI before adapter dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AdapterExecutionTarget { + app_root: PathBuf, + component: Option, + platform_manifest: Option, +} + +impl AdapterExecutionTarget { + /// Canonical application root used for this action. + #[must_use] + #[inline] + pub fn app_root(&self) -> &Path { + &self.app_root + } + + /// Optional platform component selected by the manifest. + #[must_use] + #[inline] + pub fn component(&self) -> Option<&str> { + self.component.as_deref() + } + + /// Construct a target after the caller has canonicalized and validated it. + #[must_use] + #[inline] + pub fn new( + app_root: PathBuf, + component: Option, + platform_manifest: Option, + ) -> Self { + Self { + app_root, + component, + platform_manifest, + } + } + + /// Canonical platform manifest selected for this action, when applicable. + #[must_use] + #[inline] + pub fn platform_manifest(&self) -> Option<&Path> { + self.platform_manifest.as_deref() + } +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -269,11 +318,18 @@ pub enum ReadConfigEntry { /// Interface implemented by adapter crates to integrate with the `EdgeZero` CLI. /// /// The non-`execute` methods carry the adapter's `config validate` -/// rules. They take primitive parameters (no `Manifest` / -/// `SecretField` from `edgezero-core`) so this crate stays dep-free -/// of `edgezero-core`. Defaults are no-ops; adapters override what -/// they actually need. +/// rules. Defaults are no-ops; adapters override what they actually need. pub trait Adapter: Sync + Send { + /// Report this adapter's support for an application capability. + /// + /// The fail-closed default prevents an adapter from accidentally claiming + /// support merely because a new capability was added to core. + #[must_use] + #[inline] + fn capability(&self, _capability: Capability) -> CapabilitySupport { + CapabilitySupport::Unsupported + } + /// Execute the requested action with optional adapter-specific args. /// /// `args` is a stringly-typed pass-through for arguments meant @@ -290,6 +346,27 @@ pub trait Adapter: Sync + Send { /// Returns an error string if the requested adapter action fails. fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + /// Execute against a target already selected and validated by the CLI. + /// + /// The default refuses rather than falling back to [`Self::execute`], which + /// could rediscover a different project from the process working directory. + /// + /// # Errors + /// Returns an explicit unsupported error unless the adapter implements + /// pinned-target dispatch. + #[inline] + fn execute_target( + &self, + _action: AdapterAction, + _target: &AdapterExecutionTarget, + _args: &[String], + ) -> Result<(), String> { + Err(format!( + "adapter `{}` does not support pinned execution targets", + self.name() + )) + } + /// Reclaim chunk entries that no LIVE config pointer references. /// /// Deliberately NOT part of `config push`. On an eventually-consistent @@ -666,6 +743,35 @@ mod tests { name: &'static str, } + struct CapabilityAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only the behavior under test" + )] + impl Adapter for CapabilityAdapter { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "Capability is non-exhaustive and this fixture defaults all untested capabilities to unsupported" + )] + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::LazyStreamedResponsePassthrough => CapabilitySupport::BestEffort, + Capability::OutboundDeadlines => CapabilitySupport::BoundedCooperative, + Capability::OutboundHttp => CapabilitySupport::Native, + _ => CapabilitySupport::Unsupported, + } + } + + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "capability-fixture" + } + } + #[expect( clippy::missing_trait_methods, reason = "TestAdapter only exercises register / get / execute; the validation methods inherit the trait defaults (no-ops)" @@ -687,6 +793,47 @@ mod tests { HIT.store(0, Ordering::SeqCst); } + #[test] + fn adapter_capability_default_is_unsupported() { + assert_eq!( + FIRST.capability(Capability::OutboundHttp), + CapabilitySupport::Unsupported + ); + } + + #[test] + fn adapter_capability_reports_each_support_level() { + let adapter = CapabilityAdapter; + assert_eq!( + adapter.capability(Capability::OutboundHttp), + CapabilitySupport::Native + ); + assert_eq!( + adapter.capability(Capability::OutboundDeadlines), + CapabilitySupport::BoundedCooperative + ); + assert_eq!( + adapter.capability(Capability::LazyStreamedResponsePassthrough), + CapabilitySupport::BestEffort + ); + assert_eq!( + adapter.capability(Capability::OutboundCompleteResourceAccounting), + CapabilitySupport::Unsupported + ); + } + + #[test] + fn adapter_execute_target_default_refuses_rediscovery() { + let _guard = TEST_LOCK.lock().expect("lock"); + reset(); + let target = AdapterExecutionTarget::new(PathBuf::from("/tmp"), None, None); + let error = FIRST + .execute_target(AdapterAction::Build, &target, &[]) + .expect_err("default target execution must fail closed"); + assert!(error.contains("pinned execution targets")); + assert_eq!(HIT.load(Ordering::SeqCst), 0); + } + #[test] fn registers_and_fetches_adapter() { let _guard = TEST_LOCK.lock().expect("lock"); diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 54dc6253..9e9a4d64 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,5 +1,9 @@ use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; -use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; +use edgezero_core::manifest::{ + CapabilitySupport, Manifest, ManifestContract, ManifestLoader, ResolvedEnvironment, +}; + +use crate::manifest_source::{ResolvedAdapterTarget, ResolvedRuntime}; use std::env; use std::fmt; @@ -163,6 +167,10 @@ pub fn execute( /// the inherited stdio, so there is nothing for us to capture and the /// caller must fall back to another source of truth (for Fastly deploy: /// the Fastly API). +#[expect( + dead_code, + reason = "retained operational dispatcher API; runtime-producing actions use execute_capture_runtime" +)] pub fn execute_capture( adapter_name: &str, action: Action, @@ -190,20 +198,183 @@ pub fn execute_capture( Ok(None) } -/// Whether `action` for `adapter_name` resolves to a manifest-declared -/// shell command (rather than the registered adapter's built-in logic). -/// -/// Callers use this to decide whether an EdgeZero-internal directive -/// (e.g. `--manifest-path`, understood only by the built-in adapter) is -/// safe to thread into `adapter_args`: a manifest shell command receives -/// those args verbatim and would choke on a flag its own CLI lacks. -pub fn has_manifest_command( - manifest_loader: Option<&ManifestLoader>, +/// Dispatch a runtime-producing action against one pre-resolved target/contract pair. +pub(crate) fn execute_runtime( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + ensure_action_capabilities(runtime)?; + execute_after_gate(runtime, adapter_args) +} + +/// Capturing variant of [`execute_runtime`] for manifest shell commands. +pub(crate) fn execute_capture_runtime( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + ensure_action_capabilities(runtime)?; + if let ResolvedAdapterTarget::Shell(shell) = runtime.target() { + return run_shell_tee( + shell.command(), + shell.root(), + runtime.adapter_name(), + runtime.action(), + Some(shell.environment().clone()), + (shell.bind_host().map(str::to_owned), shell.bind_port()), + adapter_args, + ) + .map(Some); + } + execute_registered_after_gate(runtime, adapter_args)?; + Ok(None) +} + +fn ensure_action_capabilities(runtime: &ResolvedRuntime) -> Result<(), String> { + if !produces_current_runtime(runtime.action()) { + return Err("operational action entered outbound runtime dispatcher".to_owned()); + } + ensure_capabilities( + runtime.adapter_name(), + ManifestContract::from_opt(runtime.manifest()), + ) +} + +fn execute_after_gate(runtime: &ResolvedRuntime, adapter_args: &[String]) -> Result<(), String> { + match runtime.target() { + ResolvedAdapterTarget::Registered(_target) => { + execute_registered_after_gate(runtime, adapter_args) + } + ResolvedAdapterTarget::Shell(shell) => run_shell( + shell.command(), + shell.root(), + runtime.adapter_name(), + runtime.action(), + Some(shell.environment().clone()), + (shell.bind_host().map(str::to_owned), shell.bind_port()), + adapter_args, + ), + } +} + +fn execute_registered_after_gate( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + let ResolvedAdapterTarget::Registered(target) = runtime.target() else { + return Err("registered runtime dispatcher received a shell target".to_owned()); + }; + let adapter = adapter_registry::get_adapter(runtime.adapter_name()).ok_or_else(|| { + let available = adapter_registry::registered_adapters(); + if available.is_empty() { + format!( + "adapter `{}` is not registered (no adapters available)", + runtime.adapter_name() + ) + } else { + format!( + "adapter `{}` is not registered (available: {})", + runtime.adapter_name(), + available.join(", ") + ) + } + })?; + adapter.execute_target(AdapterAction::from(runtime.action()), target, adapter_args) +} + +pub(crate) fn ensure_capabilities( adapter_name: &str, - action: Action, -) -> bool { - manifest_loader - .is_some_and(|loader| manifest_command(loader.manifest(), adapter_name, action).is_some()) + contract: ManifestContract<'_>, +) -> Result<(), String> { + let verified_manifest = match contract { + ManifestContract::Malformed(reason) => { + return Err(format!( + "capability check aborted: {reason}. This is an EdgeZero/app! contract bug; the baked manifest is unreadable, so required capabilities cannot be verified. Refusing to proceed rather than silently skipping enforcement." + )); + } + ManifestContract::None => return Ok(()), + ManifestContract::Present(present_manifest) => present_manifest, + _ => { + return Err( + "capability check aborted: unrecognized manifest-contract state. Refusing to proceed rather than skipping enforcement." + .to_owned(), + ); + } + }; + let capabilities = &verified_manifest.capabilities; + let Some(adapter) = adapter_registry::get_adapter(adapter_name) else { + if capabilities.required.is_empty() { + if capabilities.optional.is_empty() { + log::warn!( + "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)" + ); + } else { + log::warn!( + "adapter '{adapter_name}' not in registry; cannot verify its OPTIONAL capabilities; proceeding, since optional capabilities never hard-fail" + ); + } + return Ok(()); + } + return Err(format!( + "adapter '{adapter_name}' is not in the registry; cannot verify REQUIRED capabilities. Register an adapter stub that returns capability metadata, or move those entries to `optional`." + )); + }; + + let mut best_effort = Vec::new(); + let mut unsupported = Vec::new(); + for capability in capabilities.required.iter().copied() { + match adapter.capability(capability) { + CapabilitySupport::BestEffort => best_effort.push(capability.as_str()), + CapabilitySupport::BoundedCooperative => log::info!( + "adapter '{adapter_name}': required capability '{}' is bounded-cooperative; see capability docs for the bound", + capability.as_str() + ), + CapabilitySupport::Native => {} + CapabilitySupport::Unsupported | _ => unsupported.push(capability.as_str()), + } + } + if !unsupported.is_empty() { + return Err(format!( + "adapter '{adapter_name}' does not support required capabilities: {}", + unsupported.join(", ") + )); + } + if !best_effort.is_empty() { + return Err(format!( + "adapter '{adapter_name}': required capabilities are only best-effort: {}. See https://edgezero.dev/guide/capabilities and declare them `optional` only when the documented limitation is acceptable.", + best_effort.join(", ") + )); + } + + for capability in capabilities.optional.iter().copied() { + match adapter.capability(capability) { + CapabilitySupport::BestEffort => log::warn!( + "adapter '{adapter_name}': optional capability '{}' is best-effort; see https://edgezero.dev/guide/capabilities", + capability.as_str() + ), + CapabilitySupport::BoundedCooperative | CapabilitySupport::Native => {} + CapabilitySupport::Unsupported => log::warn!( + "adapter '{adapter_name}': optional capability '{}' unavailable", + capability.as_str() + ), + _ => log::warn!( + "adapter '{adapter_name}': optional capability '{}' reports an unrecognized support level; treating as degraded", + capability.as_str() + ), + } + } + Ok(()) +} + +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaged | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } } fn manifest_command<'manifest>( @@ -441,12 +612,59 @@ fn shell_join(args: &[String]) -> String { #[cfg(test)] mod tests { - use super::{ResolvedEnvironment, apply_environment}; + use super::{ResolvedEnvironment, apply_environment, ensure_capabilities}; use crate::test_support::manifest_guard; - use edgezero_core::manifest::ResolvedEnvironmentBinding; + use edgezero_adapter::registry::{Adapter, AdapterAction, register_adapter}; + use edgezero_core::manifest::{ + Capability, CapabilitySupport, ManifestContract, ManifestLoader, ResolvedEnvironmentBinding, + }; use edgezero_core::test_env::EnvOverride; use std::process::Command; + static BEST_EFFORT_ADAPTER: GateAdapter = GateAdapter { + name: "gate-best-effort", + support: CapabilitySupport::BestEffort, + }; + static BOUNDED_ADAPTER: GateAdapter = GateAdapter { + name: "gate-bounded", + support: CapabilitySupport::BoundedCooperative, + }; + static NATIVE_ADAPTER: GateAdapter = GateAdapter { + name: "gate-native", + support: CapabilitySupport::Native, + }; + static UNSUPPORTED_ADAPTER: GateAdapter = GateAdapter { + name: "gate-unsupported", + support: CapabilitySupport::Unsupported, + }; + + struct GateAdapter { + name: &'static str, + support: CapabilitySupport, + } + + #[expect( + clippy::missing_trait_methods, + reason = "capability-gate fixture overrides only the relevant trait methods" + )] + impl Adapter for GateAdapter { + fn capability(&self, _capability: Capability) -> CapabilitySupport { + self.support + } + + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + self.name + } + } + + fn capability_manifest(section: &str) -> ManifestLoader { + ManifestLoader::load_from_str(&format!("[capabilities]\n{section}\n[adapters.gate]\n")) + } + #[test] fn apply_environment_sets_defaults_and_checks_secrets() { let _lock = manifest_guard().lock().expect("env lock"); @@ -553,6 +771,78 @@ mod tests { ); } + #[test] + fn capability_gate_accepts_native_and_bounded_required_support() { + register_adapter(&NATIVE_ADAPTER); + register_adapter(&BOUNDED_ADAPTER); + let loader = capability_manifest("required = [\"outbound-http\"]"); + for name in [NATIVE_ADAPTER.name, BOUNDED_ADAPTER.name] { + assert_eq!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))), + Ok(()) + ); + } + } + + #[test] + fn capability_gate_fails_closed_for_malformed_contract() { + let result = ensure_capabilities( + "gate-missing-malformed", + ManifestContract::Malformed("fixture corruption"), + ); + assert!(result.is_err_and(|error| error.contains("fixture corruption"))); + } + + #[test] + fn capability_gate_rejects_best_effort_and_unsupported_required_support() { + register_adapter(&BEST_EFFORT_ADAPTER); + register_adapter(&UNSUPPORTED_ADAPTER); + let loader = capability_manifest("required = [\"outbound-http\"]"); + for name in [BEST_EFFORT_ADAPTER.name, UNSUPPORTED_ADAPTER.name] { + assert!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))) + .is_err() + ); + } + } + + #[test] + fn capability_gate_treats_missing_registry_by_requirement_level() { + let required = capability_manifest("required = [\"outbound-http\"]"); + let optional = capability_manifest("optional = [\"outbound-http\"]"); + assert!( + ensure_capabilities( + "gate-missing-required", + ManifestContract::from_opt(Some(required.manifest())) + ) + .is_err() + ); + assert_eq!( + ensure_capabilities( + "gate-missing-optional", + ManifestContract::from_opt(Some(optional.manifest())) + ), + Ok(()) + ); + assert_eq!( + ensure_capabilities("gate-missing-none", ManifestContract::None), + Ok(()) + ); + } + + #[test] + fn optional_capability_degradation_never_hard_fails() { + register_adapter(&BEST_EFFORT_ADAPTER); + register_adapter(&UNSUPPORTED_ADAPTER); + let loader = capability_manifest("optional = [\"outbound-http\"]"); + for name in [BEST_EFFORT_ADAPTER.name, UNSUPPORTED_ADAPTER.name] { + assert_eq!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))), + Ok(()) + ); + } + } + #[test] fn shell_escape_quotes_and_spaces() { assert_eq!(super::shell_escape("plain"), "plain"); diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 3efe5a4c..0f703642 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -25,5 +25,6 @@ pub fn run_demo() -> Result<(), String> { use app_demo_core::App; use edgezero_adapter_axum::dev_server::run_app; + crate::demo_capability_gate::()?; run_app::().map_err(|err| format!("demo server error: {err}")) } diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index f566e76b..7431f404 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -805,8 +805,10 @@ fn initialize_git_repo(out_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use edgezero_core::Capability; use edgezero_core::app_config::app_name_prefix; - use edgezero_core::test_env::PathPrepend as PathOverride; + use edgezero_core::manifest::ManifestLoader; + use edgezero_core::test_env::{PathPrepend as PathOverride, env_lock}; use std::path::Path; use tempfile::TempDir; @@ -1012,6 +1014,77 @@ mod tests { } } + fn with_generated_demo_app(assertions: impl FnOnce(&Path)) { + let _lock = env_lock().lock().expect("env lock"); + let temp = TempDir::new().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + write_git_stub(&bin_dir); + let _path_guard = PathOverride::new(&bin_dir); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + }; + generate_new(&args).expect("scaffold succeeds"); + assertions(&temp.path().join("demo-app")); + } + + #[test] + fn generated_manifest_declares_outbound_http_optional() { + with_generated_demo_app(|project_dir| { + let source = + fs::read_to_string(project_dir.join("edgezero.toml")).expect("read manifest"); + let manifest = ManifestLoader::load_from_str(&source); + assert_eq!( + manifest.manifest().capabilities.optional, + [Capability::OutboundHttp] + ); + let hosts = manifest + .manifest() + .capabilities + .outbound + .hosts + .as_deref() + .expect("generated outbound hosts"); + assert_eq!(hosts, ["https://*:*"]); + + let readme = fs::read_to_string(project_dir.join("README.md")).expect("read README.md"); + assert!( + readme.contains("may fail at runtime"), + "generated README must explain optional capability behavior" + ); + assert!( + readme.contains("promote `outbound-http` to `required`"), + "generated README must explain how to require outbound success" + ); + }); + } + + #[test] + fn generated_spin_hosts_default_to_https_only() { + with_generated_demo_app(|project_dir| { + let source = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-spin/spin.toml")) + .expect("read spin.toml"); + let manifest: toml::Value = toml::from_str(&source).expect("parse spin.toml"); + let hosts = manifest + .get("component") + .and_then(|components| components.get("demo-app-adapter-spin")) + .and_then(|component| component.get("allowed_outbound_hosts")) + .and_then(toml::Value::as_array) + .expect("allowed outbound hosts"); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].as_str(), Some("https://*:*")); + assert!( + hosts.iter().all(|host| { + host.as_str() + .is_some_and(|value| !value.starts_with("http://")) + }), + "generated Spin manifest must not grant cleartext implicitly" + ); + }); + } + fn assert_scaffold_files(project_dir: &Path) { assert!(project_dir.is_dir(), "project directory created"); assert!(project_dir.join("Cargo.toml").exists()); @@ -1124,6 +1197,10 @@ mod tests { ); } + #[expect( + clippy::too_many_lines, + reason = "the scaffold contract is clearest as one exhaustive generated-workspace assertion" + )] fn assert_scaffold_workspace(project_dir: &Path) { let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); @@ -1156,6 +1233,27 @@ mod tests { let manifest = fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); + let manifest_value: toml::Value = toml::from_str(&manifest).expect("parse edgezero.toml"); + assert_eq!( + manifest_value + .get("capabilities") + .and_then(|capabilities| capabilities.get("optional")) + .and_then(toml::Value::as_array) + .and_then(|values| values.first()) + .and_then(toml::Value::as_str), + Some("outbound-http"), + "generated manifest must explicitly opt into portable outbound HTTP" + ); + assert_eq!( + manifest_value + .get("capabilities") + .and_then(|capabilities| capabilities.get("outbound")) + .and_then(|outbound| outbound.get("hosts")) + .and_then(toml::Value::as_array) + .and_then(|values| values.first()) + .and_then(toml::Value::as_str), + Some("https://*:*") + ); assert!(manifest.contains("[adapters.cloudflare.adapter]")); assert!(manifest.contains("[adapters.fastly.adapter]")); assert!( @@ -1266,7 +1364,117 @@ mod tests { /// templates shipped a production `.expect(...)` in the `stream` handler, /// infallible `IntoResponse` test usage, and adapter host stubs that /// tripped `print_stderr` / `exit`. + fn fallback_initializer_block(core_lib: &str) -> &str { + const VARIANT: &str = "AdmissionDecision::ReadBodyBeforeFallback"; + let variant_start = core_lib + .find(VARIANT) + .expect("fallback decision initializer"); + let after_variant = core_lib + .get(variant_start..) + .expect("fallback decision start boundary"); + let open_brace = after_variant + .find('{') + .and_then(|offset| variant_start.checked_add(offset)) + .expect("fallback decision opening brace"); + let mut depth = 0_usize; + + let after_open_brace = core_lib + .get(open_brace..) + .expect("fallback opening brace boundary"); + for (offset, character) in after_open_brace.char_indices() { + match character { + '{' => depth = depth.checked_add(1).expect("fallback brace depth"), + '}' => { + depth = depth.checked_sub(1).expect("balanced fallback braces"); + if depth == 0 { + let block_end = open_brace + .checked_add(offset) + .and_then(|end| end.checked_add(character.len_utf8())) + .expect("fallback initializer end"); + return core_lib + .get(variant_start..block_end) + .expect("fallback initializer boundaries"); + } + } + _ => {} + } + } + + panic!("fallback decision closing brace"); + } + + fn normalize_source_whitespace(source: &str) -> String { + source.split_whitespace().collect::>().join(" ") + } + + fn assert_generated_fallback_policy(core_lib: &str) { + let fallback = normalize_source_whitespace(fallback_initializer_block(core_lib)); + assert!( + core_lib.contains("FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024") + && fallback + .contains("grant: IngressGrant::new(AdmissionLease { route_class: None })") + && fallback.contains("max_body_bytes: FALLBACK_INGRESS_BODY_BYTES") + && fallback.contains(concat!( + "on_exceeded: BufferedIngressResponse::text( ", + "StatusCode::BAD_REQUEST, ", + "\"request body too large\\n\", ),", + )) + && fallback.contains(concat!( + "on_timeout: BufferedIngressResponse::text( ", + "StatusCode::REQUEST_TIMEOUT, ", + "\"request timeout\\n\", ),", + )), + "generated admission policy must own the fallback lease and exact terminal responses", + ); + } + + #[test] + #[should_panic( + expected = "generated admission policy must own the fallback lease and exact terminal responses" + )] + fn generated_fallback_policy_rejects_swapped_terminal_mappings() { + assert_generated_fallback_policy( + r#"AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(AdmissionLease { route_class: None }), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + on_exceeded: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + } + const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ),"#, + ); + } + fn assert_generated_sources_are_lint_clean(project_dir: &Path) { + let core_lib = fs::read_to_string(project_dir.join("crates/demo-app-core/src/lib.rs")) + .expect("read core lib.rs"); + assert!( + core_lib.contains("configure = crate::configure_app"), + "generated app macro must install the lifecycle configuration callback", + ); + assert!( + core_lib.contains("set_ingress_admission_policy"), + "generated app must configure ingress admission", + ); + assert!( + core_lib.contains("IngressGrant::new"), + "generated admission policy must issue an app-owned grant", + ); + assert_generated_fallback_policy(&core_lib); + let handlers = fs::read_to_string(project_dir.join("crates/demo-app-core/src/handlers.rs")) .expect("read handlers.rs"); assert!( @@ -1281,6 +1489,53 @@ mod tests { handlers.contains(".into_response()"), "handler tests must use the fallible IntoResponse pattern", ); + assert!( + handlers.contains("HttpClient::with_client(TestOutboundClient)"), + "generated tests must execute the portable outbound client", + ); + assert!( + handlers.contains("fn generated_outbound_http_smoke()"), + "generated core must contain the outbound smoke sentinel", + ); + for required in [ + ".max_request_body_bytes(", + ".max_encoded_response_bytes(", + ".max_decoded_response_bytes(", + ".max_response_bytes(", + ".max_response_header_bytes(", + ".max_response_header_count(", + ".max_chunk_bytes(", + ".max_brotli_window_bits(", + ".max_brotli_decoder_bytes(", + ".timeout(", + "send_all(", + "slot.elapsed", + ] { + assert!( + handlers.contains(required), + "generated outbound example must contain `{required}`", + ); + } + + let manifest = + fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); + assert!( + manifest.contains("path = \"/admission\"") + && manifest.contains("class = \"diagnostic\""), + "generated manifest must wire the admission diagnostic class", + ); + assert!( + manifest.contains("path = \"/fanout\"") && manifest.contains("class = \"outbound\""), + "generated manifest must wire the outbound fanout class", + ); + + let spin_manifest = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-spin/spin.toml")) + .expect("read spin.toml"); + assert!( + spin_manifest.contains("allowed_outbound_hosts = [\"https://*:*\"]"), + "generated Spin hosts must default to HTTPS only", + ); let axum_main = fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) @@ -1301,24 +1556,13 @@ mod tests { #[test] fn generate_new_scaffolds_workspace_layout() { - let temp = TempDir::new().expect("temp dir"); - let bin_dir = temp.path().join("bin"); - write_git_stub(&bin_dir); - let _path_guard = PathOverride::new(&bin_dir); - - let args = NewArgs { - name: "demo-app".into(), - dir: Some(temp.path().to_string_lossy().into_owned()), - }; - - generate_new(&args).expect("scaffold succeeds"); - - let project_dir = temp.path().join("demo-app"); - assert_scaffold_files(&project_dir); - assert_scaffold_workspace(&project_dir); - assert_scaffold_app_config(&project_dir); - assert_scaffold_crate_lints(&project_dir); - assert_scaffold_cli_full_command_set(&project_dir); + with_generated_demo_app(|project_dir| { + assert_scaffold_files(project_dir); + assert_scaffold_workspace(project_dir); + assert_scaffold_app_config(project_dir); + assert_scaffold_crate_lints(project_dir); + assert_scaffold_cli_full_command_set(project_dir); + }); } /// The scaffolded `-cli` must diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index cbe17c49..1d9ae42e 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -32,6 +32,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod manifest_source; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; @@ -58,8 +60,10 @@ pub use provision::run_provision; use args::{ ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, }; +#[cfg(any(test, feature = "demo-example"))] +use edgezero_core::app::Hooks; #[cfg(feature = "cli")] -use edgezero_core::manifest::ManifestLoader; +use edgezero_core::manifest::{Manifest, ManifestLoader}; #[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] @@ -138,17 +142,11 @@ pub fn init_cli_logger() { #[cfg(feature = "cli")] #[inline] pub fn run_build(args: &BuildArgs) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - if let Some(loader) = &manifest { - log_store_bindings(&args.adapter, loader); + let runtime = manifest_source::resolve_runtime(&args.adapter, adapter::Action::Build)?; + if let Some(manifest) = runtime.manifest() { + log_store_bindings(runtime.adapter_name(), manifest); } - adapter::execute( - &args.adapter, - adapter::Action::Build, - manifest.as_ref(), - &args.adapter_args, - ) + adapter::execute_runtime(&runtime, &args.adapter_args) } /// Deploy the project to a target edge adapter. @@ -181,9 +179,6 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { )); } - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - // Thread `--service-id` into the adapter invocation // when provided, ahead of any operator passthrough args. Fastly // consumes it; adapters that don't need a service id ignore it. @@ -192,6 +187,8 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { } else { adapter::Action::Deploy }; + let runtime = manifest_source::resolve_runtime(&args.adapter, action)?; + let adapter_name = runtime.adapter_name().to_owned(); let mut passthrough: Vec = Vec::new(); // Thread the manifest-configured platform manifest path (resolved @@ -210,13 +207,6 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // command already runs in the manifest root and picks its own // project directory. (Staged deploys are never manifest-declared // commands, so they always get the flag.) - if !adapter::has_manifest_command(manifest.as_ref(), &args.adapter, action) - && let Some(manifest_path) = - resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter)? - { - passthrough.push("--manifest-path".to_owned()); - passthrough.push(manifest_path); - } if let Some(service_id) = &args.service_id { passthrough.push("--service-id".to_owned()); passthrough.push(service_id.clone()); @@ -229,8 +219,8 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // adapter reads config usage from THIS list, never a remote probe — // avoiding a lookup that fails open. One inline token per store; the // adapter strips them before `fastly compute update`. - if let Some(loader) = manifest.as_ref() - && let Some(config) = loader.manifest().stores.config.as_ref() + if let Some(manifest) = runtime.manifest() + && let Some(config) = manifest.stores.config.as_ref() { for id in &config.ids { passthrough.push(format!("--edgezero-staging-config={id}")); @@ -240,12 +230,7 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // package to a new draft, mark it staged, and emit the staged // version. Never runs the manifest `deploy` // command, which would activate production. - return adapter::execute( - &args.adapter, - adapter::Action::DeployStaged, - manifest.as_ref(), - &passthrough, - ); + return adapter::execute_runtime(&runtime, &passthrough); } // Production deploy also emits the activated version @@ -264,13 +249,8 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // (`EmitVersion`), which needs a live API + a real token. // 3. If BOTH fail: a clear `Err`. We never silently emit an empty // version — that was the original bug. - if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - let captured = adapter::execute_capture( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; + if args.service_id.is_some() && adapter_name.eq_ignore_ascii_case("fastly") { + let captured = adapter::execute_capture_runtime(&runtime, &passthrough)?; if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { log::info!("version={version}"); return Ok(()); @@ -283,9 +263,9 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { let mut emit_args = passthrough.clone(); emit_args.push("--require-active".to_owned()); return adapter::execute( - &args.adapter, + &adapter_name, adapter::Action::EmitVersion, - manifest.as_ref(), + None, &emit_args, ) .map_err(|err| { @@ -297,12 +277,7 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { }); } - adapter::execute( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - ) + adapter::execute_runtime(&runtime, &passthrough) } /// Parse an activated service version out of a deploy command's output. @@ -388,6 +363,7 @@ fn parse_native_version_mention(output: &str) -> Option { /// required to be a regular file beneath the (canonicalized) manifest /// root, and any escape is a hard error. #[cfg(feature = "cli")] +#[cfg(test)] fn resolve_adapter_manifest_path( loader: Option<&ManifestLoader>, adapter: &str, @@ -562,14 +538,8 @@ pub fn run_active_version(args: &ActiveVersionArgs) -> Result<(), String> { #[cfg(feature = "cli")] #[inline] pub fn run_serve(args: &ServeArgs) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - adapter::execute( - &args.adapter, - adapter::Action::Serve, - manifest.as_ref(), - &[], - ) + let runtime = manifest_source::resolve_runtime(&args.adapter, adapter::Action::Serve)?; + adapter::execute_runtime(&runtime, &[]) } /// Create a new `EdgeZero` app skeleton. @@ -597,10 +567,14 @@ pub fn run_demo() -> Result<(), String> { demo_server::run_demo() } +#[cfg(any(test, feature = "demo-example"))] +fn demo_capability_gate() -> Result<(), String> { + adapter::ensure_capabilities("axum", Application::manifest().as_contract()) +} + #[cfg(feature = "cli")] -fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { - let manifest_data = manifest.manifest(); - if !manifest_data.secret_store_enabled(adapter_name) { +fn store_bindings_message(adapter_name: &str, manifest: &Manifest) -> Option { + if !manifest.secret_store_enabled(adapter_name) { return None; } @@ -625,7 +599,7 @@ fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Opti } #[cfg(feature = "cli")] -fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { +fn log_store_bindings(adapter_name: &str, manifest: &Manifest) { if let Some(message) = store_bindings_message(adapter_name, manifest) { log::info!("{message}"); } @@ -680,11 +654,36 @@ fn load_manifest_optional() -> Result, String> { mod tests { use super::*; use crate::test_support::{BASIC_MANIFEST, EnvOverride, manifest_guard}; - use edgezero_core::manifest::ManifestLoader; + use edgezero_core::app::Hooks; + use edgezero_core::manifest::{BakedManifest, Manifest, ManifestLoader}; + use edgezero_core::router::RouterService; use std::fs; use std::path::Path; use tempfile::TempDir; + struct CapabilityGateApp; + + #[expect( + clippy::missing_trait_methods, + reason = "test hook overrides only routes and the baked manifest under test" + )] + impl Hooks for CapabilityGateApp { + fn manifest() -> BakedManifest { + Manifest::from_baked_json( + r#"{"capabilities":{"required":["outbound-complete-resource-accounting"]}}"#, + ) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn demo_capability_gate_runs_before_server_start() { + assert!(demo_capability_gate::().is_err()); + } + #[test] fn load_manifest_optional_hard_errors_when_explicit_env_path_missing() { // An explicit `EDGEZERO_MANIFEST` pointing at a missing file must @@ -1072,19 +1071,20 @@ ids = ["MY_SECRETS"] "#, ); - let axum = store_bindings_message("axum", &loader).expect("axum message"); + let axum = store_bindings_message("axum", loader.manifest()).expect("axum message"); assert!(axum.contains("environment variables")); - let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); + let cloudflare = + store_bindings_message("cloudflare", loader.manifest()).expect("cloudflare message"); assert!(cloudflare.contains("wrangler")); - let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); + let fastly = store_bindings_message("fastly", loader.manifest()).expect("fastly message"); assert!(fastly.contains("secrets enabled")); } #[test] fn store_bindings_message_is_absent_without_secret_store() { let loader = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(store_bindings_message("fastly", &loader).is_none()); + assert!(store_bindings_message("fastly", loader.manifest()).is_none()); } } diff --git a/crates/edgezero-cli/src/manifest_source.rs b/crates/edgezero-cli/src/manifest_source.rs new file mode 100644 index 00000000..7d8a7028 --- /dev/null +++ b/crates/edgezero-cli/src/manifest_source.rs @@ -0,0 +1,488 @@ +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use edgezero_adapter::cli_support::{find_workspace_root, path_distance}; +use edgezero_adapter::registry::AdapterExecutionTarget; +use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; + +use crate::adapter::Action; + +pub(crate) enum ResolvedAdapterTarget { + Registered(AdapterExecutionTarget), + Shell(ResolvedShellTarget), +} + +pub(crate) struct ResolvedManifest { + loader: ManifestLoader, + path: PathBuf, +} + +pub(crate) struct ResolvedRuntime { + action: Action, + adapter: String, + contract: Option, + target: ResolvedAdapterTarget, +} + +pub(crate) struct ResolvedShellTarget { + bind_host: Option, + bind_port: Option, + command: String, + environment: ResolvedEnvironment, + root: PathBuf, +} + +impl ResolvedManifest { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "read-only accessor is part of the resolved-manifest contract and exercised by focused tests" + ) + )] + #[inline] + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl ResolvedRuntime { + #[inline] + pub(crate) fn action(&self) -> Action { + self.action + } + + #[inline] + pub(crate) fn adapter_name(&self) -> &str { + &self.adapter + } + + #[inline] + pub(crate) fn manifest(&self) -> Option<&Manifest> { + self.contract + .as_ref() + .map(|contract| contract.loader.manifest()) + } + + #[inline] + pub(crate) fn target(&self) -> &ResolvedAdapterTarget { + &self.target + } +} + +impl ResolvedShellTarget { + #[inline] + pub(crate) fn bind_host(&self) -> Option<&str> { + self.bind_host.as_deref() + } + + #[inline] + pub(crate) fn bind_port(&self) -> Option { + self.bind_port + } + + #[inline] + pub(crate) fn command(&self) -> &str { + &self.command + } + + #[inline] + pub(crate) fn environment(&self) -> &ResolvedEnvironment { + &self.environment + } + + #[inline] + pub(crate) fn root(&self) -> &Path { + &self.root + } +} + +pub(crate) fn resolve_runtime(adapter: &str, action: Action) -> Result { + let invocation_dir = env::current_dir() + .map_err(|error| format!("failed to read invocation directory: {error}"))?; + resolve_runtime_from( + adapter, + action, + &invocation_dir, + env::var_os("EDGEZERO_MANIFEST"), + ) +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("failed to resolve {label} {}: {error}", path.display()))?; + if !canonical.is_dir() { + return Err(format!( + "{label} {} is not a directory", + canonical.display() + )); + } + Ok(canonical) +} + +fn canonical_regular_file(path: &Path, label: &str) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("failed to resolve {label} {}: {error}", path.display()))?; + if !canonical.is_file() { + return Err(format!( + "{label} {} is not a regular file", + canonical.display() + )); + } + Ok(canonical) +} + +#[expect( + clippy::filetype_is_file, + reason = "manifest discovery intentionally excludes symbolic links before canonicalization" +)] +fn collect_named_files(root: &Path, name: &str, depth: usize, output: &mut Vec) { + if depth == 0 { + return; + } + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_file() && path.file_name().is_some_and(|file_name| file_name == name) { + output.push(path); + } else if file_type.is_dir() + && !matches!( + path.file_name().and_then(|file_name| file_name.to_str()), + Some(".git" | "target") + ) + { + collect_named_files(&path, name, depth.saturating_sub(1), output); + } else { + // Other files and excluded directories are not manifest candidates. + } + } +} + +fn command_for(manifest: &Manifest, adapter: &str, action: Action) -> Option { + let (_canonical, config) = manifest.adapter_entry(adapter)?; + match action { + Action::Build => config.commands.build.clone(), + Action::Deploy => config.commands.deploy.clone(), + Action::Serve => config.commands.serve.clone(), + Action::DeployStaged + | Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => None, + } +} + +fn discover_default_manifest(invocation_dir: &Path) -> Result, String> { + for ancestor in invocation_dir.ancestors() { + let candidate = ancestor.join("edgezero.toml"); + if candidate.is_file() { + return canonical_regular_file(&candidate, "application manifest").map(Some); + } + } + + let workspace = canonical_directory(&find_workspace_root(invocation_dir), "workspace root")?; + let mut candidates = Vec::new(); + collect_named_files(&workspace, "edgezero.toml", 9, &mut candidates); + choose_nearest(invocation_dir, candidates, "application manifest") +} + +fn discover_platform_manifest( + adapter: &str, + invocation_dir: &Path, +) -> Result, String> { + let name = platform_manifest_name(adapter); + for ancestor in invocation_dir.ancestors() { + let candidate = ancestor.join(name); + if candidate.is_file() && ancestor.join("Cargo.toml").is_file() { + return canonical_regular_file(&candidate, "platform manifest").map(Some); + } + } + let workspace = canonical_directory(&find_workspace_root(invocation_dir), "workspace root")?; + let mut candidates = Vec::new(); + collect_named_files(&workspace, name, 9, &mut candidates); + candidates.retain(|path| { + path.parent() + .is_some_and(|parent| parent.join("Cargo.toml").is_file()) + }); + choose_nearest(invocation_dir, candidates, "platform manifest") +} + +fn choose_nearest( + invocation_dir: &Path, + candidates: Vec, + label: &str, +) -> Result, String> { + let mut ranked = candidates + .into_iter() + .map(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + (path_distance(invocation_dir, parent), path) + }) + .collect::>(); + ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + let Some((distance, selected)) = ranked.first() else { + return Ok(None); + }; + if let Some((_next_distance, next)) = ranked.get(1).filter(|next| next.0 == *distance) { + return Err(format!( + "ambiguous {label} selection: {} and {} are equally near the invocation directory", + selected.display(), + next.display() + )); + } + canonical_regular_file(selected, label).map(Some) +} + +fn load_resolved_manifest(path: PathBuf) -> Result { + let loader = ManifestLoader::from_path(&path) + .map_err(|error| format!("failed to load {}: {error}", path.display()))?; + Ok(ResolvedManifest { loader, path }) +} + +fn platform_manifest_from_contract( + manifest: &Manifest, + adapter: &str, + app_root: &Path, +) -> Result, String> { + let Some((_canonical, config)) = manifest.adapter_entry(adapter) else { + return Err(format!( + "adapter `{adapter}` is not configured in {}", + app_root.join("edgezero.toml").display() + )); + }; + let Some(relative) = config.adapter.manifest.as_deref() else { + return Ok(None); + }; + let relative_path = Path::new(relative); + if relative_path.is_absolute() { + return Err(format!( + "adapter `{adapter}` platform manifest must be relative to the application root" + )); + } + let platform = canonical_regular_file( + &app_root.join(relative_path), + &format!("adapter `{adapter}` platform manifest"), + )?; + if !platform.starts_with(app_root) { + return Err(format!( + "adapter `{adapter}` platform manifest {} resolves outside application root {}", + platform.display(), + app_root.display() + )); + } + Ok(Some(platform)) +} + +fn platform_manifest_name(adapter: &str) -> &'static str { + if adapter.eq_ignore_ascii_case("axum") { + "axum.toml" + } else if adapter.eq_ignore_ascii_case("cloudflare") { + "wrangler.toml" + } else if adapter.eq_ignore_ascii_case("fastly") { + "fastly.toml" + } else if adapter.eq_ignore_ascii_case("spin") { + "spin.toml" + } else { + "edgezero-platform.toml" + } +} + +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaged | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } +} + +fn resolve_contract_path( + invocation_dir: &Path, + explicit: Option, +) -> Result, String> { + if let Some(raw) = explicit { + let configured = PathBuf::from(raw); + let candidate = if configured.is_absolute() { + configured + } else { + invocation_dir.join(configured) + }; + return canonical_regular_file(&candidate, "explicit application manifest").map(Some); + } + discover_default_manifest(invocation_dir) +} + +fn resolve_runtime_from( + adapter: &str, + action: Action, + invocation_dir: &Path, + explicit: Option, +) -> Result { + if !produces_current_runtime(action) { + return Err("operational action entered outbound runtime resolver".to_owned()); + } + let canonical_invocation_dir = canonical_directory(invocation_dir, "invocation directory")?; + let contract_path = resolve_contract_path(&canonical_invocation_dir, explicit)?; + let contract = contract_path.map(load_resolved_manifest).transpose()?; + + if let Some(resolved_manifest) = contract { + let manifest = resolved_manifest.loader.manifest(); + let (canonical_adapter, config) = manifest.adapter_entry(adapter).ok_or_else(|| { + format!( + "adapter `{adapter}` is not configured in {}", + resolved_manifest.path.display() + ) + })?; + let app_root = canonical_directory( + manifest + .root() + .ok_or_else(|| "resolved manifest has no application root".to_owned())?, + "application root", + )?; + let adapter_name = canonical_adapter.to_ascii_lowercase(); + let target = if let Some(command) = command_for(manifest, &adapter_name, action) { + ResolvedAdapterTarget::Shell(ResolvedShellTarget { + bind_host: config.adapter.host.clone(), + bind_port: config.adapter.port, + command, + environment: manifest.environment_for(&adapter_name), + root: app_root, + }) + } else { + let platform_manifest = + platform_manifest_from_contract(manifest, &adapter_name, &app_root)?; + ResolvedAdapterTarget::Registered(AdapterExecutionTarget::new( + app_root, + config.adapter.component.clone(), + platform_manifest, + )) + }; + return Ok(ResolvedRuntime { + action, + adapter: adapter_name, + contract: Some(resolved_manifest), + target, + }); + } + + let platform_manifest = discover_platform_manifest(adapter, &canonical_invocation_dir)?; + let app_root = platform_manifest + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or(canonical_invocation_dir); + Ok(ResolvedRuntime { + action, + adapter: adapter.to_ascii_lowercase(), + contract: None, + target: ResolvedAdapterTarget::Registered(AdapterExecutionTarget::new( + app_root, + None, + platform_manifest, + )), + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use tempfile::TempDir; + + use super::*; + + fn write_manifest(root: &Path, adapter: &str, platform: &str, command: Option<&str>) { + fs::create_dir_all(root).expect("create app root"); + fs::write(root.join(platform), "# platform fixture\n").expect("write platform manifest"); + let command_section = command.map_or_else(String::new, |configured_command| { + format!("\n[adapters.{adapter}.commands]\nbuild = {configured_command:?}\n") + }); + fs::write( + root.join("edgezero.toml"), + format!( + "[app]\nname = \"fixture\"\n[adapters.{adapter}.adapter]\nmanifest = {platform:?}\n{command_section}" + ), + ) + .expect("write app manifest"); + fs::write(root.join("Cargo.toml"), "[workspace]\n").expect("write workspace manifest"); + } + + #[test] + fn explicit_manifest_is_authoritative() { + let temp = TempDir::new().expect("temp dir"); + let invocation = temp.path().join("invocation"); + let selected = temp.path().join("selected"); + write_manifest(&invocation, "axum", "axum.toml", None); + write_manifest(&selected, "fastly", "fastly.toml", Some("printf selected")); + + let runtime = resolve_runtime_from( + "fastly", + Action::Build, + &invocation, + Some(OsString::from("../selected/edgezero.toml")), + ) + .expect("resolve explicit manifest"); + let expected_path = + fs::canonicalize(selected.join("edgezero.toml")).expect("canonical selected"); + assert_eq!(runtime.adapter_name(), "fastly"); + assert_eq!( + runtime.contract.as_ref().map(ResolvedManifest::path), + Some(expected_path.as_path()) + ); + assert!(matches!(runtime.target(), ResolvedAdapterTarget::Shell(_))); + } + + #[test] + fn runtime_resolver_rejects_cross_app_pair() { + let temp = TempDir::new().expect("temp dir"); + let selected = temp.path().join("selected"); + let other = temp.path().join("other"); + fs::create_dir_all(&selected).expect("selected root"); + fs::create_dir_all(&other).expect("other root"); + fs::write(other.join("fastly.toml"), "# other\n").expect("platform"); + fs::write(selected.join("Cargo.toml"), "[workspace]\n").expect("cargo"); + fs::write( + selected.join("edgezero.toml"), + "[app]\nname = \"fixture\"\n[adapters.fastly.adapter]\nmanifest = \"../other/fastly.toml\"\n", + ) + .expect("manifest"); + + let result = resolve_runtime_from( + "fastly", + Action::Build, + &selected, + Some(OsString::from("edgezero.toml")), + ); + assert!(result.is_err_and(|error| error.contains("outside application root"))); + } + + #[test] + fn runtime_resolver_rejects_equal_distance_ambiguity() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("Cargo.toml"), "[workspace]\n").expect("workspace"); + write_manifest(&temp.path().join("left"), "axum", "axum.toml", None); + write_manifest(&temp.path().join("right"), "axum", "axum.toml", None); + + let result = resolve_runtime_from("axum", Action::Build, temp.path(), None); + assert!(result.is_err_and(|error| error.contains("ambiguous application manifest"))); + } + + #[test] + fn runtime_resolver_rejects_operational_action() { + let temp = TempDir::new().expect("temp dir"); + let result = resolve_runtime_from("axum", Action::AuthStatus, temp.path(), None); + assert!(result.is_err_and(|error| error.contains("operational action"))); + } +} diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index 5efa10fc..4f74d48f 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -20,7 +20,11 @@ use edgezero_cli::args::{ use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; #[derive(Parser, Debug)] -#[command(name = "{{proj_cli}}", version, about = "{{name}} edge CLI")] +#[command( + name = "{{proj_cli}}", + version, + about = "{{name}} edge CLI" +)] struct Args { #[command(subcommand)] cmd: Cmd, @@ -69,6 +73,12 @@ enum {{NameUpperCamel}}ConfigCmd { /// store and report changes. Exits 0 (no changes), 1 (changes with /// `--exit-code`), or 2 (unsupported / error). Diff(ConfigDiffArgs), + /// Reclaim orphaned chunk entries the config store leaked from prior + /// oversized pushes. Store-derived and untyped (no `{{NameUpperCamel}}Config`). + /// A dry-run by default; deletes only with `--yes` + an explicit + /// `--older-than` (YOUR assertion that nothing superseded within that + /// window is still being served, and no push is running). + Gc(ConfigGcArgs), /// Push `{{name}}.toml` as a single blob envelope to the /// adapter's config store. The blob carries every field verbatim /// (Model A -- `#[secret]` fields store the key NAME, @@ -78,12 +88,6 @@ enum {{NameUpperCamel}}ConfigCmd { /// Validate `edgezero.toml` and `{{name}}.toml` against the /// typed `{{NameUpperCamel}}Config` contract. Validate(ConfigValidateArgs), - /// Reclaim orphaned chunk entries the config store leaked from prior - /// oversized pushes. Store-derived and untyped (no `{{NameUpperCamel}}Config`). - /// A dry-run by default; deletes only with `--yes` + an explicit - /// `--older-than` (YOUR assertion that nothing superseded within that - /// window is still being served, and no push is running). - Gc(ConfigGcArgs), } fn main() { @@ -105,15 +109,15 @@ fn main() { Err(err) => Err(err), } } + // `gc` inspects the STORE, not the typed config, so it is not + // parameterised over `{{NameUpperCamel}}Config`. + Cmd::Config({{NameUpperCamel}}ConfigCmd::Gc(args)) => edgezero_cli::run_config_gc(&args), Cmd::Config({{NameUpperCamel}}ConfigCmd::Push(args)) => { edgezero_cli::run_config_push_typed::<{{NameUpperCamel}}Config>(&args) } Cmd::Config({{NameUpperCamel}}ConfigCmd::Validate(args)) => { edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) } - // `gc` inspects the STORE, not the typed config, so it is not - // parameterised over `{{NameUpperCamel}}Config`. - Cmd::Config({{NameUpperCamel}}ConfigCmd::Gc(args)) => edgezero_cli::run_config_gc(&args), Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), Cmd::New(args) => edgezero_cli::run_new(&args), diff --git a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs index c990f915..743f3c2a 100644 --- a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs @@ -4,13 +4,38 @@ use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::extractor::{Headers, Json, Path}; -use edgezero_core::http::{self, Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{self, Method, Response, StatusCode, Uri}; +use edgezero_core::outbound::{OutboundRequest, OutboundSlotResult}; use edgezero_core::response::Text; +use edgezero_core::time::Deadline; use futures::{StreamExt as _, stream}; use std::env; +use std::io::Error as IoError; +use std::num::NonZeroU64; +use std::time::Duration; + +use crate::AdmissionLease; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const MAX_BROTLI_DECODER_BYTES: u64 = 0x0200_0000; +const MAX_BROTLI_WINDOW_BITS: u8 = 24; +const MAX_DECODED_RESPONSE_BYTES: u64 = 0x0010_0000; +const MAX_ENCODED_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_FANOUT_INPUT_BYTES: usize = 0x4000; +const MAX_FANOUT_REQUESTS: usize = 8; +const MAX_FINAL_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_OUTBOUND_REQUEST_BODY_BYTES: u64 = 0x0010_0000; +const MAX_RESPONSE_CHUNK_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_COUNT: u64 = 100; +const OUTBOUND_BATCH_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(serde::Serialize)] +struct AdmissionView { + grant_consumed_once: bool, + route_class: Option, +} #[derive(serde::Deserialize)] pub struct EchoBody { @@ -22,6 +47,25 @@ pub struct EchoParams { pub name: String, } +#[derive(serde::Deserialize)] +struct FanoutRequest { + paths: Vec, +} + +#[derive(serde::Serialize)] +struct FanoutSlot { + elapsed_ms: u128, + index: usize, + outcome: FanoutSlotOutcome, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum FanoutSlotOutcome { + Error { category: &'static str, status: u16 }, + Response { status: u16 }, +} + #[derive(serde::Deserialize)] struct ProxyPath { #[serde(default)] @@ -65,23 +109,81 @@ pub async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } +#[action] +pub async fn admission(RequestContext(ctx): RequestContext) -> Result { + let route_class = ctx + .route_metadata() + .and_then(|metadata| metadata.class()) + .map(str::to_owned); + let grant = ctx + .take_ingress_grant() + .ok_or_else(|| EdgeError::internal(IoError::other("admission grant was not installed")))?; + let lease = grant + .downcast::() + .map_err(|_grant| EdgeError::internal(IoError::other("admission grant type mismatch")))?; + if lease.route_class != route_class { + return Err(EdgeError::internal(IoError::other( + "admission grant route class mismatch", + ))); + } + + json_response(&AdmissionView { + grant_consumed_once: ctx.take_ingress_grant().is_none(), + route_class, + }) +} + +#[action] +pub async fn fanout(RequestContext(ctx): RequestContext) -> Result { + let Some(client) = ctx.http_client() else { + return proxy_not_available_response(); + }; + let input: FanoutRequest = ctx.json_within(MAX_FANOUT_INPUT_BYTES).await?; + if input.paths.len() > MAX_FANOUT_REQUESTS { + return Err(EdgeError::validation(format!( + "fanout accepts at most {MAX_FANOUT_REQUESTS} paths" + ))); + } + + let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); + let now = ctx.monotonic_clock().now(); + let deadline = Deadline::at_instant(now.checked_add(OUTBOUND_BATCH_BUDGET).unwrap_or(now)); + let source_uri = Uri::from_static("/fanout"); + let requests = input + .paths + .into_iter() + .map(|path| { + let target = build_proxy_target(&base, &path, &source_uri)?; + Ok(outbound_policy(OutboundRequest::new(Method::GET, target)?).deadline(deadline)) + }) + .collect::, EdgeError>>()?; + let slots = client.send_all(requests).await; + let output = slots + .into_iter() + .enumerate() + .map(|(index, slot)| fanout_slot(index, slot)) + .collect::>(); + + json_response(&output) +} + #[action] pub async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { let params: ProxyPath = ctx.path()?; - let proxy_handle = ctx.proxy_handle(); - let request = ctx.into_request(); - let target = build_proxy_target(¶ms.rest, request.uri())?; - let proxy_request = ProxyRequest::from_request(request, target); + let http_client = ctx.http_client(); + let request = ctx.into_request()?; + let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); + let target = build_proxy_target(&base, ¶ms.rest, request.uri())?; + let outbound_request = outbound_policy(OutboundRequest::from_request(request, target)?); - if let Some(handle) = proxy_handle { - handle.forward(proxy_request).await + if let Some(client) = http_client { + client.send(outbound_request).await?.into_response() } else { proxy_not_available_response() } } -fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result { - let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); +fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result { let mut target = base.trim_end_matches('/').to_owned(); let trimmed_rest = rest.trim_start_matches('/'); if !trimmed_rest.is_empty() { @@ -101,10 +203,50 @@ fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result .map_err(|err| EdgeError::bad_request(format!("invalid proxy target URI: {err}"))) } +fn error_category(status: StatusCode) -> &'static str { + match status { + StatusCode::BAD_REQUEST => "bad_request", + StatusCode::BAD_GATEWAY => "bad_gateway", + StatusCode::GATEWAY_TIMEOUT => "gateway_timeout", + StatusCode::REQUEST_TIMEOUT => "request_timeout", + StatusCode::UNPROCESSABLE_ENTITY => "validation", + _ => "error", + } +} + +fn fanout_slot(index: usize, slot: OutboundSlotResult) -> FanoutSlot { + let outcome = match slot.outcome { + Ok(response) => FanoutSlotOutcome::Response { + status: response.status().as_u16(), + }, + Err(error) => FanoutSlotOutcome::Error { + category: error_category(error.status()), + status: error.status().as_u16(), + }, + }; + FanoutSlot { + elapsed_ms: slot.elapsed.as_millis(), + index, + outcome, + } +} + +fn outbound_policy(request: OutboundRequest) -> OutboundRequest { + request + .max_brotli_decoder_bytes(MAX_BROTLI_DECODER_BYTES) + .max_brotli_window_bits(MAX_BROTLI_WINDOW_BITS) + .max_decoded_response_bytes(MAX_DECODED_RESPONSE_BYTES) + .max_encoded_response_bytes(MAX_ENCODED_RESPONSE_BYTES) + .max_request_body_bytes(MAX_OUTBOUND_REQUEST_BODY_BYTES) + .max_response_bytes(MAX_FINAL_RESPONSE_BYTES) + .max_chunk_bytes(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + .max_response_header_bytes(MAX_RESPONSE_HEADER_BYTES) + .max_response_header_count(MAX_RESPONSE_HEADER_COUNT) + .timeout(OUTBOUND_REQUEST_TIMEOUT) +} + fn proxy_not_available_response() -> Result { - let body = Body::text( - "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", - ); + let body = Body::text("outbound HTTP is not enabled for this adapter build"); http::response_builder() .status(StatusCode::NOT_IMPLEMENTED) .header("content-type", "text/plain; charset=utf-8") @@ -112,6 +254,18 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +fn json_response(value: &T) -> Result +where + T: serde::Serialize, +{ + let body = Body::json(value).map_err(EdgeError::internal)?; + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + // `cfg.api_token` is the RESOLVED secret value (not the // key name). The framework's secret walk populated it at // extract time; the handler uses it directly. No @@ -133,28 +287,113 @@ fn proxy_not_available_response() -> Result { mod tests { use super::*; use async_trait::async_trait; + use edgezero_core::BudgetSource; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; - use edgezero_core::http::{Method, StatusCode, Uri, request_builder}; + use edgezero_core::http::{HeaderMap, Method, StatusCode, Uri, request_builder}; + use edgezero_core::outbound::{ + HttpClient, OutboundHttpClient, OutboundRequestParts, OutboundResponse, OutboundSlotResult, + ResponseMode, + }; use edgezero_core::params::PathParams; - use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; - use edgezero_core::test_env::{EnvOverride, env_lock}; + use edgezero_core::test_env::env_lock; use futures::executor::block_on; use std::collections::HashMap; + use std::time::Duration; - struct TestProxyClient; + struct TestOutboundClient; #[async_trait(?Send)] - impl ProxyClient for TestProxyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, uri, _headers, _body, _) = request.into_parts(); - assert!(uri.to_string().contains("status/201")); - Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + impl OutboundHttpClient for TestOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + let parts = request.into_parts(); + assert_eq!(parts.method, Method::GET); + assert!(parts.uri.to_string().contains("status/201")); + assert!(parts.deadline.is_none()); + assert_outbound_policy(&parts); + response_for(parts) + } + + async fn send_all(&self, requests: Vec) -> Vec { + let mut deadline = None; + let mut results = Vec::with_capacity(requests.len()); + for (index, request) in requests.into_iter().enumerate() { + let parts = request.into_parts(); + assert_outbound_policy(&parts); + let slot_deadline = parts.deadline.expect("batch deadline"); + if let Some(expected) = deadline { + assert_eq!(slot_deadline.instant(), expected); + } else { + deadline = Some(slot_deadline.instant()); + } + let elapsed = Duration::from_millis( + u64::try_from(index).expect("slot index").saturating_add(1), + ); + results.push(OutboundSlotResult::new(elapsed, response_for(parts))); + } + results } } + fn assert_outbound_policy(parts: &OutboundRequestParts) { + assert_eq!(parts.timeout, Some(OUTBOUND_REQUEST_TIMEOUT)); + assert_eq!( + parts.max_request_body_bytes, + MAX_OUTBOUND_REQUEST_BODY_BYTES + ); + assert_eq!( + parts.max_encoded_response_bytes, + Some(MAX_ENCODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.max_decoded_response_bytes, + Some(MAX_DECODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: MAX_FINAL_RESPONSE_BYTES, + } + ); + assert_eq!( + parts.max_response_header_bytes, + Some(MAX_RESPONSE_HEADER_BYTES) + ); + assert_eq!( + parts.max_response_header_count, + Some(MAX_RESPONSE_HEADER_COUNT) + ); + assert_eq!( + parts.max_chunk_bytes, + Some(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + ); + assert_eq!(parts.max_brotli_window_bits, MAX_BROTLI_WINDOW_BITS); + assert_eq!(parts.max_brotli_decoder_bytes, MAX_BROTLI_DECODER_BYTES); + } + + fn response_for(parts: OutboundRequestParts) -> Result { + if parts.uri.path() == "/fail" { + return Err(EdgeError::gateway_timeout_caused( + "provider URL https://user:token@example.invalid", + BudgetSource::BatchDeadline, + )); + } + + let status = match parts.uri.path() { + "/status/201" => StatusCode::CREATED, + "/status/204" => StatusCode::NO_CONTENT, + _ => StatusCode::OK, + }; + Ok(OutboundResponse::new( + parts.method, + status, + HeaderMap::new(), + Body::empty(), + )) + } + #[test] fn root_returns_static_body() { let ctx = empty_context("/"); @@ -227,16 +466,56 @@ mod tests { #[test] fn build_proxy_target_merges_segments_and_query() { - let _lock = env_lock().lock().expect("env lock"); - let _env = EnvOverride::set("API_BASE_URL", "https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); - let target = build_proxy_target("status/200", &original).expect("target uri"); + let target = build_proxy_target("https://example.com/api", "status/200", &original) + .expect("target uri"); assert_eq!( target.to_string(), "https://example.com/api/status/200?foo=bar" ); } + #[test] + fn fanout_reports_positional_elapsed_and_typed_outcomes() { + let ctx = fanout_context(r#"{"paths":["status/200","status/204","fail"]}"#); + let response = block_on(fanout(ctx)).expect("fanout response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert!( + !String::from_utf8_lossy(response.body().as_bytes().expect("buffered")) + .contains("token@example.invalid") + ); + + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload[0]["index"], 0_i64); + assert_eq!(payload[0]["elapsed_ms"], 1_i64); + assert_eq!(payload[0]["outcome"]["kind"], "response"); + assert_eq!(payload[0]["outcome"]["status"], 200_i64); + assert_eq!(payload[1]["index"], 1_i64); + assert_eq!(payload[1]["elapsed_ms"], 2_i64); + assert_eq!(payload[1]["outcome"]["status"], 204_i64); + assert_eq!(payload[2]["index"], 2_i64); + assert_eq!(payload[2]["elapsed_ms"], 3_i64); + assert_eq!(payload[2]["outcome"]["kind"], "error"); + assert_eq!(payload[2]["outcome"]["category"], "gateway_timeout"); + assert_eq!(payload[2]["outcome"]["status"], 504_i64); + } + + #[test] + fn fanout_with_empty_input_returns_an_empty_array() { + let ctx = fanout_context(r#"{"paths":[]}"#); + let response = block_on(fanout(ctx)).expect("fanout response"); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload, serde_json::json!([])); + } + + #[test] + fn fanout_rejects_more_than_eight_paths() { + let ctx = fanout_context(r#"{"paths":["1","2","3","4","5","6","7","8","9"]}"#); + let error = block_on(fanout(ctx)).expect_err("oversized fanout must fail"); + assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + #[test] fn proxy_demo_without_handle_returns_placeholder() { let _lock = env_lock().lock().expect("env lock"); @@ -246,7 +525,7 @@ mod tests { } #[test] - fn proxy_demo_uses_injected_handle() { + fn generated_outbound_http_smoke() { let _lock = env_lock().lock().expect("env lock"); let mut request = request_builder() @@ -256,7 +535,7 @@ mod tests { .expect("request"); request .extensions_mut() - .insert(ProxyHandle::with_client(TestProxyClient)); + .insert(HttpClient::with_client(TestOutboundClient)); let mut params = HashMap::new(); params.insert("rest".to_owned(), "status/201".to_owned()); @@ -275,6 +554,18 @@ mod tests { RequestContext::new(request, PathParams::default()) } + fn fanout_context(json: &str) -> RequestContext { + let mut request = request_builder() + .method(Method::POST) + .uri("/fanout") + .body(Body::from(json)) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(TestOutboundClient)); + RequestContext::new(request, PathParams::default()) + } + fn context_with_params(path: &str, params: &[(&str, &str)]) -> RequestContext { let request = request_builder() .method(Method::GET) diff --git a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs index dee67bfe..5aea4d60 100644 --- a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs @@ -1,4 +1,139 @@ pub mod config; mod handlers; -edgezero_core::app!("../../edgezero.toml"); +use std::time::Duration; + +use edgezero_core::app::App as EdgeZeroApp; +use edgezero_core::http::StatusCode; +use edgezero_core::{AdmissionDecision, BufferedIngressResponse, IngressGrant, RouteResolution}; + +const DEFAULT_INGRESS_READ_BUDGET: Duration = Duration::from_secs(30); +const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; +const FALLBACK_INGRESS_READ_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_INGRESS_READ_BUDGET: Duration = Duration::from_secs(10); + +#[derive(Debug, Eq, PartialEq)] +struct AdmissionLease { + route_class: Option, +} + +/// Installs request-lifecycle policy before any adapter begins polling a body. +fn configure_app(app: &mut EdgeZeroApp) { + app.set_ingress_admission_policy(|head| match head.route_resolution().clone() { + RouteResolution::Matched(metadata) => { + let route_class = metadata.class().map(str::to_owned); + let read_budget = if route_class.as_deref() == Some("outbound") { + OUTBOUND_INGRESS_READ_BUDGET + } else { + DEFAULT_INGRESS_READ_BUDGET + }; + AdmissionDecision::Admit { + grant: IngressGrant::new(AdmissionLease { route_class }), + read_deadline: head.read_deadline_after(read_budget), + } + } + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound | _ => { + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(AdmissionLease { route_class: None }), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + read_deadline: head.read_deadline_after(FALLBACK_INGRESS_READ_BUDGET), + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + } + } + }); +} + +edgezero_core::app!("../../edgezero.toml", configure = crate::configure_app); + +#[cfg(test)] +mod lifecycle_tests { + use edgezero_core::app::{App as EdgeZeroApp, Hooks as _}; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderMap, Method, Version, request_builder}; + use edgezero_core::ingress::{IngressBeginOutcome, IngressHeadParts}; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + use std::time::Duration; + + #[test] + fn manifest_route_classes_reach_route_resolution() { + let resolved = crate::build_router().resolve(&Method::GET, "/proxy/status/200"); + let RouteResolution::Matched(metadata) = resolved.resolution().clone() else { + panic!("proxy route must resolve"); + }; + assert_eq!(metadata.class(), Some("outbound")); + } + + #[test] + fn configured_admission_uses_finite_class_aware_deadlines() { + let app = super::App::build_app(); + let start = MonotonicInstant::now(); + + let outbound = begin_ingress(&app, "/proxy/status/200", start); + assert_eq!( + outbound.read_deadline().instant(), + start + .checked_add(Duration::from_secs(10)) + .expect("deadline") + ); + + let health = begin_ingress(&app, "/", start); + assert_eq!( + health.read_deadline().instant(), + start + .checked_add(Duration::from_secs(30)) + .expect("deadline") + ); + + let fallback = begin_ingress(&app, "/missing", start); + assert_eq!( + fallback.read_deadline().instant(), + start.checked_add(Duration::from_secs(5)).expect("deadline") + ); + } + + #[test] + fn admission_handler_consumes_the_typed_grant_once() { + let app = super::App::build_app(); + let start = MonotonicInstant::now(); + let prepared = begin_ingress(&app, "/admission", start); + let request = request_builder() + .method(Method::GET) + .uri("/admission") + .body(Body::empty()) + .expect("request"); + + let response = block_on(app.dispatch_admitted(prepared, request)) + .expect("dispatch") + .into_response(); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload["route_class"], "diagnostic"); + assert_eq!(payload["grant_consumed_once"], true); + } + + fn begin_ingress( + app: &EdgeZeroApp, + path: &str, + start: MonotonicInstant, + ) -> edgezero_core::PreparedIngress { + let head = IngressHeadParts::new( + Method::GET, + path.parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + match app.begin_ingress(head, start).expect("begin ingress") { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(_) => panic!("demo policy must admit request"), + _ => panic!("unknown admission outcome"), + } + } +} diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 810a010b..170679e2 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -13,7 +13,29 @@ This workspace demonstrates a multi-target EdgeZero app. - `GET /headers` - prints the incoming `user-agent` - `GET /stream` - emits a short streamed response body - `POST /echo` - echoes JSON payloads -- `GET|POST /proxy/{*rest}` - forwards requests to `API_BASE_URL`, falling back to a 501 when the active adapter does not expose a proxy client +- `GET /admission` - confirms route-aware admission and one-time grant consumption +- `GET|POST /proxy/{*rest}` - sends the request to `API_BASE_URL`, falling back to a 501 when the active adapter does not inject an outbound HTTP client +- `POST /fanout` - sends up to eight relative paths concurrently and returns index-aligned status, error category, and per-slot elapsed milliseconds + +## Outbound HTTP + +The generated `edgezero.toml` declares `outbound-http` as optional and grants +HTTPS destinations only. An optional capability accepts documented behavioral +deviations and may fail at runtime when a deployment prerequisite has not been +verified. If application behavior depends on successful outbound requests, +promote `outbound-http` to `required` and choose an adapter and deployment whose +capability check passes. See the +[capability guide](https://stackpop.github.io/edgezero/guide/capabilities) for +the current support matrix and deployment prerequisites. + +Both outbound examples set a 5-second timeout, a 1 MiB request-body cap, +independent 2 MiB encoded / 1 MiB decoded / 2 MiB final-buffer response caps, +64 KiB / 100-field response-header caps, and explicit Brotli window and decoder +memory limits. `/fanout` also gives every slot one shared absolute deadline and +returns the adapter-recorded elapsed time for that slot. This elapsed value +includes EdgeZero and provider processing rather than representing pure wire +RTT. Cross-slot timing isolation is adapter-dependent; deployments that require +it must declare `send-all-slot-isolation` as a required capability. ## Dev diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index c8e53cfa..9ae5ead4 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -7,12 +7,19 @@ version = "0.1.0" kind = "http" entry = "crates/{{proj_core}}" +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://*:*"] + [[triggers.http]] id = "root" path = "/" methods = ["GET"] handler = "{{proj_core_mod}}::handlers::root" adapters = [{{{adapter_list}}}] +class = "health" description = "Default health-check endpoint" [[triggers.http]] @@ -44,6 +51,14 @@ methods = ["POST"] handler = "{{proj_core_mod}}::handlers::echo_json" adapters = [{{{adapter_list}}}] +[[triggers.http]] +id = "admission" +path = "/admission" +methods = ["GET"] +handler = "{{proj_core_mod}}::handlers::admission" +adapters = [{{{adapter_list}}}] +class = "diagnostic" +description = "Confirms route-aware admission and one-time grant consumption" [[triggers.http]] id = "proxy_demo" @@ -51,6 +66,16 @@ path = "/proxy/{*rest}" methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +class = "outbound" + +[[triggers.http]] +id = "fanout" +path = "/fanout" +methods = ["POST"] +handler = "{{proj_core_mod}}::handlers::fanout" +adapters = [{{{adapter_list}}}] +class = "outbound" +description = "Concurrent positional outbound batch with per-slot elapsed time" # -- Introspection routes ------------------------------------------------------ @@ -60,6 +85,7 @@ path = "/_{{name}}/manifest" methods = ["GET"] handler = "edgezero_core::introspection::manifest" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "App manifest as JSON" [[triggers.http]] @@ -68,6 +94,7 @@ path = "/_{{name}}/config" methods = ["GET"] handler = "edgezero_core::introspection::config" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "Effective app config (secret-safe)" [[triggers.http]] @@ -76,6 +103,7 @@ path = "/_{{name}}/routes" methods = ["GET"] handler = "edgezero_core::introspection::routes" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "Registered route table" # -- Stores ---------------------------------------------------------------- @@ -118,4 +146,4 @@ description = "Registered route table" # adapters = [{{{adapter_list}}}] # env = "API_TOKEN" -{{{adapter_manifest_sections}}} \ No newline at end of file +{{{adapter_manifest_sections}}} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index da77786a..14857cff 100644 --- a/crates/edgezero-cli/tests/generated_project_builds.rs +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -19,6 +19,38 @@ mod tests { use std::path::Path; use std::process::{Command, ExitStatus}; + fn require_listed_test(output: &str, sentinel: &str) -> Result<(), String> { + let tests: Vec<_> = output + .lines() + .filter_map(|line| line.trim().strip_suffix(": test")) + .collect(); + if tests.is_empty() { + return Err("generated core test listing contained zero tests".to_owned()); + } + let matches = tests + .iter() + .filter(|name| { + **name == sentinel + || name + .strip_suffix(sentinel) + .is_some_and(|prefix| prefix.ends_with("::")) + }) + .count(); + if matches != 1 { + return Err(format!( + "expected exactly one generated `{sentinel}` test, found {matches}" + )); + } + Ok(()) + } + + #[test] + fn generated_core_test_gate_rejects_zero_tests() { + let error = require_listed_test("0 tests, 0 benchmarks\n", "generated_outbound_http_smoke") + .expect_err("a zero-test listing must fail closed"); + assert!(error.contains("zero tests")); + } + /// Targets installed for the toolchain that builds `project`. A wasm /// check is skipped when its target is absent (e.g. a local run where /// the project sits outside a checkout that pins the wasm targets); CI @@ -42,6 +74,24 @@ mod tests { .unwrap_or_else(|err| panic!("run generated CLI with {args:?}: {err}")) } + fn assert_cargo_success(project: &Path, args: &[&str], expectation: &str) { + let status = Command::new(env!("CARGO")) + .args(args) + .current_dir(project) + .status() + .unwrap_or_else(|err| panic!("run cargo with {args:?}: {err}")); + assert!(status.success(), "{expectation}"); + } + + fn assert_edgezero_success(project: &Path, args: &[&str], expectation: &str) { + let status = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(args) + .current_dir(project) + .status() + .unwrap_or_else(|err| panic!("run edgezero with {args:?}: {err}")); + assert!(status.success(), "{expectation}"); + } + #[test] #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] #[expect( @@ -69,13 +119,9 @@ mod tests { // `cargo check` so a manifest/config drift surfaces as a // fast, clear error -- not as a compilation cascade from // a downstream macro tripping over the bad config. - let validate = Command::new(env!("CARGO_BIN_EXE_edgezero")) - .args(["config", "validate"]) - .current_dir(&project) - .status() - .expect("run `edgezero config validate` on the generated workspace"); - assert!( - validate.success(), + assert_edgezero_success( + &project, + &["config", "validate"], "generated workspace should pass `edgezero config validate`", ); @@ -86,26 +132,57 @@ mod tests { // with a malformed handler or a manifest that violates the // adapter capability matrix would silently pass plain // validate but fail under strict. - let validate_strict = Command::new(env!("CARGO_BIN_EXE_edgezero")) - .args(["config", "validate", "--strict"]) - .current_dir(&project) - .status() - .expect("run `edgezero config validate --strict` on the generated workspace"); - assert!( - validate_strict.success(), + assert_edgezero_success( + &project, + &["config", "validate", "--strict"], "generated workspace should pass `edgezero config validate --strict`", ); + assert_cargo_success( + &project, + &["fmt", "--all", "--", "--check"], + "generated workspace should be rustfmt-clean", + ); + // Host target: the whole workspace, including the generated CLI // crate that imports `edgezero_cli`. - let host = Command::new(env!("CARGO")) - .args(["check", "--workspace"]) + assert_cargo_success( + &project, + &["check", "--workspace"], + "generated workspace should compile for the host target", + ); + + assert_cargo_success( + &project, + &[ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ], + "generated workspace should pass strict clippy", + ); + + let listed = Command::new(env!("CARGO")) + .args(["test", "-p", "scaffold-probe-core", "--lib", "--", "--list"]) .current_dir(&project) - .status() - .expect("run `cargo check` on the generated workspace"); + .output() + .expect("list generated core tests"); assert!( - host.success(), - "generated workspace should compile for the host target", + listed.status.success(), + "generated core test list should succeed" + ); + let listed_stdout = String::from_utf8_lossy(&listed.stdout); + require_listed_test(&listed_stdout, "generated_outbound_http_smoke") + .expect("generated outbound smoke test must be listed exactly once"); + + assert_cargo_success( + &project, + &["test", "-p", "scaffold-probe-core", "--lib"], + "generated core tests should pass", ); // Typed config validation via the generated `-cli` binary. @@ -141,8 +218,9 @@ mod tests { continue; } let crate_name = format!("scaffold-probe-adapter-{adapter}"); - let wasm = Command::new(env!("CARGO")) - .args([ + assert_cargo_success( + &project, + &[ "check", "-p", &crate_name, @@ -150,13 +228,8 @@ mod tests { target, "--features", adapter, - ]) - .current_dir(&project) - .status() - .expect("run `cargo check` for a wasm adapter target"); - assert!( - wasm.success(), - "generated {adapter} adapter should compile for {target}", + ], + &format!("generated {adapter} adapter should compile for {target}"), ); } } diff --git a/crates/edgezero-core/Cargo.toml b/crates/edgezero-core/Cargo.toml index 108197eb..b62b728a 100644 --- a/crates/edgezero-core/Cargo.toml +++ b/crates/edgezero-core/Cargo.toml @@ -32,6 +32,7 @@ thiserror = { workspace = true } toml = { workspace = true } tower-service = { workspace = true } tracing = { workspace = true } +url = { workspace = true } validator = { workspace = true } log = { workspace = true } # `web-time` is intentionally unconditional: `std::time::Instant` is @@ -48,5 +49,6 @@ test-utils = [] [dev-dependencies] brotli = { workspace = true } +brotli-decompressor = { workspace = true } flate2 = { workspace = true } tempfile = { workspace = true } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 6d1ebc89..ea8c6445 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -1,4 +1,22 @@ -use crate::router::RouterService; +use std::sync::Arc; + +use crate::config_store::ConfigExtractionLimits; +use crate::error::EdgeError; +use crate::http::{Request, Response}; +use crate::ingress::{ + AdmissionDecision, IngressAdmissionOutcome, IngressAdmissionPolicy, IngressBeginOutcome, + IngressFraming, IngressHead, IngressHeadAccounting, IngressHeadLimits, IngressHeadParts, + PreparedIngress, apply_admission_policy, default_admission_policy, +}; +use crate::manifest::BakedManifest; +use crate::response::IntoResponse as _; +use crate::response_egress::{ + ResponseEgressEnvelope, ResponseEgressHead, ResponseEgressObserver, + ResponseEgressObserverHandle, ResponseEgressPolicy, ResponseEgressPolicyCallback, + default_response_egress_policy, +}; +use crate::router::{RouteMetadata, RouteResolution, RouterService}; +use crate::time::{MonotonicClock, MonotonicInstant}; /// Canonical adapter name for the Axum adapter. pub const AXUM_ADAPTER: &str = "axum"; @@ -12,11 +30,71 @@ pub const SPIN_ADAPTER: &str = "spin"; /// Lightweight container around a `RouterService` that can be extended via hook implementations. pub struct App { + config_extraction_limits: ConfigExtractionLimits, + ingress_head_limits: IngressHeadLimits, + ingress_policy: IngressAdmissionPolicy, + monotonic_clock: MonotonicClock, name: String, + response_egress_observer: ResponseEgressObserverHandle, + response_egress_policy: ResponseEgressPolicyCallback, router: RouterService, } impl App { + /// Runs the configured body-blind admission policy exactly once. + /// + /// # Errors + /// Returns an internal policy error if the resulting absolute deadline cannot be + /// normalized safely or if fallback draining is selected for a matched route. + #[inline] + pub fn admit_ingress(&self, head: &IngressHead) -> Result { + match apply_admission_policy(&self.ingress_policy, head, self.monotonic_clock())? { + IngressAdmissionOutcome::Admitted(admitted) => Ok(IngressAdmissionOutcome::Admitted( + admitted.with_config_extraction_limits(self.config_extraction_limits), + )), + IngressAdmissionOutcome::Refused(response) => { + Ok(IngressAdmissionOutcome::Refused(response)) + } + } + } + + /// Resolves and admits a normalized request head before an adapter transfers native body + /// ownership into a core [`Request`]. + /// + /// # Errors + /// Returns an internal policy error if the admission deadline cannot be normalized safely + /// or if fallback draining is selected for a matched route. + #[inline] + pub fn begin_ingress( + &self, + head_parts: IngressHeadParts, + request_start: MonotonicInstant, + ) -> Result { + let resolved = self + .router + .resolve(head_parts.method(), head_parts.target().path()); + let route = match resolved.resolution() { + RouteResolution::Matched(route) => Some(route.clone()), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => None, + }; + let head = head_parts.into_head(request_start, resolved.resolution().clone()); + + match self.admit_ingress(&head)? { + IngressAdmissionOutcome::Admitted(admitted) => Ok(IngressBeginOutcome::Admitted( + PreparedIngress::new(resolved, admitted), + )), + IngressAdmissionOutcome::Refused(response) => Ok(IngressBeginOutcome::Refused( + self.response_egress_envelope(response, request_start, route), + )), + } + } + + #[must_use] + #[inline] + pub fn config_extraction_limits(&self) -> ConfigExtractionLimits { + self.config_extraction_limits + } + /// Default name used when none is provided. #[must_use] #[inline] @@ -24,6 +102,62 @@ impl App { DEFAULT_APP_NAME } + /// Dispatches a request whose native body was wrapped after admission. + /// + /// # Errors + /// Returns an error only when handler/routing error rendering fails. + #[inline] + pub async fn dispatch_admitted( + &self, + prepared: PreparedIngress, + request: Request, + ) -> Result { + let request_start = prepared.request_start(); + let route = prepared.route_metadata().cloned(); + let (resolved, admitted) = prepared.into_parts(); + let response = match self + .router + .dispatch_resolved(resolved, request, admitted) + .await + { + Ok(response) => response, + Err(error) => error.into_response()?, + }; + Ok(self.response_egress_envelope(response, request_start, route)) + } + + /// Resolves, admits, and dispatches one normalized inbound request. + /// + /// Adapters must call this before polling the request body. The route selected for admission + /// is consumed during dispatch, so handlers cannot observe a different route identity. + /// + /// # Errors + /// Returns an error only when admission or error rendering fails. Handler and routing errors + /// are rendered with the same semantics as [`RouterService::oneshot`]. + #[inline] + pub async fn dispatch_ingress( + &self, + request: Request, + request_start: MonotonicInstant, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Result { + let head_parts = IngressHeadParts::from_request(&request, head_accounting, framing); + match self.begin_ingress(head_parts, request_start)? { + IngressBeginOutcome::Admitted(prepared) => self + .dispatch_admitted(prepared, request) + .await + .map(ResponseEgressEnvelope::into_response), + IngressBeginOutcome::Refused(response) => Ok(response.into_response()), + } + } + + #[must_use] + #[inline] + pub fn ingress_head_limits(&self) -> IngressHeadLimits { + self.ingress_head_limits + } + /// Consume the app and return the contained router service. #[must_use] #[inline] @@ -31,6 +165,20 @@ impl App { self.router } + /// Returns the application clock used to stamp and enforce admitted ingress lifetimes. + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() + } + + /// Captures one instant from the application clock. + #[must_use] + #[inline] + pub fn monotonic_now(&self) -> MonotonicInstant { + self.monotonic_clock.now() + } + /// Name assigned to the application. #[must_use] #[inline] @@ -45,6 +193,36 @@ impl App { Self::with_name(router, DEFAULT_APP_NAME) } + fn response_egress_envelope( + &self, + response: Response, + request_start: MonotonicInstant, + route: Option, + ) -> ResponseEgressEnvelope { + ResponseEgressEnvelope::new( + response, + request_start, + route, + self.response_egress_policy(), + self.response_egress_observer(), + self.monotonic_clock(), + ) + } + + /// Returns an owned handle to the configured terminal response-egress observer. + #[must_use] + #[inline] + pub fn response_egress_observer(&self) -> ResponseEgressObserverHandle { + self.response_egress_observer.clone() + } + + /// Returns an owned handle to the configured synchronous response-egress policy callback. + #[must_use] + #[inline] + pub fn response_egress_policy(&self) -> ResponseEgressPolicyCallback { + Arc::clone(&self.response_egress_policy) + } + /// Access the underlying router service. #[must_use] #[inline] @@ -52,6 +230,40 @@ impl App { &self.router } + /// Installs validated typed-config extraction limits. + /// + /// # Errors + /// Returns an internal startup-policy error when limits are zero, inconsistent, or unbounded. + #[inline] + pub fn set_config_extraction_limits( + &mut self, + limits: ConfigExtractionLimits, + ) -> Result<(), EdgeError> { + self.config_extraction_limits = limits.validate()?; + Ok(()) + } + + /// Installs the synchronous body-blind ingress admission callback. + #[inline] + pub fn set_ingress_admission_policy(&mut self, policy: Policy) + where + Policy: Fn(&IngressHead) -> AdmissionDecision + Send + Sync + 'static, + { + self.ingress_policy = Arc::new(policy); + } + + /// Installs already-validated finite request-head limits. + #[inline] + pub fn set_ingress_head_limits(&mut self, limits: IngressHeadLimits) { + self.ingress_head_limits = limits; + } + + /// Installs the monotonic clock used by subsequent ingress attempts. + #[inline] + pub fn set_monotonic_clock(&mut self, clock: MonotonicClock) { + self.monotonic_clock = clock; + } + /// Update the application name. #[inline] pub fn set_name(&mut self, name: S) @@ -61,6 +273,27 @@ impl App { self.name = name.into(); } + /// Installs the terminal response-egress observer used by adapter conversion attempts. + #[inline] + pub fn set_response_egress_observer(&mut self, observer: Observer) + where + Observer: ResponseEgressObserver, + { + self.response_egress_observer = ResponseEgressObserverHandle::new(observer); + } + + /// Installs the synchronous body-blind response-egress policy callback. + #[inline] + pub fn set_response_egress_policy(&mut self, policy: Policy) + where + Policy: for<'head> Fn(&ResponseEgressHead<'head>, MonotonicInstant) -> ResponseEgressPolicy + + Send + + Sync + + 'static, + { + self.response_egress_policy = Arc::new(policy); + } + /// Construct a new application with the provided router and name. #[inline] pub fn with_name(router: RouterService, name: S) -> Self @@ -68,8 +301,14 @@ impl App { S: Into, { Self { - router, + config_extraction_limits: ConfigExtractionLimits::default(), + ingress_head_limits: IngressHeadLimits::default(), + ingress_policy: default_admission_policy(), + monotonic_clock: MonotonicClock::default(), name: name.into(), + response_egress_observer: ResponseEgressObserverHandle::default(), + response_egress_policy: Arc::new(default_response_egress_policy), + router, } } } @@ -120,6 +359,23 @@ pub trait Hooks { #[inline] fn configure(_app: &mut App) {} + /// Parsed and finalized manifest contract baked by `app!`. + /// + /// The default is deliberately uncached: every macro-generated application + /// supplies its own per-implementation cache. + #[must_use] + #[inline] + fn manifest() -> BakedManifest { + BakedManifest::Absent + } + + /// Raw manifest JSON baked at compile time by `app!`. + #[must_use] + #[inline] + fn manifest_json() -> Option<&'static str> { + None + } + /// Display name for the application. Defaults to `"EdgeZero App"`. #[must_use] #[inline] @@ -152,18 +408,72 @@ pub trait Hooks { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + use std::time::Duration; + use std::{future::Future as _, str}; + use super::*; use crate::body::Body; + use crate::config_store::ConfigExtractionLimits; use crate::context::RequestContext; use crate::error::EdgeError; - use crate::http::{Method, StatusCode, request_builder}; + use crate::http::{ + HeaderMap, HeaderValue, Method, StatusCode, Version, request_builder, response_builder, + }; + use crate::ingress::{ + BufferedIngressResponse, IngressBeginOutcome, IngressFraming, IngressGrant, + IngressHeadAccounting, IngressHeadParts, + }; + use crate::manifest::BakedManifest; + use crate::middleware::{Middleware, Next}; + use crate::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, ResponseEgressAttempt, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressOutcome, ResponseEgressPolicy, ResponseEgressReport, + }; + use crate::router::{RouteMetadata, RouteResolution}; + use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + use async_trait::async_trait; + use bytes::Bytes; + use futures::StreamExt as _; use futures::executor::block_on; + use futures::stream::{iter, poll_fn}; + use futures::task::noop_waker_ref; use tower_service::Service as _; struct DefaultHooks; struct TestHooks; + struct CountingMiddleware(Arc); + + struct GrantDropProbe(Arc); + + #[derive(Clone)] + struct AppEgressObserver(Arc>>); + + impl ResponseEgressObserver for AppEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + impl Drop for GrantDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[expect( clippy::missing_trait_methods, reason = "test stub — only `routes` is overridden; every other Hooks method intentionally uses its trait default" @@ -214,10 +524,92 @@ mod tests { } } + fn assert_buffered_terminal_response( + response: &Response, + status: StatusCode, + marker: &str, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!( + response + .headers() + .get("x-fallback-terminal") + .expect("terminal marker"), + marker + ); + assert_eq!(response.body().as_bytes().expect("buffered body"), body); + } + + fn assert_no_fallback_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn buffered_terminal( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + let mut headers = HeaderMap::new(); + headers.insert("x-fallback-terminal", HeaderValue::from_static(marker)); + BufferedIngressResponse::new(status, headers, Bytes::from_static(body)) + } + + fn configure_fallback_policy( + app: &mut App, + drops: &Arc, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + ) { + let grant_drops = Arc::clone(drops); + app.set_ingress_admission_policy(move |_| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(GrantDropProbe(Arc::clone(&grant_drops))), + max_body_bytes, + read_deadline, + on_exceeded: on_exceeded.clone(), + on_timeout: on_timeout.clone(), + }); + } + + fn dispatch_fallback(app: &App, body: Body) -> Response { + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response") + } + fn empty_router() -> RouterService { RouterService::builder().build() } + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let call_counter = Arc::clone(&handler_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("unexpected") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { let app = TestHooks::build_app(); @@ -248,6 +640,793 @@ mod tests { assert_eq!(app.name(), App::default_name()); } + #[test] + fn app_pairs_admitted_ingress_with_its_injected_clock() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let mut app = App::new(empty_router()); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + + let request_start = app.monotonic_now(); + assert_eq!(request_start, start); + let head = IngressHeadParts::new( + Method::GET, + "/".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, request_start).expect("admission") + else { + panic!("expected admission"); + }; + assert_eq!(prepared.request_start(), start); + + let advanced = start + .checked_add(Duration::from_secs(1)) + .expect("advanced instant"); + *now.lock().expect("clock lock") = advanced; + assert_eq!(prepared.monotonic_clock().now(), advanced); + } + + #[test] + fn app_owns_default_and_configurable_response_egress_policy() { + let mut app = App::new(empty_router()); + let headers = HeaderMap::new(); + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_millis(5)) + .expect("egress start"); + let route = RouteMetadata::new(Method::GET, "/items/{id}"); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + request_start, + Some(&route), + ); + + let default_policy = app.response_egress_policy(); + assert_eq!( + default_policy(&head, egress_started_at) + .write_deadline + .instant(), + egress_started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default deadline") + ); + + app.set_response_egress_policy(move |response_head, started_at| { + assert_eq!(response_head.status(), StatusCode::OK); + assert_eq!(response_head.request_start(), request_start); + assert_eq!( + response_head.route().map(RouteMetadata::pattern), + Some("/items/{id}") + ); + assert_eq!(started_at, egress_started_at); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at), + } + }); + let configured_policy = app.response_egress_policy(); + assert_eq!( + configured_policy(&head, egress_started_at) + .write_deadline + .instant(), + egress_started_at + ); + } + + #[test] + fn app_owns_configurable_response_egress_observer() { + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(empty_router()); + app.set_response_egress_observer(AppEgressObserver(Arc::clone(&reports))); + + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::NO_CONTENT, + Version::HTTP_11, + &headers, + started_at, + None, + ); + let mut attempt = + ResponseEgressAttempt::new(&head, started_at, app.response_egress_observer()); + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + + let observed_reports = reports.lock().expect("reports lock"); + assert_eq!(observed_reports.len(), 1); + assert_eq!( + observed_reports[0].outcome, + ResponseEgressOutcome::Completed + ); + } + + #[test] + fn config_extraction_limits_are_validated_and_reach_routed_context() { + let limits = ConfigExtractionLimits { + max_backend_bytes: 64, + max_blob_bytes: 32, + max_secret_bytes: 16, + max_total_bytes: 48, + timeout: Duration::from_secs(2), + }; + let router = RouterService::builder() + .get("/limits", move |ctx: RequestContext| async move { + assert_eq!(ctx.config_extraction_limits(), limits); + Ok::<_, EdgeError>("ok") + }) + .build(); + let mut app = App::new(router); + app.set_config_extraction_limits(limits) + .expect("valid extraction limits"); + + let request = request_builder() + .method(Method::GET) + .uri("/limits") + .body(Body::empty()) + .expect("request"); + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn default_app_admits_with_finite_deadline_and_empty_grant() { + let app = App::new(empty_router()); + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + + let IngressAdmissionOutcome::Admitted(admitted) = + app.admit_ingress(&head).expect("admitted") + else { + panic!("expected admission"); + }; + assert_eq!(admitted.request_start(), start); + assert!(admitted.read_deadline().instant() > start); + assert!( + admitted.read_deadline().instant() + <= start.checked_add(DEADLINE_FAR_FUTURE).expect("maximum") + ); + let (_, _, grant, _, _) = admitted.into_parts(); + grant.downcast::<()>().expect_err("empty grant"); + } + + #[test] + fn ingress_refusal_skips_handler_and_body_poll() { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .post("/upload", move |_ctx: RequestContext| { + let call_counter = Arc::clone(&handler_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("unexpected") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let mut app = App::new(router); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let admission_counter = Arc::clone(&admission_calls); + app.set_ingress_admission_policy(move |_| { + admission_counter.fetch_add(1, Ordering::SeqCst); + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-admission", "saturated") + .body(Body::from("busy")) + .expect("response"), + ) + }); + let body_polls = Arc::new(AtomicUsize::new(0)); + let poll_counter = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + poll_counter.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None::) + })); + let request = request_builder() + .method(Method::POST) + .uri("/upload") + .body(body) + .expect("request"); + + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response.headers().get("x-admission").expect("header"), + "saturated" + ); + assert_eq!(response.body().as_bytes().expect("body"), b"busy"); + assert_eq!(admission_calls.load(Ordering::SeqCst), 1); + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[test] + fn ingress_refusal_preserves_metadata_through_response_egress() { + let router = RouterService::builder() + .post("/upload/{id}", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(router); + app.set_ingress_admission_policy(|_| { + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("response"), + ) + }); + app.set_response_egress_observer(AppEgressObserver(Arc::clone(&reports))); + let request_start = MonotonicInstant::now(); + let started_at = request_start + .checked_add(Duration::from_millis(5)) + .expect("egress start"); + app.set_monotonic_clock(MonotonicClock::new(move || started_at)); + app.set_response_egress_policy(move |head, egress_started_at| { + assert_eq!(head.request_start(), request_start); + assert_eq!( + head.route().map(RouteMetadata::pattern), + Some("/upload/{id}") + ); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + egress_started_at + .checked_add(Duration::from_secs(1)) + .expect("deadline"), + ), + } + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload/42".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + let IngressBeginOutcome::Refused(egress) = + app.begin_ingress(head, request_start).expect("refusal") + else { + panic!("expected refusal"); + }; + let (response, _, mut attempt, egress_clock) = egress.begin().expect("begin egress"); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(egress_clock.now(), started_at); + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].request_start, request_start); + assert_eq!( + observed[0].route.as_ref().map(RouteMetadata::pattern), + Some("/upload/{id}") + ); + } + + #[test] + fn fallback_body_policy_rejects_matched_routes_before_body_construction() { + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::empty(), + max_body_bytes: 4_096, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + assert!(matches!( + app.begin_ingress(head, MonotonicInstant::now()), + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn fallback_body_exact_cap_preserves_not_found() { + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::empty(), + max_body_bytes: 4, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::stream(iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ]))) + .expect("request"); + + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn fallback_body_overflow_precedes_method_not_allowed() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::empty(), + max_body_bytes: 4, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(Body::stream(iter([ + Bytes::from_static(b"abcd"), + Bytes::from_static(b"e"), + ]))) + .expect("request"); + + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn fallback_body_zero_cap_accepts_only_empty_body() { + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::empty(), + max_body_bytes: 0, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + + for (body, expected) in [ + (Body::empty(), StatusCode::NOT_FOUND), + (Body::from_bytes("a"), StatusCode::PAYLOAD_TOO_LARGE), + ] { + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(body) + .expect("request"); + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), expected); + } + } + + #[test] + fn fallback_body_deadline_precedes_body_poll_and_not_found() { + let start = MonotonicInstant::now(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(Some(Bytes::from_static(b"a"))) + })); + let mut app = App::new(empty_router()); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::empty(), + max_body_bytes: 4_096, + read_deadline: Deadline::at_instant(head.request_start()), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text( + StatusCode::GATEWAY_TIMEOUT, + "fallback timeout", + ), + }); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(body) + .expect("request"); + + let response = block_on(app.dispatch_ingress( + request, + start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"fallback timeout" + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[test] + fn fallback_grant_is_live_during_exact_cap_eof_and_drops_before_egress() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let mut poll = 0_usize; + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + let next = match poll { + 0 => Some(Bytes::from_static(b"ab")), + 1 => Some(Bytes::from_static(b"cd")), + 2 => None, + _ => panic!("body polled after EOF"), + }; + poll += 1; + Poll::Ready(next) + })); + let head = IngressHeadParts::new( + Method::POST, + "/known".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress") + else { + panic!("expected admission"); + }; + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + + let egress = block_on(app.dispatch_admitted(prepared, request)).expect("dispatch"); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!( + egress.into_response().status(), + StatusCode::METHOD_NOT_ALLOWED + ); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_overflow_returns_exact_application_response_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal( + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"limit\0exceeded", + ), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::stream( + iter([Bytes::from_static(b"abcd"), Bytes::from_static(b"e")]).inspect(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + }), + ); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"limit\0exceeded", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_deadline_expiry_returns_exact_application_response_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::at_instant(deadline), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"deadline\0expired"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + *now.lock().expect("clock lock") = deadline; + Poll::Ready(Some(Bytes::from_static(b"a"))) + })); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"deadline\0expired", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_adapter_request_timeout_uses_application_timeout_response() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"adapter timeout"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::from_stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + Poll::Ready(Some(Err(EdgeError::request_timeout( + "adapter deadline wrapper expired", + )))) + })); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"adapter timeout", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_non_timeout_source_error_is_preserved_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::from_stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + Poll::Ready(Some(Err(EdgeError::service_unavailable( + "source unavailable", + )))) + })); + + let response = dispatch_fallback(&app, body); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().get("x-fallback-terminal").is_none()); + assert!( + str::from_utf8(response.body().as_bytes().expect("buffered body")) + .expect("JSON body") + .contains("service_unavailable") + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn cancelling_fallback_dispatch_drops_live_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending::> + })); + let head = IngressHeadParts::new( + Method::POST, + "/known".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress") + else { + panic!("expected admission"); + }; + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + let mut dispatch = Box::pin(app.dispatch_admitted(prepared, request)); + let mut cx = Context::from_waker(noop_waker_ref()); + + assert!(matches!(dispatch.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + + drop(dispatch); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn ordinary_admit_does_not_poll_fallback_bodies() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let app = App::new(router); + + for (path, expected) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None::) + })); + let request = request_builder() + .method(Method::POST) + .uri(path) + .body(body) + .expect("request"); + + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + + assert_eq!(response.status(), expected); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn ingress_two_phase_admission_precedes_body_construction() { + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("accepted") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(metadata) if metadata.pattern() == "/upload" + )); + AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: Deadline::after(Duration::from_secs(1)), + } + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let start = MonotonicInstant::now(); + + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, start).expect("begin ingress") + else { + panic!("expected admission"); + }; + assert_eq!(prepared.request_start(), start); + + let request = request_builder() + .method(Method::POST) + .uri("/upload") + .body(Body::from("payload")) + .expect("request"); + let response = block_on(app.dispatch_admitted(prepared, request)).expect("dispatch"); + assert_eq!(response.into_response().status(), StatusCode::OK); + } + #[test] fn default_hooks_do_not_own_logging() { assert!(!DefaultHooks::owns_logging()); @@ -257,6 +1436,8 @@ mod tests { fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); assert_eq!(app.name(), App::default_name()); + assert!(matches!(DefaultHooks::manifest(), BakedManifest::Absent)); + assert_eq!(DefaultHooks::manifest_json(), None); assert_eq!(DefaultHooks::stores(), StoresMetadata::default()); let router = app.into_router(); assert!(router.routes().is_empty()); diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 43056e29..4eeee51f 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -8,12 +8,14 @@ use serde::de::DeserializeOwned; use crate::error::EdgeError; +pub type BodyStream = LocalBoxStream<'static, Result>; + /// Lightweight HTTP body that can either contain a single `Bytes` buffer or a streaming source of /// chunks. The streaming variant is implemented with `LocalBoxStream` so it remains compatible with /// `wasm32` targets that lack thread support. pub enum Body { Once(Bytes), - Stream(LocalBoxStream<'static, Result>), + Stream(BodyStream), } impl Body { @@ -43,18 +45,28 @@ impl Body { } #[inline] - pub fn from_stream(stream: S) -> Self + pub fn from_external_stream(stream: S) -> Self where S: Stream> + 'static, anyhow::Error: From, { Self::Stream( stream - .map(|res| res.map_err(anyhow::Error::from)) + .map(|result| { + result.map_err(|error| EdgeError::internal(anyhow::Error::from(error))) + }) .boxed_local(), ) } + #[inline] + pub fn from_stream(stream: S) -> Self + where + S: Stream> + 'static, + { + Self::Stream(stream.boxed_local()) + } + /// Consume a buffered body and return its bytes, or `None` if this is a /// streaming body. To collect a streaming body, use /// [`Body::into_bytes_bounded`]. @@ -84,11 +96,14 @@ impl Body { Body::Stream(mut stream) => { let mut buf = Vec::new(); while let Some(result) = StreamExt::next(&mut stream).await { - let chunk = result.map_err(EdgeError::internal)?; - buf.extend_from_slice(&chunk); - if buf.len() > max_size { + let chunk = result?; + let next_len = buf.len().checked_add(chunk.len()).ok_or_else(|| { + EdgeError::bad_request("request body size accounting overflow") + })?; + if next_len > max_size { return Err(EdgeError::bad_request("request body too large")); } + buf.extend_from_slice(&chunk); } Ok(Bytes::from(buf)) } @@ -96,7 +111,7 @@ impl Body { } #[inline] - pub fn into_stream(self) -> Option>> { + pub fn into_stream(self) -> Option { match self { Body::Once(_) => None, Body::Stream(stream) => Some(stream), @@ -123,7 +138,7 @@ impl Body { where S: Stream + 'static, { - Self::Stream(stream.map(Ok::).boxed_local()) + Self::Stream(stream.map(Ok::).boxed_local()) } #[inline] @@ -170,6 +185,13 @@ impl fmt::Debug for Body { } } +impl From for Body { + #[inline] + fn from(value: Bytes) -> Self { + Body::Once(value) + } +} + impl From> for Body { #[inline] fn from(value: Vec) -> Self { @@ -201,9 +223,12 @@ impl From for Body { #[cfg(test)] mod tests { use super::*; + use crate::error::ResponseLimitReason; use futures::executor::block_on; use futures_util::stream; + use std::cell::Cell; use std::io; + use std::rc::Rc; #[test] fn as_bytes_returns_none_for_stream() { @@ -248,12 +273,12 @@ mod tests { } #[test] - fn from_stream_maps_errors() { + fn body_from_external_stream_maps_to_internal() { let source = stream::iter(vec![ Ok(Bytes::from_static(b"ok")), Err(io::Error::other("boom")), ]); - let body = Body::from_stream(source); + let body = Body::from_external_stream(source); let mut chunks = body.into_stream().expect("stream"); let (first, second) = block_on(async { let first = chunks.next().await.expect("first").expect("ok"); @@ -262,9 +287,67 @@ mod tests { }); assert_eq!(first, Bytes::from_static(b"ok")); let err = second.expect_err("error"); + assert!(matches!(err, EdgeError::Internal { .. })); assert!(err.to_string().contains("boom")); } + #[test] + fn body_from_stream_preserves_edge_error() { + let source = stream::iter([Err(EdgeError::response_too_large_with_reason( + "encoded cap", + ResponseLimitReason::EncodedBody, + ))]); + let body = Body::from_stream(source); + let mut chunks = body.into_stream().expect("stream"); + let error = block_on(chunks.next()) + .expect("item") + .expect_err("typed error"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + } + + #[test] + fn body_into_bytes_bounded_preserves_edge_error() { + let source = stream::iter([Err(EdgeError::gateway_timeout("expired"))]); + let error = + block_on(Body::from_stream(source).into_bytes_bounded(100)).expect_err("typed error"); + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn body_stream_accepts_infallible_bytes() { + let body = Body::stream(stream::iter([ + Bytes::from_static(b"one"), + Bytes::from_static(b"two"), + ])); + let bytes = block_on(body.into_bytes_bounded(6)).expect("body"); + assert_eq!(bytes, Bytes::from_static(b"onetwo")); + assert_eq!( + Body::from(Bytes::from_static(b"bytes")).as_bytes(), + Some(b"bytes".as_slice()) + ); + } + + #[test] + fn body_bounded_checks_before_append() { + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let source = stream::iter([ + Ok(Bytes::from_static(b"too-large")), + Ok(Bytes::from_static(b"must-not-poll")), + ]) + .inspect(move |_| observed.set(observed.get().saturating_add(1))); + let error = + block_on(Body::from_stream(source).into_bytes_bounded(3)).expect_err("over limit"); + assert!(matches!(error, EdgeError::BadRequest { .. })); + assert_eq!(polls.get(), 1); + } + #[test] fn from_vec_u8_builds_buffered_body() { let body = Body::from(vec![1_u8, 2_u8, 3_u8]); diff --git a/crates/edgezero-core/src/compression.rs b/crates/edgezero-core/src/compression.rs index 5c1243f7..0bac4410 100644 --- a/crates/edgezero-core/src/compression.rs +++ b/crates/edgezero-core/src/compression.rs @@ -1,83 +1,413 @@ use std::io; +use std::str; use async_compression::futures::bufread::{BrotliDecoder, GzipDecoder}; -use async_stream::try_stream; +use async_stream::stream; use bytes::Bytes; -use futures::TryStream; -use futures::io::{AsyncReadExt as _, BufReader}; -use futures::stream::Stream; -use futures_util::TryStreamExt as _; +use futures::io::AsyncReadExt as _; +use futures_util::{StreamExt as _, TryStreamExt as _, stream as futures_stream}; + +use crate::body::BodyStream; +use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; +use crate::http::HeaderMap; +use crate::http::header::CONTENT_ENCODING; const BUFFER_SIZE: usize = 8 * 1024; +/// Conservative non-window charge for the decoder implementation pinned by +/// the workspace lockfiles. +/// +/// The audit covers the boxed decoder state, initial context-map table, block +/// type/length Huffman arrays, literal and distance context maps, context +/// modes, three Huffman tree groups and their index arrays, and decoder bridge +/// buffers in `brotli-decompressor 5.0.1`/`compression-codecs 0.4.38`. Their +/// grammar-bounded maxima fit within 16 MiB. The ring allocation is charged as +/// `2^WBITS`; dependency upgrades must repeat this source audit before changing +/// either pin or charge. +#[expect( + clippy::decimal_literal_representation, + reason = "the decimal byte count is the reviewed public contract" +)] +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct EdgeErrorCarrier(EdgeError); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BrotliPrefix { + NeedSecondByte, + WindowBits(u8), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContentEncoding { + Brotli, + Gzip, + Identity, + Passthrough, +} + +#[must_use] +#[inline] +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding { + let mut values = headers.get_all(CONTENT_ENCODING).iter(); + let Some(header_value) = values.next() else { + return ContentEncoding::Identity; + }; + if values.next().is_some() { + return ContentEncoding::Passthrough; + } + let Ok(raw_encoding) = str::from_utf8(header_value.as_bytes()) else { + return ContentEncoding::Passthrough; + }; + let encoding = raw_encoding.trim_matches([' ', '\t']); + if encoding.eq_ignore_ascii_case("br") { + ContentEncoding::Brotli + } else if encoding.eq_ignore_ascii_case("gzip") { + ContentEncoding::Gzip + } else if encoding.eq_ignore_ascii_case("identity") { + ContentEncoding::Identity + } else { + ContentEncoding::Passthrough + } +} + +/// Return the conservative decoder-state charge for an advertised Brotli window. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when `window_bits` is outside the Brotli range accepted by +/// `EdgeZero`, or a typed response-limit error if decoder-memory accounting overflows. +#[inline] +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result { + if !(10..=30).contains(&window_bits) { + return Err(EdgeError::bad_request( + "brotli window bits must be between 10 and 30 inclusive", + )); + } + let window = 1_u64.checked_shl(u32::from(window_bits)).ok_or_else(|| { + EdgeError::response_too_large_with_reason( + "brotli decoder memory accounting overflow", + ResponseLimitReason::DecoderMemory, + ) + })?; + BROTLI_DECODER_FIXED_CHARGE_BYTES + .checked_add(window) + .ok_or_else(|| { + EdgeError::response_too_large_with_reason( + "brotli decoder memory accounting overflow", + ResponseLimitReason::DecoderMemory, + ) + }) +} + /// Decode a stream of gzip-compressed chunks into plain bytes. +#[must_use] #[inline] -pub fn decode_gzip_stream(stream: S) -> impl Stream> -where - S: TryStream, Error = io::Error> + Unpin, -{ - try_stream! { - let reader = BufReader::new(stream.into_async_read()); +pub fn decode_gzip_stream(stream: BodyStream) -> BodyStream { + stream! { + let reader = stream.map_err(edge_error_to_io).into_async_read(); let mut decoder = GzipDecoder::new(reader); + decoder.multiple_members(true); let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { - let read = decoder.read(&mut buffer).await?; + let read = match decoder.read(&mut buffer).await { + Ok(read) => read, + Err(error) => { + yield Err(io_to_edge_error(error, BadGatewayDecodeReason::Gzip)); + return; + } + }; if read == 0 { break; } - let chunk = buffer.get(..read).ok_or_else(|| { - io::Error::other(format!( - "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" - )) - })?; - yield Bytes::copy_from_slice(chunk); + let Some(chunk) = buffer.get(..read) else { + yield Err(codec_error( + BadGatewayDecodeReason::Gzip, + format!("decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer"), + )); + return; + }; + yield Ok(Bytes::copy_from_slice(chunk)); } } + .boxed_local() } /// Decode a stream of brotli-compressed chunks into plain bytes. +#[must_use] #[inline] -pub fn decode_brotli_stream(stream: S) -> impl Stream> -where - S: TryStream, Error = io::Error> + Unpin, -{ - try_stream! { - let reader = BufReader::new(stream.into_async_read()); +pub fn decode_brotli_stream( + mut source: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream { + stream! { + let mut prefix = Vec::with_capacity(2); + let mut initial_chunks = Vec::new(); + let window_bits = loop { + let Some(item) = source.next().await else { + yield Err(codec_error( + BadGatewayDecodeReason::Brotli, + "brotli stream ended before its window prefix", + )); + return; + }; + let chunk = match item { + Ok(chunk) => chunk, + Err(error) => { + yield Err(error); + return; + } + }; + if chunk.is_empty() { + continue; + } + let prefix_remaining = 2_usize.saturating_sub(prefix.len()); + prefix.extend(chunk.iter().take(prefix_remaining)); + initial_chunks.push(chunk); + + match parse_brotli_prefix(&prefix) { + Ok(BrotliPrefix::WindowBits(bits)) => break bits, + Ok(BrotliPrefix::NeedSecondByte) => {} + Err(error) => { + yield Err(error); + return; + } + } + }; + + if window_bits > max_window_bits { + yield Err(EdgeError::response_too_large_with_reason( + format!( + "brotli response advertises a {window_bits}-bit window; limit is {max_window_bits}" + ), + ResponseLimitReason::BrotliWindow, + )); + return; + } + let charge = match brotli_decoder_memory_charge(window_bits) { + Ok(charge) => charge, + Err(error) => { + yield Err(error); + return; + } + }; + if charge > max_decoder_bytes { + yield Err(EdgeError::response_too_large_with_reason( + format!("brotli decoder requires {charge} bytes; limit is {max_decoder_bytes}"), + ResponseLimitReason::DecoderMemory, + )); + return; + } + + let replayed_stream = futures_stream::iter(initial_chunks.into_iter().map(Ok)).chain(source); + let reader = replayed_stream.map_err(edge_error_to_io).into_async_read(); let mut decoder = BrotliDecoder::new(reader); let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { - let read = decoder.read(&mut buffer).await?; + let read = match decoder.read(&mut buffer).await { + Ok(read) => read, + Err(error) => { + yield Err(io_to_edge_error(error, BadGatewayDecodeReason::Brotli)); + return; + } + }; if read == 0 { break; } - let chunk = buffer.get(..read).ok_or_else(|| { - io::Error::other(format!( - "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" - )) - })?; - yield Bytes::copy_from_slice(chunk); + let Some(chunk) = buffer.get(..read) else { + yield Err(codec_error( + BadGatewayDecodeReason::Brotli, + format!("decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer"), + )); + return; + }; + yield Ok(Bytes::copy_from_slice(chunk)); } + + // Brotli has no multi-member HTTP representation. Recover any input + // read ahead by the decoder, then poll the native source once more so + // trailing bytes and late transport errors cannot be hidden by codec EOF. + let mut native_reader = decoder.into_inner(); + let mut trailing = [0_u8; 1]; + match native_reader.read(&mut trailing).await { + Ok(0) => {} + Ok(_) => { + yield Err(codec_error( + BadGatewayDecodeReason::Brotli, + "brotli response contains trailing data or a second stream", + )); + } + Err(error) => { + yield Err(io_to_edge_error(error, BadGatewayDecodeReason::Brotli)); + } + } + } + .boxed_local() +} + +fn codec_error(reason: BadGatewayDecodeReason, message: impl Into) -> EdgeError { + EdgeError::bad_gateway_with_reason(message, BadGatewayReason::Decode(reason)) +} + +fn edge_error_to_io(error: EdgeError) -> io::Error { + io::Error::other(EdgeErrorCarrier(error)) +} + +fn io_to_edge_error(error: io::Error, reason: BadGatewayDecodeReason) -> EdgeError { + let diagnostic = error.to_string(); + if let Some(source) = error.into_inner() + && let Ok(carrier) = source.downcast::() + { + return carrier.0; + } + codec_error( + reason, + format!("upstream response decode failed: {diagnostic}"), + ) +} + +fn parse_brotli_prefix(prefix: &[u8]) -> Result { + let Some(&first) = prefix.first() else { + return Ok(BrotliPrefix::NeedSecondByte); + }; + if first & 1 == 0 { + return Ok(BrotliPrefix::WindowBits(16)); + } + let short_code = match first & 0x0f { + 0x03 => Some(18), + 0x05 => Some(19), + 0x07 => Some(20), + 0x09 => Some(21), + 0x0b => Some(22), + 0x0d => Some(23), + 0x0f => Some(24), + _ => None, + }; + if let Some(bits) = short_code { + return Ok(BrotliPrefix::WindowBits(bits)); + } + let long_code = match first & 0x7f { + 0x71 => Some(15), + 0x61 => Some(14), + 0x51 => Some(13), + 0x41 => Some(12), + 0x31 => Some(11), + 0x21 => Some(10), + 0x01 => Some(17), + _ => None, + }; + if let Some(bits) = long_code { + return Ok(BrotliPrefix::WindowBits(bits)); + } + if first != 0x11 { + return Err(codec_error( + BadGatewayDecodeReason::Brotli, + "invalid brotli window prefix", + )); } + let Some(&second) = prefix.get(1) else { + return Ok(BrotliPrefix::NeedSecondByte); + }; + let bits = second & 0x3f; + if !(10..=30).contains(&bits) { + return Err(codec_error( + BadGatewayDecodeReason::Brotli, + "invalid brotli large-window prefix", + )); + } + Ok(BrotliPrefix::WindowBits(bits)) } #[cfg(test)] mod tests { use super::*; + use crate::error::BudgetSource; + use crate::http::{HeaderMap, HeaderValue}; use brotli::CompressorWriter; + use bytes::Bytes; use flate2::{Compression, write::GzEncoder}; use futures::executor::block_on; use futures_util::stream; use std::io::Write as _; - #[test] - fn decode_gzip_stream_yields_plain_bytes() { + fn source(items: Vec>) -> BodyStream { + stream::iter(items).boxed_local() + } + + fn gzip(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); + encoder.write_all(input).unwrap(); + encoder.finish().unwrap() + } + + fn brotli(input: &[u8]) -> Vec { + let mut encoded = Vec::new(); + let mut compressor = CompressorWriter::new(&mut encoded, 4096, 5, 21); + compressor.write_all(input).unwrap(); + drop(compressor); + encoded + } + + #[test] + fn content_encoding_classifier_covers_every_visible_shape() { + let cases = [ + (None, ContentEncoding::Identity), + (Some(b"identity".as_slice()), ContentEncoding::Identity), + (Some(b" GZIP \t".as_slice()), ContentEncoding::Gzip), + (Some(b"Br".as_slice()), ContentEncoding::Brotli), + (Some(b"zstd".as_slice()), ContentEncoding::Passthrough), + (Some(b"gzip, br".as_slice()), ContentEncoding::Passthrough), + ( + Some(b"gzip; level=1".as_slice()), + ContentEncoding::Passthrough, + ), + (Some(b"".as_slice()), ContentEncoding::Passthrough), + (Some(b"\xff".as_slice()), ContentEncoding::Passthrough), + ]; - let stream = stream::iter(vec![Ok::, io::Error>(compressed)]); + for (value, expected) in cases { + let mut headers = HeaderMap::new(); + if let Some(raw_value) = value { + headers.append( + "content-encoding", + HeaderValue::from_bytes(raw_value).expect("header"), + ); + } + assert_eq!(classify_content_encoding(&headers), expected); + } + + let mut repeated = HeaderMap::new(); + repeated.append("content-encoding", HeaderValue::from_static("gzip")); + repeated.append("content-encoding", HeaderValue::from_static("gzip")); + assert_eq!( + classify_content_encoding(&repeated), + ContentEncoding::Passthrough + ); + } + + #[test] + fn brotli_decoder_memory_charge_is_pinned_and_checked() { + assert_eq!( + brotli_decoder_memory_charge(24_u8).unwrap(), + BROTLI_DECODER_FIXED_CHARGE_BYTES + (1_u64 << 24_u32) + ); + assert!(matches!( + brotli_decoder_memory_charge(9), + Err(EdgeError::BadRequest { .. }) + )); + assert!(matches!( + brotli_decoder_memory_charge(31), + Err(EdgeError::BadRequest { .. }) + )); + } + + #[test] + fn decode_gzip_stream_yields_plain_bytes() { + let stream = source(vec![Ok(Bytes::from(gzip(b"hello gzip")))]); let decoded = block_on(async { decode_gzip_stream(stream) .try_collect::>() @@ -91,14 +421,9 @@ mod tests { #[test] fn decode_brotli_stream_yields_plain_bytes() { - let mut brotli_bytes = Vec::new(); - let mut compressor = CompressorWriter::new(&mut brotli_bytes, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - drop(compressor); - - let stream = stream::iter(vec![Ok::, io::Error>(brotli_bytes)]); + let stream = source(vec![Ok(Bytes::from(brotli(b"hello brotli")))]); let decoded = block_on(async { - decode_brotli_stream(stream) + decode_brotli_stream(stream, 24, 1_u64 << 25) .try_collect::>() .await .map(|chunks| chunks.concat()) @@ -110,23 +435,168 @@ mod tests { #[test] fn decode_gzip_stream_surfaces_error_on_invalid_input() { - let garbage = b"this is definitely not a gzip member".to_vec(); - let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); + let garbage = Bytes::from_static(b"this is definitely not a gzip member"); + let stream = source(vec![Ok(garbage)]); let result = block_on(async { decode_gzip_stream(stream).try_collect::>().await }); - assert!(result.is_err(), "invalid gzip must decode to an error"); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + .. + }) + )); } #[test] fn decode_brotli_stream_surfaces_error_on_invalid_input() { // A high-bit-set lead byte is not a valid brotli stream prefix. - let garbage = vec![0xFF_u8; 64]; - let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); + let garbage = Bytes::from(vec![0xFF_u8; 64]); + let stream = source(vec![Ok(garbage)]); let result = block_on(async { - decode_brotli_stream(stream) + decode_brotli_stream(stream, 24, 1_u64 << 25) .try_collect::>() .await }); - assert!(result.is_err(), "invalid brotli must decode to an error"); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + .. + }) + )); + } + + #[test] + fn decoder_carrier_restores_exact_edge_error() { + let expected = BudgetSource::BatchDeadline; + let stream = source(vec![ + Ok(Bytes::from_static(&[0x1f, 0x8b])), + Err(EdgeError::gateway_timeout_caused("late", expected)), + ]); + let result = block_on(decode_gzip_stream(stream).try_collect::>()); + assert!(matches!( + result, + Err(EdgeError::GatewayTimeout { cause, .. }) if cause == expected + )); + } + + #[test] + fn decode_gzip_drains_every_member_to_native_eof() { + let mut encoded = gzip(b"one"); + encoded.extend(gzip(b"two")); + let decoded = block_on( + decode_gzip_stream(source(vec![Ok(Bytes::from(encoded))])).try_collect::>(), + ) + .unwrap() + .concat(); + assert_eq!(decoded, b"onetwo"); + + let encoded_member = gzip(b"one"); + let result = block_on( + decode_gzip_stream(source(vec![ + Ok(Bytes::from(encoded_member)), + Err(EdgeError::bad_gateway_with_reason( + "late transport failure", + BadGatewayReason::Transport, + )), + ])) + .try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + } + + #[test] + fn decode_brotli_rejects_trailing_data() { + let mut encoded = brotli(b"one"); + encoded.extend_from_slice(b"trailing"); + let result = block_on( + decode_brotli_stream(source(vec![Ok(Bytes::from(encoded))]), 24, 1_u64 << 25) + .try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + .. + }) + )); + } + + #[test] + fn brotli_window_parser_covers_standard_and_large_forms() { + let standard = [ + (0x21, 10), + (0x31, 11), + (0x41, 12), + (0x51, 13), + (0x61, 14), + (0x71, 15), + (0x00, 16), + (0x01, 17), + (0x03, 18), + (0x05, 19), + (0x07, 20), + (0x09, 21), + (0x0b, 22), + (0x0d, 23), + (0x0f, 24), + ]; + for (byte, bits) in standard { + assert_eq!( + parse_brotli_prefix(&[byte]).unwrap(), + BrotliPrefix::WindowBits(bits) + ); + } + for bits in 10_u8..=30 { + assert_eq!( + parse_brotli_prefix(&[0x11, bits]).unwrap(), + BrotliPrefix::WindowBits(bits) + ); + } + assert_eq!( + parse_brotli_prefix(&[0x11]).unwrap(), + BrotliPrefix::NeedSecondByte + ); + parse_brotli_prefix(&[0x91, 24]).unwrap_err(); + parse_brotli_prefix(&[0x11, 9]).unwrap_err(); + parse_brotli_prefix(&[0x11, 31]).unwrap_err(); + } + + #[test] + fn brotli_window_rejects_before_decoder_allocation() { + let window_error = block_on( + decode_brotli_stream(source(vec![Ok(Bytes::from_static(&[0x0f]))]), 23, u64::MAX) + .try_collect::>(), + ); + assert!(matches!( + window_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BrotliWindow, + .. + }) + )); + + let memory_error = block_on( + decode_brotli_stream( + source(vec![Ok(Bytes::from_static(&[0x0f]))]), + 24, + brotli_decoder_memory_charge(24).unwrap() - 1, + ) + .try_collect::>(), + ); + assert!(matches!( + memory_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecoderMemory, + .. + }) + )); } } diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 67086233..d5e49db5 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -6,11 +6,15 @@ use std::fmt; use std::sync::Arc; +use std::time::Duration; use anyhow::Error as AnyError; use async_trait::async_trait; use thiserror::Error; +use crate::error::EdgeError; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline}; + // --------------------------------------------------------------------------- // Contract test macro // --------------------------------------------------------------------------- @@ -153,6 +157,65 @@ macro_rules! config_store_contract_tests { }; } +pub const DEFAULT_CONFIG_BACKEND_BYTES: u64 = 0x0100_0000; +pub const DEFAULT_CONFIG_BLOB_BYTES: u64 = 0x0080_0000; +pub const DEFAULT_CONFIG_EXTRACTION_BYTES: u64 = 0x0100_0000; +pub const DEFAULT_CONFIG_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_CONFIG_SECRET_BYTES: u64 = 0x0010_0000; + +/// Per-extraction limits shared by the root config blob and every referenced secret. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigExtractionLimits { + pub max_backend_bytes: u64, + pub max_blob_bytes: u64, + pub max_secret_bytes: u64, + pub max_total_bytes: u64, + pub timeout: Duration, +} + +impl ConfigExtractionLimits { + /// Validates this startup policy before an adapter begins serving. + /// + /// # Errors + /// Returns an internal policy error for zero, inconsistent, or unbounded values. + #[inline] + pub fn validate(self) -> Result { + if self.max_backend_bytes == 0 + || self.max_blob_bytes == 0 + || self.max_secret_bytes == 0 + || self.max_total_bytes == 0 + { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction byte limits must be nonzero" + ))); + } + if self.max_total_bytes < self.max_blob_bytes.max(self.max_secret_bytes) { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction total byte limit is below a per-value limit" + ))); + } + if self.timeout.is_zero() || self.timeout > DEADLINE_FAR_FUTURE { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction timeout must be finite and nonzero" + ))); + } + Ok(self) + } +} + +impl Default for ConfigExtractionLimits { + #[inline] + fn default() -> Self { + Self { + max_backend_bytes: DEFAULT_CONFIG_BACKEND_BYTES, + max_blob_bytes: DEFAULT_CONFIG_BLOB_BYTES, + max_secret_bytes: DEFAULT_CONFIG_SECRET_BYTES, + max_total_bytes: DEFAULT_CONFIG_EXTRACTION_BYTES, + timeout: DEFAULT_CONFIG_EXTRACTION_TIMEOUT, + } + } +} + // --------------------------------------------------------------------------- // Trait // --------------------------------------------------------------------------- @@ -163,6 +226,9 @@ macro_rules! config_store_contract_tests { #[derive(Debug, Error)] #[non_exhaustive] pub enum ConfigStoreError { + /// The absolute read deadline expired before a complete value was available. + #[error("config store read deadline exceeded")] + DeadlineExceeded, /// An unexpected backend or provider failure occurred. #[error("config store error: {source}")] Internal { source: AnyError }, @@ -172,6 +238,9 @@ pub enum ConfigStoreError { /// The configured backend cannot currently serve requests. #[error("config store unavailable: {message}")] Unavailable { message: String }, + /// The value or guest-visible backend read exceeded its supplied allowance. + #[error("config store value exceeds configured byte limit")] + ValueTooLarge, } impl ConfigStoreError { @@ -203,6 +272,13 @@ impl ConfigStoreError { } } +/// Result of one bounded store lookup, including all bytes exposed to guest code. +#[derive(Debug)] +pub struct BoundedStoreRead { + pub backend_bytes: u64, + pub value: Option, +} + /// Object-safe interface for read-only configuration store backends. /// /// Implementations exist per adapter: @@ -217,6 +293,39 @@ pub trait ConfigStore: Send + Sync { /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. async fn get(&self, key: &str) -> Result, ConfigStoreError>; + + /// Retrieves one value under an absolute deadline and independent backend/value caps. + /// + /// The default is a cooperative compatibility implementation: it checks before and after + /// the unbounded provider call, then discards an oversized materialized result. Providers + /// must override it before claiming native allocation or cancellation guarantees. + #[inline] + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = self.get(key).await?; + if deadline.is_expired() { + return Err(ConfigStoreError::DeadlineExceeded); + } + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()) + .map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(ConfigStoreError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } // --------------------------------------------------------------------------- @@ -246,6 +355,23 @@ impl ConfigStoreHandle { self.store.get(key).await } + /// Get a config value under one absolute deadline and two byte limits. + /// + /// # Errors + /// Preserves the provider's typed bounded-read error. + #[inline] + pub async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + self.store + .get_bounded(key, deadline, max_backend_bytes, max_value_bytes) + .await + } + /// Create a new handle wrapping a config store implementation. #[inline] pub fn new(store: Arc) -> Self { @@ -259,6 +385,11 @@ impl ConfigStoreHandle { #[cfg(test)] mod tests { + #![expect( + clippy::missing_trait_methods, + reason = "legacy provider stubs intentionally exercise the bounded-read compatibility default" + )] + // Run the shared contract tests against TestConfigStore. crate::config_store_contract_tests!( test_config_store_contract, @@ -266,8 +397,10 @@ mod tests { ); use super::*; + use crate::time::Deadline; use futures::executor::block_on; use std::collections::HashMap; + use std::time::Duration; struct FailingConfigStore; @@ -322,6 +455,54 @@ mod tests { ); } + #[test] + fn bounded_exact_cap_succeeds_and_over_cap_discards_value() { + let store_handle = handle(&[("feature.checkout", "true")]); + let exact = block_on(store_handle.get_bounded( + "feature.checkout", + Deadline::after(Duration::from_secs(1)), + 4, + 4, + )) + .expect("exact bounded read"); + assert_eq!(exact.backend_bytes, 4); + assert_eq!(exact.value.as_deref(), Some("true")); + + let error = block_on(store_handle.get_bounded( + "feature.checkout", + Deadline::after(Duration::from_secs(1)), + 3, + 4, + )) + .expect_err("backend cap"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + + #[test] + fn bounded_limit_defaults_are_finite_and_validation_rejects_invalid_relationships() { + let limits = ConfigExtractionLimits::default(); + assert_eq!(limits.max_blob_bytes, DEFAULT_CONFIG_BLOB_BYTES); + assert_eq!(limits.max_backend_bytes, DEFAULT_CONFIG_BACKEND_BYTES); + assert_eq!(limits.max_secret_bytes, DEFAULT_CONFIG_SECRET_BYTES); + assert_eq!(limits.max_total_bytes, DEFAULT_CONFIG_EXTRACTION_BYTES); + assert_eq!(limits.timeout, DEFAULT_CONFIG_EXTRACTION_TIMEOUT); + limits.validate().expect("valid defaults"); + + let invalid_total = ConfigExtractionLimits { + max_total_bytes: 1, + ..limits + }; + invalid_total + .validate() + .expect_err("total below per-value cap"); + + let invalid_timeout = ConfigExtractionLimits { + timeout: Duration::ZERO, + ..limits + }; + invalid_timeout.validate().expect_err("zero timeout"); + } + #[test] fn config_store_handle_debug_output() { let store_handle = handle(&[]); diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 4bde2dea..9f8ac72f 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,24 +1,253 @@ +use std::{cell::RefCell, mem}; + use crate::body::Body; -use crate::error::EdgeError; -use crate::http::Request; +use crate::config_store::ConfigExtractionLimits; +use crate::error::{ + BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, StoreExtractionReason, +}; +use crate::http::{Extensions, HeaderMap, Method, Request, RequestParts, Uri, Version}; +use crate::ingress::{AdmittedIngress, IngressGrant}; +use crate::outbound::HttpClient; use crate::params::PathParams; -use crate::proxy::ProxyHandle; +use crate::router::RouteMetadata; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use crate::time::{Deadline, MonotonicClock, MonotonicInstant}; +use futures_util::StreamExt as _; use serde::de::DeserializeOwned; +/// Default maximum body size accepted by JSON extractors (8 MiB). +pub const DEFAULT_INBOUND_JSON_BYTES: usize = 8 * 1024 * 1024; +/// Default maximum body size accepted by form extractors (1 MiB). +pub const DEFAULT_INBOUND_FORM_BYTES: usize = 1024 * 1024; + +/// Non-consuming snapshot of inbound body ownership. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum BodyKind { + Cached { len: usize }, + Draining, + Initial, + Poisoned, + Taken, +} + +enum BodyState { + Cached(bytes::Bytes), + Draining, + Initial(Body), + Poisoned(StoredError), + Taken, +} + +pub(crate) enum FallbackDrainOutcome { + Complete, + Exceeded, + TimedOut, +} + +enum StoredError { + BadGateway(String, BadGatewayReason), + BadRequest(String), + ConfigOutOfDate(String, String), + GatewayTimeout(String, BudgetSource), + Internal(String), + MethodNotAllowed(Method, String), + NotFound(String), + NotImplemented(String), + RequestHeaderFieldsTooLarge(String), + RequestTimeout(String), + ResponseTooLarge(String, ResponseLimitReason), + ServiceUnavailable(String), + StoreExtraction(StoreExtractionReason, String, Option), + UriTooLong(String), + Validation(String), +} + +impl StoredError { + fn cancelled() -> Self { + Self::Internal("inbound body drain cancelled".to_owned()) + } + + fn capture(error: EdgeError) -> Self { + match error { + EdgeError::BadGateway { message, reason } => Self::BadGateway(message, reason), + EdgeError::BadRequest { message } => Self::BadRequest(message), + EdgeError::ConfigOutOfDate { + message, + field_path, + } => Self::ConfigOutOfDate(message, field_path), + EdgeError::GatewayTimeout { message, cause } => Self::GatewayTimeout(message, cause), + EdgeError::Internal { source } => Self::Internal(source.to_string()), + EdgeError::MethodNotAllowed { method, allowed } => { + Self::MethodNotAllowed(method, allowed) + } + EdgeError::NotFound { path } => Self::NotFound(path), + EdgeError::NotImplemented { message } => Self::NotImplemented(message), + EdgeError::RequestHeaderFieldsTooLarge { message } => { + Self::RequestHeaderFieldsTooLarge(message) + } + EdgeError::RequestTimeout { message } => Self::RequestTimeout(message), + EdgeError::ResponseTooLarge { message, reason } => { + Self::ResponseTooLarge(message, reason) + } + EdgeError::ServiceUnavailable { message } => Self::ServiceUnavailable(message), + EdgeError::StoreExtraction { + reason, + message, + field_path, + } => Self::StoreExtraction(reason, message, field_path), + EdgeError::UriTooLong { message } => Self::UriTooLong(message), + EdgeError::Validation { message } => Self::Validation(message), + } + } + + fn to_edge_error(&self) -> EdgeError { + match self { + Self::BadGateway(message, reason) => { + EdgeError::bad_gateway_with_reason(message.clone(), *reason) + } + Self::BadRequest(message) => EdgeError::bad_request(message.clone()), + Self::ConfigOutOfDate(message, field_path) => { + EdgeError::config_out_of_date(message.clone(), field_path.clone()) + } + Self::GatewayTimeout(message, cause) => { + EdgeError::gateway_timeout_caused(message.clone(), *cause) + } + Self::Internal(rendered) => EdgeError::internal(anyhow::anyhow!(rendered.clone())), + Self::MethodNotAllowed(method, allowed) => EdgeError::MethodNotAllowed { + method: method.clone(), + allowed: allowed.clone(), + }, + Self::NotFound(path) => EdgeError::not_found(path.clone()), + Self::NotImplemented(message) => EdgeError::not_implemented(message.clone()), + Self::RequestHeaderFieldsTooLarge(message) => { + EdgeError::request_header_fields_too_large(message.clone()) + } + Self::RequestTimeout(message) => EdgeError::request_timeout(message.clone()), + Self::ResponseTooLarge(message, reason) => { + EdgeError::response_too_large_with_reason(message.clone(), *reason) + } + Self::ServiceUnavailable(message) => EdgeError::service_unavailable(message.clone()), + Self::StoreExtraction(reason, message, field_path) => { + EdgeError::store_extraction(*reason, message.clone(), field_path.clone()) + } + Self::UriTooLong(message) => EdgeError::uri_too_long(message.clone()), + Self::Validation(message) => EdgeError::validation(message.clone()), + } + } +} + /// Request context exposed to handlers and middleware. pub struct RequestContext { + body: RefCell, + config_extraction_limits: ConfigExtractionLimits, + ingress_grant: RefCell>, + monotonic_clock: MonotonicClock, + parts: RequestParts, path_params: PathParams, - request: Request, + read_deadline: Option, + request_start: MonotonicInstant, + route_metadata: Option, +} + +struct DrainGuard<'cell> { + armed: bool, + body: &'cell RefCell, +} + +impl DrainGuard<'_> { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for DrainGuard<'_> { + fn drop(&mut self) { + if self.armed + && let Ok(mut state) = self.body.try_borrow_mut() + { + *state = BodyState::Poisoned(StoredError::cancelled()); + } + } } impl RequestContext { + /// Drains and caches the inbound body under the caller's byte cap. + /// + /// # Errors + /// Returns 400 on overflow, 408 on an admitted read deadline, and preserves a sticky + /// source error for every later accessor. #[inline] - pub fn body(&self) -> &Body { - self.request.body() + pub async fn body_bytes(&self, max: usize) -> Result { + let body = { + let mut state = self.body.borrow_mut(); + match mem::replace(&mut *state, BodyState::Draining) { + BodyState::Cached(bytes) => { + let result = check_cached_body(&bytes, max); + *state = BodyState::Cached(bytes); + return result; + } + BodyState::Draining => { + *state = BodyState::Draining; + return Err(EdgeError::internal(anyhow::anyhow!( + "body read already in progress" + ))); + } + BodyState::Initial(body) => body, + BodyState::Poisoned(error) => { + let returned = error.to_edge_error(); + *state = BodyState::Poisoned(error); + return Err(returned); + } + BodyState::Taken => { + *state = BodyState::Taken; + return Err(EdgeError::internal(anyhow::anyhow!( + "body already consumed via take_body" + ))); + } + } + }; + + let mut guard = DrainGuard { + armed: true, + body: &self.body, + }; + let result = drain_body(body, max, self.read_deadline, &self.monotonic_clock).await; + match result { + Ok(bytes) => { + *self.body.borrow_mut() = BodyState::Cached(bytes.clone()); + guard.disarm(); + Ok(bytes) + } + Err(error) => { + let stored = StoredError::capture(error); + let returned = stored.to_edge_error(); + *self.body.borrow_mut() = BodyState::Poisoned(stored); + guard.disarm(); + Err(returned) + } + } + } + + #[must_use] + #[inline] + pub fn body_kind(&self) -> BodyKind { + match &*self.body.borrow() { + BodyState::Cached(bytes) => BodyKind::Cached { len: bytes.len() }, + BodyState::Draining => BodyKind::Draining, + BodyState::Initial(_) => BodyKind::Initial, + BodyState::Poisoned(_) => BodyKind::Poisoned, + BodyState::Taken => BodyKind::Taken, + } + } + + #[must_use] + #[inline] + pub fn config_extraction_limits(&self) -> ConfigExtractionLimits { + self.config_extraction_limits } /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup: when a @@ -29,8 +258,8 @@ impl RequestContext { /// bug rather than a hand-wired single-handle adapter (spec hard-cutoff). #[inline] pub fn config_store(&self, id: &str) -> Option { - self.request - .extensions() + self.parts + .extensions .get::() .and_then(|registry| registry.named(id)) .map(|binding| binding.handle) @@ -40,10 +269,8 @@ impl RequestContext { #[must_use] #[inline] pub fn config_store_binding(&self, id: &str) -> Option<&ConfigStoreBinding> { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named_ref(id)) + let registry = self.parts.extensions.get::()?; + registry.named_ref(id) } /// Resolve the default [`BoundConfigStore`] — the wired registry's @@ -51,8 +278,8 @@ impl RequestContext { /// See [`Self::config_store`] for the hard-cutoff rationale. #[inline] pub fn config_store_default(&self) -> Option { - self.request - .extensions() + self.parts + .extensions .get::() .and_then(StoreRegistry::default) .map(|binding| binding.handle) @@ -63,10 +290,8 @@ impl RequestContext { #[must_use] #[inline] pub fn config_store_default_binding(&self) -> Option<&ConfigStoreBinding> { - self.request - .extensions() - .get::() - .and_then(|registry| registry.default_ref()) + let registry = self.parts.extensions.get::()?; + registry.default_ref() } /// Clone a request extension of type `T`, if present. Used by the @@ -78,40 +303,82 @@ impl RequestContext { where T: Clone + Send + Sync + 'static, { - self.request.extensions().get::().cloned() + self.parts.extensions.get::().cloned() } + #[must_use] + #[inline] + pub fn extensions(&self) -> &Extensions { + &self.parts.extensions + } + + #[inline] + pub fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.parts.extensions + } + + /// Buffers at most `max` bytes and deserializes form-urlencoded data. + /// /// # Errors - /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. + /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. #[inline] - pub fn form(&self) -> Result + pub async fn form_within(&self, max: usize) -> Result where T: DeserializeOwned, { - match self.request.body() { - Body::Once(bytes) => serde_urlencoded::from_bytes(bytes.as_ref()) - .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))), - Body::Stream(_) => Err(EdgeError::bad_request( - "streaming bodies are not supported for form extraction", - )), - } + let bytes = self.body_bytes(max).await?; + serde_urlencoded::from_bytes(bytes.as_ref()) + .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))) + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.parts.headers + } + + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.parts.headers + } + + #[must_use] + #[inline] + pub fn http_client(&self) -> Option { + self.parts.extensions.get::().cloned() } + /// Reassembles the request while preserving an initial or cached body. + /// + /// # Errors + /// Returns the sticky body error or an internal error if a drain is in progress. #[inline] - pub fn into_request(self) -> Request { - self.request + pub fn into_request(self) -> Result { + let body = match self.body.into_inner() { + BodyState::Cached(bytes) => Body::from(bytes), + BodyState::Draining => { + return Err(EdgeError::internal(anyhow::anyhow!( + "body read in progress" + ))); + } + BodyState::Initial(body) => body, + BodyState::Poisoned(error) => return Err(error.to_edge_error()), + BodyState::Taken => Body::empty(), + }; + Ok(Request::from_parts(self.parts, body)) } + /// Buffers at most `max` bytes and deserializes JSON. + /// /// # Errors - /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. + /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. #[inline] - pub fn json(&self) -> Result + pub async fn json_within(&self, max: usize) -> Result where T: DeserializeOwned, { - self.request - .body() - .to_json() + let bytes = self.body_bytes(max).await?; + serde_json::from_slice(bytes.as_ref()) .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) } @@ -122,10 +389,8 @@ impl RequestContext { /// the conventional `"default"` id (spec hard-cutoff). #[inline] pub fn kv_store(&self, id: &str) -> Option { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named(id)) + let registry = self.parts.extensions.get::()?; + registry.named(id) } /// Resolve the default [`BoundKvStore`] — the wired registry's @@ -133,20 +398,72 @@ impl RequestContext { /// See [`Self::kv_store`] for the hard-cutoff rationale. #[inline] pub fn kv_store_default(&self) -> Option { - self.request - .extensions() - .get::() - .and_then(StoreRegistry::default) + let registry = self.parts.extensions.get::()?; + registry.default() + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.parts.method + } + + /// Returns the clock paired with this request's admitted ingress lifetime. + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() } #[inline] pub fn new(request: Request, params: PathParams) -> Self { + let (parts, body) = request.into_parts(); + Self { + body: RefCell::new(BodyState::Initial(body)), + config_extraction_limits: ConfigExtractionLimits::default(), + ingress_grant: RefCell::new(None), + monotonic_clock: MonotonicClock::default(), + parts, + path_params: params, + read_deadline: None, + request_start: MonotonicInstant::now(), + route_metadata: None, + } + } + + pub(crate) fn new_routed( + request: Request, + params: PathParams, + route_metadata: RouteMetadata, + ingress: AdmittedIngress, + ) -> Self { + let (request_start, read_deadline, grant, config_extraction_limits, monotonic_clock) = + ingress.into_parts(); + let (parts, body) = request.into_parts(); Self { + body: RefCell::new(BodyState::Initial(body)), + config_extraction_limits, + ingress_grant: RefCell::new(Some(grant)), + monotonic_clock, + parts, path_params: params, - request, + read_deadline: Some(read_deadline), + request_start, + route_metadata: Some(route_metadata), } } + #[must_use] + #[inline] + pub fn parts(&self) -> &RequestParts { + &self.parts + } + + #[inline] + pub fn parts_mut(&mut self) -> &mut RequestParts { + &mut self.parts + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the path parameters cannot be deserialized into `T`. #[inline] @@ -164,11 +481,6 @@ impl RequestContext { &self.path_params } - #[inline] - pub fn proxy_handle(&self) -> Option { - self.request.extensions().get::().cloned() - } - /// # Errors /// Returns [`EdgeError::bad_request`] if the query string cannot be deserialized into `T`. #[inline] @@ -176,19 +488,31 @@ impl RequestContext { where T: DeserializeOwned, { - let query = self.request.uri().query().unwrap_or(""); + let query = self.parts.uri.query().unwrap_or(""); serde_urlencoded::from_str(query) .map_err(|err| EdgeError::bad_request(format!("invalid query string: {err}"))) } + /// Absolute deadline governing the admitted inbound body, if this context entered + /// through the adapter admission seam. + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Option { + self.read_deadline + } + + /// Monotonic instant captured at ingress entry or low-level context construction. + #[must_use] #[inline] - pub fn request(&self) -> &Request { - &self.request + pub fn request_start(&self) -> MonotonicInstant { + self.request_start } + /// Canonical matched route metadata, absent for low-level contexts. + #[must_use] #[inline] - pub fn request_mut(&mut self) -> &mut Request { - &mut self.request + pub fn route_metadata(&self) -> Option<&RouteMetadata> { + self.route_metadata.as_ref() } /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup: when a @@ -198,10 +522,8 @@ impl RequestContext { /// registry under the conventional `"default"` id (spec hard-cutoff). #[inline] pub fn secret_store(&self, id: &str) -> Option { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named(id)) + let registry = self.parts.extensions.get::()?; + registry.named(id) } /// Resolve the default [`BoundSecretStore`] — the wired registry's @@ -209,27 +531,201 @@ impl RequestContext { /// See [`Self::secret_store`] for the hard-cutoff rationale. #[inline] pub fn secret_store_default(&self) -> Option { - self.request - .extensions() - .get::() - .and_then(StoreRegistry::default) + let registry = self.parts.extensions.get::()?; + registry.default() + } + + /// Consumes body ownership without buffering it. + /// + /// # Errors + /// Returns the sticky body error or an internal error if a drain is in progress. + #[inline] + pub fn take_body(&self) -> Result { + let mut state = self.body.borrow_mut(); + match mem::replace(&mut *state, BodyState::Taken) { + BodyState::Cached(bytes) => Ok(Body::from(bytes)), + BodyState::Draining => { + *state = BodyState::Draining; + Err(EdgeError::internal(anyhow::anyhow!( + "body read in progress" + ))) + } + BodyState::Initial(body) => Ok(body), + BodyState::Poisoned(error) => { + let returned = error.to_edge_error(); + *state = BodyState::Poisoned(error); + Err(returned) + } + BodyState::Taken => { + *state = BodyState::Taken; + Ok(Body::empty()) + } + } + } + + /// Takes the application-owned ingress grant at most once. + #[must_use] + #[inline] + pub fn take_ingress_grant(&self) -> Option { + self.ingress_grant.borrow_mut().take() + } + + #[must_use] + #[inline] + pub fn uri(&self) -> &Uri { + &self.parts.uri + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.parts.version + } +} + +fn check_cached_body(bytes: &bytes::Bytes, max: usize) -> Result { + if bytes.len() > max { + return Err(EdgeError::bad_request("request body too large")); + } + Ok(bytes.clone()) +} + +fn check_read_deadline( + deadline: Option, + monotonic_clock: &MonotonicClock, +) -> Result<(), EdgeError> { + if deadline.is_some_and(|candidate| candidate.is_expired_at(monotonic_clock.now())) { + return Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )); + } + Ok(()) +} + +pub(crate) async fn drain_body( + body: Body, + max: usize, + deadline: Option, + monotonic_clock: &MonotonicClock, +) -> Result { + check_read_deadline(deadline, monotonic_clock)?; + match body { + Body::Once(bytes) => { + check_read_deadline(deadline, monotonic_clock)?; + check_cached_body(&bytes, max) + } + Body::Stream(mut stream) => { + let mut buffered = Vec::new(); + loop { + check_read_deadline(deadline, monotonic_clock)?; + let next = stream.next().await; + // Deadline wins a simultaneous body/error/EOF observation. + check_read_deadline(deadline, monotonic_clock)?; + let Some(result) = next else { + return Ok(bytes::Bytes::from(buffered)); + }; + let chunk = result?; + let next_len = buffered.len().checked_add(chunk.len()).ok_or_else(|| { + EdgeError::bad_request("request body size accounting overflow") + })?; + if next_len > max { + return Err(EdgeError::bad_request("request body too large")); + } + buffered.extend_from_slice(&chunk); + } + } + } +} + +fn fallback_deadline_outcome( + deadline: Deadline, + monotonic_clock: &MonotonicClock, +) -> Option { + deadline + .is_expired_at(monotonic_clock.now()) + .then_some(FallbackDrainOutcome::TimedOut) +} + +pub(crate) async fn drain_body_discard( + body: Body, + max: usize, + deadline: Deadline, + monotonic_clock: &MonotonicClock, +) -> Result { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + match body { + Body::Once(bytes) => { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + if bytes.len() > max { + return Ok(FallbackDrainOutcome::Exceeded); + } + Ok(FallbackDrainOutcome::Complete) + } + Body::Stream(mut stream) => { + let mut consumed = 0_usize; + loop { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + let next = stream.next().await; + // Deadline wins a simultaneous body/error/EOF observation. + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + let Some(result) = next else { + return Ok(FallbackDrainOutcome::Complete); + }; + let chunk = match result { + Ok(chunk) => chunk, + Err(EdgeError::RequestTimeout { .. }) => { + return Ok(FallbackDrainOutcome::TimedOut); + } + Err(error) => return Err(error), + }; + let Some(next_consumed) = consumed.checked_add(chunk.len()) else { + return Ok(FallbackDrainOutcome::Exceeded); + }; + consumed = next_consumed; + if consumed > max { + return Ok(FallbackDrainOutcome::Exceeded); + } + } + } } } #[cfg(test)] mod tests { + #![expect( + clippy::missing_trait_methods, + reason = "legacy provider stubs intentionally exercise the bounded-read compatibility default" + )] + use super::*; - use crate::http::{HeaderValue, Method, StatusCode, Uri, request_builder}; + use crate::http::{HeaderMap, HeaderValue, Method, StatusCode, request_builder}; + use crate::outbound::{ + HttpClient, OutboundHttpClient, OutboundRequest, OutboundResponse, OutboundSlotResult, + }; use crate::params::PathParams; - use crate::proxy::{ProxyClient, ProxyHandle, ProxyRequest, ProxyResponse}; use async_trait::async_trait; use bytes::Bytes; use futures::executor::block_on; use futures::stream; + use futures::task::noop_waker_ref; use serde::{Deserialize, Serialize}; + use std::cell::Cell; use std::collections::HashMap; + use std::future::Future as _; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + use std::time::Duration; - struct DummyClient; + struct DummyOutboundClient; #[derive(Debug, PartialEq, Deserialize, Serialize)] struct PathData { @@ -237,9 +733,18 @@ mod tests { } #[async_trait(?Send)] - impl ProxyClient for DummyClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Ok(ProxyResponse::new(StatusCode::OK, Body::empty())) + impl OutboundHttpClient for DummyOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::OK, + HeaderMap::new(), + Body::empty(), + )) + } + + async fn send_all(&self, _requests: Vec) -> Vec { + Vec::new() } } @@ -272,7 +777,8 @@ mod tests { } let body = Body::from("name=demo"); let ctx = ctx("/submit", body, PathParams::default()); - let parsed: FormData = ctx.form().expect("form data"); + let parsed: FormData = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); assert_eq!( parsed, FormData { @@ -284,15 +790,214 @@ mod tests { } #[test] - fn form_streaming_body_not_supported() { + fn body_bytes_drains_once_and_rechecks_each_callers_cap() { + let polls = Rc::new(Cell::new(0_u8)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(stream::poll_fn(move |_cx| { + let current = observed_polls.get(); + observed_polls.set(current.saturating_add(1)); + match current { + 0 => Poll::Ready(Some(Ok(Bytes::from_static(b"ok")))), + _ => Poll::Ready(None), + } + })); + let ctx = ctx("/body", body, PathParams::default()); + + assert_eq!( + block_on(ctx.body_bytes(2)).expect("exact cap"), + Bytes::from_static(b"ok") + ); + let stricter = block_on(ctx.body_bytes(1)).expect_err("stricter cached cap"); + assert_eq!(stricter.status(), StatusCode::BAD_REQUEST); + assert_eq!( + block_on(ctx.body_bytes(2)).expect("cached body remains usable"), + Bytes::from_static(b"ok") + ); + assert_eq!(polls.get(), 2, "the source is drained exactly once"); + } + + #[test] + fn initial_drain_overflow_is_sticky_and_never_retries_source() { + let polls = Rc::new(Cell::new(0_u8)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(stream::poll_fn(move |_cx| { + observed_polls.set(observed_polls.get().saturating_add(1)); + Poll::Ready(Some(Ok(Bytes::from_static(b"too large")))) + })); + let ctx = ctx("/body", body, PathParams::default()); + + let first = block_on(ctx.body_bytes(3)).expect_err("overflow"); + let second = block_on(ctx.body_bytes(usize::MAX)).expect_err("sticky overflow"); + assert_eq!(first.status(), StatusCode::BAD_REQUEST); + assert_eq!(second.status(), first.status()); + assert_eq!(second.message(), first.message()); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + assert_eq!(polls.get(), 1, "a poisoned source must not be polled again"); + assert_eq!( + ctx.take_body().expect_err("poison survives take").status(), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn source_error_replays_typed_fields_without_repolling() { + let body = Body::from_stream(stream::iter([Err(EdgeError::bad_gateway_with_reason( + "upstream body failed", + BadGatewayReason::Transport, + ))])); + let ctx = ctx("/body", body, PathParams::default()); + + for _ in 0_u8..2_u8 { + let error = block_on(ctx.body_bytes(32)).expect_err("sticky source error"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )); + assert_eq!(error.message(), "upstream body failed"); + } + } + + #[test] + fn cancelled_drain_poison_is_sticky() { + let body = Body::from_stream(stream::poll_fn(|_cx| Poll::Pending)); + let ctx = ctx("/body", body, PathParams::default()); + let mut first = Box::pin(ctx.body_bytes(32)); + let mut task = Context::from_waker(noop_waker_ref()); + assert!(matches!(first.as_mut().poll(&mut task), Poll::Pending)); + assert_eq!(ctx.body_kind(), BodyKind::Draining); + + drop(first); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + let error = block_on(ctx.body_bytes(32)).expect_err("cancel poison"); + assert_eq!( + error.message(), + "internal error: inbound body drain cancelled" + ); + } + + #[test] + fn reentrant_read_errors_without_disrupting_first_drain() { + let step = Rc::new(Cell::new(0_u8)); + let observed_step = Rc::clone(&step); + let body = Body::from_stream(stream::poll_fn(move |_cx| match observed_step.get() { + 0 => { + observed_step.set(1); + Poll::Pending + } + 1 => { + observed_step.set(2); + Poll::Ready(Some(Ok(Bytes::from_static(b"ok")))) + } + _ => Poll::Ready(None), + })); + let ctx = ctx("/body", body, PathParams::default()); + let mut first = Box::pin(ctx.body_bytes(2)); + let mut task = Context::from_waker(noop_waker_ref()); + assert!(matches!(first.as_mut().poll(&mut task), Poll::Pending)); + + let reentrant = block_on(ctx.body_bytes(2)).expect_err("reentrant read"); + assert_eq!( + reentrant.message(), + "internal error: body read already in progress" + ); + let Poll::Ready(first_result) = first.as_mut().poll(&mut task) else { + panic!("scripted drain should complete"); + }; + assert_eq!(first_result.expect("first read"), Bytes::from_static(b"ok")); + drop(first); + assert_eq!(ctx.body_kind(), BodyKind::Cached { len: 2 }); + } + + #[test] + fn expired_read_deadline_wins_and_poison_is_request_timeout() { + let mut ctx = ctx("/body", Body::from("available"), PathParams::default()); + ctx.read_deadline = Some(Deadline::after(Duration::ZERO)); + + let first = block_on(ctx.body_bytes(32)).expect_err("expired"); + let second = block_on(ctx.body_bytes(32)).expect_err("sticky expired"); + assert_eq!(first.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(second.status(), StatusCode::REQUEST_TIMEOUT); + assert!(matches!(second, EdgeError::RequestTimeout { .. })); + } + + #[test] + fn injected_clock_controls_read_deadline_and_sticky_poison() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let mut ctx = ctx("/body", Body::from("available"), PathParams::default()); + ctx.monotonic_clock = + MonotonicClock::new(move || *observed_now.lock().expect("clock lock")); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + ctx.read_deadline = Some(Deadline::at_instant(deadline)); + *now.lock().expect("clock lock") = deadline; + + let first = block_on(ctx.body_bytes(32)).expect_err("expired"); + let second = block_on(ctx.body_bytes(32)).expect_err("sticky expired"); + assert_eq!(first.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(second.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + } + + #[test] + fn take_body_and_into_request_cover_initial_cached_and_taken() { + let initial_stream = Body::from_stream(stream::iter([Ok(Bytes::from_static(b"stream"))])); + let initial = ctx("/initial", initial_stream, PathParams::default()); + assert!(initial.take_body().expect("initial body").is_stream()); + assert!( + initial + .take_body() + .expect("taken becomes empty") + .into_bytes() + .is_some_and(|body_bytes| body_bytes.is_empty()) + ); + assert_eq!(initial.body_kind(), BodyKind::Taken); + + let cached = ctx("/cached", Body::from("cached"), PathParams::default()); + assert_eq!( + block_on(cached.body_bytes(6)).expect("cache"), + Bytes::from_static(b"cached") + ); + assert_eq!( + cached.take_body().expect("cached body").into_bytes(), + Some(Bytes::from_static(b"cached")) + ); + + let reassembled = ctx("/request", Body::from("body"), PathParams::default()); + assert_eq!( + block_on(reassembled.body_bytes(4)).expect("cache"), + Bytes::from_static(b"body") + ); + let request = reassembled.into_request().expect("reassembled request"); + assert_eq!(request.uri().path(), "/request"); + assert_eq!( + request.into_body().into_bytes(), + Some(Bytes::from_static(b"body")) + ); + } + + #[test] + fn poisoned_into_request_returns_the_stored_error() { + let ctx = ctx("/request", Body::from("oversized"), PathParams::default()); + let first = block_on(ctx.body_bytes(1)).expect_err("overflow"); + let later = ctx.into_request().expect_err("poisoned request"); + assert_eq!(later.status(), first.status()); + assert_eq!(later.message(), first.message()); + } + + #[test] + fn form_streaming_body_is_bounded_and_supported() { let stream = stream::iter(vec![Ok::(Bytes::from("name=demo"))]); - let body = Body::from_stream(stream); + let body = Body::from_external_stream(stream); let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().expect_err("expected error"); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert!( - err.message() - .contains("streaming bodies are not supported for form extraction") + let parsed: serde_json::Value = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); + assert_eq!( + parsed.get("name").and_then(|value| value.as_str()), + Some("demo") ); } @@ -300,7 +1005,8 @@ mod tests { fn form_value_deserialises_successfully() { let body = Body::from("name=demo"); let ctx = ctx("/submit", body, PathParams::default()); - let parsed: serde_json::Value = ctx.form().expect("form data"); + let parsed: serde_json::Value = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); assert_eq!( parsed.get("name").and_then(|value| value.as_str()), Some("demo") @@ -310,13 +1016,14 @@ mod tests { #[test] fn invalid_form_returns_bad_request() { #[expect(dead_code, reason = "field exercised only via Deserialize")] - #[derive(Deserialize)] + #[derive(Debug, Deserialize)] struct FormData { age: u8, } let body = Body::from("age=not-a-number"); let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().err().expect("expected error"); + let err = block_on(ctx.form_within::(DEFAULT_INBOUND_FORM_BYTES)) + .expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid form payload")); } @@ -325,7 +1032,8 @@ mod tests { fn invalid_json_returns_bad_request() { let body = Body::from(&b"not json"[..]); let ctx = ctx("/echo", body, PathParams::default()); - let err = ctx.json::().expect_err("expected error"); + let err = block_on(ctx.json_within::(DEFAULT_INBOUND_JSON_BYTES)) + .expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid JSON payload")); } @@ -371,7 +1079,8 @@ mod tests { }) .expect("json body"); let ctx = ctx("/echo", body, PathParams::default()); - let parsed: Payload = ctx.json().expect("json payload"); + let parsed: Payload = + block_on(ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)).expect("json payload"); assert_eq!( parsed, Payload { @@ -380,6 +1089,21 @@ mod tests { ); } + #[test] + fn http_client_is_retrieved_when_present() { + let mut request = request_builder() + .method(Method::GET) + .uri("/outbound") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(DummyOutboundClient)); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.http_client().is_some()); + } + // `RequestContext::kv_handle()` was removed. The // present/absent behaviour is now covered by `kv_store_*` // tests against a wired `KvRegistry`. @@ -393,29 +1117,6 @@ mod tests { assert!(serialized.contains("42")); } - #[test] - fn proxy_handle_forwards_with_dummy_client() { - let handle = ProxyHandle::with_client(DummyClient); - let request = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let response = block_on(handle.forward(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_handle_is_retrieved_when_present() { - let mut request = request_builder() - .method(Method::GET) - .uri("/proxy") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(ProxyHandle::with_client(DummyClient)); - - let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.proxy_handle().is_some()); - } - #[test] fn query_defaults_to_empty_when_missing() { #[derive(Debug, Deserialize, PartialEq)] @@ -445,21 +1146,25 @@ mod tests { Body::from("payload"), params(&[("id", "123")]), ); - assert_eq!(ctx.request().uri().path(), "/items/123"); - ctx.request_mut() - .headers_mut() + assert_eq!(ctx.uri().path(), "/items/123"); + ctx.headers_mut() .insert("x-test", HeaderValue::from_static("value")); assert_eq!( - ctx.request() - .headers() + ctx.headers() .get("x-test") .and_then(|value| value.to_str().ok()), Some("value") ); assert_eq!(ctx.path_params().get("id"), Some("123")); - assert_eq!(ctx.body().as_bytes().expect("buffered"), b"payload"); + assert_eq!( + block_on(ctx.body_bytes(7)).expect("buffered"), + Bytes::from_static(b"payload") + ); + assert!(ctx.route_metadata().is_none()); + assert!(ctx.read_deadline().is_none()); + assert!(ctx.take_ingress_grant().is_none()); - let request = ctx.into_request(); + let request = ctx.into_request().expect("request"); assert_eq!(request.uri().path(), "/items/123"); } diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 3928f9ab..7658edd7 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -12,10 +12,125 @@ use crate::http::{ }; use crate::response::{IntoResponse, response_with_body}; +/// Stable identity for an EdgeZero-owned upstream decode failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { + Brotli, + Gzip, + Json, +} + +/// Stable classification for upstream failures that map to HTTP 502. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +/// Configured budget input that selected an effective deadline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BudgetSource { + BatchDeadline, + Default, + PerCallTimeout, + Unspecified, +} + +/// Stable identity for a response resource limit enforced by `EdgeZero`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseLimitReason { + BrotliWindow, + BufferedBody, + DecodedBody, + DecoderMemory, + EncodedBody, + HeaderBytes, + HeaderCount, + Unspecified, +} + +/// Stable classification for typed configuration extraction failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum StoreExtractionReason { + BackendFailure, + BackendUnavailable, + DeadlineExceeded, + Deserialization, + IntegrityMismatch, + InvalidKey, + InvalidSecretValue, + MalformedEnvelope, + MissingBlob, + MissingRegistry, + MissingSecret, + SecretBackendUnavailable, + UnknownStore, + UnsupportedVersion, + Validation, + ValueTooLarge, +} + +impl StoreExtractionReason { + fn wire_kind(self) -> &'static str { + match self { + Self::BackendFailure + | Self::IntegrityMismatch + | Self::InvalidSecretValue + | Self::MalformedEnvelope + | Self::MissingRegistry + | Self::UnsupportedVersion + | Self::UnknownStore + | Self::ValueTooLarge => "internal", + Self::BackendUnavailable | Self::DeadlineExceeded | Self::SecretBackendUnavailable => { + "service_unavailable" + } + Self::InvalidKey => "bad_request", + Self::Deserialization | Self::MissingBlob | Self::MissingSecret | Self::Validation => { + "config_out_of_date" + } + } + } + + fn wire_status(self) -> StatusCode { + match self { + Self::BackendFailure + | Self::IntegrityMismatch + | Self::InvalidSecretValue + | Self::MalformedEnvelope + | Self::MissingRegistry + | Self::UnsupportedVersion + | Self::UnknownStore + | Self::ValueTooLarge => StatusCode::INTERNAL_SERVER_ERROR, + Self::BackendUnavailable + | Self::DeadlineExceeded + | Self::Deserialization + | Self::MissingBlob + | Self::MissingSecret + | Self::SecretBackendUnavailable + | Self::Validation => StatusCode::SERVICE_UNAVAILABLE, + Self::InvalidKey => StatusCode::BAD_REQUEST, + } + } +} + /// Application-level error that carries an HTTP status code. #[derive(Debug, Error)] #[non_exhaustive] pub enum EdgeError { + /// Upstream or transport failure. HTTP 502. + #[error("{message}")] + BadGateway { + message: String, + reason: BadGatewayReason, + }, #[error("{message}")] BadRequest { message: String }, /// The blob's `data` shape disagrees with the deployed `C` @@ -24,6 +139,12 @@ pub enum EdgeError { /// `"config_out_of_date"`, carries `Retry-After: 60`. #[error("config out of date: {message}")] ConfigOutOfDate { message: String, field_path: String }, + /// A wall-clock deadline or per-request timeout fired. HTTP 504. + #[error("{message}")] + GatewayTimeout { + message: String, + cause: BudgetSource, + }, #[error("internal error: {source}")] Internal { #[from] @@ -35,13 +156,47 @@ pub enum EdgeError { NotFound { path: String }, #[error("not implemented: {message}")] NotImplemented { message: String }, + #[error("{message}")] + RequestHeaderFieldsTooLarge { message: String }, + #[error("{message}")] + RequestTimeout { message: String }, + /// An upstream response exceeded an EdgeZero-owned resource policy. HTTP 502. + #[error("{message}")] + ResponseTooLarge { + message: String, + reason: ResponseLimitReason, + }, #[error("service unavailable: {message}")] ServiceUnavailable { message: String }, + #[error("{message}")] + StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, + }, + #[error("{message}")] + UriTooLong { message: String }, #[error("validation error: {message}")] Validation { message: String }, } impl EdgeError { + #[inline] + pub fn bad_gateway>(message: S) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason: BadGatewayReason::Unspecified, + } + } + + #[inline] + pub fn bad_gateway_with_reason>(message: S, reason: BadGatewayReason) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason, + } + } + #[inline] pub fn bad_request>(message: S) -> Self { EdgeError::BadRequest { @@ -65,34 +220,19 @@ impl EdgeError { } } - /// Construct from a `serde_path_to_error` error returned by - /// the deserialise wrapper around the blob's `data` field. - #[must_use] #[inline] - pub fn config_out_of_date_from_serde(serde_err: &SerdePathError) -> Self { - // The serde message embeds the offending stored VALUE (e.g. `invalid - // type: string "hunter2", expected u32`), and this message is serialised - // into the HTTP error body. The config blob may hold secrets, so the - // VALUE must not escape — report only the category. - // - // The `field_path` STRING segments are redacted (structure kept): a map - // key is indistinguishable from a struct field here and may be a secret, - // and the redaction invariant forbids a stored string on any path. The - // exact path is available from a local `config validate`. See - // `redact_serde_path`. - use serde_json::error::Category; - let category = match serde_err.inner().classify() { - Category::Data => "wrong type or invalid value", - Category::Syntax => "malformed JSON", - Category::Eof => "unexpected end of input", - Category::Io => "i/o error while reading", - }; - Self::ConfigOutOfDate { - message: format!( - "typed app-config is out of date ({category}; value redacted) — \ - run ` config push` for this deploy" - ), - field_path: redact_serde_path(serde_err.path()), + pub fn gateway_timeout>(message: S) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause: BudgetSource::Unspecified, + } + } + + #[inline] + pub fn gateway_timeout_caused>(message: S, cause: BudgetSource) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause, } } @@ -107,11 +247,18 @@ impl EdgeError { pub fn inner(&self) -> Option<&AnyError> { match self { EdgeError::Internal { source } => Some(source), - EdgeError::BadRequest { .. } + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::MethodNotAllowed { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } | EdgeError::ServiceUnavailable { .. } => None, } @@ -129,13 +276,20 @@ impl EdgeError { fn kind_str(&self) -> &'static str { match self { + EdgeError::BadGateway { .. } => "bad_gateway", EdgeError::BadRequest { .. } => "bad_request", EdgeError::ConfigOutOfDate { .. } => "config_out_of_date", + EdgeError::GatewayTimeout { .. } => "gateway_timeout", EdgeError::Internal { .. } => "internal", EdgeError::MethodNotAllowed { .. } => "method_not_allowed", EdgeError::NotFound { .. } => "not_found", EdgeError::NotImplemented { .. } => "not_implemented", + EdgeError::RequestHeaderFieldsTooLarge { .. } => "request_header_fields_too_large", + EdgeError::RequestTimeout { .. } => "request_timeout", + EdgeError::ResponseTooLarge { .. } => "response_too_large", EdgeError::ServiceUnavailable { .. } => "service_unavailable", + EdgeError::StoreExtraction { reason, .. } => reason.wire_kind(), + EdgeError::UriTooLong { .. } => "uri_too_long", EdgeError::Validation { .. } => "validation", } } @@ -144,8 +298,15 @@ impl EdgeError { #[inline] pub fn message(&self) -> String { match self { - EdgeError::BadRequest { message } + EdgeError::BadGateway { message, .. } + | EdgeError::BadRequest { message } | EdgeError::ConfigOutOfDate { message, .. } + | EdgeError::GatewayTimeout { message, .. } + | EdgeError::RequestHeaderFieldsTooLarge { message } + | EdgeError::RequestTimeout { message } + | EdgeError::ResponseTooLarge { message, .. } + | EdgeError::StoreExtraction { message, .. } + | EdgeError::UriTooLong { message } | EdgeError::Validation { message } | EdgeError::NotImplemented { message } | EdgeError::ServiceUnavailable { message } => message.clone(), @@ -188,6 +349,39 @@ impl EdgeError { } } + #[inline] + pub fn request_header_fields_too_large>(message: S) -> Self { + Self::RequestHeaderFieldsTooLarge { + message: message.into(), + } + } + + #[inline] + pub fn request_timeout>(message: S) -> Self { + Self::RequestTimeout { + message: message.into(), + } + } + + #[inline] + pub fn response_too_large>(message: S) -> Self { + EdgeError::ResponseTooLarge { + message: message.into(), + reason: ResponseLimitReason::Unspecified, + } + } + + #[inline] + pub fn response_too_large_with_reason>( + message: S, + reason: ResponseLimitReason, + ) -> Self { + EdgeError::ResponseTooLarge { + message: message.into(), + reason, + } + } + #[inline] pub fn service_unavailable>(message: S) -> Self { EdgeError::ServiceUnavailable { @@ -199,32 +393,137 @@ impl EdgeError { #[inline] pub fn status(&self) -> StatusCode { match self { + EdgeError::BadGateway { .. } | EdgeError::ResponseTooLarge { .. } => { + StatusCode::BAD_GATEWAY + } EdgeError::BadRequest { .. } => StatusCode::BAD_REQUEST, EdgeError::ConfigOutOfDate { .. } | EdgeError::ServiceUnavailable { .. } => { StatusCode::SERVICE_UNAVAILABLE } + EdgeError::GatewayTimeout { .. } => StatusCode::GATEWAY_TIMEOUT, + EdgeError::RequestHeaderFieldsTooLarge { .. } => { + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + } + EdgeError::RequestTimeout { .. } => StatusCode::REQUEST_TIMEOUT, EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, EdgeError::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, + EdgeError::StoreExtraction { reason, .. } => reason.wire_status(), + EdgeError::UriTooLong { .. } => StatusCode::URI_TOO_LONG, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } + /// Constructs a redacted deserialization failure from a serde path error. + #[must_use] + #[inline] + pub fn store_deserialization_from_serde(serde_err: &SerdePathError) -> Self { + use serde_json::error::Category; + + let category = match serde_err.inner().classify() { + Category::Data => "wrong type or invalid value", + Category::Syntax => "malformed JSON", + Category::Eof => "unexpected end of input", + Category::Io => "i/o error while reading", + }; + let path = redact_serde_path(serde_err.path()); + Self::store_extraction( + StoreExtractionReason::Deserialization, + format!("typed app-config is out of date ({category}; value redacted)"), + Some(path), + ) + } + + #[must_use] + #[inline] + pub fn store_extraction>( + reason: StoreExtractionReason, + message: Msg, + field_path: Option, + ) -> Self { + Self::StoreExtraction { + reason, + message: message.into(), + field_path: field_path.filter(|path| !path.is_empty()), + } + } + + #[must_use] + #[inline] + pub fn store_extraction_reason(&self) -> Option { + match self { + Self::StoreExtraction { reason, .. } => Some(*reason), + Self::BadGateway { .. } + | Self::BadRequest { .. } + | Self::ConfigOutOfDate { .. } + | Self::GatewayTimeout { .. } + | Self::Internal { .. } + | Self::MethodNotAllowed { .. } + | Self::NotFound { .. } + | Self::NotImplemented { .. } + | Self::RequestHeaderFieldsTooLarge { .. } + | Self::RequestTimeout { .. } + | Self::ResponseTooLarge { .. } + | Self::ServiceUnavailable { .. } + | Self::UriTooLong { .. } + | Self::Validation { .. } => None, + } + } + + #[inline] + pub fn uri_too_long>(message: S) -> Self { + Self::UriTooLong { + message: message.into(), + } + } + #[inline] pub fn validation>(message: S) -> Self { EdgeError::Validation { message: message.into(), } } + + fn wire_message(&self) -> String { + match self { + EdgeError::BadGateway { .. } => "bad gateway".to_owned(), + EdgeError::GatewayTimeout { .. } => "gateway timeout".to_owned(), + EdgeError::Internal { .. } => "internal server error".to_owned(), + EdgeError::ResponseTooLarge { .. } => { + "upstream response exceeded configured limits".to_owned() + } + EdgeError::BadRequest { .. } + | EdgeError::ConfigOutOfDate { .. } + | EdgeError::MethodNotAllowed { .. } + | EdgeError::NotFound { .. } + | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } + | EdgeError::Validation { .. } => self.message(), + } + } } impl From for EdgeError { #[inline] fn from(err: ConfigStoreError) -> Self { match err { - ConfigStoreError::InvalidKey { message } => EdgeError::bad_request(message), - ConfigStoreError::Unavailable { message } => EdgeError::service_unavailable(message), + ConfigStoreError::DeadlineExceeded => { + EdgeError::service_unavailable("config store read deadline exceeded") + } + ConfigStoreError::InvalidKey { .. } => { + EdgeError::bad_request("config store rejected the requested key") + } + ConfigStoreError::Unavailable { .. } => { + EdgeError::service_unavailable("config store is unavailable") + } + ConfigStoreError::ValueTooLarge => { + EdgeError::internal(anyhow::anyhow!("config store value too large")) + } ConfigStoreError::Internal { source } => EdgeError::internal(source), } } @@ -234,7 +533,7 @@ impl IntoResponse for EdgeError { #[inline] fn into_response(self) -> Result { let kind = self.kind_str(); - let is_config_out_of_date = matches!(self, EdgeError::ConfigOutOfDate { .. }); + let is_config_out_of_date = self.kind_str() == "config_out_of_date"; // `ConfigOutOfDate { field_path: String::new(), .. }` (the missing-blob // path) must OMIT the `field_path` JSON key entirely, not emit // `"field_path": ""`. Per spec 6.3.1. @@ -242,17 +541,28 @@ impl IntoResponse for EdgeError { EdgeError::ConfigOutOfDate { field_path, .. } if !field_path.is_empty() => { Some(field_path.as_str()) } - EdgeError::BadRequest { .. } + EdgeError::StoreExtraction { + field_path: Some(field_path), + .. + } => Some(field_path.as_str()), + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::Internal { .. } | EdgeError::MethodNotAllowed { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } => None, }; let status = self.status(); - let message = self.message(); + let message = self.wire_message(); let mut error_obj = serde_json::Map::new(); error_obj.insert("status".into(), serde_json::Value::from(status.as_u16())); @@ -331,6 +641,227 @@ mod tests { use serde::ser; use std::str; + #[test] + fn bad_gateway_and_gateway_timeout_surface() { + for (err, code, msg) in [ + ( + EdgeError::bad_gateway("upstream refused"), + StatusCode::BAD_GATEWAY, + "upstream refused", + ), + ( + EdgeError::gateway_timeout("deadline expired"), + StatusCode::GATEWAY_TIMEOUT, + "deadline expired", + ), + ] { + assert_eq!(err.status(), code); + assert_eq!(err.message(), msg); + assert!(err.inner().is_none()); + assert!(err.to_string().contains(msg)); + } + } + + #[test] + fn bad_gateway_and_gateway_timeout_json_shape() { + for (err, code, kind, msg) in [ + ( + EdgeError::bad_gateway("nope"), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Protocol), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Unreachable), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Transport), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::gateway_timeout("late"), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::PerCallTimeout), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::BatchDeadline), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::Default), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ] { + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), code); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], code); + assert_eq!(body_json["error"]["kind"], serde_json::Value::from(kind)); + assert_eq!(body_json["error"]["message"], serde_json::Value::from(msg)); + assert!( + body_json["error"].get("field_path").is_none(), + "502/504 carry no field_path" + ); + assert!( + body_json["error"].get("reason").is_none(), + "reason is not part of the wire shape" + ); + assert!( + body_json["error"].get("cause").is_none(), + "cause is not part of the wire shape" + ); + } + } + + #[test] + fn bad_gateway_decode_reason_is_not_serialized() { + for reason in [ + BadGatewayDecodeReason::Brotli, + BadGatewayDecodeReason::Gzip, + BadGatewayDecodeReason::Json, + ] { + let err = EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Decode(reason)); + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), 502); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], 502_u16); + assert_eq!(body_json["error"]["kind"], "bad_gateway"); + assert_eq!(body_json["error"]["message"], "bad gateway"); + assert!(body_json["error"].get("reason").is_none()); + } + } + + #[test] + fn server_error_wire_messages_do_not_expose_internal_diagnostics() { + const TOKEN: &str = "super-secret-token"; + let diagnostic = format!( + "provider failed for https://example.com/bid?access_token={TOKEN}: connection refused" + ); + let cases = [ + ( + EdgeError::bad_gateway_with_reason( + diagnostic.clone(), + BadGatewayReason::Unreachable, + ), + "bad gateway", + ), + ( + EdgeError::gateway_timeout_caused(diagnostic.clone(), BudgetSource::PerCallTimeout), + "gateway timeout", + ), + ( + EdgeError::internal(anyhow::anyhow!(diagnostic.clone())), + "internal server error", + ), + ( + EdgeError::response_too_large_with_reason( + diagnostic, + ResponseLimitReason::EncodedBody, + ), + "upstream response exceeded configured limits", + ), + ]; + + for (error, public_message) in cases { + assert!( + error.message().contains(TOKEN), + "the diagnostic remains available before wire conversion" + ); + let response = error.into_response().expect("response"); + let body = parse_body(response); + assert_eq!(body["error"]["message"], public_message); + let encoded = body.to_string(); + assert!(!encoded.contains(TOKEN), "token escaped in {encoded}"); + assert!( + !encoded.contains("access_token"), + "query escaped in {encoded}" + ); + assert!( + !encoded.contains("connection refused"), + "provider diagnostic escaped in {encoded}" + ); + } + } + + #[test] + fn config_store_provider_diagnostics_do_not_reach_wire() { + const TOKEN: &str = "provider-secret-token"; + let cases = [ + ( + ConfigStoreError::InvalidKey { + message: format!("invalid key includes {TOKEN}"), + }, + "config store rejected the requested key", + ), + ( + ConfigStoreError::Unavailable { + message: format!("provider unavailable at https://user:{TOKEN}@config.invalid"), + }, + "config store is unavailable", + ), + ]; + + for (provider_error, public_message) in cases { + let response = EdgeError::from(provider_error) + .into_response() + .expect("response"); + let body = parse_body(response); + assert_eq!(body["error"]["message"], public_message); + assert!(!body.to_string().contains(TOKEN)); + } + } + + #[test] + fn bad_gateway_reason_is_typed() { + let EdgeError::BadGateway { + reason: default_reason, + .. + } = EdgeError::bad_gateway("x") + else { + panic!("expected BadGateway"); + }; + assert_eq!(default_reason, BadGatewayReason::Unspecified); + + for expected in [ + BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + BadGatewayReason::Protocol, + BadGatewayReason::Transport, + BadGatewayReason::Unreachable, + BadGatewayReason::Unspecified, + ] { + let EdgeError::BadGateway { reason, .. } = + EdgeError::bad_gateway_with_reason("x", expected) + else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, expected); + } + } + #[test] fn bad_request_sets_status_and_message() { let err = EdgeError::bad_request("oops"); @@ -338,6 +869,14 @@ mod tests { assert_eq!(err.message(), "oops"); } + #[test] + fn bare_gateway_timeout_is_unspecified() { + let EdgeError::GatewayTimeout { cause, .. } = EdgeError::gateway_timeout("x") else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, BudgetSource::Unspecified); + } + #[test] fn config_out_of_date_constructor_round_trips() { let err = EdgeError::config_out_of_date("missing field", "feature.new_checkout"); @@ -349,12 +888,19 @@ mod tests { assert_eq!(message, "missing field"); assert_eq!(field_path, "feature.new_checkout"); } - EdgeError::BadRequest { .. } + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::Internal { .. } | EdgeError::MethodNotAllowed { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), } } @@ -368,7 +914,7 @@ mod tests { } #[test] - fn config_out_of_date_from_serde_extracts_path_and_message() { + fn store_deserialization_from_serde_extracts_path_and_message() { use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -390,27 +936,24 @@ mod tests { let result: Result = serde_path_to_error::deserialize(de); let serde_err = result.expect_err("expected deserialization error"); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); + let err = EdgeError::store_deserialization_from_serde(&serde_err); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!(!err.message().is_empty()); - match err { - EdgeError::ConfigOutOfDate { field_path, .. } => { - // String segments redacted, structure preserved. - assert_eq!(field_path, "."); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let EdgeError::StoreExtraction { + field_path: Some(field_path), + reason: StoreExtractionReason::Deserialization, + .. + } = err + else { + panic!("expected deserialization store extraction"); + }; + // String segments redacted, structure preserved. + assert_eq!(field_path, "."); } #[test] - fn config_out_of_date_from_serde_redacts_map_key_from_path_and_message() { + fn store_deserialization_from_serde_redacts_map_key_from_path_and_message() { use serde::Deserialize; use std::collections::BTreeMap; @@ -436,35 +979,29 @@ mod tests { let result: Result = serde_path_to_error::deserialize(de); let serde_err = result.expect_err("expected deserialization error"); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); - match err { - EdgeError::ConfigOutOfDate { - field_path, - message, - } => { - assert!( - !message.contains(SENTINEL), - "a stored map key must never reach the message: {message}" - ); - assert!( - !field_path.contains(SENTINEL), - "a stored map key must never reach the field_path: {field_path}" - ); - // Structure is preserved (items..port -> redacted, dotted). - assert_eq!(field_path, ".."); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let err = EdgeError::store_deserialization_from_serde(&serde_err); + let EdgeError::StoreExtraction { + field_path: Some(field_path), + message, + reason: StoreExtractionReason::Deserialization, + } = err + else { + panic!("expected deserialization store extraction"); + }; + assert!( + !message.contains(SENTINEL), + "a stored map key must never reach the message: {message}" + ); + assert!( + !field_path.contains(SENTINEL), + "a stored map key must never reach the field_path: {field_path}" + ); + // Structure is preserved (items..port -> redacted, dotted). + assert_eq!(field_path, ".."); } #[test] - fn config_out_of_date_from_serde_root_error_passes_through_sentinel() { + fn store_deserialization_from_serde_root_error_passes_through_sentinel() { use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -481,25 +1018,22 @@ mod tests { let serde_err = result.expect_err("expected deserialization error"); let expected_path = serde_err.path().to_string(); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); + let err = EdgeError::store_deserialization_from_serde(&serde_err); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - match err { - EdgeError::ConfigOutOfDate { field_path, .. } => { - // The from_serde constructor passes the library's path through - // verbatim; for root-level errors that is ".". - assert_eq!( - field_path, expected_path, - "field_path should match serde_path_to_error sentinel" - ); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let EdgeError::StoreExtraction { + field_path: Some(field_path), + reason: StoreExtractionReason::Deserialization, + .. + } = err + else { + panic!("expected deserialization store extraction"); + }; + // The serde constructor passes the library's path through verbatim; + // for root-level errors that is ".". + assert_eq!( + field_path, expected_path, + "field_path should match serde_path_to_error sentinel" + ); } #[test] @@ -513,14 +1047,31 @@ mod tests { fn config_store_error_invalid_key_maps_to_bad_request() { let err = EdgeError::from(ConfigStoreError::invalid_key("invalid config key")); assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert_eq!(err.message(), "invalid config key"); + assert_eq!(err.message(), "config store rejected the requested key"); } #[test] fn config_store_error_unavailable_maps_to_service_unavailable() { let err = EdgeError::from(ConfigStoreError::unavailable("backend offline")); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(err.message(), "backend offline"); + assert_eq!(err.message(), "config store is unavailable"); + } + + #[test] + fn gateway_timeout_caused_preserves_cause() { + for expected in [ + BudgetSource::BatchDeadline, + BudgetSource::Default, + BudgetSource::PerCallTimeout, + BudgetSource::Unspecified, + ] { + let EdgeError::GatewayTimeout { cause, .. } = + EdgeError::gateway_timeout_caused("x", expected) + else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, expected); + } } #[test] @@ -636,6 +1187,7 @@ mod tests { }}; } + assert_kind!(EdgeError::bad_gateway("x"), "bad_gateway", 502_u16); assert_kind!(EdgeError::bad_request("x"), "bad_request", 400_u16); assert_kind!( EdgeError::config_out_of_date("x", "f"), @@ -654,6 +1206,12 @@ mod tests { ); assert_kind!(EdgeError::not_found("/x"), "not_found", 404_u16); assert_kind!(EdgeError::not_implemented("x"), "not_implemented", 501_u16); + assert_kind!( + EdgeError::response_too_large("x"), + "response_too_large", + 502_u16 + ); + assert_kind!(EdgeError::gateway_timeout("x"), "gateway_timeout", 504_u16); assert_kind!( EdgeError::service_unavailable("x"), "service_unavailable", @@ -676,15 +1234,233 @@ mod tests { }}; } + assert_retry_after!(EdgeError::bad_gateway("x"), false); assert_retry_after!(EdgeError::bad_request("x"), false); + assert_retry_after!(EdgeError::gateway_timeout("x"), false); assert_retry_after!(EdgeError::internal(anyhow::anyhow!("x")), false); + assert_retry_after!(EdgeError::response_too_large("x"), false); // ServiceUnavailable is also 503 but must NOT carry Retry-After assert_retry_after!(EdgeError::service_unavailable("x"), false); assert_retry_after!(EdgeError::config_out_of_date("x", "f"), true); } + #[test] + fn response_too_large_constructors_preserve_unspecified_and_specific_reason() { + let EdgeError::ResponseTooLarge { reason, .. } = EdgeError::response_too_large("large") + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(reason, ResponseLimitReason::Unspecified); + + let EdgeError::ResponseTooLarge { + reason: specific_reason, + .. + } = EdgeError::response_too_large_with_reason("large", ResponseLimitReason::EncodedBody) + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(specific_reason, ResponseLimitReason::EncodedBody); + } + + #[test] + fn response_too_large_preserves_every_reason_without_serializing_it() { + for reason in [ + ResponseLimitReason::BrotliWindow, + ResponseLimitReason::BufferedBody, + ResponseLimitReason::DecodedBody, + ResponseLimitReason::DecoderMemory, + ResponseLimitReason::EncodedBody, + ResponseLimitReason::HeaderBytes, + ResponseLimitReason::HeaderCount, + ResponseLimitReason::Unspecified, + ] { + let error = EdgeError::response_too_large_with_reason("response limit", reason); + assert_eq!(error.status(), StatusCode::BAD_GATEWAY); + let EdgeError::ResponseTooLarge { + reason: stored_reason, + .. + } = &error + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(*stored_reason, reason); + + let response = error.into_response().expect("response"); + assert!(response.headers().get(RETRY_AFTER).is_none()); + let body = parse_body(response); + assert_eq!(body["error"]["status"], 502_u16); + assert_eq!(body["error"]["kind"], "response_too_large"); + assert_eq!( + body["error"]["message"], + "upstream response exceeded configured limits" + ); + assert!(body["error"].get("reason").is_none()); + assert!(body["error"].get("field_path").is_none()); + } + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the exhaustive extraction-reason wire-policy table is clearer in one test" + )] + fn store_extraction_reason_wire_policy_table() { + let cases = [ + ( + StoreExtractionReason::BackendFailure, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::BackendUnavailable, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::DeadlineExceeded, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::IntegrityMismatch, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MalformedEnvelope, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::InvalidKey, + 400_u16, + "bad_request", + false, + ), + ( + StoreExtractionReason::InvalidSecretValue, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MissingBlob, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::MissingRegistry, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MissingSecret, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::Deserialization, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::SecretBackendUnavailable, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::UnsupportedVersion, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::UnknownStore, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::Validation, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::ValueTooLarge, + 500_u16, + "internal", + false, + ), + ]; + + for (reason, status, kind, retry_after) in cases { + let error = EdgeError::store_extraction( + reason, + "safe extraction diagnostic", + Some(String::from("field")), + ); + assert_eq!(error.store_extraction_reason(), Some(reason)); + let response = error.into_response().expect("response"); + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers().contains_key(RETRY_AFTER), retry_after); + let body = parse_body(response); + assert_eq!(body["error"]["kind"], kind); + assert_eq!(body["error"]["field_path"], "field"); + assert!(body["error"].get("reason").is_none()); + } + + let error = EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, + "missing", + Some(String::new()), + ); + let body = parse_body(error.into_response().expect("response")); + assert!(body["error"].get("field_path").is_none()); + } + + #[test] + fn inbound_boundary_errors_have_distinct_status_and_kind() { + for (error, status, kind) in [ + ( + EdgeError::request_header_fields_too_large("headers"), + 431_u16, + "request_header_fields_too_large", + ), + ( + EdgeError::request_timeout("request body deadline exceeded"), + 408_u16, + "request_timeout", + ), + (EdgeError::uri_too_long("target"), 414_u16, "uri_too_long"), + ] { + assert_eq!(error.status().as_u16(), status); + let body = parse_body(error.into_response().expect("response")); + assert_eq!(body["error"]["kind"], kind); + assert!(body["error"].get("field_path").is_none()); + } + } + #[test] fn field_path_only_on_config_out_of_date() { + for err in [EdgeError::bad_gateway("x"), EdgeError::gateway_timeout("x")] { + let body = parse_body(err.into_response().expect("response")); + assert!( + body["error"].get("field_path").is_none(), + "field_path should be absent for gateway errors" + ); + } + let bad_req_err = EdgeError::bad_request("x"); let bad_req_body = parse_body(bad_req_err.into_response().expect("response")); assert!( diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 11902205..a45be189 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -10,15 +10,16 @@ use validator::Validate; use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::{BlobEnvelope, BlobEnvelopeError}; -use crate::config_store::ConfigStoreHandle; -use crate::context::RequestContext; -use crate::error::EdgeError; +use crate::config_store::{ConfigExtractionLimits, ConfigStoreError, ConfigStoreHandle}; +use crate::context::{DEFAULT_INBOUND_FORM_BYTES, DEFAULT_INBOUND_JSON_BYTES, RequestContext}; +use crate::error::{EdgeError, StoreExtractionReason}; use crate::http::HeaderMap; use crate::secret_store::SecretError; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, }; +use crate::time::{Deadline, MonotonicClock}; use serde::de::IntoDeserializer as _; #[async_trait(?Send)] @@ -35,7 +36,7 @@ where { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.json().map(Json) + ctx.json_within(DEFAULT_INBOUND_JSON_BYTES).await.map(Json) } } @@ -79,6 +80,47 @@ where } } +/// Validated JSON extractor with a compile-time byte cap. +pub struct ValidatedJsonWithin(pub T); + +#[async_trait(?Send)] +impl FromRequest for ValidatedJsonWithin +where + T: DeserializeOwned + Validate + Send + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + let value: T = ctx.json_within(MAX).await?; + value + .validate() + .map_err(|err| EdgeError::validation(err.to_string()))?; + Ok(Self(value)) + } +} + +impl Deref for ValidatedJsonWithin { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidatedJsonWithin { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl ValidatedJsonWithin { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + impl Deref for ValidatedJson { type Target = T; @@ -108,7 +150,7 @@ pub struct Headers(pub HeaderMap); impl FromRequest for Headers { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - Ok(Headers(ctx.request().headers().clone())) + Ok(Headers(ctx.headers().clone())) } } @@ -153,7 +195,7 @@ pub struct Host(pub String); impl FromRequest for Host { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) @@ -202,7 +244,7 @@ pub struct ForwardedHost(pub String); impl FromRequest for ForwardedHost { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let host = headers .get("x-forwarded-host") .or_else(|| headers.get(header::HOST)) @@ -391,7 +433,7 @@ where { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.form().map(Form) + ctx.form_within(DEFAULT_INBOUND_FORM_BYTES).await.map(Form) } } @@ -458,6 +500,47 @@ impl ValidatedForm { } } +/// Validated form extractor with a compile-time byte cap. +pub struct ValidatedFormWithin(pub T); + +#[async_trait(?Send)] +impl FromRequest for ValidatedFormWithin +where + T: DeserializeOwned + Validate + Send + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + let value: T = ctx.form_within(MAX).await?; + value + .validate() + .map_err(|err| EdgeError::validation(err.to_string()))?; + Ok(Self(value)) + } +} + +impl Deref for ValidatedFormWithin { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidatedFormWithin { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl ValidatedFormWithin { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + /// Extractor that yields the per-request [`KvRegistry`]. /// /// Handlers pick a bound store by id at the call site: @@ -493,8 +576,7 @@ impl FromRequest for Kv { // legacy bare-handle inputs to single-id registries at the // dispatch boundary, so this path no longer needs a // fallback — a missing registry is a real bug. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Kv) @@ -615,8 +697,7 @@ impl FromRequest for Secrets { // Hard-cutoff: see `impl FromRequest for Kv`. Adapter // dispatchers normalise legacy bare-handle inputs to // single-id `SecretRegistry`s at the dispatch boundary. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Secrets) @@ -672,8 +753,7 @@ impl FromRequest for Config { // Hard-cutoff: see `impl FromRequest for Kv`. Adapter // dispatchers normalise legacy bare-handle inputs to // single-id `ConfigRegistry`s at the dispatch boundary. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Config) @@ -755,9 +835,11 @@ where #[inline] async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered \u{2014} check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered; check [stores.config] in edgezero.toml", + None, + ) })?; let key = binding.default_key.clone(); extract_from_handle::(ctx, &binding.handle, &key) @@ -783,9 +865,11 @@ where key: Option<&str>, ) -> Result { let binding = ctx.config_store_binding(store_id).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no config store registered for id `{store_id}`" - )) + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + "no config store is registered for the requested id", + None, + ) })?; let resolved_key = key.unwrap_or(&binding.default_key).to_owned(); extract_from_handle::(ctx, &binding.handle, &resolved_key).await @@ -803,14 +887,130 @@ where #[inline] pub async fn named(ctx: &RequestContext, key: &str) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered \u{2014} check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered; check [stores.config] in edgezero.toml", + None, + ) })?; extract_from_handle::(ctx, &binding.handle, key).await } } +/// Extraction-scoped accounting. One instance is created for each public +/// `AppConfig` call and shared by the root blob and all secret reads. +struct ConfigExtractionBudget { + clock: MonotonicClock, + deadline: Deadline, + max_blob_bytes: u64, + max_secret_bytes: u64, + remaining_backend_bytes: u64, + remaining_total_bytes: u64, +} + +impl ConfigExtractionBudget { + fn accept_read( + &mut self, + backend_bytes: u64, + value_length: Option, + max_value_bytes: u64, + ) -> Result<(), EdgeError> { + if self.deadline.is_expired_at(self.clock.now()) { + return Err(store_extraction_error( + StoreExtractionReason::DeadlineExceeded, + "typed app-config extraction deadline exceeded", + None, + )); + } + + let converted_value_bytes = value_length + .map(u64::try_from) + .transpose() + .map_err(|_length_error| backend_contract_error())?; + if backend_bytes > self.remaining_backend_bytes + || converted_value_bytes + .is_some_and(|bytes| bytes > max_value_bytes || backend_bytes < bytes) + { + return Err(backend_contract_error()); + } + + self.remaining_backend_bytes = self + .remaining_backend_bytes + .checked_sub(backend_bytes) + .ok_or_else(backend_contract_error)?; + if let Some(retained_bytes) = converted_value_bytes { + self.remaining_total_bytes = self + .remaining_total_bytes + .checked_sub(retained_bytes) + .ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::ValueTooLarge, + "typed app-config extraction exceeded its cumulative byte limit", + None, + ) + })?; + } + Ok(()) + } + + fn deadline(&self) -> Deadline { + self.deadline + } + + fn max_blob_bytes(&self) -> u64 { + self.max_blob_bytes + } + + fn max_secret_bytes(&self) -> u64 { + self.max_secret_bytes + } + + fn remaining_backend_bytes(&self) -> u64 { + self.remaining_backend_bytes + } + + fn start( + configured_limits: ConfigExtractionLimits, + clock: MonotonicClock, + ) -> Result { + let validated_limits = configured_limits.validate()?; + let started_at = clock.now(); + let deadline = started_at + .checked_add(validated_limits.timeout) + .ok_or_else(|| { + EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + "config extraction deadline could not be represented", + None, + ) + })?; + Ok(Self { + clock, + deadline: Deadline::at_instant(deadline), + max_blob_bytes: validated_limits.max_blob_bytes, + max_secret_bytes: validated_limits.max_secret_bytes, + remaining_backend_bytes: validated_limits.max_backend_bytes, + remaining_total_bytes: validated_limits.max_total_bytes, + }) + } +} + +fn backend_contract_error() -> EdgeError { + store_extraction_error( + StoreExtractionReason::BackendFailure, + "a store backend violated the bounded-read contract", + None, + ) +} + +fn store_extraction_error( + reason: StoreExtractionReason, + message: impl Into, + field_path: Option, +) -> EdgeError { + EdgeError::store_extraction(reason, message, field_path) +} + /// A redacted reason when `raw` is a NEWER format this build must not apply, or /// `None` when it is not (that stays ordinary corruption). /// @@ -855,14 +1055,8 @@ fn future_format_reason(raw: &str) -> Option { /// /// # Errors /// -/// - [`EdgeError::ConfigOutOfDate`] — missing blob, missing secret key, -/// deserialise failure, or validation failure on a non-secret field. -/// - [`EdgeError::Internal`] — envelope parse failure or SHA mismatch -/// (envelope integrity failures indicate a corrupt/tampered store entry, -/// not a stale config — they surface as 500 Internal). -/// - [`EdgeError::ServiceUnavailable`] — config-store backend temporarily down -/// (`ConfigStoreError::Unavailable`). -/// - [`EdgeError::BadRequest`] — malformed key (`ConfigStoreError::InvalidKey`). +/// Returns [`EdgeError::StoreExtraction`] with the stable reason corresponding +/// to store, envelope, secret, or schema failure. async fn extract_from_handle( ctx: &RequestContext, handle: &ConfigStoreHandle, @@ -871,107 +1065,122 @@ async fn extract_from_handle( where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // ConfigStoreError → EdgeError uses the existing `impl - // From for EdgeError` at - // `crates/edgezero-core/src/error.rs`, which maps: - // Unavailable → ServiceUnavailable (503) - // InvalidKey → BadRequest (400) - // Internal → Internal (500) - // NEVER `map_err(EdgeError::internal)` here — that collapses - // backpressure / bad-key signals into 500s. - let raw = handle - .get(key) + let mut budget = + ConfigExtractionBudget::start(ctx.config_extraction_limits(), ctx.monotonic_clock())?; + let read = handle + .get_bounded( + key, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) .await - .map_err(EdgeError::from)? - .ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "missing typed app-config blob at key `{key}` — \ - run ` config push` for this deploy" - ), - String::new(), - ) - })?; - // Neither the parse error nor the verify error is echoed into the - // client-facing message: a serde error embeds the offending input, and a - // `BlobEnvelope` integrity failure names the stored hashes — both are - // config-store values that may hold secrets, and this message reaches the - // HTTP body. Report a category only. - // - // A value written by a NEWER format (a bumped envelope version OR an unknown - // `edgezero_kind` discriminator) needs a DIFFERENT remediation than - // corruption: re-pushing the same config cannot help a build older than its - // config; the deployed build must be UPGRADED. The version case is already - // caught by `verify()` below (`UnknownVersion`); the two it does NOT catch are - // an `edgezero_kind` on a v1-shaped value (serde ignores the unknown field) - // and a schema-changed envelope that fails the v1 deserialize outright. A - // cheap substring gate keeps the second full JSON parse (`future_format_reason`, - // which reparses the whole blob as a `Value`) OFF the happy path -- this runs - // per request in the edge guest, over a blob that is by definition large - // wherever chunking matters. The reason names only the version/field, never a - // value, so surfacing it is safe. - let kind_tagged = raw.contains("edgezero_kind"); - let envelope: BlobEnvelope = match serde_json::from_str::(&raw) { - Ok(envelope) if !kind_tagged => envelope, - parsed => { - if let Some(reason) = future_format_reason(&raw) { - return Err(EdgeError::internal(anyhow::anyhow!( - "typed app-config blob uses {reason}, which this build does not understand; \ - redeploy this service with an updated build (re-pushing will not help)" - ))); - } - parsed.map_err(|_err| { - EdgeError::internal(anyhow::anyhow!( - "typed app-config blob is not a valid envelope (details redacted)" - )) - })? - } - }; + .map_err(|store_error| map_config_store_error(&store_error))?; + budget.accept_read( + read.backend_bytes, + read.value.as_ref().map(String::len), + budget.max_blob_bytes(), + )?; + let raw = read.value.ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::MissingBlob, + format!( + "missing typed app-config blob at key `{key}`; run ` config push` for this deploy" + ), + None, + ) + })?; + + // Neither parsing nor verification diagnostics may echo stored values. + if let Some(reason) = future_format_reason(&raw) { + return Err(store_extraction_error( + StoreExtractionReason::UnsupportedVersion, + format!( + "typed app-config blob uses {reason}, which this build does not understand; redeploy this service with an updated build" + ), + None, + )); + } + let envelope: BlobEnvelope = serde_json::from_str(&raw).map_err(|_err| { + store_extraction_error( + StoreExtractionReason::MalformedEnvelope, + "typed app-config blob is not a valid envelope (details redacted)", + None, + ) + })?; envelope.verify().map_err(|err| match err { - BlobEnvelopeError::UnknownVersion(version) => EdgeError::internal(anyhow::anyhow!( - "typed app-config blob uses envelope version {version}, which this build does not \ - understand; redeploy this service with an updated build (re-pushing will not help)" - )), - BlobEnvelopeError::ShaMismatch { .. } => EdgeError::internal(anyhow::anyhow!( - "typed app-config blob failed its integrity check (details redacted)" - )), + BlobEnvelopeError::UnknownVersion(version) => store_extraction_error( + StoreExtractionReason::UnsupportedVersion, + format!( + "typed app-config blob uses envelope version {version}, which this build does not understand; redeploy this service with an updated build" + ), + None, + ), + BlobEnvelopeError::ShaMismatch { .. } => store_extraction_error( + StoreExtractionReason::IntegrityMismatch, + "typed app-config blob failed its integrity check (details redacted)", + None, + ), })?; let mut data = envelope.into_data(); - // Secret walk per spec 3.3.3. - secret_walk::(ctx, &mut data).await?; - // Deserialise via serde_path_to_error so failures carry a dotted - // field path for ConfigOutOfDate per spec 4.3. + secret_walk::(ctx, &mut budget, &mut data).await?; let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(|err| EdgeError::config_out_of_date_from_serde(&err))?; - // RUNTIME uses cfg.validate(): after secret_walk the fields hold - // RESOLVED values, so every validator rule — including those on - // secret fields (e.g. length/regex on the actual token value) — - // MUST run. Spec 3.3.8 split: PUSH skips secret-field validators - // (via validate_excluding_secrets) because the value at push time - // is a key NAME; RUNTIME runs cfg.validate() because the value is - // now the resolved secret. + .map_err(|serde_error| EdgeError::store_deserialization_from_serde(&serde_error))?; cfg.validate().map_err(|err| { - // SECURITY: `secret_walk` has replaced `#[secret]` fields with their - // RESOLVED values, and `validator`'s error params echo the rejected - // value — so `err.to_string()` here can leak a secret into the HTTP - // response body and logs. Keep the field path (structural, not secret) - // but drop the formatted validator details on this runtime path. let field = first_violating_field(&err).unwrap_or_default(); let message = if field.is_empty() { "app config failed validation".to_owned() } else { format!("app config failed validation for field `{field}`") }; - EdgeError::config_out_of_date(message, field) + store_extraction_error( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), + ) })?; Ok(cfg) } +fn map_config_store_error(err: &ConfigStoreError) -> EdgeError { + match err { + ConfigStoreError::DeadlineExceeded => store_extraction_error( + StoreExtractionReason::DeadlineExceeded, + "typed app-config store read deadline exceeded", + None, + ), + ConfigStoreError::Internal { .. } => store_extraction_error( + StoreExtractionReason::BackendFailure, + "typed app-config store read failed (details redacted)", + None, + ), + ConfigStoreError::InvalidKey { .. } => store_extraction_error( + StoreExtractionReason::InvalidKey, + "typed app-config store rejected the requested key (details redacted)", + None, + ), + ConfigStoreError::Unavailable { .. } => store_extraction_error( + StoreExtractionReason::BackendUnavailable, + "typed app-config store is unavailable", + None, + ), + ConfigStoreError::ValueTooLarge => store_extraction_error( + StoreExtractionReason::ValueTooLarge, + "typed app-config store value exceeded its read limit", + None, + ), + } +} + /// Walk `C::secret_fields()` and replace each `#[secret]` key NAME in `data` /// with the resolved secret VALUE from the appropriate secret store. /// /// `StoreRef` fields are skipped — their value is a store id, not a key. -async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> +async fn secret_walk( + ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, + data: &mut serde_json::Value, +) -> Result<(), EdgeError> where C: AppConfigMeta, { @@ -982,7 +1191,7 @@ where if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + resolve_secret_field(ctx, budget, data, &field, &field.path, String::new()).await?; } Ok(()) } @@ -992,6 +1201,7 @@ where /// indices) for error hints. fn resolve_secret_field<'walk>( ctx: &'walk RequestContext, + budget: &'walk mut ConfigExtractionBudget, node: &'walk mut serde_json::Value, field: &'walk SecretField, remaining: &'walk [SecretPathSegment], @@ -1001,7 +1211,7 @@ fn resolve_secret_field<'walk>( match remaining.split_first() { // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { - resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await } Some((SecretPathSegment::OptionalField(name), [])) => { if node.as_object().is_some_and(|parent| { @@ -1012,7 +1222,7 @@ fn resolve_secret_field<'walk>( }) { return Ok(()); } - resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await } // Required intermediates still reject stale blobs. Optional // intermediates are represented explicitly below rather than by the @@ -1020,27 +1230,29 @@ fn resolve_secret_field<'walk>( Some((SecretPathSegment::Field(name), rest)) => { let next_rendered = join_field(&rendered, name.as_ref()); match node.get_mut(name.as_ref()) { - None | Some(serde_json::Value::Null) => Err(EdgeError::config_out_of_date( + None | Some(serde_json::Value::Null) => Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("missing or null value at `{next_rendered}`"), - next_rendered, + Some(next_rendered), )), Some(child) => { - resolve_secret_field(ctx, child, field, rest, next_rendered).await + resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await } } } Some((SecretPathSegment::OptionalField(name), rest)) => { let Some(parent) = node.as_object_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an object at `{rendered}`"), - rendered, + Some(rendered), )); }; let next_rendered = join_field(&rendered, name.as_ref()); match parent.get_mut(name.as_ref()) { None | Some(serde_json::Value::Null) => Ok(()), Some(child) => { - resolve_secret_field(ctx, child, field, rest, next_rendered).await + resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await } } } @@ -1048,14 +1260,15 @@ fn resolve_secret_field<'walk>( // intermediate unless its containing field was optional above. Some((SecretPathSegment::ArrayEach, rest)) => { let Some(items) = node.as_array_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an array at `{rendered}`"), - rendered, + Some(rendered), )); }; for (idx, item) in items.iter_mut().enumerate() { let indexed = format!("{rendered}[{idx}]"); - resolve_secret_field(ctx, item, field, rest, indexed).await?; + resolve_secret_field(ctx, budget, item, field, rest, indexed).await?; } Ok(()) } @@ -1077,6 +1290,7 @@ fn join_field(prefix: &str, name: &str) -> String { /// within `parent`. async fn resolve_leaf( ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, parent: &mut serde_json::Value, field: &SecretField, key: &str, @@ -1089,9 +1303,10 @@ async fn resolve_leaf( let leaf_path = join_field(rendered_parent, key); let Some(parent_obj) = parent.as_object_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an object containing `{key}` at `{rendered_parent}`"), - leaf_path, + Some(leaf_path), )); }; @@ -1103,9 +1318,10 @@ async fn resolve_leaf( // cases must skip — not just the missing-key case. None | Some(serde_json::Value::Null) if field.optional => return Ok(()), _ => { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{leaf_path}`"), - leaf_path, + Some(leaf_path), )); } }; @@ -1113,12 +1329,13 @@ async fn resolve_leaf( let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::MissingRegistry, format!( "secret field `{leaf_path}` has kind KeyInDefault but no default secret \ store is registered" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })?; let id = bound.store_name().to_owned(); @@ -1130,11 +1347,12 @@ async fn resolve_leaf( .get(store_ref_field) .and_then(|val| val.as_str()) .ok_or_else(|| { - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::Deserialization, format!( "missing store_ref `{store_ref_field}` for secret field `{leaf_path}`" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })? .to_owned(); @@ -1142,22 +1360,49 @@ async fn resolve_leaf( // `store_id_str` is the blob's store_ref VALUE — config data that // may be sensitive — and this message reaches the HTTP body. Name // the field, not the stored id. - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::UnknownStore, format!( "secret field `{leaf_path}` names a store_ref that is not declared in \ [stores.secrets] (id redacted)" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })?; (bound, store_id_str) } }; - let secret = bound - .require_str(&key_name) + let read = bound + .get_bytes_bounded( + &key_name, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) .await .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + budget.accept_read( + read.backend_bytes, + read.value.as_ref().map(bytes::Bytes::len), + budget.max_secret_bytes(), + )?; + let secret_bytes = read.value.ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{leaf_path}` was not found in its store (identifier redacted)" + ), + Some(leaf_path.clone()), + ) + })?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_utf8_error| { + store_extraction_error( + StoreExtractionReason::InvalidSecretValue, + format!("secret for field `{leaf_path}` is not valid UTF-8"), + Some(leaf_path.clone()), + ) + })?; parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); Ok(()) } @@ -1175,24 +1420,40 @@ fn map_secret_error( _key_name: &str, ) -> EdgeError { match err { - SecretError::NotFound { .. } => EdgeError::config_out_of_date( + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, format!( "the secret referenced by `{field_name}` was not found in its store (identifier redacted)" ), - field_name.to_owned(), + Some(field_name.to_owned()), ), - SecretError::Validation(_msg) => EdgeError::config_out_of_date( + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), + ), + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, format!( "the secret referenced by `{field_name}` was rejected by its store (details redacted)" ), - field_name.to_owned(), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!("the secret referenced by `{field_name}` exceeds its configured byte limit"), + Some(field_name.to_owned()), ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "the secret store for `{field_name}` is unreachable" - )), - SecretError::Internal(_source) => EdgeError::internal(anyhow::anyhow!( - "secret resolution for `{field_name}` failed (details redacted)" - )), } } @@ -1254,6 +1515,11 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option #[cfg(test)] mod tests { + #![expect( + clippy::missing_trait_methods, + reason = "legacy provider stubs intentionally exercise the bounded-read compatibility default" + )] + use super::*; use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; @@ -1264,11 +1530,12 @@ mod tests { use crate::params::PathParams; use crate::secret_store::{InMemorySecretStore, NoopSecretStore, SecretHandle, SecretStore}; use crate::store_registry::StoreRegistry; + use crate::time::{MonotonicClock, MonotonicInstant}; use futures::executor::block_on; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashMap; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use validator::Validate; #[derive(Clone, Debug, PartialEq)] @@ -1531,8 +1798,7 @@ mod tests { #[test] fn headers_extractor_clones_request_headers() { let mut ctx = ctx(Body::empty(), PathParams::default()); - ctx.request_mut() - .headers_mut() + ctx.headers_mut() .insert("x-test", HeaderValue::from_static("value")); let headers = block_on(Headers::from_request(&ctx)).expect("headers"); assert_eq!( @@ -2333,6 +2599,43 @@ mod tests { serde_json::to_string(&envelope).expect("serialise envelope") } + async fn test_secret_walk( + ctx: &RequestContext, + data: &mut serde_json::Value, + ) -> Result<(), EdgeError> + where + C: AppConfigMeta, + { + let mut budget = + ConfigExtractionBudget::start(ConfigExtractionLimits::default(), ctx.monotonic_clock()) + .expect("test budget"); + secret_walk::(ctx, &mut budget, data).await + } + + #[test] + fn config_extraction_budget_uses_the_request_clock() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let limits = ConfigExtractionLimits::default(); + let deadline = start.checked_add(limits.timeout).expect("deadline"); + let mut budget = ConfigExtractionBudget::start(limits, clock).expect("test budget"); + *observed.lock().expect("clock lock") = deadline; + + let error = budget + .accept_read(0, Some(0), 1) + .expect_err("injected clock reached deadline"); + + assert!(matches!( + error, + EdgeError::StoreExtraction { + reason: StoreExtractionReason::DeadlineExceeded, + .. + } + )); + } + #[test] fn app_config_extractor_happy_path() { struct FixedStore(String); @@ -2353,7 +2656,176 @@ mod tests { } #[test] - fn app_config_extractor_returns_config_out_of_date_on_missing_blob() { + fn app_config_extractor_uses_bounded_root_read() { + use std::sync::Mutex; + + use crate::config_store::{BoundedStoreRead, ConfigExtractionLimits}; + use crate::time::Deadline; + + struct BoundedOnlyStore { + blob: String, + observed: Arc>>, + } + + #[async_trait(?Send)] + impl ConfigStore for BoundedOnlyStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + panic!("typed extraction must not call the unbounded config-store API"); + } + + async fn get_bounded( + &self, + _key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + *self.observed.lock().expect("record bounded read") = + Some((deadline, max_backend_bytes, max_value_bytes)); + Ok(BoundedStoreRead { + backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), + value: Some(self.blob.clone()), + }) + } + } + + let observed = Arc::new(Mutex::new(None)); + let blob = + make_envelope(serde_json::json!({ "greeting": "bounded", "timeout_ms": 500_u32 })); + let ctx = ctx_with_config_store( + BoundedOnlyStore { + blob, + observed: Arc::clone(&observed), + }, + "the_key", + ); + + let AppConfig(cfg) = + block_on(AppConfig::::from_request(&ctx)).expect("bounded extraction"); + assert_eq!(cfg.greeting, "bounded"); + + let (_, max_backend_bytes, max_value_bytes) = observed + .lock() + .expect("read observation") + .expect("bounded read called"); + let limits = ConfigExtractionLimits::default(); + assert_eq!(max_backend_bytes, limits.max_backend_bytes); + assert_eq!(max_value_bytes, limits.max_blob_bytes); + } + + #[test] + fn app_config_extractor_shares_deadline_and_budget_with_secret_reads() { + use std::sync::Mutex; + + use crate::config_store::BoundedStoreRead; + use crate::time::{Deadline, MonotonicInstant}; + use bytes::Bytes; + + type Observation = (MonotonicInstant, u64, u64); + + struct RecordingConfigStore { + blob: String, + observed: Arc>>, + } + + #[async_trait(?Send)] + impl ConfigStore for RecordingConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + panic!("typed extraction must not call unbounded config reads"); + } + + async fn get_bounded( + &self, + _key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + self.observed.lock().expect("config observation").push(( + deadline.instant(), + max_backend_bytes, + max_value_bytes, + )); + Ok(BoundedStoreRead { + backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), + value: Some(self.blob.clone()), + }) + } + } + + struct RecordingSecretStore { + observed: Arc>>, + } + + #[async_trait(?Send)] + impl SecretStore for RecordingSecretStore { + async fn get_bytes( + &self, + _store_name: &str, + _key: &str, + ) -> Result, SecretError> { + panic!("typed extraction must not call unbounded secret reads"); + } + + async fn get_bytes_bounded( + &self, + _store_name: &str, + _key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + self.observed.lock().expect("secret observation").push(( + deadline.instant(), + max_backend_bytes, + max_value_bytes, + )); + Ok(BoundedStoreRead { + backend_bytes: 6, + value: Some(Bytes::from_static(b"secret")), + }) + } + } + + let observed = Arc::new(Mutex::new(Vec::new())); + let blob = + make_envelope(serde_json::json!({ "greeting": "bounded", "api_token": "token-key" })); + let blob_bytes = u64::try_from(blob.len()).expect("fixture length"); + let ctx = ctx_with_config_and_secrets( + RecordingConfigStore { + blob, + observed: Arc::clone(&observed), + }, + "the_key", + RecordingSecretStore { + observed: Arc::clone(&observed), + }, + "vault", + ); + + let AppConfig(cfg) = + block_on(AppConfig::::from_request(&ctx)).expect("bounded extraction"); + assert_eq!(cfg.api_token, "secret"); + + let locked_observations = observed.lock().expect("observations"); + assert_eq!(locked_observations.len(), 2); + assert_eq!( + locked_observations[0].0, locked_observations[1].0, + "deadline must not reset" + ); + assert_eq!( + locked_observations[1].1, + locked_observations[0].1 - blob_bytes, + "secret read receives the remaining backend allowance" + ); + assert_eq!( + locked_observations[1].2, + ConfigExtractionLimits::default().max_secret_bytes + ); + } + + #[test] + fn app_config_extractor_returns_typed_missing_blob() { struct EmptyStore; #[async_trait(?Send)] impl ConfigStore for EmptyStore { @@ -2365,9 +2837,9 @@ mod tests { let ctx = ctx_with_config_store(EmptyStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("missing blob must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "expected ConfigOutOfDate, got {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingBlob) ); assert!( err.message().contains("missing typed app-config blob"), @@ -2380,7 +2852,7 @@ mod tests { } #[test] - fn app_config_extractor_maps_config_store_unavailable_to_service_unavailable() { + fn app_config_extractor_maps_config_store_unavailable() { struct DownStore; #[async_trait(?Send)] impl ConfigStore for DownStore { @@ -2392,14 +2864,14 @@ mod tests { let ctx = ctx_with_config_store(DownStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("unavailable store must error"); - assert!( - matches!(err, EdgeError::ServiceUnavailable { .. }), - "ConfigStoreError::Unavailable must map to ServiceUnavailable (not Internal): {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::BackendUnavailable) ); } #[test] - fn app_config_extractor_maps_config_store_invalid_key_to_bad_request() { + fn app_config_extractor_maps_config_store_invalid_key() { struct BadKeyStore; #[async_trait(?Send)] impl ConfigStore for BadKeyStore { @@ -2411,14 +2883,14 @@ mod tests { let ctx = ctx_with_config_store(BadKeyStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("invalid key must error"); - assert!( - matches!(err, EdgeError::BadRequest { .. }), - "ConfigStoreError::InvalidKey must map to BadRequest (not Internal): {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::InvalidKey) ); } #[test] - fn app_config_extractor_maps_config_store_internal_to_internal() { + fn app_config_extractor_maps_config_store_internal() { struct BrokenStore; #[async_trait(?Send)] impl ConfigStore for BrokenStore { @@ -2430,14 +2902,14 @@ mod tests { let ctx = ctx_with_config_store(BrokenStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("internal store error must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "ConfigStoreError::Internal must map to Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::BackendFailure) ); } #[test] - fn app_config_extractor_returns_internal_on_sha_mismatch() { + fn app_config_extractor_returns_integrity_mismatch_on_sha_mismatch() { const SENTINEL: &str = "SUPER_SECRET_STORED_HASH"; struct TamperedStore; #[async_trait(?Send)] @@ -2459,10 +2931,9 @@ mod tests { let ctx = ctx_with_config_store(TamperedStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("sha mismatch must error"); - // SHA mismatch → internal error (envelope integrity failure). - assert!( - matches!(err, EdgeError::Internal { .. }), - "SHA mismatch must surface as Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::IntegrityMismatch) ); // The client-facing message (the HTTP body) must not carry the stored // hash, only a redacted category. @@ -2501,9 +2972,9 @@ mod tests { let ctx = ctx_with_config_store(FutureVersionStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("a future envelope version must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "a future version is Internal, not transient: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::UnsupportedVersion) ); let message = err.message().to_lowercase(); assert!( @@ -2542,13 +3013,41 @@ mod tests { .expect_err("an unknown edgezero_kind must error"); let message = err.message().to_lowercase(); assert!( - matches!(err, EdgeError::Internal { .. }) + err.store_extraction_reason() == Some(StoreExtractionReason::UnsupportedVersion) && message.contains("redeploy") && message.contains("edgezero_kind"), "an unknown discriminator must ask to redeploy: {err:?}" ); } + #[test] + fn app_config_extractor_rejects_an_escaped_edgezero_kind_key() { + struct EscapedKindTaggedStore; + #[async_trait(?Send)] + impl ConfigStore for EscapedKindTaggedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + let env = BlobEnvelope::new( + serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), + "2026-01-01T00:00:00Z".into(), + ); + let serialized = serde_json::to_string(&env).expect("envelope"); + let serialized_body = serialized.strip_prefix('{').expect("JSON object"); + Ok(Some(format!( + "{{\"edgezero_\\u006bind\":\"new_format\",{serialized_body}" + ))) + } + } + + let ctx = ctx_with_config_store(EscapedKindTaggedStore, "key"); + let err = block_on(AppConfig::::from_request(&ctx)) + .expect_err("an escaped edgezero_kind must error"); + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::UnsupportedVersion) + ); + assert!(err.message().contains("edgezero_kind")); + } + #[test] fn app_config_extractor_does_not_leak_data_value_in_deserialize_error() { const SENTINEL: &str = "SUPER_SECRET_FIELD_VALUE"; @@ -2571,9 +3070,9 @@ mod tests { let ctx = ctx_with_config_store(TypeErrorStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("a type mismatch in the typed config must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "a typed deserialize failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Deserialization) ); assert!( !err.message().contains(SENTINEL), @@ -2581,16 +3080,18 @@ mod tests { ); // The field path segments are redacted (a map key is indistinguishable // from a struct field and may be a secret); structure is kept. - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert!( - !field_path.contains("timeout_ms") && field_path.contains(""), - "the field path must be redacted: {field_path}" + field_path.as_deref().is_some_and(|path| { + !path.contains("timeout_ms") && path.contains("") + }), + "the field path must be redacted: {field_path:?}" ); } } #[test] - fn app_config_extractor_returns_internal_on_bad_envelope_json() { + fn app_config_extractor_returns_malformed_envelope_on_bad_json() { struct GarbageStore; #[async_trait(?Send)] impl ConfigStore for GarbageStore { @@ -2602,9 +3103,9 @@ mod tests { let ctx = ctx_with_config_store(GarbageStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("bad envelope JSON must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "Envelope parse failure must be Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MalformedEnvelope) ); assert!( err.message().contains("not a valid envelope"), @@ -2619,7 +3120,7 @@ mod tests { } #[test] - fn app_config_extractor_returns_config_out_of_date_on_deserialise_failure() { + fn app_config_extractor_returns_deserialization_on_deserialise_failure() { use crate::config_store::{ConfigStore, ConfigStoreError}; // Blob has wrong type for `timeout_ms` (string instead of u32). @@ -2638,21 +3139,22 @@ mod tests { let ctx = ctx_with_config_store(BadDataStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("type mismatch must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "deserialise failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Deserialization) ); // The field path segment is redacted (indistinguishable from a map key). - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "", + field_path.as_deref(), + Some(""), "the field path must be redacted: {err:?}" ); } } #[test] - fn app_config_extractor_returns_config_out_of_date_on_validation_failure() { + fn app_config_extractor_returns_validation_on_validation_failure() { use crate::config_store::{ConfigStore, ConfigStoreError}; // `timeout_ms = 0` violates `range(min = 1)`. @@ -2668,13 +3170,14 @@ mod tests { let ctx = ctx_with_config_store(ZeroTimeoutStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("validation failure must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "validation failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Validation) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "timeout_ms", + field_path.as_deref(), + Some("timeout_ms"), "field_path names the violator: {err:?}" ); } @@ -2709,7 +3212,7 @@ mod tests { } #[test] - fn app_config_secret_walk_missing_key_in_default_store_is_config_out_of_date() { + fn app_config_secret_walk_missing_key_has_typed_reason() { use crate::config_store::{ConfigStore, ConfigStoreError}; struct BlobStore(String); #[async_trait(?Send)] @@ -2726,13 +3229,14 @@ mod tests { let ctx = ctx_with_config_and_secrets(BlobStore(blob), "key", NoopSecretStore, "vault"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("missing secret must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "missing secret must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingSecret) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "api_token", + field_path.as_deref(), + Some("api_token"), "field_path names the secret field: {err:?}" ); } @@ -2813,7 +3317,7 @@ mod tests { let mut data = serde_json::json!({ "integrations": { "datadome": { "server_side_key": "dd_key" } } }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!( data["integrations"]["datadome"]["server_side_key"], serde_json::json!("resolved-dd") @@ -2826,7 +3330,7 @@ mod tests { let mut data = serde_json::json!({ "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); } @@ -2837,7 +3341,7 @@ mod tests { let mut data = serde_json::json!({ "vaulted": { "token": "tok_key", "vault": "named" } }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); // The store_ref sibling is left intact (it names a store, not a secret). assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); @@ -2847,7 +3351,7 @@ mod tests { fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing store_ref sibling"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!(err.to_string().contains("vaulted.token")); @@ -2857,7 +3361,8 @@ mod tests { fn secret_walk_skips_absent_optional_leaf() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key - block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + block_on(test_secret_walk::(&ctx, &mut data)) + .expect("absent optional is fine"); assert!(data.get("maybe_key").is_none()); } @@ -2867,7 +3372,7 @@ mod tests { // not omitted). The walk must skip a null optional leaf, not error it. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "maybe_key": null }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("null optional is skipped, not treated as non-string"); assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched } @@ -2876,7 +3381,7 @@ mod tests { fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing required nested leaf"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( @@ -2893,7 +3398,7 @@ mod tests { // vaguer serde error downstream. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "greeting": "hi" }); // no `integrations` - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing required intermediate"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( @@ -2906,7 +3411,7 @@ mod tests { fn secret_walk_skips_absent_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": {} }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("absent optional intermediate is fine"); } @@ -2914,7 +3419,7 @@ mod tests { fn secret_walk_skips_null_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": null } }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("null optional intermediate is fine"); } @@ -2922,7 +3427,7 @@ mod tests { fn secret_walk_rejects_scalar_parent_of_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": "not-an-object" }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("a present optional intermediate must have an object parent"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); @@ -2930,31 +3435,39 @@ mod tests { err.to_string().contains("integrations"), "error names the malformed parent: {err}" ); - let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { - panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + let EdgeError::StoreExtraction { + reason, field_path, .. + } = &err + else { + panic!("malformed optional parent must be typed: {err:?}"); }; - assert_eq!(field_path, "integrations"); + assert_eq!(*reason, StoreExtractionReason::Deserialization); + assert_eq!(field_path.as_deref(), Some("integrations")); } #[test] fn secret_walk_rejects_scalar_parent_of_terminal_optional_field() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": "not-an-object" }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("a terminal optional field must have an object parent"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { - panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + let EdgeError::StoreExtraction { + reason, field_path, .. + } = &err + else { + panic!("malformed optional parent must be typed: {err:?}"); }; - assert_eq!(field_path, "integrations.webhook_key"); + assert_eq!(*reason, StoreExtractionReason::Deserialization); + assert_eq!(field_path.as_deref(), Some("integrations.webhook_key")); } #[test] fn secret_walk_present_intermediate_absent_optional_leaf_is_ok() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("absent optional leaf under present intermediates is fine"); } @@ -3023,8 +3536,7 @@ mod tests { } #[test] - fn app_config_no_registry_returns_internal_error() { - // No ConfigRegistry in extensions → Internal error. + fn app_config_no_registry_returns_typed_error() { let request = request_builder() .method(Method::GET) .uri("/cfg") @@ -3033,9 +3545,9 @@ mod tests { let ctx = RequestContext::new(request, PathParams::default()); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("no registry must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "no registry must surface as Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingRegistry) ); assert!( err.message().contains("no default config store registered"), @@ -3092,7 +3604,7 @@ mod tests { } /// Spec 3.3.8: a resolved secret that FAILS a validator on the secret - /// field must produce `ConfigOutOfDate` — the runtime runs the full validator. + /// field must produce a typed schema mismatch; runtime runs the full validator. #[test] fn runtime_rejects_resolved_secret_failing_validator() { use crate::config_store::{ConfigStore, ConfigStoreError}; @@ -3129,13 +3641,14 @@ mod tests { let ctx = ctx_with_config_and_secrets(BlobStore(blob), "key", secret_store, "vault"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("short resolved secret must fail validator"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "validator failure on resolved secret must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Validation) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "api_token", + field_path.as_deref(), + Some("api_token"), "field_path names the violating secret field: {err:?}" ); } diff --git a/crates/edgezero-core/src/http.rs b/crates/edgezero-core/src/http.rs index 60ead491..7415749c 100644 --- a/crates/edgezero-core/src/http.rs +++ b/crates/edgezero-core/src/http.rs @@ -12,7 +12,7 @@ pub mod header { use std::future::Future; use std::pin::Pin; -use http::request::Builder as HttpRequestBuilder; +use http::request::{Builder as HttpRequestBuilder, Parts as HttpRequestParts}; use http::response::Builder as HttpResponseBuilder; use crate::body::Body; @@ -30,6 +30,7 @@ pub type HeaderValue = http::HeaderValue; pub type Method = http::Method; pub type Request = http::Request; pub type RequestBuilder = HttpRequestBuilder; +pub type RequestParts = HttpRequestParts; pub type Response = http::Response; pub type ResponseBuilder = HttpResponseBuilder; pub type StatusCode = http::StatusCode; diff --git a/crates/edgezero-core/src/ingress.rs b/crates/edgezero-core/src/ingress.rs new file mode 100644 index 00000000..c600513f --- /dev/null +++ b/crates/edgezero-core/src/ingress.rs @@ -0,0 +1,1115 @@ +use std::any::Any; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; + +use crate::body::Body; +use crate::config_store::ConfigExtractionLimits; +use crate::error::EdgeError; +use crate::http::{ + Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Response, StatusCode, Uri, + Version, + header::{CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING}, +}; +use crate::response_egress::ResponseEgressEnvelope; +use crate::router::{ResolvedDispatch, RouteMetadata, RouteResolution}; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + +pub const DEFAULT_INBOUND_READ_BUDGET: Duration = Duration::from_secs(30); +pub const DEFAULT_MAX_REQUEST_HEADER_BYTES: u64 = 0x0001_0000; +pub const DEFAULT_MAX_REQUEST_HEADER_COUNT: u64 = 100; +pub const DEFAULT_MAX_REQUEST_TARGET_BYTES: u64 = 8_192; + +/// Opaque application-owned lease transferred to one admitted request. +pub struct IngressGrant { + value: Option>, +} + +impl IngressGrant { + /// Extracts the application-owned value without exposing its type metadata. + /// + /// # Errors + /// Returns the unchanged grant when it is empty or stores a different type. + #[inline] + pub fn downcast(mut self) -> Result + where + T: Send + Sync + 'static, + { + let Some(boxed) = self.value.take() else { + return Err(self); + }; + match boxed.downcast::() { + Ok(typed) => Ok(*typed), + Err(original) => { + self.value = Some(original); + Err(self) + } + } + } + + #[must_use] + #[inline] + pub fn empty() -> Self { + Self { value: None } + } + + #[must_use] + #[inline] + pub fn new(value: T) -> Self + where + T: Send + Sync + 'static, + { + Self { + value: Some(Box::new(value)), + } + } +} + +impl fmt::Debug for IngressGrant { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IngressGrant") + .field("occupied", &self.value.is_some()) + .finish() + } +} + +/// Cloneable application response restricted to finite buffered body bytes. +#[derive(Clone, Debug)] +pub struct BufferedIngressResponse { + body: Bytes, + headers: HeaderMap, + status: StatusCode, +} + +impl BufferedIngressResponse { + /// Returns the finite buffered response bytes. + #[must_use] + #[inline] + pub fn body(&self) -> &[u8] { + &self.body + } + + /// Returns the application-selected response headers. + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + pub(crate) fn into_response(self) -> Response { + let mut response = Response::new(Body::from_bytes(self.body)); + *response.status_mut() = self.status; + *response.headers_mut() = self.headers; + response + } + + /// Constructs an exact buffered response from application-selected parts. + #[must_use] + #[inline] + pub fn new(status: StatusCode, headers: HeaderMap, body: B) -> Self + where + B: Into, + { + Self { + body: body.into(), + headers, + status, + } + } + + /// Returns the application-selected response status. + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } + + /// Constructs a buffered UTF-8 plain-text response with an exact content length. + #[must_use] + #[inline] + pub fn text(status: StatusCode, text: S) -> Self + where + S: Into, + { + let body = Bytes::from(text.into()); + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + headers.insert(CONTENT_LENGTH, HeaderValue::from(body.len())); + Self::new(status, headers, body) + } +} + +/// Application decision made before an inbound body can be consumed. +#[non_exhaustive] +pub enum AdmissionDecision { + Admit { + grant: IngressGrant, + read_deadline: Deadline, + }, + /// Drains an unmatched or wrong-method request body before returning the canonical + /// pre-resolved 404/405 response. + ReadBodyBeforeFallback { + grant: IngressGrant, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + }, + Refuse(Response), +} + +struct FallbackDispatch { + max_body_bytes: usize, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, +} + +enum IngressDispatchDisposition { + Dispatch, + ReadBodyBeforeFallback(Box), +} + +pub(crate) struct FallbackIngress { + dispatch: Box, + grant: IngressGrant, + monotonic_clock: MonotonicClock, + read_deadline: Deadline, +} + +impl FallbackIngress { + pub(crate) fn into_parts( + self, + ) -> ( + IngressGrant, + usize, + Deadline, + MonotonicClock, + BufferedIngressResponse, + BufferedIngressResponse, + ) { + let FallbackDispatch { + max_body_bytes, + on_exceeded, + on_timeout, + } = *self.dispatch; + ( + self.grant, + max_body_bytes, + self.read_deadline, + self.monotonic_clock, + on_exceeded, + on_timeout, + ) + } +} + +/// Validated policy outcome consumed by an adapter before native body ownership moves. +/// +/// `Admitted` includes both normal routed dispatch and an opt-in bounded fallback drain. +#[non_exhaustive] +pub enum IngressAdmissionOutcome { + Admitted(AdmittedIngress), + Refused(Response), +} + +/// Result of route resolution plus application admission before native body ownership moves. +#[non_exhaustive] +pub enum IngressBeginOutcome { + Admitted(PreparedIngress), + Refused(ResponseEgressEnvelope), +} + +/// Proof that the application selected one request disposition with a finite read deadline. +pub struct AdmittedIngress { + config_extraction_limits: ConfigExtractionLimits, + dispatch_disposition: IngressDispatchDisposition, + grant: IngressGrant, + monotonic_clock: MonotonicClock, + read_deadline: Deadline, + request_start: MonotonicInstant, +} + +impl AdmittedIngress { + pub(crate) fn into_fallback(self) -> Option { + let Self { + dispatch_disposition, + grant, + monotonic_clock, + read_deadline, + .. + } = self; + let IngressDispatchDisposition::ReadBodyBeforeFallback(dispatch) = dispatch_disposition + else { + return None; + }; + Some(FallbackIngress { + dispatch, + grant, + monotonic_clock, + read_deadline, + }) + } + + pub(crate) fn into_parts( + self, + ) -> ( + MonotonicInstant, + Deadline, + IngressGrant, + ConfigExtractionLimits, + MonotonicClock, + ) { + ( + self.request_start, + self.read_deadline, + self.grant, + self.config_extraction_limits, + self.monotonic_clock, + ) + } + + pub(crate) fn is_fallback(&self) -> bool { + matches!( + self.dispatch_disposition, + IngressDispatchDisposition::ReadBodyBeforeFallback(_) + ) + } + + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() + } + + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Deadline { + self.read_deadline + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + pub(crate) fn with_config_extraction_limits(mut self, limits: ConfigExtractionLimits) -> Self { + self.config_extraction_limits = limits; + self + } +} + +/// Whether request-head accounting was enforced before normalized request construction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressHeadAccounting { + HostManaged, + RawValidated { + request_header_bytes: u64, + request_header_count: u64, + request_target_bytes: u64, + }, +} + +/// Trusted framing result, or an explicit marker that the host owns validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressFraming { + Chunked, + ContentLength(u64), + HostManaged, + NoBody, + ProtocolManaged, +} + +/// Finite request-head limits installed before an adapter begins serving. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct IngressHeadLimits { + header_bytes: u64, + header_count: u64, + target_bytes: u64, +} + +impl IngressHeadLimits { + #[must_use] + #[inline] + pub fn max_request_header_bytes(&self) -> u64 { + self.header_bytes + } + + #[must_use] + #[inline] + pub fn max_request_header_count(&self) -> u64 { + self.header_count + } + + #[must_use] + #[inline] + pub fn max_request_target_bytes(&self) -> u64 { + self.target_bytes + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_header_bytes(mut self, max: u64) -> Result { + require_nonzero("max_request_header_bytes", max)?; + self.header_bytes = max; + Ok(self) + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_header_count(mut self, max: u64) -> Result { + require_nonzero("max_request_header_count", max)?; + self.header_count = max; + Ok(self) + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_target_bytes(mut self, max: u64) -> Result { + require_nonzero("max_request_target_bytes", max)?; + self.target_bytes = max; + Ok(self) + } +} + +impl Default for IngressHeadLimits { + #[inline] + fn default() -> Self { + Self { + header_bytes: DEFAULT_MAX_REQUEST_HEADER_BYTES, + header_count: DEFAULT_MAX_REQUEST_HEADER_COUNT, + target_bytes: DEFAULT_MAX_REQUEST_TARGET_BYTES, + } + } +} + +/// Normalized, body-free request metadata supplied by an adapter before admission. +pub struct IngressHeadParts { + extensions: Extensions, + framing: IngressFraming, + head_accounting: IngressHeadAccounting, + headers: HeaderMap, + method: Method, + target: Uri, + version: Version, +} + +impl IngressHeadParts { + /// Copies normalized metadata from body-free request parts. + #[must_use] + #[inline] + pub fn from_parts( + parts: &RequestParts, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + Self { + extensions: parts.extensions.clone(), + framing, + head_accounting, + headers: parts.headers.clone(), + method: parts.method.clone(), + target: parts.uri.clone(), + version: parts.version, + } + } + + /// Copies normalized metadata from a core request without inspecting its body. + #[must_use] + #[inline] + pub fn from_request( + request: &Request, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + Self { + extensions: request.extensions().clone(), + framing, + head_accounting, + headers: request.headers().clone(), + method: request.method().clone(), + target: request.uri().clone(), + version: request.version(), + } + } + + pub(crate) fn into_head( + self, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + ) -> IngressHead { + IngressHead { + extensions: self.extensions, + framing: self.framing, + head_accounting: self.head_accounting, + headers: self.headers, + method: self.method, + request_start, + route_resolution, + target: self.target, + version: self.version, + } + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Creates host-managed request-head metadata. Adapters with a raw parser boundary replace + /// the accounting and framing markers through the builder methods below. + #[must_use] + #[inline] + pub fn new(method: Method, target: Uri, version: Version, headers: HeaderMap) -> Self { + Self { + extensions: Extensions::new(), + framing: IngressFraming::HostManaged, + head_accounting: IngressHeadAccounting::HostManaged, + headers, + method, + target, + version, + } + } + + #[must_use] + #[inline] + pub fn target(&self) -> &Uri { + &self.target + } + + /// Applies normalized defense-in-depth checks without making a raw-boundary claim. + /// + /// # Errors + /// Returns 414/431 for normalized head overages and 400 for visible ambiguous framing. + #[inline] + pub fn validate_normalized(&self, limits: IngressHeadLimits) -> Result<(), EdgeError> { + validate_normalized_head(&self.target, self.version, &self.headers, limits) + } + + #[must_use] + #[inline] + pub fn with_extension(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.extensions.insert(value); + self + } + + #[must_use] + #[inline] + pub fn with_framing(mut self, framing: IngressFraming) -> Self { + self.framing = framing; + self + } + + #[must_use] + #[inline] + pub fn with_head_accounting(mut self, head_accounting: IngressHeadAccounting) -> Self { + self.head_accounting = head_accounting; + self + } +} + +/// Immutable request-head view supplied to the application admission policy. +pub struct IngressHead { + extensions: Extensions, + framing: IngressFraming, + head_accounting: IngressHeadAccounting, + headers: HeaderMap, + method: Method, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + target: Uri, + version: Version, +} + +impl IngressHead { + #[must_use] + #[inline] + pub fn extension(&self) -> Option<&T> + where + T: Send + Sync + 'static, + { + self.extensions.get::() + } + + #[must_use] + #[inline] + pub fn framing(&self) -> IngressFraming { + self.framing + } + + /// Builds the body-blind admission view from a normalized core request. + #[must_use] + #[inline] + pub fn from_request( + request: &Request, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + IngressHeadParts::from_request(request, head_accounting, framing) + .into_head(request_start, route_resolution) + } + + #[must_use] + #[inline] + pub fn head_accounting(&self) -> IngressHeadAccounting { + self.head_accounting + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Builds a finite deadline relative to this head's captured request start. + /// + /// The duration is clamped to the configured far-future bound. Arithmetic + /// overflow fails closed by returning a deadline at the request start. + #[must_use] + #[inline] + pub fn read_deadline_after(&self, duration: Duration) -> Deadline { + let bounded = duration.min(DEADLINE_FAR_FUTURE); + Deadline::at_instant( + self.request_start + .checked_add(bounded) + .unwrap_or(self.request_start), + ) + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + #[must_use] + #[inline] + pub fn route_resolution(&self) -> &RouteResolution { + &self.route_resolution + } + + #[must_use] + #[inline] + pub fn target(&self) -> &Uri { + &self.target + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.version + } + + #[must_use] + #[inline] + pub fn with_extension(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.extensions.insert(value); + self + } +} + +/// Opaque, single-use admission proof paired with the exact resolved dispatch token. +pub struct PreparedIngress { + admitted: AdmittedIngress, + resolved: ResolvedDispatch, +} + +impl PreparedIngress { + pub(crate) fn into_parts(self) -> (ResolvedDispatch, AdmittedIngress) { + (self.resolved, self.admitted) + } + + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.admitted.monotonic_clock() + } + + pub(crate) fn new(resolved: ResolvedDispatch, admitted: AdmittedIngress) -> Self { + Self { admitted, resolved } + } + + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Deadline { + self.admitted.read_deadline() + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.admitted.request_start() + } + + /// Canonical metadata for the admitted matched route, if any. + #[must_use] + #[inline] + pub fn route_metadata(&self) -> Option<&RouteMetadata> { + match self.resolved.resolution() { + RouteResolution::Matched(route) => Some(route), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => None, + } + } + + #[must_use] + #[inline] + pub fn route_resolution(&self) -> &RouteResolution { + self.resolved.resolution() + } +} + +pub(crate) type IngressAdmissionPolicy = + Arc AdmissionDecision + Send + Sync>; + +pub(crate) fn default_admission_policy() -> IngressAdmissionPolicy { + Arc::new(|head| AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(DEFAULT_INBOUND_READ_BUDGET), + }) +} + +pub(crate) fn apply_admission_policy( + policy: &IngressAdmissionPolicy, + head: &IngressHead, + monotonic_clock: MonotonicClock, +) -> Result { + let (grant, read_deadline, dispatch_disposition) = match policy(head) { + AdmissionDecision::Refuse(response) => { + return Ok(IngressAdmissionOutcome::Refused(response)); + } + AdmissionDecision::Admit { + grant, + read_deadline, + } => (grant, read_deadline, IngressDispatchDisposition::Dispatch), + AdmissionDecision::ReadBodyBeforeFallback { + grant, + max_body_bytes, + read_deadline, + on_exceeded, + on_timeout, + } => { + match head.route_resolution() { + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => {} + RouteResolution::Matched(_) => { + return Err(EdgeError::internal(anyhow::anyhow!( + "fallback body policy requires an unmatched or wrong-method route" + ))); + } + } + ( + grant, + read_deadline, + IngressDispatchDisposition::ReadBodyBeforeFallback(Box::new(FallbackDispatch { + max_body_bytes, + on_exceeded, + on_timeout, + })), + ) + } + }; + let maximum = head + .request_start() + .checked_add(DEADLINE_FAR_FUTURE) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "ingress admission deadline arithmetic overflow" + )) + })?; + let deadline = Deadline::at_instant(read_deadline.instant().min(maximum)); + Ok(IngressAdmissionOutcome::Admitted(AdmittedIngress { + config_extraction_limits: ConfigExtractionLimits::default(), + dispatch_disposition, + grant, + monotonic_clock, + read_deadline: deadline, + request_start: head.request_start(), + })) +} + +fn require_nonzero(name: &str, value: u64) -> Result<(), EdgeError> { + if value == 0 { + return Err(EdgeError::internal(anyhow::anyhow!( + "ingress head limit `{name}` must be nonzero" + ))); + } + Ok(()) +} + +/// Applies finite defense-in-depth limits and framing checks to a normalized head. +/// +/// This cannot recover raw HTTP field-line or request-target octets and therefore never +/// upgrades [`IngressHeadAccounting::HostManaged`] or [`IngressFraming::HostManaged`]. +/// +/// # Errors +/// Returns 414/431 for normalized head overages and 400 for visible ambiguous framing. +#[inline] +pub fn validate_normalized_ingress_parts( + parts: &RequestParts, + limits: IngressHeadLimits, +) -> Result<(), EdgeError> { + validate_normalized_head(&parts.uri, parts.version, &parts.headers, limits) +} + +fn validate_normalized_head( + target: &Uri, + version: Version, + headers: &HeaderMap, + limits: IngressHeadLimits, +) -> Result<(), EdgeError> { + let target_bytes = u64::try_from(target.to_string().len()).map_err(|_length_error| { + EdgeError::uri_too_long("normalized request target is too large") + })?; + if target_bytes > limits.max_request_target_bytes() { + return Err(EdgeError::uri_too_long( + "normalized request target exceeds configured limit", + )); + } + + let header_count = u64::try_from(headers.len()).map_err(|_length_error| { + EdgeError::request_header_fields_too_large("normalized request has too many headers") + })?; + if header_count > limits.max_request_header_count() { + return Err(EdgeError::request_header_fields_too_large( + "normalized request header count exceeds configured limit", + )); + } + + let mut header_bytes = 2_u64; + for (name, value) in headers { + let line_bytes = name + .as_str() + .len() + .checked_add(value.as_bytes().len()) + .and_then(|bytes| bytes.checked_add(4)) + .and_then(|bytes| u64::try_from(bytes).ok()) + .ok_or_else(|| { + EdgeError::request_header_fields_too_large( + "normalized request header size accounting overflow", + ) + })?; + header_bytes = header_bytes.checked_add(line_bytes).ok_or_else(|| { + EdgeError::request_header_fields_too_large( + "normalized request header size accounting overflow", + ) + })?; + if header_bytes > limits.max_request_header_bytes() { + return Err(EdgeError::request_header_fields_too_large( + "normalized request headers exceed configured limit", + )); + } + } + + validate_normalized_framing(version, headers) +} + +fn validate_normalized_framing(version: Version, headers: &HeaderMap) -> Result<(), EdgeError> { + let content_lengths = headers.get_all(CONTENT_LENGTH).iter().collect::>(); + let transfer_encodings = headers + .get_all(TRANSFER_ENCODING) + .iter() + .collect::>(); + + if !content_lengths.is_empty() && !transfer_encodings.is_empty() { + return Err(EdgeError::bad_request( + "ambiguous request framing is not accepted", + )); + } + if content_lengths.len() > 1 { + return Err(EdgeError::bad_request( + "duplicate content-length is not accepted", + )); + } + if let Some(value) = content_lengths.first() { + let raw = value + .to_str() + .map_err(|_utf8_error| EdgeError::bad_request("malformed content-length"))? + .trim(); + if raw.is_empty() + || raw.contains(',') + || raw.starts_with(['+', '-']) + || raw.parse::().is_err() + { + return Err(EdgeError::bad_request("malformed content-length")); + } + } + + if transfer_encodings.len() > 1 { + return Err(EdgeError::bad_request( + "duplicate transfer-encoding is not accepted", + )); + } + if let Some(value) = transfer_encodings.first() { + if version != Version::HTTP_11 { + return Err(EdgeError::bad_request( + "transfer-encoding is not accepted for this HTTP version", + )); + } + let raw = value + .to_str() + .map_err(|_utf8_error| EdgeError::bad_request("malformed transfer-encoding"))?; + if raw.contains(',') || !raw.trim().eq_ignore_ascii_case("chunked") { + return Err(EdgeError::bad_request( + "unsupported or malformed transfer-encoding", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::body::Body; + use crate::http::{HeaderName, HeaderValue, StatusCode, request_builder, response_builder}; + + fn parts_with_headers(version: Version, headers: &[(&str, &str)]) -> RequestParts { + let mut request = request_builder() + .uri("/") + .version(version) + .body(Body::empty()) + .expect("request"); + for (name, value) in headers { + request.headers_mut().append( + name.parse::().expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ); + } + request.into_parts().0 + } + + #[test] + fn ingress_grant_downcasts_once_without_exposing_type_metadata() { + let string_grant = IngressGrant::new(String::from("lease")); + let retained_grant = string_grant.downcast::().expect_err("wrong type"); + assert_eq!( + retained_grant.downcast::().expect("right type"), + "lease" + ); + IngressGrant::empty() + .downcast::() + .expect_err("empty grant"); + } + + #[test] + fn buffered_ingress_response_is_cloneable_and_builds_exact_plain_text() { + let response = + BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "fallback body exceeded"); + let cloned = response.clone(); + + assert_eq!(cloned.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + cloned.headers().get("content-type").expect("content type"), + "text/plain; charset=utf-8" + ); + assert_eq!( + cloned + .headers() + .get("content-length") + .expect("content length"), + "22" + ); + assert_eq!(cloned.body(), b"fallback body exceeded"); + } + + #[test] + fn ingress_head_limits_are_finite_nonzero_and_independently_configurable() { + let limits = IngressHeadLimits::default(); + assert_eq!(limits.max_request_header_bytes(), 0x0001_0000); + assert_eq!(limits.max_request_header_count(), 100); + assert_eq!(limits.max_request_target_bytes(), 8_192); + limits + .with_max_request_header_bytes(0) + .expect_err("zero header bytes"); + limits + .with_max_request_header_count(0) + .expect_err("zero header count"); + limits + .with_max_request_target_bytes(0) + .expect_err("zero target bytes"); + } + + #[test] + fn normalized_head_limits_reject_before_admission_without_raw_claims() { + let target_request = request_builder() + .uri("/1234") + .body(Body::empty()) + .expect("request"); + let target_parts = target_request.into_parts().0; + let target_limits = IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"); + assert_eq!( + validate_normalized_ingress_parts(&target_parts, target_limits) + .expect_err("target over limit") + .status(), + StatusCode::URI_TOO_LONG + ); + + let count_parts = parts_with_headers(Version::HTTP_11, &[("x-a", "1"), ("x-b", "2")]); + let count_limits = IngressHeadLimits::default() + .with_max_request_header_count(1) + .expect("count limit"); + assert_eq!( + validate_normalized_ingress_parts(&count_parts, count_limits) + .expect_err("header count over limit") + .status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + + let bytes_parts = parts_with_headers(Version::HTTP_11, &[("x", "1")]); + let bytes_limits = IngressHeadLimits::default() + .with_max_request_header_bytes(7) + .expect("bytes limit"); + assert_eq!( + validate_normalized_ingress_parts(&bytes_parts, bytes_limits) + .expect_err("header bytes over limit") + .status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + } + + #[test] + fn normalized_framing_defense_rejects_visible_ambiguous_shapes() { + let cases = [ + parts_with_headers( + Version::HTTP_11, + &[("content-length", "1"), ("transfer-encoding", "chunked")], + ), + parts_with_headers( + Version::HTTP_11, + &[("content-length", "1"), ("content-length", "1")], + ), + parts_with_headers(Version::HTTP_11, &[("content-length", "1, 1")]), + parts_with_headers(Version::HTTP_11, &[("content-length", "+1")]), + parts_with_headers( + Version::HTTP_11, + &[("content-length", "18446744073709551616")], + ), + parts_with_headers(Version::HTTP_11, &[("transfer-encoding", "gzip, chunked")]), + parts_with_headers(Version::HTTP_2, &[("transfer-encoding", "chunked")]), + ]; + for parts in cases { + assert_eq!( + validate_normalized_ingress_parts(&parts, IngressHeadLimits::default()) + .expect_err("ambiguous framing") + .status(), + StatusCode::BAD_REQUEST + ); + } + + for parts in [ + parts_with_headers(Version::HTTP_11, &[("content-length", "1")]), + parts_with_headers(Version::HTTP_11, &[("transfer-encoding", "chunked")]), + ] { + validate_normalized_ingress_parts(&parts, IngressHeadLimits::default()) + .expect("unambiguous visible framing"); + } + } + + #[test] + fn admission_clamps_deadline_and_preserves_refusal() { + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let too_late = Deadline::at_instant( + start + .checked_add(DEADLINE_FAR_FUTURE + Duration::from_secs(1)) + .expect("deadline"), + ); + let admit_policy: IngressAdmissionPolicy = Arc::new(move |_| AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: too_late, + }); + let IngressAdmissionOutcome::Admitted(admitted) = + apply_admission_policy(&admit_policy, &head, MonotonicClock::default()) + .expect("admission") + else { + panic!("expected admission"); + }; + assert_eq!( + admitted.read_deadline().instant(), + start.checked_add(DEADLINE_FAR_FUTURE).expect("maximum") + ); + + let refuse_policy: IngressAdmissionPolicy = Arc::new(|_| { + AdmissionDecision::Refuse( + response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("response"), + ) + }); + let IngressAdmissionOutcome::Refused(response) = + apply_admission_policy(&refuse_policy, &head, MonotonicClock::default()) + .expect("refusal") + else { + panic!("expected refusal"); + }; + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[test] + fn ingress_head_builds_relative_deadlines_in_its_start_clock_domain() { + let global = MonotonicInstant::now(); + let request_start = global + .checked_add(Duration::from_hours(24)) + .expect("offset request start"); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + request_start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + + assert_eq!( + head.read_deadline_after(Duration::from_secs(2)).instant(), + request_start + .checked_add(Duration::from_secs(2)) + .expect("relative deadline") + ); + } +} diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index d74ba664..75defade 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -116,6 +116,11 @@ pub async fn config(ctx: RequestContext) -> Result { #[cfg(test)] mod tests { + #![expect( + clippy::missing_trait_methods, + reason = "legacy provider stubs intentionally exercise the bounded-read compatibility default" + )] + use super::*; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use crate::http::{Method, Response, request_builder}; diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa8545..5bdf241c 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -27,14 +27,16 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod ingress; pub mod introspection; pub mod key_value_store; pub mod manifest; pub mod middleware; +pub mod outbound; pub mod params; -pub mod proxy; pub mod responder; pub mod response; +pub mod response_egress; pub mod router; pub mod secret_store; pub mod store_registry; @@ -42,5 +44,81 @@ pub mod store_registry; /// module docs. Enable via the `test-utils` feature in `[dev-dependencies]`. #[cfg(any(test, feature = "test-utils"))] pub mod test_env; +pub mod time; + +pub use body::{Body, BodyStream}; +pub use compression::{ + BROTLI_DECODER_FIXED_CHARGE_BYTES, ContentEncoding, brotli_decoder_memory_charge, + classify_content_encoding, decode_brotli_stream, decode_gzip_stream, +}; +pub use config_store::{ + BoundedStoreRead, ConfigExtractionLimits, DEFAULT_CONFIG_BACKEND_BYTES, + DEFAULT_CONFIG_BLOB_BYTES, DEFAULT_CONFIG_EXTRACTION_BYTES, DEFAULT_CONFIG_EXTRACTION_TIMEOUT, + DEFAULT_CONFIG_SECRET_BYTES, +}; pub use edgezero_macros::{AppConfig, action, app}; +pub use error::{ + BadGatewayDecodeReason, BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, + StoreExtractionReason, +}; +pub use ingress::{ + AdmissionDecision, AdmittedIngress, BufferedIngressResponse, DEFAULT_INBOUND_READ_BUDGET, + DEFAULT_MAX_REQUEST_HEADER_BYTES, DEFAULT_MAX_REQUEST_HEADER_COUNT, + DEFAULT_MAX_REQUEST_TARGET_BYTES, IngressAdmissionOutcome, IngressBeginOutcome, IngressFraming, + IngressGrant, IngressHead, IngressHeadAccounting, IngressHeadLimits, IngressHeadParts, + PreparedIngress, +}; +pub use manifest::{ + AtomicHost, BakedManifest, Capability, CapabilitySupport, HostParseError, HostPat, + ManifestCapabilities, ManifestContract, ManifestOutboundCapability, Port, Scheme, + canonicalize_outbound_host, +}; +pub use outbound::{ + DEFAULT_MAX_BROTLI_DECODER_BYTES, DEFAULT_MAX_RESPONSE_BYTES, + DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, HttpClient, OutboundHttpClient, OutboundRequest, + OutboundRequestParts, OutboundResponse, OutboundSlotResult, PROXY_HEADER, + ResponseBodyDisposition, ResponseHeaderLimiter, ResponseMode, collect_response_stream, + collect_response_stream_until_with_clock, enforce_payload_content_length, limit_decoded_stream, + limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, rechunk_stream, + validate_for_dispatch, +}; +pub use response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, ResponseEgressAttempt, ResponseEgressEnvelope, + ResponseEgressHead, ResponseEgressObserver, ResponseEgressObserverHandle, + ResponseEgressOutcome, ResponseEgressPolicy, ResponseEgressPolicyCallback, + ResponseEgressReport, default_response_egress_policy, +}; +pub use router::{ResolvedDispatch, RouteId, RouteInfo, RouteMetadata, RouteResolution}; +pub use time::{ + BATCH_DISPATCH_SLACK_MAX, DEADLINE_FAR_FUTURE, DEFAULT_NO_DEADLINE_BUDGET, Deadline, + DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, +}; + +#[cfg(test)] +mod public_config_extraction_contract_tests { + use std::time::Duration; + + #[test] + fn bounded_config_types_and_defaults_are_root_exports() { + let limits = crate::ConfigExtractionLimits::default(); + let _: crate::BoundedStoreRead = crate::BoundedStoreRead { + backend_bytes: 0, + value: None, + }; + assert_eq!(limits.max_blob_bytes, crate::DEFAULT_CONFIG_BLOB_BYTES); + assert_eq!( + limits.max_backend_bytes, + crate::DEFAULT_CONFIG_BACKEND_BYTES + ); + assert_eq!(limits.max_secret_bytes, crate::DEFAULT_CONFIG_SECRET_BYTES); + assert_eq!( + limits.max_total_bytes, + crate::DEFAULT_CONFIG_EXTRACTION_BYTES + ); + assert_eq!(limits.timeout, crate::DEFAULT_CONFIG_EXTRACTION_TIMEOUT); + assert_eq!(crate::BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + assert_eq!(crate::DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(crate::DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + } +} diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 02dad65d..3559cfd6 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -2,11 +2,247 @@ use log::LevelFilter; use serde::de::Error as DeError; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; +use std::net::Ipv6Addr; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use std::sync::Arc; use std::{env, fs, io}; use validator::{Validate, ValidationError}; +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct AtomicHost { + pub host: HostPat, + pub port: Port, + pub scheme: Scheme, +} + +impl AtomicHost { + fn render_host_and_port(&self, scheme: &str, host: &str) -> String { + let mut rendered = format!("{scheme}://{host}"); + match self.port { + Port::Any => rendered.push_str(":*"), + Port::Exact(port) if port != self.scheme.default_port() => { + rendered.push(':'); + rendered.push_str(&port.to_string()); + } + Port::Exact(_) => {} + } + rendered + } + + #[inline] + #[must_use] + pub fn render_spin_host(&self) -> String { + let scheme = match self.scheme { + Scheme::Http => "http", + Scheme::Https => "https", + }; + let host = match &self.host { + HostPat::Any => "*", + HostPat::Exact(host) => host, + HostPat::WildcardSubdomain(host) => return self.render_wildcard_host(scheme, host), + }; + self.render_host_and_port(scheme, host) + } + + fn render_wildcard_host(&self, scheme: &str, host: &str) -> String { + self.render_host_and_port(scheme, &format!("*.{host}")) + } +} + +/// Compile-time manifest state exposed by macro-generated application hooks. +/// +/// This distinguishes an application without a baked manifest from a corrupt +/// baked contract so capability enforcement cannot fail open. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub enum BakedManifest { + /// No manifest was baked into this application. + Absent, + /// A baked manifest was present but could not be reconstructed safely. + Malformed(&'static str), + /// A parsed, validated, and finalized baked manifest. + Present(&'static Manifest), +} + +impl BakedManifest { + /// Borrow this baked state as the lifetime-neutral capability-gate input. + #[must_use] + #[inline] + pub fn as_contract(&self) -> ManifestContract<'_> { + match self { + Self::Absent => ManifestContract::None, + Self::Malformed(reason) => ManifestContract::Malformed(reason), + Self::Present(manifest) => ManifestContract::Present(manifest), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum Capability { + ConfigReadAllocationBounds, + ConfigReadDeadlines, + InboundReadDeadlines, + IngressAdmission, + LazyStreamedResponsePassthrough, + OutboundCompleteResourceAccounting, + OutboundDeadlines, + OutboundFlexiblePhaseBudget, + OutboundHeaderFidelity, + OutboundHttp, + RawIngressFramingValidation, + RawIngressHeadLimits, + ResponseEgressAbort, + ResponseEgressBackpressure, + ResponseEgressCompletion, + ResponseWriteDeadlines, + SendAllSlotIsolation, + StreamedUploadDeadlines, +} + +impl Capability { + #[expect( + clippy::allow_attributes, + reason = "the included macro copy is private while the core API is public" + )] + #[allow( + clippy::trivially_copy_pass_by_ref, + reason = "the public capability contract specifies as_str(&self)" + )] + #[inline] + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::ConfigReadAllocationBounds => "config-read-allocation-bounds", + Self::ConfigReadDeadlines => "config-read-deadlines", + Self::InboundReadDeadlines => "inbound-read-deadlines", + Self::IngressAdmission => "ingress-admission", + Self::LazyStreamedResponsePassthrough => "lazy-streamed-response-passthrough", + Self::OutboundCompleteResourceAccounting => "outbound-complete-resource-accounting", + Self::OutboundDeadlines => "outbound-deadlines", + Self::OutboundFlexiblePhaseBudget => "outbound-flexible-phase-budget", + Self::OutboundHeaderFidelity => "outbound-header-fidelity", + Self::OutboundHttp => "outbound-http", + Self::RawIngressFramingValidation => "raw-ingress-framing-validation", + Self::RawIngressHeadLimits => "raw-ingress-head-limits", + Self::ResponseEgressAbort => "response-egress-abort", + Self::ResponseEgressBackpressure => "response-egress-backpressure", + Self::ResponseEgressCompletion => "response-egress-completion", + Self::ResponseWriteDeadlines => "response-write-deadlines", + Self::SendAllSlotIsolation => "send-all-slot-isolation", + Self::StreamedUploadDeadlines => "streamed-upload-deadlines", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CapabilitySupport { + BestEffort, + BoundedCooperative, + Native, + Unsupported, +} + +/// Manifest input accepted by runtime capability gates. +/// +/// File-backed manifests may be borrowed for any lifetime, while a baked +/// manifest converts through [`BakedManifest::as_contract`]. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub enum ManifestContract<'manifest> { + /// A manifest contract exists but cannot be verified. + Malformed(&'static str), + /// No capability contract exists. + None, + /// A parsed and validated manifest contract. + Present(&'manifest Manifest), +} + +impl<'manifest> ManifestContract<'manifest> { + /// Convert an optional file-backed manifest reference into a contract. + #[must_use] + #[inline] + pub fn from_opt(manifest: Option<&'manifest Manifest>) -> Self { + manifest.map_or(Self::None, Self::Present) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum HostParseError { + Empty, + FragmentNotAllowed, + InvalidHost, + InvalidPort, + InvalidScheme, + InvalidWildcard, + MissingAuthority, + NonAsciiHost, + PathNotAllowed, + QueryNotAllowed, + UserinfoNotAllowed, + Whitespace, +} + +impl fmt::Display for HostParseError { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "host entry is empty", + Self::FragmentNotAllowed => "fragment is not allowed", + Self::InvalidHost => "host is invalid", + Self::InvalidPort => "port must be * or 1..=65535", + Self::InvalidScheme => "scheme must be http or https", + Self::InvalidWildcard => "wildcard is invalid", + Self::MissingAuthority => "authority is required", + Self::NonAsciiHost => "host must be ASCII", + Self::PathNotAllowed => "path is not allowed", + Self::QueryNotAllowed => "query is not allowed", + Self::UserinfoNotAllowed => "userinfo is not allowed", + Self::Whitespace => "whitespace is not allowed", + }) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "HostParseError has no source or backtrace data beyond Display" +)] +impl Error for HostParseError {} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum HostPat { + Any, + Exact(String), + WildcardSubdomain(String), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Port { + Any, + Exact(u16), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Scheme { + Http, + Https, +} + +impl Scheme { + const fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + pub struct ManifestLoader { manifest: Arc, } @@ -17,8 +253,7 @@ impl ManifestLoader { #[inline] pub fn from_path(path: &Path) -> Result { let contents = fs::read_to_string(path)?; - let mut manifest: Manifest = toml::from_str(&contents) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut manifest = deserialize_manifest(&contents)?; let cwd = env::current_dir()?; let root_path = resolve_root_path(path, &cwd); manifest.root = Some(root_path); @@ -71,8 +306,7 @@ impl ManifestLoader { /// Returns an [`io::Error`] if `contents` is not valid TOML or fails manifest validation. #[inline] pub fn try_load_from_str(contents: &str) -> Result { - let mut manifest: Manifest = toml::from_str(contents) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut manifest = deserialize_manifest(contents)?; manifest .validate() .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; @@ -84,6 +318,7 @@ impl ManifestLoader { } #[derive(Debug, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, @@ -98,6 +333,9 @@ pub struct Manifest { pub app: ManifestApp, #[serde(default)] #[validate(nested)] + pub capabilities: ManifestCapabilities, + #[serde(default)] + #[validate(nested)] pub environment: ManifestEnvironment, #[serde(default)] #[validate(nested)] @@ -114,6 +352,29 @@ pub struct Manifest { pub triggers: ManifestTriggers, } +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = "validate_capabilities_disjoint"))] +#[non_exhaustive] +pub struct ManifestCapabilities { + #[serde(default)] + pub optional: Vec, + #[serde(default)] + #[validate(nested)] + pub outbound: ManifestOutboundCapability, + #[serde(default)] + pub required: Vec, +} + +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct ManifestOutboundCapability { + #[serde(default)] + #[validate(length(min = 1_u64), custom(function = "validate_outbound_hosts"))] + pub hosts: Option>, +} + impl Manifest { /// Look up a `[adapters.]` entry by adapter name, matching /// case-insensitively against the manifest's declared keys. @@ -186,6 +447,37 @@ impl Manifest { self.logging_resolved = resolved; } + /// Parse, validate, and finalize JSON emitted by the `app!` macro. + /// + /// This is macro-support API. Call it at most once per application process; + /// a successful call intentionally leaks one manifest so generated hooks can + /// expose it for the process lifetime. + #[doc(hidden)] + #[inline] + #[must_use] + pub fn from_baked_json(json: &'static str) -> BakedManifest { + let value: serde_json::Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(_error) => { + return BakedManifest::Malformed("baked manifest did not parse"); + } + }; + if reject_misplaced_capabilities_json(&value).is_err() { + return BakedManifest::Malformed("baked manifest has misplaced capabilities"); + } + let mut manifest: Self = match serde_json::from_value(value) { + Ok(manifest) => manifest, + Err(_error) => { + return BakedManifest::Malformed("baked manifest did not parse"); + } + }; + if manifest.validate().is_err() { + return BakedManifest::Malformed("baked manifest failed validation"); + } + manifest.finalize(); + BakedManifest::Present(Box::leak(Box::new(manifest))) + } + #[must_use] #[inline] pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { @@ -249,6 +541,9 @@ pub struct ManifestHttpTrigger { #[serde(rename = "body-mode")] #[serde(default)] pub body_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub class: Option, #[serde(default)] #[validate(length(min = 1_u64))] pub description: Option, @@ -764,6 +1059,57 @@ impl serde::Serialize for LogLevel { } } +/// Canonicalizes one outbound host declaration into atomic scheme/host/port entries. +/// +/// # Errors +/// Returns [`HostParseError`] when `entry` is outside the manifest host grammar. +#[inline] +pub fn canonicalize_outbound_host(entry: &str) -> Result, HostParseError> { + if entry.is_empty() { + return Err(HostParseError::Empty); + } + if entry.chars().any(char::is_whitespace) { + return Err(HostParseError::Whitespace); + } + if entry == "*" { + return Ok(vec![ + AtomicHost { + host: HostPat::Any, + port: Port::Any, + scheme: Scheme::Http, + }, + AtomicHost { + host: HostPat::Any, + port: Port::Any, + scheme: Scheme::Https, + }, + ]); + } + + let (scheme, authority) = parse_scheme(entry)?; + if authority.is_empty() { + return Err(HostParseError::MissingAuthority); + } + if authority.contains('@') { + return Err(HostParseError::UserinfoNotAllowed); + } + if authority.contains('/') { + return Err(HostParseError::PathNotAllowed); + } + if authority.contains('?') { + return Err(HostParseError::QueryNotAllowed); + } + if authority.contains('#') { + return Err(HostParseError::FragmentNotAllowed); + } + if !authority.is_ascii() { + return Err(HostParseError::NonAsciiHost); + } + + let (host, port) = parse_authority(authority, scheme)?; + Ok(vec![AtomicHost { host, port, scheme }]) +} + /// Serialize a `[[environment.secrets]]` list without exposing `value`. /// Secret bindings share `ManifestBinding` with variables, whose `value` /// is safe to emit; secret values must never appear in manifest output. @@ -806,6 +1152,214 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } } +fn deserialize_manifest(contents: &str) -> Result { + let value: toml::Value = toml::from_str(contents) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + reject_misplaced_capabilities(&value) + .map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))?; + value + .try_into() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[must_use] +pub(crate) fn is_reserved_capabilities_key(key: &str) -> bool { + key.eq_ignore_ascii_case("capabilities") +} + +fn parse_authority(authority: &str, scheme: Scheme) -> Result<(HostPat, Port), HostParseError> { + if let Some(bracketed) = authority.strip_prefix('[') { + let Some((address, remainder)) = bracketed.split_once(']') else { + return Err(HostParseError::InvalidHost); + }; + let ipv6 = Ipv6Addr::from_str(address).map_err(|_error| HostParseError::InvalidHost)?; + let port = if remainder.is_empty() { + Port::Exact(scheme.default_port()) + } else if let Some(raw_port) = remainder.strip_prefix(':') { + parse_port(raw_port)? + } else { + return Err(HostParseError::InvalidHost); + }; + return Ok((HostPat::Exact(format!("[{ipv6}]")), port)); + } + + if authority.contains(['[', ']']) || authority.matches(':').count() > 1 { + return Err(HostParseError::InvalidHost); + } + let (raw_host, raw_port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = parse_host_pattern(raw_host)?; + let port = raw_port.map_or(Ok(Port::Exact(scheme.default_port())), parse_port)?; + Ok((host, port)) +} + +fn parse_host_pattern(host: &str) -> Result { + if host == "*" { + return Ok(HostPat::Any); + } + if host.contains('*') { + let Some(suffix) = host.strip_prefix("*.") else { + return Err(HostParseError::InvalidWildcard); + }; + if suffix.contains('*') || !is_valid_dns_name(suffix) { + return Err(if suffix.contains('*') { + HostParseError::InvalidWildcard + } else { + HostParseError::InvalidHost + }); + } + return Ok(HostPat::WildcardSubdomain(suffix.to_ascii_lowercase())); + } + if !is_valid_dns_name(host) { + return Err(HostParseError::InvalidHost); + } + Ok(HostPat::Exact(host.to_ascii_lowercase())) +} + +fn parse_port(port: &str) -> Result { + if port == "*" { + return Ok(Port::Any); + } + if !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(HostParseError::InvalidPort); + } + let parsed_port = port + .parse::() + .map_err(|_error| HostParseError::InvalidPort)?; + if parsed_port == 0 { + return Err(HostParseError::InvalidPort); + } + Ok(Port::Exact(parsed_port)) +} + +fn parse_scheme(entry: &str) -> Result<(Scheme, &str), HostParseError> { + let Some((raw_scheme, authority)) = entry.split_once("://") else { + return Ok((Scheme::Https, entry)); + }; + let parsed_scheme = if raw_scheme.eq_ignore_ascii_case("http") { + Scheme::Http + } else if raw_scheme.eq_ignore_ascii_case("https") { + Scheme::Https + } else { + return Err(HostParseError::InvalidScheme); + }; + Ok((parsed_scheme, authority)) +} + +fn is_valid_dns_name(host: &str) -> bool { + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn validate_capabilities_disjoint( + capabilities: &ManifestCapabilities, +) -> Result<(), ValidationError> { + let mut required = BTreeSet::new(); + if capabilities + .required + .iter() + .any(|capability| !required.insert(capability.as_str())) + { + return Err(ValidationError::new("capability_required_duplicate")); + } + + let mut optional = BTreeSet::new(); + if capabilities + .optional + .iter() + .any(|capability| !optional.insert(capability.as_str())) + { + return Err(ValidationError::new("capability_optional_duplicate")); + } + if required + .iter() + .any(|capability| optional.contains(capability)) + { + return Err(ValidationError::new("capability_required_optional_overlap")); + } + Ok(()) +} + +fn validate_outbound_hosts(hosts: &[String]) -> Result<(), ValidationError> { + for host in hosts { + if let Err(error) = canonicalize_outbound_host(host) { + let mut validation_error = ValidationError::new("outbound_host"); + validation_error.message = Some(error.to_string().into()); + return Err(validation_error); + } + } + Ok(()) +} + +pub(crate) fn reject_misplaced_capabilities(value: &toml::Value) -> Result<(), &'static str> { + fn walk(value: &toml::Value, top_level: bool) -> Result<(), &'static str> { + match value { + toml::Value::Array(values) => { + for array_value in values { + walk(array_value, false)?; + } + } + toml::Value::Table(table) => { + for (key, nested_value) in table { + if is_reserved_capabilities_key(key) && (!top_level || key != "capabilities") { + return Err( + "capabilities is reserved for the exact lowercase top-level table", + ); + } + walk(nested_value, false)?; + } + } + toml::Value::Boolean(_) + | toml::Value::Datetime(_) + | toml::Value::Float(_) + | toml::Value::Integer(_) + | toml::Value::String(_) => {} + } + Ok(()) + } + + walk(value, true) +} + +fn reject_misplaced_capabilities_json(value: &serde_json::Value) -> Result<(), ()> { + fn walk(value: &serde_json::Value, top_level: bool) -> Result<(), ()> { + match value { + serde_json::Value::Array(values) => { + for array_value in values { + walk(array_value, false)?; + } + } + serde_json::Value::Object(object) => { + for (key, nested_value) in object { + if is_reserved_capabilities_key(key) && (!top_level || key != "capabilities") { + return Err(()); + } + walk(nested_value, false)?; + } + } + serde_json::Value::Bool(_) + | serde_json::Value::Null + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => {} + } + Ok(()) + } + + walk(value, true) +} + /// Validates a single `[adapters..adapter]` block. The portable /// manifest model lists the declared fields explicitly; an unknown key /// would otherwise be silently dropped by serde, so we surface it as a @@ -1051,6 +1605,580 @@ name = "API_TOKEN" env = "APP_TOKEN" "#; + #[test] + #[expect( + clippy::too_many_lines, + reason = "the exhaustive capability parse-and-validation table is clearer in one test" + )] + fn capability_manifest_rejects_unknown_duplicate_and_overlap() { + let source = r#" +[capabilities] +required = [ + "config-read-allocation-bounds", + "config-read-deadlines", + "inbound-read-deadlines", + "ingress-admission", + "lazy-streamed-response-passthrough", + "outbound-complete-resource-accounting", + "outbound-deadlines", + "outbound-flexible-phase-budget", + "outbound-header-fidelity", + "outbound-http", + "raw-ingress-framing-validation", + "raw-ingress-head-limits", + "response-egress-abort", + "response-egress-backpressure", + "response-egress-completion", + "response-write-deadlines", + "send-all-slot-isolation", + "streamed-upload-deadlines", +] +optional = [] + +[capabilities.outbound] +hosts = ["*", "HTTPS://Example.COM", "api.example.com:8443"] +"#; + let loader = ManifestLoader::try_load_from_str(source).expect("capability manifest"); + let expected = [ + Capability::ConfigReadAllocationBounds, + Capability::ConfigReadDeadlines, + Capability::InboundReadDeadlines, + Capability::IngressAdmission, + Capability::LazyStreamedResponsePassthrough, + Capability::OutboundCompleteResourceAccounting, + Capability::OutboundDeadlines, + Capability::OutboundFlexiblePhaseBudget, + Capability::OutboundHeaderFidelity, + Capability::OutboundHttp, + Capability::RawIngressFramingValidation, + Capability::RawIngressHeadLimits, + Capability::ResponseEgressAbort, + Capability::ResponseEgressBackpressure, + Capability::ResponseEgressCompletion, + Capability::ResponseWriteDeadlines, + Capability::SendAllSlotIsolation, + Capability::StreamedUploadDeadlines, + ]; + assert_eq!(loader.manifest().capabilities.required, expected); + assert!(loader.manifest().capabilities.optional.is_empty()); + assert_eq!( + loader.manifest().capabilities.outbound.hosts.as_deref(), + Some( + ["*", "HTTPS://Example.COM", "api.example.com:8443"] + .map(String::from) + .as_slice() + ) + ); + for (capability, expected_name) in expected.iter().zip([ + "config-read-allocation-bounds", + "config-read-deadlines", + "inbound-read-deadlines", + "ingress-admission", + "lazy-streamed-response-passthrough", + "outbound-complete-resource-accounting", + "outbound-deadlines", + "outbound-flexible-phase-budget", + "outbound-header-fidelity", + "outbound-http", + "raw-ingress-framing-validation", + "raw-ingress-head-limits", + "response-egress-abort", + "response-egress-backpressure", + "response-egress-completion", + "response-write-deadlines", + "send-all-slot-isolation", + "streamed-upload-deadlines", + ]) { + assert_eq!(capability.as_str(), expected_name); + } + + let serialized = toml::to_string(loader.manifest()).expect("serialize manifest"); + let reparsed = ManifestLoader::try_load_from_str(&serialized).expect("round trip"); + assert_eq!(reparsed.manifest().capabilities.required, expected); + assert_eq!( + reparsed.manifest().capabilities.outbound.hosts.as_deref(), + loader.manifest().capabilities.outbound.hosts.as_deref() + ); + + for invalid in [ + "[capabilities]\nrequired = [\"outbound-retries\"]\n", + "[capabilities]\nrequired = [\"Outbound-Http\"]\n", + "[capabilities]\nrequire = [\"outbound-http\"]\n", + "[capabilities.outbound]\nhost = [\"*\"]\n", + "[capabilites]\nrequired = [\"outbound-http\"]\n", + "[capability]\nrequired = [\"outbound-http\"]\n", + "[custom]\nvalue = true\n", + "[capabilities]\nrequired = [\"outbound-http\", \"outbound-http\"]\n", + "[capabilities]\noptional = [\"outbound-http\", \"outbound-http\"]\n", + "[capabilities]\nrequired = [\"outbound-http\"]\noptional = [\"outbound-http\"]\n", + "[capabilities.outbound]\nhosts = []\n", + ] { + assert!( + ManifestLoader::try_load_from_str(invalid).is_err(), + "manifest unexpectedly accepted: {invalid}" + ); + } + } + + #[test] + fn capability_manifest_rejects_reserved_key_at_every_depth_and_case() { + let valid = + ManifestLoader::try_load_from_str("[capabilities]\nrequired = [\"outbound-http\"]\n") + .expect("exact lowercase top-level key"); + assert_eq!( + valid.manifest().capabilities.required, + [Capability::OutboundHttp] + ); + + let invalid = [ + "[Capabilities]\nrequired = []\n", + "[CAPABILITIES]\nrequired = []\n", + "[capabilitieS]\nrequired = []\n", + "[app.capabilities]\nrequired = []\n", + "[app.Capabilities]\nrequired = []\n", + "[[triggers.http]]\npath = \"/\"\n[triggers.http.CAPABILITIES]\nrequired = []\n", + "[[environment.variables]]\nname = \"X\"\n[environment.variables.capabilitieS]\nrequired = []\n", + "[adapters.axum.build.cApAbIlItIeS]\nrequired = []\n", + "[app]\nlayers = [{ capabilities = { required = [] } }]\n", + "[app]\nlayers = [[{ CaPaBiLiTiEs = { required = [] } }]]\n", + "[capabilities.outbound.capabilities]\nrequired = []\n", + ]; + + for source in invalid { + assert!( + ManifestLoader::try_load_from_str(source).is_err(), + "reserved key unexpectedly accepted: {source}" + ); + } + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the required grammar table keeps accepted and rejected cases together" + )] + fn outbound_host_grammar_table() { + let label_63 = "a".repeat(63); + let name_253 = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(61) + ); + let accepted = [ + ( + "*".to_owned(), + vec![ + AtomicHost { + scheme: Scheme::Http, + host: HostPat::Any, + port: Port::Any, + }, + AtomicHost { + scheme: Scheme::Https, + host: HostPat::Any, + port: Port::Any, + }, + ], + vec!["http://*:*", "https://*:*"], + ), + ( + "*.example.com".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::WildcardSubdomain("example.com".to_owned()), + port: Port::Exact(443), + }], + vec!["https://*.example.com"], + ), + ( + "x:8443".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(8443), + }], + vec!["https://x:8443"], + ), + ( + "x:1".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(1), + }], + vec!["https://x:1"], + ), + ( + "x:65535".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(u16::MAX), + }], + vec!["https://x:65535"], + ), + ( + "https://x:443".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(443), + }], + vec!["https://x"], + ), + ( + "https://[::1]".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("[::1]".to_owned()), + port: Port::Exact(443), + }], + vec!["https://[::1]"], + ), + ( + "HTTP://[2001:0DB8::1]:80".to_owned(), + vec![AtomicHost { + scheme: Scheme::Http, + host: HostPat::Exact("[2001:db8::1]".to_owned()), + port: Port::Exact(80), + }], + vec!["http://[2001:db8::1]"], + ), + ( + "https://127.0.0.1".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("127.0.0.1".to_owned()), + port: Port::Exact(443), + }], + vec!["https://127.0.0.1"], + ), + ( + "xn--caf-dma.com".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("xn--caf-dma.com".to_owned()), + port: Port::Exact(443), + }], + vec!["https://xn--caf-dma.com"], + ), + ( + "HTTPS://Example.COM:*".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("example.com".to_owned()), + port: Port::Any, + }], + vec!["https://example.com:*"], + ), + ( + format!("https://{label_63}"), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact(label_63), + port: Port::Exact(443), + }], + vec![], + ), + ( + name_253.clone(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact(name_253), + port: Port::Exact(443), + }], + vec![], + ), + ]; + + for (entry, expected, rendered) in accepted { + let actual = canonicalize_outbound_host(&entry).expect("accepted host"); + assert_eq!(actual, expected, "entry: {entry}"); + if !rendered.is_empty() { + assert_eq!( + actual + .iter() + .map(AtomicHost::render_spin_host) + .collect::>(), + rendered, + "entry: {entry}" + ); + for host in &actual { + assert_eq!( + canonicalize_outbound_host(&host.render_spin_host()), + Ok(vec![host.clone()]), + "rendered host must parse to the same atomic" + ); + } + } + + let source = format!("[capabilities.outbound]\nhosts = [{entry:?}]\n"); + ManifestLoader::try_load_from_str(&source).expect("manifest host validation"); + } + + let label_64 = "a".repeat(64); + let name_254 = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(62) + ); + let rejected = [ + (String::new(), HostParseError::Empty, "host entry is empty"), + ( + " x".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "x.com ".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "x .com".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "https:// x".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "ftp://x".to_owned(), + HostParseError::InvalidScheme, + "scheme must be http or https", + ), + ( + "https://".to_owned(), + HostParseError::MissingAuthority, + "authority is required", + ), + ( + "https://u:p@x".to_owned(), + HostParseError::UserinfoNotAllowed, + "userinfo is not allowed", + ), + ( + "https://x/p".to_owned(), + HostParseError::PathNotAllowed, + "path is not allowed", + ), + ( + "https://x?q".to_owned(), + HostParseError::QueryNotAllowed, + "query is not allowed", + ), + ( + "https://x#f".to_owned(), + HostParseError::FragmentNotAllowed, + "fragment is not allowed", + ), + ( + "ex\u{20ac}ample.com".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "caf\u{e9}.com".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "ex*ample.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "*.*.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "a.*.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "**.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "-x.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x-.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x..com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x_y.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x.com.".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + (label_64, HostParseError::InvalidHost, "host is invalid"), + (name_254, HostParseError::InvalidHost, "host is invalid"), + ( + "https://[::g]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[:::1]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[12345::]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[1:2:3:4:5:6:7:8:9]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[::1".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://::1]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://::1".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://x:0".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:70000".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:abc".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "x:+443".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "ftp:// x/p?q#f".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "ftp://".to_owned(), + HostParseError::InvalidScheme, + "scheme must be http or https", + ), + ( + "https://u@x/p?q#f".to_owned(), + HostParseError::UserinfoNotAllowed, + "userinfo is not allowed", + ), + ( + "https://x/p?q#f".to_owned(), + HostParseError::PathNotAllowed, + "path is not allowed", + ), + ( + "https://x?q#f".to_owned(), + HostParseError::QueryNotAllowed, + "query is not allowed", + ), + ( + "https://x#caf\u{e9}".to_owned(), + HostParseError::FragmentNotAllowed, + "fragment is not allowed", + ), + ( + "caf\u{e9}*.com:0".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "ex*ample_.com:0".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "x_y.com:0".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ]; + + for (entry, expected, message) in rejected { + let error = canonicalize_outbound_host(&entry).expect_err("rejected host"); + assert_eq!(error, expected, "entry: {entry}"); + assert_eq!(error.to_string(), message, "entry: {entry}"); + assert!(!error.to_string().is_empty()); + + let source = format!("[capabilities.outbound]\nhosts = [{entry:?}]\n"); + assert!( + ManifestLoader::try_load_from_str(&source).is_err(), + "invalid manifest host accepted: {entry}" + ); + } + } + + #[test] + fn outbound_host_default_is_https_only() { + let loader = ManifestLoader::try_load_from_str("").expect("empty manifest"); + assert!(loader.manifest().capabilities.outbound.hosts.is_none()); + + let absent = canonicalize_outbound_host("https://*:*").expect("https-only default"); + assert_eq!( + absent, + [AtomicHost { + scheme: Scheme::Https, + host: HostPat::Any, + port: Port::Any, + }] + ); + assert_eq!(absent[0].render_spin_host(), "https://*:*"); + + let explicit = canonicalize_outbound_host("*").expect("explicit wildcard"); + assert_eq!(explicit.len(), 2); + assert_eq!(explicit[0].scheme, Scheme::Http); + assert_eq!(explicit[1].scheme, Scheme::Https); + assert!(explicit.iter().all(|host| host.port == Port::Any)); + } + #[test] fn parse_manifest_sample() { let loader = ManifestLoader::load_from_str(SAMPLE); @@ -2005,6 +3133,7 @@ ids = ["default"] let manifest = r#" [[triggers.http]] id = "route-1" +class = "auction" path = "/api/users" methods = ["GET", "POST"] handler = "handlers::users" @@ -2015,6 +3144,7 @@ body-mode = "buffered" let loader = ManifestLoader::load_from_str(manifest); let trigger = &loader.manifest().triggers.http[0]; assert_eq!(trigger.id.as_deref(), Some("route-1")); + assert_eq!(trigger.class.as_deref(), Some("auction")); assert_eq!(trigger.path, "/api/users"); assert_eq!(trigger.methods(), vec!["GET", "POST"]); assert_eq!(trigger.handler.as_deref(), Some("handlers::users")); @@ -2026,6 +3156,19 @@ body-mode = "buffered" assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); } + #[test] + fn http_trigger_rejects_empty_route_class() { + let manifest = r#" +[[triggers.http]] +class = "" +path = "/api/users" +"#; + let error = ManifestLoader::try_load_from_str(manifest) + .err() + .expect("an empty route class must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + // -- Secret store config ----------------------------------------------- #[test] @@ -2173,4 +3316,29 @@ default = "feature__flags" .err() .expect("double-underscore store id must fail validation"); } + + #[test] + fn baked_manifest_states_are_distinct() { + assert!(matches!( + Manifest::from_baked_json("not json"), + BakedManifest::Malformed("baked manifest did not parse") + )); + assert!(matches!( + Manifest::from_baked_json(r#"{"app":{"Capabilities":{}}}"#), + BakedManifest::Malformed("baked manifest has misplaced capabilities") + )); + let baked = Manifest::from_baked_json(r#"{"capabilities":{"required":["outbound-http"]}}"#); + let BakedManifest::Present(manifest) = baked else { + panic!("expected present baked manifest"); + }; + assert_eq!(manifest.capabilities.required, [Capability::OutboundHttp]); + assert!(matches!( + BakedManifest::Absent.as_contract(), + ManifestContract::None + )); + assert!(matches!( + ManifestContract::from_opt(Some(manifest)), + ManifestContract::Present(_) + )); + } } diff --git a/crates/edgezero-core/src/middleware.rs b/crates/edgezero-core/src/middleware.rs index a9edaf2c..7d5b2a45 100644 --- a/crates/edgezero-core/src/middleware.rs +++ b/crates/edgezero-core/src/middleware.rs @@ -77,8 +77,8 @@ pub struct RequestLogger; impl Middleware for RequestLogger { #[inline] async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let method = ctx.request().method().clone(); - let path = ctx.request().uri().path().to_owned(); + let method = ctx.method().clone(); + let path = ctx.uri().path().to_owned(); let start = Instant::now(); match next.run(ctx).await { diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs new file mode 100644 index 00000000..0ee9a65f --- /dev/null +++ b/crates/edgezero-core/src/outbound.rs @@ -0,0 +1,2435 @@ +use std::net::IpAddr; +use std::num::NonZeroU64; +use std::str; +use std::sync::Arc; +use std::time::Duration; + +use async_stream::stream; +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::StreamExt as _; +use serde::Serialize; +use serde::de::DeserializeOwned; +use url::Url; + +use crate::body::{Body, BodyStream}; +use crate::compression::ContentEncoding; +use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; +use crate::http::header::{ + CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HOST, PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, TE, TRAILER, TRANSFER_ENCODING, UPGRADE, +}; +use crate::http::{ + HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri, + response_builder, +}; +use crate::time::{Deadline, MonotonicClock}; + +pub const DEFAULT_MAX_BROTLI_DECODER_BYTES: u64 = 32 * 1024 * 1024; +pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1024 * 1024; +pub const DEFAULT_OUTBOUND_REQUEST_BODY_BYTES: u64 = 8 * 1024 * 1024; +/// Response header identifying the adapter that completed an outbound request. +pub const PROXY_HEADER: &str = "x-edgezero-proxy"; + +#[derive(Clone, Copy)] +pub(crate) struct BudgetInputs { + pub deadline: Option, + pub timeout: Option, +} + +#[derive(Debug)] +pub struct OutboundRequest { + body: Body, + deadline: Option, + headers: HeaderMap, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_request_body_bytes: u64, + max_response_header_bytes: Option, + max_response_header_count: Option, + method: Method, + response_mode: ResponseMode, + timeout: Option, + uri: Uri, +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundRequestParts { + pub body: Body, + pub deadline: Option, + pub headers: HeaderMap, + pub max_brotli_decoder_bytes: u64, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResponseMode { + Buffered { max_bytes: u64 }, + Streamed, +} + +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + /// Sends one outbound request. + /// + /// # Errors + /// Returns a typed request, transport, deadline, or response-policy failure. + async fn send(&self, request: OutboundRequest) -> Result; + + async fn send_all(&self, requests: Vec) -> Vec; +} + +#[derive(Debug)] +pub struct OutboundResponse { + body: Body, + headers: HeaderMap, + monotonic_clock: MonotonicClock, + request_method: Method, + status: StatusCode, +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundSlotResult { + pub elapsed: Duration, + pub outcome: Result, +} + +impl OutboundSlotResult { + #[must_use] + #[inline] + pub fn new(elapsed: Duration, outcome: Result) -> Self { + Self { elapsed, outcome } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResponseBodyDisposition { + FramingBodyless, + Payload, + ResetContent { declared_body: bool }, +} + +pub struct ResponseHeaderLimiter { + max_bytes: Option, + max_count: Option, + observed_bytes: u64, + observed_count: u64, +} + +impl ResponseHeaderLimiter { + #[must_use] + #[inline] + pub fn new(max_bytes: Option, max_count: Option) -> Self { + Self { + max_bytes, + max_count, + observed_bytes: 0, + observed_count: 0, + } + } + + /// Adds one guest-visible field section to the cumulative response-header budget. + /// + /// # Errors + /// Returns a typed response limit error on count, byte, or accounting overflow. + #[inline] + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError> { + for (name, value) in headers { + let next_count = self + .observed_count + .checked_add(1) + .ok_or_else(|| response_limit_error(ResponseLimitReason::HeaderCount))?; + self.observed_count = next_count; + if self.max_count.is_some_and(|max| next_count > max) { + return Err(response_limit_error(ResponseLimitReason::HeaderCount)); + } + + let name_bytes = u64::try_from(name.as_str().len()).unwrap_or(u64::MAX); + let value_bytes = u64::try_from(value.as_bytes().len()).unwrap_or(u64::MAX); + let next_bytes = self + .observed_bytes + .checked_add(name_bytes) + .and_then(|total| total.checked_add(value_bytes)) + .ok_or_else(|| response_limit_error(ResponseLimitReason::HeaderBytes))?; + self.observed_bytes = next_bytes; + if self.max_bytes.is_some_and(|max| next_bytes > max) { + return Err(response_limit_error(ResponseLimitReason::HeaderBytes)); + } + } + Ok(()) + } +} + +impl HttpClient { + #[inline] + pub fn new(client: Arc) -> Self { + Self { inner: client } + } + + /// Delegates one request to the adapter-owned outbound implementation. + /// + /// # Errors + /// Returns the adapter's typed outbound failure unchanged. + #[inline] + pub async fn send(&self, request: OutboundRequest) -> Result { + self.inner.send(request).await + } + + #[inline] + pub async fn send_all(&self, requests: Vec) -> Vec { + self.inner.send_all(requests).await + } + + #[inline] + pub fn with_client(client: Client) -> Self + where + Client: OutboundHttpClient + 'static, + { + Self::new(Arc::new(client)) + } +} + +impl OutboundResponse { + #[must_use] + #[inline] + pub fn body(&self) -> &Body { + &self.body + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + #[must_use] + #[inline] + pub fn into_body(self) -> Body { + self.body + } + + /// Collects the body under a final response-buffer limit. + /// + /// # Errors + /// Returns a typed source error or `BufferedBody` response-limit error. + #[inline] + pub async fn into_bytes_bounded(self, max: u64) -> Result { + match self.body { + Body::Once(bytes) => validate_buffered_response_bytes(bytes, max), + Body::Stream(stream) => collect_response_stream(stream, max).await, + } + } + + /// Collects the body under a final response-buffer limit and cooperative deadline. + /// + /// # Errors + /// Returns 504 when the deadline is observed expired, otherwise a typed source or + /// `BufferedBody` response-limit error. + #[inline] + pub async fn into_bytes_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result { + let clock = self.monotonic_clock.clone(); + ensure_deadline_live(deadline, &clock)?; + match self.body { + Body::Once(bytes) => { + let outcome = validate_buffered_response_bytes(bytes, max); + ensure_deadline_live(deadline, &clock)?; + outcome + } + Body::Stream(stream) => { + collect_response_stream_until_with_clock(stream, max, deadline, &clock).await + } + } + } + + #[must_use] + /// Destructures the adapter-facing response representation without re-running response-header + /// normalization. Call [`Self::into_response`] at an application boundary that requires the + /// final defensive framing pass. + #[inline] + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body) { + (self.request_method, self.status, self.headers, self.body) + } + + /// Converts the outbound value after one final defensive metadata pass. + /// + /// # Errors + /// Returns a typed protocol error for malformed framing, or an internal error if the + /// already-validated response cannot be assembled. + #[inline] + pub fn into_response(mut self) -> Result { + normalize_response_headers(&self.request_method, self.status, &mut self.headers)?; + let mut builder = response_builder().status(self.status); + for (name, value) in &self.headers { + builder = builder.header(name, value); + } + builder.body(self.body).map_err(EdgeError::internal) + } + + #[must_use] + #[inline] + pub fn is_success(&self) -> bool { + self.status.is_success() + } + + /// Deserializes an already-buffered upstream body as JSON. + /// + /// # Errors + /// Returns protocol 502 for a streamed body and decode 502 for malformed JSON. + #[inline] + pub fn json(&self) -> Result + where + Value: DeserializeOwned, + { + let Body::Once(bytes) = &self.body else { + return Err(EdgeError::bad_gateway_with_reason( + "response body not buffered; use json_bounded(max) or json_bounded_until(max, deadline)", + BadGatewayReason::Protocol, + )); + }; + decode_json(bytes) + } + + /// Collects and deserializes an upstream body as JSON. + /// + /// # Errors + /// Returns typed source/limit failures or decode 502 for malformed JSON. + #[inline] + pub async fn json_bounded(self, max: u64) -> Result + where + Value: DeserializeOwned, + { + let bytes = self.into_bytes_bounded(max).await?; + decode_json(&bytes) + } + + /// Collects and deserializes an upstream body under a cooperative deadline. + /// + /// # Errors + /// Returns 504 on observed expiry, typed source/limit failures, or decode 502 for malformed + /// JSON. + #[inline] + pub async fn json_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result + where + Value: DeserializeOwned, + { + let clock = self.monotonic_clock.clone(); + let bytes = self.into_bytes_bounded_until(max, deadline).await?; + ensure_deadline_live(deadline, &clock)?; + decode_json(&bytes) + } + + #[must_use] + #[inline] + pub fn new(request_method: Method, status: StatusCode, headers: HeaderMap, body: Body) -> Self { + Self::new_with_monotonic_clock( + request_method, + status, + headers, + body, + MonotonicClock::default(), + ) + } + + /// Constructs an adapter response paired with its application clock. + #[doc(hidden)] + #[must_use] + #[inline] + pub fn new_with_monotonic_clock( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + monotonic_clock: MonotonicClock, + ) -> Self { + Self { + body, + headers, + monotonic_clock, + request_method, + status, + } + } + + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } +} + +impl OutboundRequest { + #[must_use] + #[inline] + pub fn backend_target(&self) -> String { + let host = bracket_ipv6(self.host_name()); + format!("{host}:{}", self.resolved_port()) + } + + #[must_use] + #[inline] + pub fn body(mut self, body: BodyValue) -> Self + where + BodyValue: Into, + { + self.body = body.into(); + self + } + + pub(crate) fn budget_inputs(&self) -> BudgetInputs { + BudgetInputs { + deadline: self.deadline, + timeout: self.timeout, + } + } + + #[must_use] + #[inline] + pub fn cert_host(&self) -> Option<&str> { + (self.uri.scheme_str() == Some("https")).then(|| self.host_name()) + } + + #[must_use] + #[inline] + pub fn deadline(mut self, deadline: Deadline) -> Self { + self.deadline = Some(deadline); + self + } + + /// Reassembles a request and revalidates its target. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when `parts.uri` is not a canonicalizable HTTP(S) + /// target. + #[inline] + pub fn from_parts(parts: OutboundRequestParts) -> Result { + let canonical_uri = canonicalize_typed_uri(&parts.uri)?; + Ok(Self { + body: parts.body, + deadline: parts.deadline, + headers: parts.headers, + max_brotli_decoder_bytes: parts.max_brotli_decoder_bytes, + max_brotli_window_bits: parts.max_brotli_window_bits, + max_chunk_bytes: parts.max_chunk_bytes, + max_decoded_response_bytes: parts.max_decoded_response_bytes, + max_encoded_response_bytes: parts.max_encoded_response_bytes, + max_request_body_bytes: parts.max_request_body_bytes, + max_response_header_bytes: parts.max_response_header_bytes, + max_response_header_count: parts.max_response_header_count, + method: parts.method, + response_mode: parts.response_mode, + timeout: parts.timeout, + uri: canonical_uri, + }) + } + + fn from_raw(method: Method, raw: &str) -> Result { + reject_ambiguous_raw_target(raw)?; + let uri = canonicalize_url(raw)?; + Ok(Self::with_canonical_uri(method, uri)) + } + + /// Creates an outbound request from an inbound request while removing framing and + /// hop-by-hop metadata. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the target is invalid or a `connection` + /// nomination is malformed. + #[inline] + pub fn from_request(request: Request, target: Uri) -> Result { + let (parts, body) = request.into_parts(); + let mut outbound = Self::new(parts.method, target)?; + outbound.body = body; + outbound.headers = parts.headers; + normalize_request_headers(&mut outbound.headers)?; + Ok(outbound) + } + + /// Creates a canonical GET request from a raw URL. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URL is not an absolute HTTP(S) target. + #[inline] + pub fn get(uri: Target) -> Result + where + Target: AsRef, + { + Self::from_raw(Method::GET, uri.as_ref()) + } + + /// Appends one validated header value. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the name is invalid, the value is not UTF-8, + /// or the value contains bytes forbidden by HTTP header syntax. + #[inline] + pub fn header(mut self, name: N, value: V) -> Result + where + N: AsRef<[u8]>, + V: AsRef<[u8]>, + { + let header_name = HeaderName::from_bytes(name.as_ref()) + .map_err(|_error| EdgeError::bad_request("invalid outbound header name"))?; + if str::from_utf8(value.as_ref()).is_err() { + return Err(EdgeError::bad_request(format!( + "header value is not valid UTF-8: {header_name}" + ))); + } + let header_value = HeaderValue::from_bytes(value.as_ref()).map_err(|_error| { + EdgeError::bad_request(format!( + "header value contains forbidden bytes: {header_name}" + )) + })?; + self.headers.append(header_name, header_value); + Ok(self) + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + #[must_use] + #[inline] + pub fn host_authority(&self) -> String { + self.uri + .authority() + .map_or_else(String::new, |authority| authority.as_str().to_owned()) + } + + #[must_use] + #[inline] + pub fn host_name(&self) -> &str { + let host = self.uri.host().unwrap_or_default(); + host.strip_prefix('[') + .and_then(|unbracketed| unbracketed.strip_suffix(']')) + .unwrap_or(host) + } + + #[must_use] + #[inline] + pub fn into_parts(self) -> OutboundRequestParts { + OutboundRequestParts { + body: self.body, + deadline: self.deadline, + headers: self.headers, + max_brotli_decoder_bytes: self.max_brotli_decoder_bytes, + max_brotli_window_bits: self.max_brotli_window_bits, + max_chunk_bytes: self.max_chunk_bytes, + max_decoded_response_bytes: self.max_decoded_response_bytes, + max_encoded_response_bytes: self.max_encoded_response_bytes, + max_request_body_bytes: self.max_request_body_bytes, + max_response_header_bytes: self.max_response_header_bytes, + max_response_header_count: self.max_response_header_count, + method: self.method, + response_mode: self.response_mode, + timeout: self.timeout, + uri: self.uri, + } + } + + #[must_use] + #[inline] + pub fn is_stream_body(&self) -> bool { + self.body.is_stream() + } + + #[must_use] + #[inline] + pub fn is_stream_response(&self) -> bool { + self.response_mode == ResponseMode::Streamed + } + + /// Serializes a request body as JSON. + /// + /// # Errors + /// Returns [`EdgeError::Internal`] when serialization fails. + #[inline] + pub fn json(mut self, value: &T) -> Result { + self.body = Body::json(value).map_err(EdgeError::internal)?; + if !self.headers.contains_key(CONTENT_TYPE) { + self.headers + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + } + Ok(self) + } + + #[must_use] + #[inline] + pub fn max_brotli_decoder_bytes(mut self, bytes: u64) -> Self { + self.max_brotli_decoder_bytes = bytes; + self + } + + #[must_use] + #[inline] + pub fn max_brotli_window_bits(mut self, bits: u8) -> Self { + self.max_brotli_window_bits = bits; + self + } + + #[must_use] + #[inline] + pub fn max_chunk_bytes(mut self, bytes: NonZeroU64) -> Self { + self.max_chunk_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_decoded_response_bytes(mut self, bytes: u64) -> Self { + self.max_decoded_response_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_encoded_response_bytes(mut self, bytes: u64) -> Self { + self.max_encoded_response_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_request_body_bytes(mut self, bytes: u64) -> Self { + self.max_request_body_bytes = bytes; + self + } + + #[must_use] + /// Selects buffered response mode and sets its final collection limit. + /// + /// This is a last-write-wins mode setter: a later [`Self::stream_response`] call selects + /// streamed mode instead. + #[inline] + pub fn max_response_bytes(mut self, bytes: u64) -> Self { + self.response_mode = ResponseMode::Buffered { max_bytes: bytes }; + self + } + + #[must_use] + #[inline] + pub fn max_response_header_bytes(mut self, bytes: u64) -> Self { + self.max_response_header_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_response_header_count(mut self, count: u64) -> Self { + self.max_response_header_count = Some(count); + self + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Creates an outbound request from a typed URI. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URI is not an absolute HTTP(S) target. + #[expect( + clippy::needless_pass_by_value, + reason = "the public request constructor takes ownership consistently with http::Request" + )] + #[inline] + pub fn new(method: Method, uri: Uri) -> Result { + let canonical_uri = canonicalize_typed_uri(&uri)?; + Ok(Self::with_canonical_uri(method, canonical_uri)) + } + + /// Creates a canonical POST request from a raw URL. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URL is not an absolute HTTP(S) target. + #[inline] + pub fn post(uri: Target) -> Result + where + Target: AsRef, + { + Self::from_raw(Method::POST, uri.as_ref()) + } + + fn resolved_port(&self) -> u16 { + self.uri.port_u16().unwrap_or_else(|| { + if self.uri.scheme_str() == Some("https") { + 443 + } else { + 80 + } + }) + } + + #[must_use] + #[inline] + pub fn sni_hostname(&self) -> Option<&str> { + let host = self.host_name(); + (self.uri.scheme_str() == Some("https") && host.parse::().is_err()).then_some(host) + } + + #[must_use] + /// Selects streamed response mode. + /// + /// This is a last-write-wins mode setter: a later [`Self::max_response_bytes`] call selects + /// buffered mode instead. + #[inline] + pub fn stream_response(mut self) -> Self { + self.response_mode = ResponseMode::Streamed; + self + } + + #[must_use] + #[inline] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + #[must_use] + #[inline] + pub fn uri(&self) -> &Uri { + &self.uri + } + + fn with_canonical_uri(method: Method, uri: Uri) -> Self { + Self { + body: Body::empty(), + deadline: None, + headers: HeaderMap::new(), + max_brotli_decoder_bytes: DEFAULT_MAX_BROTLI_DECODER_BYTES, + max_brotli_window_bits: 24, + max_chunk_bytes: None, + max_decoded_response_bytes: None, + max_encoded_response_bytes: None, + max_request_body_bytes: DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, + max_response_header_bytes: None, + max_response_header_count: None, + method, + response_mode: ResponseMode::Buffered { + max_bytes: DEFAULT_MAX_RESPONSE_BYTES, + }, + timeout: None, + uri, + } + } +} + +/// Collects a response stream under the final buffered-body cap. +/// +/// # Errors +/// Returns a typed source error unchanged or a `BufferedBody` response-limit error. +#[inline] +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result { + collect_response_stream_inner(stream, max, None).await +} + +/// Applies sound pre-poll checks to a normalized payload `content-length`. +/// +/// # Errors +/// Returns a protocol 502 for malformed length metadata or a typed response-limit 502 when +/// the declared wire/final size exceeds a comparable configured cap. +#[inline] +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError> { + let Some(length) = parse_content_length(headers)? else { + return Ok(()); + }; + if max_encoded_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::EncodedBody)); + } + match encoding { + ContentEncoding::Identity => { + if max_decoded_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::DecodedBody)); + } + if max_buffered_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + } + ContentEncoding::Passthrough => { + if max_buffered_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + } + ContentEncoding::Brotli | ContentEncoding::Gzip => {} + } + Ok(()) +} + +#[must_use] +#[inline] +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream { + limit_response_stream(stream, max, ResponseLimitReason::DecodedBody) +} + +#[must_use] +#[inline] +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream { + limit_response_stream(stream, max, ResponseLimitReason::EncodedBody) +} + +/// Reapplies request header normalization immediately before platform conversion. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when a `connection` nomination is malformed. +#[inline] +pub fn normalize_for_dispatch(request: &mut OutboundRequest) -> Result<(), EdgeError> { + normalize_request_headers(&mut request.headers) +} + +/// Normalizes raw upstream metadata before response-body policy is selected. +/// +/// # Errors +/// Returns a protocol-classified 502 when `connection` or payload framing is malformed. +#[inline] +pub fn normalize_response_headers( + request_method: &Method, + status: StatusCode, + headers: &mut HeaderMap, +) -> Result { + let nominated = connection_nominations(headers, true)?; + retain_utf8_header_values(headers, true); + strip_hop_by_hop(headers, nominated, false); + + if status.is_informational() || status == StatusCode::NO_CONTENT { + headers.remove(CONTENT_LENGTH); + return Ok(ResponseBodyDisposition::FramingBodyless); + } + + if request_method == Method::HEAD || status == StatusCode::NOT_MODIFIED { + parse_content_length(headers)?; + return Ok(ResponseBodyDisposition::FramingBodyless); + } + + let content_length = parse_content_length(headers)?; + if status == StatusCode::RESET_CONTENT { + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("0")); + return Ok(ResponseBodyDisposition::ResetContent { + declared_body: content_length.is_some_and(|length| length > 0), + }); + } + Ok(ResponseBodyDisposition::Payload) +} + +#[must_use] +#[inline] +pub fn rechunk_stream(mut source: BodyStream, optional_maximum: Option) -> BodyStream { + let Some(maximum) = optional_maximum else { + return source; + }; + let maximum_chunk = usize::try_from(maximum.get()).unwrap_or(usize::MAX); + stream! { + while let Some(item) = source.next().await { + match item { + Ok(mut bytes) => { + if bytes.is_empty() { + yield Ok(Bytes::new()); + continue; + } + while bytes.len() > maximum_chunk { + yield Ok(bytes.split_to(maximum_chunk)); + } + if !bytes.is_empty() { + yield Ok(bytes); + } + } + Err(error) => { + yield Err(error); + return; + } + } + } + } + .boxed_local() +} + +/// Validates the adapter-independent outbound method, body, and response-policy contract. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when a common contract invariant is invalid. Individual +/// adapters may reject additional target-specific constraints during request preparation. +#[inline] +pub fn validate_for_dispatch(request: &OutboundRequest) -> Result<(), EdgeError> { + if !matches!( + request.method, + Method::GET + | Method::HEAD + | Method::POST + | Method::PUT + | Method::PATCH + | Method::DELETE + | Method::OPTIONS + ) { + return Err(EdgeError::bad_request(format!( + "method {} is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS", + request.method + ))); + } + + if matches!(request.method, Method::GET | Method::HEAD) { + match &request.body { + Body::Once(bytes) if bytes.is_empty() => {} + Body::Once(_) => { + return Err(EdgeError::bad_request( + "GET/HEAD request must not carry a body", + )); + } + Body::Stream(_) => { + return Err(EdgeError::bad_request( + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + )); + } + } + } + + if !(10..=30).contains(&request.max_brotli_window_bits) { + return Err(EdgeError::bad_request( + "max_brotli_window_bits must be between 10 and 30 inclusive", + )); + } + Ok(()) +} + +fn bracket_ipv6(host: &str) -> String { + if host.contains(':') { + format!("[{host}]") + } else { + host.to_owned() + } +} + +fn canonicalize_typed_uri(uri: &Uri) -> Result { + canonicalize_url(&uri.to_string()) +} + +fn canonicalize_url(raw: &str) -> Result { + let mut parsed = Url::parse(raw) + .map_err(|_error| EdgeError::bad_request("outbound URI must be absolute with authority"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(EdgeError::bad_request( + "outbound URI scheme must be http or https", + )); + } + if parsed.host_str().is_none() { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(EdgeError::bad_request( + "outbound URI must not contain userinfo; pass credentials via the `authorization` header", + )); + } + if parsed.fragment().is_some() { + return Err(EdgeError::bad_request( + "outbound URI must not contain a fragment", + )); + } + let default_port = if parsed.scheme() == "http" { 80 } else { 443 }; + if parsed.port() == Some(default_port) { + parsed + .set_port(None) + .map_err(|()| EdgeError::bad_request("outbound URI contains an invalid port"))?; + } + parsed + .as_str() + .parse() + .map_err(|_error| EdgeError::bad_request("outbound URI cannot be represented as HTTP URI")) +} + +async fn collect_response_stream_inner( + mut source: BodyStream, + max: u64, + optional_deadline: Option<(Deadline, &MonotonicClock)>, +) -> Result { + let mut collected = Vec::new(); + let mut total = 0_u64; + loop { + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + let next_item = source.next().await; + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + let Some(item) = next_item else { + return Ok(Bytes::from(collected)); + }; + let bytes = item?; + let chunk_len = u64::try_from(bytes.len()) + .map_err(|_error| response_limit_error(ResponseLimitReason::BufferedBody))?; + let next_total = total + .checked_add(chunk_len) + .ok_or_else(|| response_limit_error(ResponseLimitReason::BufferedBody))?; + if next_total > max { + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + collected.extend_from_slice(&bytes); + total = next_total; + } +} + +/// Collects a response stream under an absolute deadline evaluated by `clock`. +/// +/// # Errors +/// Returns a typed buffered-body overflow or gateway timeout while preserving source errors. +#[doc(hidden)] +#[inline] +pub async fn collect_response_stream_until_with_clock( + stream: BodyStream, + max: u64, + deadline: Deadline, + clock: &MonotonicClock, +) -> Result { + collect_response_stream_inner(stream, max, Some((deadline, clock))).await +} + +fn decode_json(bytes: &[u8]) -> Result +where + Value: DeserializeOwned, +{ + serde_json::from_slice(bytes).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "failed to decode upstream JSON", + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + ) + }) +} + +fn ensure_deadline_live(deadline: Deadline, clock: &MonotonicClock) -> Result<(), EdgeError> { + if deadline.is_expired_at(clock.now()) { + Err(EdgeError::gateway_timeout("response body deadline expired")) + } else { + Ok(()) + } +} + +fn normalize_request_headers(headers: &mut HeaderMap) -> Result<(), EdgeError> { + let nominated = connection_nominations(headers, false)?; + retain_utf8_header_values(headers, false); + strip_hop_by_hop(headers, nominated, true); + Ok(()) +} + +fn connection_nominations( + headers: &HeaderMap, + response: bool, +) -> Result, EdgeError> { + let mut nominated = Vec::new(); + for header_value in headers.get_all(CONNECTION) { + let raw_value = str::from_utf8(header_value.as_bytes()).map_err(|_error| { + malformed_connection(response, "connection header value is not valid UTF-8") + })?; + for raw_token in raw_value.split(',') { + let token = raw_token.trim(); + if token.is_empty() { + return Err(malformed_connection( + response, + "connection header contains an empty nomination", + )); + } + let name = HeaderName::from_bytes(token.as_bytes()).map_err(|_error| { + malformed_connection(response, "connection header contains an invalid nomination") + })?; + nominated.push(name); + } + } + Ok(nominated) +} + +fn malformed_connection(response: bool, message: &'static str) -> EdgeError { + if response { + EdgeError::bad_gateway_with_reason(message, BadGatewayReason::Protocol) + } else { + EdgeError::bad_request(message) + } +} + +fn limit_response_stream( + mut source: BodyStream, + optional_maximum: Option, + reason: ResponseLimitReason, +) -> BodyStream { + let Some(maximum) = optional_maximum else { + return source; + }; + stream! { + let mut total = 0_u64; + while let Some(item) = source.next().await { + match item { + Ok(bytes) => { + let Ok(chunk_len) = u64::try_from(bytes.len()) else { + yield Err(response_limit_error(reason)); + return; + }; + let Some(next_total) = total.checked_add(chunk_len) else { + yield Err(response_limit_error(reason)); + return; + }; + if next_total > maximum { + yield Err(response_limit_error(reason)); + return; + } + total = next_total; + yield Ok(bytes); + } + Err(error) => { + yield Err(error); + return; + } + } + } + } + .boxed_local() +} + +fn parse_content_length(headers: &HeaderMap) -> Result, EdgeError> { + let mut parsed = None; + for value in headers.get_all(CONTENT_LENGTH) { + let raw = str::from_utf8(value.as_bytes()).map_err(|_error| protocol_content_length())?; + if raw.contains(',') { + return Err(protocol_content_length()); + } + let trimmed = raw.trim(); + if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(protocol_content_length()); + } + let length = trimmed + .parse::() + .map_err(|_error| protocol_content_length())?; + if parsed.is_some_and(|previous| previous != length) { + return Err(protocol_content_length()); + } + parsed = Some(length); + } + Ok(parsed) +} + +fn protocol_content_length() -> EdgeError { + EdgeError::bad_gateway_with_reason( + "upstream response has malformed or conflicting content-length", + BadGatewayReason::Protocol, + ) +} + +fn response_limit_error(reason: ResponseLimitReason) -> EdgeError { + EdgeError::response_too_large_with_reason("upstream response exceeded configured limit", reason) +} + +fn retain_utf8_header_values(headers: &mut HeaderMap, response: bool) { + let mut retained = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers.iter() { + if name != CONNECTION + && !(response && name == CONTENT_ENCODING) + && str::from_utf8(value.as_bytes()).is_err() + { + if response { + log::warn!("dropping non-UTF-8 outbound response header: {name}"); + } else { + log::warn!("dropping non-UTF-8 outbound request header: {name}"); + } + continue; + } + retained.append(name.clone(), value.clone()); + } + *headers = retained; +} + +fn strip_hop_by_hop( + headers: &mut HeaderMap, + nominated: Vec, + strip_request_framing: bool, +) { + for name in nominated { + headers.remove(name); + } + for name in [ + CONNECTION, + HeaderName::from_static("keep-alive"), + PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, + TE, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + ] { + headers.remove(name); + } + if strip_request_framing { + headers.remove(HOST); + headers.remove(CONTENT_LENGTH); + } +} + +fn validate_buffered_response_bytes(bytes: Bytes, max: u64) -> Result { + let length = u64::try_from(bytes.len()) + .map_err(|_error| response_limit_error(ResponseLimitReason::BufferedBody))?; + if length > max { + Err(response_limit_error(ResponseLimitReason::BufferedBody)) + } else { + Ok(bytes) + } +} + +fn reject_ambiguous_raw_target(raw: &str) -> Result<(), EdgeError> { + if raw.as_bytes().contains(&b'#') { + return Err(EdgeError::bad_request( + "outbound URI must not contain a fragment", + )); + } + if raw.as_bytes().contains(&b'\\') { + return Err(EdgeError::bad_request( + "outbound URI must not contain a backslash", + )); + } + let Some((_scheme, authority_tail)) = raw.split_once("://") else { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + }; + let authority = authority_tail + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if authority.is_empty() { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + } + if authority.as_bytes().contains(&b'@') { + return Err(EdgeError::bad_request( + "outbound URI must not contain userinfo; pass credentials via the `authorization` header", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::shadow_reuse, + clippy::shadow_unrelated, + reason = "table-driven tests intentionally reuse conventional request, response, and budget names" + )] + + use std::cell::Cell; + use std::num::NonZeroU64; + use std::rc::Rc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use async_trait::async_trait; + use bytes::Bytes; + use futures::executor::block_on; + use futures_util::{StreamExt as _, stream}; + + use crate::body::Body; + use crate::compression::{ContentEncoding, classify_content_encoding}; + use crate::error::{ + BadGatewayDecodeReason, BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, + }; + use crate::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, request_builder}; + use crate::time::{ + DEADLINE_FAR_FUTURE, DEFAULT_NO_DEADLINE_BUDGET, Deadline, MonotonicClock, + MonotonicInstant, dispatch_budget, + }; + + use super::{ + DEFAULT_MAX_BROTLI_DECODER_BYTES, DEFAULT_MAX_RESPONSE_BYTES, + DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, HttpClient, OutboundHttpClient, OutboundRequest, + OutboundResponse, OutboundSlotResult, ResponseBodyDisposition, ResponseHeaderLimiter, + ResponseMode, collect_response_stream, enforce_payload_content_length, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + normalize_response_headers, rechunk_stream, validate_for_dispatch, + }; + + struct MockClient { + batch_calls: AtomicUsize, + send_calls: AtomicUsize, + } + + #[async_trait(?Send)] + impl OutboundHttpClient for MockClient { + async fn send(&self, request: OutboundRequest) -> Result { + self.send_calls.fetch_add(1, Ordering::Relaxed); + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::CREATED, + HeaderMap::new(), + Body::from("single"), + )) + } + + async fn send_all(&self, requests: Vec) -> Vec { + self.batch_calls.fetch_add(1, Ordering::Relaxed); + requests + .into_iter() + .enumerate() + .map(|(index, request)| OutboundSlotResult { + elapsed: Duration::from_millis( + u64::try_from(index) + .expect("index") + .checked_add(1) + .expect("elapsed"), + ), + outcome: if request.method() == Method::DELETE { + Err(EdgeError::bad_gateway("mock failure")) + } else { + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::OK, + HeaderMap::new(), + Body::empty(), + )) + }, + }) + .collect() + } + } + + #[expect( + clippy::wildcard_enum_match_arm, + reason = "EdgeError is non-exhaustive and this assertion helper must reject future variants" + )] + fn bad_request_message(error: EdgeError) -> String { + match error { + EdgeError::BadRequest { message } => message, + other => panic!("expected bad request, got {other:?}"), + } + } + + fn response_with_body(body: Body) -> OutboundResponse { + OutboundResponse::new(Method::GET, StatusCode::OK, HeaderMap::new(), body) + } + + #[test] + fn buffered_collection_limit_is_independent() { + let source = Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ])) + .into_stream() + .expect("stream"); + let bytes = block_on(collect_response_stream(source, 4)).expect("exact cap"); + assert_eq!(bytes, Bytes::from_static(b"abcd")); + + let error = + block_on(response_with_body(Body::from("raw-passthrough")).into_bytes_bounded(3)) + .expect_err("final cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn dispatch_budget_rejects_expired_or_zero() { + let now = MonotonicInstant::now(); + let expired = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(Deadline::at_instant(now)); + let error = dispatch_budget(&expired, now).expect_err("expired"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchDeadline, + .. + } + )); + + let tied = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::ZERO) + .deadline(Deadline::at_instant(now)); + let error = dispatch_budget(&tied, now).expect_err("zero tie"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); + } + + #[test] + fn dispatch_budget_selects_and_attributes_minimum() { + let now = MonotonicInstant::now(); + + let default = OutboundRequest::get("https://example.com").expect("request"); + let budget = dispatch_budget(&default, now).expect("default budget"); + assert_eq!(budget.cause, BudgetSource::Default); + assert_eq!(budget.duration, DEFAULT_NO_DEADLINE_BUDGET); + assert_eq!( + budget.deadline.instant(), + now.checked_add(DEFAULT_NO_DEADLINE_BUDGET) + .expect("no overflow") + ); + + let timeout = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(3)); + let budget = dispatch_budget(&timeout, now).expect("timeout budget"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + assert_eq!(budget.duration, Duration::from_secs(3)); + + let deadline = Deadline::at_instant( + now.checked_add(Duration::from_secs(4)) + .expect("no overflow"), + ); + let request = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(deadline); + let budget = dispatch_budget(&request, now).expect("deadline budget"); + assert_eq!(budget.cause, BudgetSource::BatchDeadline); + assert_eq!(budget.duration, Duration::from_secs(4)); + assert_eq!(budget.deadline.instant(), deadline.instant()); + + let timeout_wins = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(2)) + .deadline(deadline); + let budget = dispatch_budget(&timeout_wins, now).expect("timeout wins"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + assert_eq!(budget.duration, Duration::from_secs(2)); + + let deadline_wins = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(5)) + .deadline(deadline); + let budget = dispatch_budget(&deadline_wins, now).expect("deadline wins"); + assert_eq!(budget.cause, BudgetSource::BatchDeadline); + assert_eq!(budget.duration, Duration::from_secs(4)); + + let tied_deadline = Deadline::at_instant( + now.checked_add(Duration::from_secs(5)) + .expect("no overflow"), + ); + let tied = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(5)) + .deadline(tied_deadline); + let budget = dispatch_budget(&tied, now).expect("equal budget"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + + let huge_timeout = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::MAX); + let budget = dispatch_budget(&huge_timeout, now).expect("clamped timeout"); + assert_eq!(budget.duration, DEADLINE_FAR_FUTURE); + + let far_deadline = Deadline::at_instant( + now.checked_add(Duration::from_hours(8_760)) + .expect("no overflow"), + ); + let far = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(far_deadline); + let budget = dispatch_budget(&far, now).expect("clamped deadline"); + assert_eq!(budget.duration, DEADLINE_FAR_FUTURE); + assert_eq!(budget.cause, BudgetSource::BatchDeadline); + } + + #[test] + fn encoded_limit_stops_before_decoder() { + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let source = Body::from_stream( + stream::iter([ + Ok(Bytes::from_static(b"four")), + Ok(Bytes::from_static(b"later")), + ]) + .inspect(move |_| observed.set(observed.get().saturating_add(1))), + ) + .into_stream() + .expect("stream"); + let mut limited = limit_encoded_stream(source, Some(3)); + let error = block_on(limited.next()) + .expect("limit result") + .expect_err("over limit"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + assert_eq!(polls.get(), 1); + assert!(block_on(limited.next()).is_none()); + } + + #[test] + fn http_client_delegates_send_and_send_all() { + let client = HttpClient::with_client(MockClient { + batch_calls: AtomicUsize::new(0), + send_calls: AtomicUsize::new(0), + }); + + let response = block_on( + client.send( + OutboundRequest::post("https://example.com") + .expect("request") + .body("request"), + ), + ) + .expect("response"); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.body().as_bytes(), Some(b"single".as_slice())); + + let empty = block_on(client.send_all(Vec::new())); + assert!(empty.is_empty()); + } + + #[test] + fn http_client_preserves_per_slot_elapsed() { + let client = HttpClient::with_client(MockClient { + batch_calls: AtomicUsize::new(0), + send_calls: AtomicUsize::new(0), + }); + let requests = vec![ + OutboundRequest::get("https://example.com/one").expect("one"), + OutboundRequest::new(Method::DELETE, Uri::from_static("https://example.com/two")) + .expect("two"), + OutboundRequest::post("https://example.com/three").expect("three"), + ]; + + let results = block_on(client.send_all(requests)); + assert_eq!(results.len(), 3); + assert_eq!(results[0].elapsed, Duration::from_millis(1)); + assert_eq!(results[1].elapsed, Duration::from_millis(2)); + assert_eq!(results[2].elapsed, Duration::from_millis(3)); + results[0].outcome.as_ref().expect("first slot success"); + assert!(matches!( + results[1].outcome, + Err(EdgeError::BadGateway { .. }) + )); + results[2].outcome.as_ref().expect("third slot success"); + } + + #[test] + fn outbound_response_parts_preserve_method_headers_and_body() { + let mut headers = HeaderMap::new(); + headers.append("set-cookie", HeaderValue::from_static("a=1")); + headers.append("set-cookie", HeaderValue::from_static("b=2")); + let mut response = OutboundResponse::new( + Method::HEAD, + StatusCode::CREATED, + headers, + Body::from("payload"), + ); + assert_eq!(response.status(), StatusCode::CREATED); + assert!(response.is_success()); + assert_eq!(response.body().as_bytes(), Some(b"payload".as_slice())); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + response + .headers_mut() + .append("x-adapter", HeaderValue::from_static("yes")); + + let (method, status, headers, body) = response.into_parts(); + assert_eq!(method, Method::HEAD); + assert_eq!(status, StatusCode::CREATED); + assert_eq!( + headers.get("x-adapter"), + Some(&HeaderValue::from_static("yes")) + ); + assert_eq!(body.as_bytes(), Some(b"payload".as_slice())); + + let body = OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::from("owned"), + ) + .into_body(); + assert_eq!(body.as_bytes(), Some(b"owned".as_slice())); + } + + #[test] + fn payload_content_length_explicit_identity_rejects_before_body_poll() { + let mut headers = HeaderMap::new(); + headers.append("content-encoding", HeaderValue::from_static("identity")); + headers.append("content-length", HeaderValue::from_static("11")); + let error = enforce_payload_content_length( + &headers, + ContentEncoding::Identity, + Some(20), + Some(10), + Some(20), + ) + .expect_err("decoded cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + } + + #[test] + fn payload_content_length_passthrough_uses_buffered_not_decoded_cap() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("15")); + enforce_payload_content_length( + &headers, + ContentEncoding::Passthrough, + Some(20), + Some(10), + Some(20), + ) + .expect("decoded cap bypassed"); + + let error = enforce_payload_content_length( + &headers, + ContentEncoding::Passthrough, + Some(14), + Some(100), + Some(20), + ) + .expect_err("buffered cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn payload_content_length_rejects_before_body_poll() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("21")); + let error = enforce_payload_content_length( + &headers, + ContentEncoding::Identity, + Some(30), + Some(30), + Some(20), + ) + .expect_err("encoded cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + + for value in ["", "+1", "-1", "bad", "1, 1"] { + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(value.as_bytes()).expect("header"), + ); + enforce_payload_content_length(&headers, ContentEncoding::Identity, None, None, None) + .expect_err("malformed content-length"); + } + } + + #[test] + fn payload_content_length_skips_output_caps_for_compressed_body() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("100")); + for encoding in [ContentEncoding::Brotli, ContentEncoding::Gzip] { + enforce_payload_content_length(&headers, encoding, Some(1), Some(1), Some(100)) + .expect("wire length is not output length"); + } + } + + #[test] + fn rechunk_stream_is_lazy_and_ordered() { + let source = Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"abcde")), + Ok(Bytes::new()), + Err(EdgeError::bad_gateway("source")), + ])) + .into_stream() + .expect("stream"); + let mut chunks = rechunk_stream(source, NonZeroU64::new(2)); + let values = block_on(async { + let mut values = Vec::new(); + while let Some(item) = chunks.next().await { + match item { + Ok(bytes) => values.push(bytes), + Err(error) => { + assert!(matches!(error, EdgeError::BadGateway { .. })); + break; + } + } + } + values + }); + assert_eq!( + values, + vec![ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + Bytes::from_static(b"e"), + Bytes::new(), + ] + ); + } + + #[test] + fn response_header_limiter_accumulates_field_sections() { + let mut limiter = ResponseHeaderLimiter::new(Some(12), Some(3)); + let mut first = HeaderMap::new(); + first.append("a", HeaderValue::from_static("123")); + limiter.observe(&first).expect("first section"); + + let mut second = HeaderMap::new(); + second.append("b", HeaderValue::from_static("456")); + limiter.observe(&second).expect("second section"); + + let mut third = HeaderMap::new(); + third.append("c", HeaderValue::from_static("789")); + limiter.observe(&third).expect("third section"); + + let mut fourth = HeaderMap::new(); + fourth.append("d", HeaderValue::from_static("0")); + let error = limiter.observe(&fourth).expect_err("cumulative count"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderCount, + .. + } + )); + } + + #[test] + fn response_resource_header_limit_count_wins_tie() { + let mut limiter = ResponseHeaderLimiter::new(Some(0), Some(0)); + let mut headers = HeaderMap::new(); + headers.append("x", HeaderValue::from_static("y")); + let error = limiter.observe(&headers).expect_err("both limits"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderCount, + .. + } + )); + } + + #[test] + fn decoded_limit_bypasses_raw_passthrough() { + let source = Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ])) + .into_stream() + .expect("stream"); + let error = block_on(async { + let mut limited = limit_decoded_stream(source, Some(3)); + assert_eq!(limited.next().await.expect("first").expect("bytes"), "ab"); + limited.next().await.expect("second").expect_err("over cap") + }); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + + let raw = Body::from_stream(stream::iter([Ok(Bytes::from_static(b"raw-body"))])) + .into_stream() + .expect("raw passthrough"); + let bytes = block_on(async { raw.collect::>().await }); + assert_eq!(bytes[0].as_ref().expect("raw").as_ref(), b"raw-body"); + } + + #[test] + fn outbound_response_into_response_reapplies_normalization() { + let mut headers = HeaderMap::new(); + headers.append("connection", HeaderValue::from_static("x-private")); + headers.append("x-private", HeaderValue::from_static("secret")); + headers.append("set-cookie", HeaderValue::from_static("a=1")); + headers.append("set-cookie", HeaderValue::from_static("b=2")); + let body = Body::stream(stream::iter([Bytes::from_static(b"lazy")])); + let response = OutboundResponse::new(Method::GET, StatusCode::OK, headers, body) + .into_response() + .expect("response"); + + assert!(response.headers().get("connection").is_none()); + assert!(response.headers().get("x-private").is_none()); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + assert!(response.into_body().is_stream()); + } + + #[test] + fn outbound_response_json_error_classification() { + let response = response_with_body(Body::from(r#"{"ok":true}"#)); + let value: serde_json::Value = response.json().expect("json"); + assert_eq!(value["ok"], true); + + let malformed = response_with_body(Body::from("not-json")); + let error = malformed + .json::() + .expect_err("malformed JSON"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + .. + } + )); + + let streamed = response_with_body(Body::stream(stream::iter([Bytes::from_static(b"{}")]))) + .json::() + .expect_err("stream requires bounded helper"); + assert!(matches!( + streamed, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + + let streamed = response_with_body(Body::stream(stream::iter([Bytes::from_static(b"{}")]))); + let value: serde_json::Value = block_on(streamed.json_bounded(2)).expect("bounded json"); + assert_eq!(value, serde_json::json!({})); + } + + #[test] + fn outbound_response_until_deadline_wins_ready_result() { + let deadline = Deadline::at_instant(MonotonicInstant::now()); + let response = response_with_body(Body::from_stream(stream::iter([Err( + EdgeError::bad_gateway("ready source error"), + )]))); + let error = block_on(response.into_bytes_bounded_until(100, deadline)) + .expect_err("expired deadline"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )); + } + + #[test] + fn outbound_response_deadline_uses_its_injected_clock() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let response = OutboundResponse::new_with_monotonic_clock( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::from("ready"), + clock, + ); + *observed.lock().expect("clock lock") = deadline; + + let error = + block_on(response.into_bytes_bounded_until(100, Deadline::at_instant(deadline))) + .expect_err("injected clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn response_normalization_precedence_table() { + let mut nominated = HeaderMap::new(); + nominated.append( + "connection", + HeaderValue::from_static("content-encoding, content-length"), + ); + nominated.append("content-encoding", HeaderValue::from_static("gzip")); + nominated.append("content-length", HeaderValue::from_static("malformed")); + nominated.append("keep-alive", HeaderValue::from_static("timeout=5")); + assert_eq!( + normalize_response_headers(&Method::GET, StatusCode::OK, &mut nominated) + .expect("nominations precede semantics"), + ResponseBodyDisposition::Payload + ); + assert!(nominated.get("content-encoding").is_none()); + assert!(nominated.get("content-length").is_none()); + assert!(nominated.get("keep-alive").is_none()); + + let mut ambiguous_encoding = HeaderMap::new(); + ambiguous_encoding.append("content-encoding", HeaderValue::from_static("gzip")); + ambiguous_encoding.append( + "content-encoding", + HeaderValue::from_bytes(&[0xff]).expect("opaque header value"), + ); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut ambiguous_encoding) + .expect("invalid encoding sibling must remain visible to classification"); + assert_eq!( + classify_content_encoding(&ambiguous_encoding), + ContentEncoding::Passthrough + ); + assert_eq!( + ambiguous_encoding + .get_all("content-encoding") + .iter() + .count(), + 2 + ); + + for malformed in ["x-good,,x-other", "bad name", ",x-good", "x-good,"] { + let mut headers = HeaderMap::new(); + headers.append( + "connection", + HeaderValue::from_bytes(malformed.as_bytes()).expect("header"), + ); + let error = normalize_response_headers(&Method::GET, StatusCode::OK, &mut headers) + .expect_err("malformed connection"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + } + + for status in [ + StatusCode::CONTINUE, + StatusCode::NO_CONTENT, + StatusCode::NOT_MODIFIED, + ] { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("7")); + assert_eq!( + normalize_response_headers(&Method::GET, status, &mut headers).expect("bodyless"), + ResponseBodyDisposition::FramingBodyless + ); + if status == StatusCode::NOT_MODIFIED { + assert_eq!(headers.get("content-length").expect("metadata"), "7"); + } else { + assert!(headers.get("content-length").is_none()); + } + } + + for (value, declared_body) in [(None, false), (Some("0"), false), (Some("7"), true)] { + let mut headers = HeaderMap::new(); + if let Some(value) = value { + headers.append( + "content-length", + HeaderValue::from_bytes(value.as_bytes()).expect("length"), + ); + } + assert_eq!( + normalize_response_headers(&Method::GET, StatusCode::RESET_CONTENT, &mut headers) + .expect("205"), + ResponseBodyDisposition::ResetContent { declared_body } + ); + assert_eq!(headers.get("content-length").expect("normalized"), "0"); + } + + let mut conflicting = HeaderMap::new(); + conflicting.append("content-length", HeaderValue::from_static("7")); + conflicting.append("content-length", HeaderValue::from_static("8")); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut conflicting) + .expect_err("conflicting content-length"); + + let mut idempotent = HeaderMap::new(); + idempotent.append("x-keep", HeaderValue::from_static("yes")); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut idempotent).expect("first"); + let once = idempotent.clone(); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut idempotent).expect("second"); + assert_eq!(idempotent, once); + } + + #[test] + fn head_and_not_modified_validate_and_retain_representation_length() { + for (method, status) in [ + (Method::HEAD, StatusCode::OK), + (Method::GET, StatusCode::NOT_MODIFIED), + ] { + for invalid in ["not-a-number", "18446744073709551616"] { + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(invalid.as_bytes()).expect("header value"), + ); + let error = normalize_response_headers(&method, status, &mut headers) + .expect_err("invalid bodyless content-length"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + } + + let mut conflicting = HeaderMap::new(); + conflicting.append("content-length", HeaderValue::from_static("7")); + conflicting.append("content-length", HeaderValue::from_static("8")); + let error = normalize_response_headers(&method, status, &mut conflicting) + .expect_err("conflicting bodyless content-length"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + + let mut valid = HeaderMap::new(); + valid.append("content-length", HeaderValue::from_static("4294967296")); + assert_eq!( + normalize_response_headers(&method, status, &mut valid) + .expect("valid bodyless metadata"), + ResponseBodyDisposition::FramingBodyless + ); + assert_eq!(valid.get("content-length").expect("length"), "4294967296"); + } + } + + #[test] + fn dispatch_validation_precedence_table() { + for method in [ + Method::GET, + Method::HEAD, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ] { + let request = + OutboundRequest::new(method, Uri::from_static("https://example.com/resource")) + .expect("portable request"); + validate_for_dispatch(&request).expect("portable method"); + } + + let request = OutboundRequest::new( + Method::CONNECT, + Uri::from_static("https://example.com/resource"), + ) + .expect("request"); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("custom method")), + "method CONNECT is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" + ); + + let request = OutboundRequest::get("https://example.com") + .expect("request") + .body("not empty"); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("GET body")), + "GET/HEAD request must not carry a body" + ); + + let request = OutboundRequest::get("https://example.com") + .expect("request") + .body(Body::stream(stream::iter([Bytes::new()]))); + assert!(request.is_stream_body()); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("GET stream")), + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream" + ); + + let request = OutboundRequest::post("https://example.com") + .expect("request") + .body(Body::stream(stream::iter([Bytes::from_static(b"data")]))) + .stream_response(); + assert!(request.is_stream_body()); + assert!(request.is_stream_response()); + validate_for_dispatch(&request).expect("POST stream"); + + for bits in [9, 31] { + let request = OutboundRequest::get("https://example.com") + .expect("request") + .max_brotli_window_bits(bits); + assert!(validate_for_dispatch(&request).is_err(), "accepted {bits}"); + } + for bits in [10, 30] { + let request = OutboundRequest::get("https://example.com") + .expect("request") + .max_brotli_window_bits(bits); + validate_for_dispatch(&request).expect("valid Brotli window"); + } + } + + #[test] + fn request_normalization_is_idempotent() { + let mut request = OutboundRequest::post("https://example.com") + .expect("request") + .header("connection", "x-first, x-second") + .expect("connection") + .header("x-first", "one") + .expect("first") + .header("x-second", "two") + .expect("second") + .header("x-keep", "yes") + .expect("keep"); + + normalize_for_dispatch(&mut request).expect("first pass"); + let once = request.headers().clone(); + normalize_for_dispatch(&mut request).expect("second pass"); + assert_eq!(request.headers(), &once); + assert_eq!( + request.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn request_normalization_strips_connection_nominations() { + let mut request = OutboundRequest::post("https://example.com") + .expect("request") + .header("connection", "x-first") + .expect("connection") + .header("connection", "x-second, X-Third") + .expect("connection") + .header("x-first", "one") + .expect("first") + .header("x-second", "two") + .expect("second") + .header("x-third", "three") + .expect("third") + .header("keep-alive", "timeout=5") + .expect("keep-alive") + .header("proxy-authenticate", "challenge") + .expect("proxy-authenticate") + .header("proxy-authorization", "secret") + .expect("proxy-authorization") + .header("te", "trailers") + .expect("te") + .header("trailer", "x-checksum") + .expect("trailer") + .header("transfer-encoding", "chunked") + .expect("transfer-encoding") + .header("upgrade", "websocket") + .expect("upgrade") + .header("host", "wrong.example") + .expect("host") + .header("content-length", "100") + .expect("content-length") + .header("x-keep", "yes") + .expect("keep"); + + normalize_for_dispatch(&mut request).expect("normalize"); + for name in [ + "connection", + "x-first", + "x-second", + "x-third", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", + ] { + assert!(request.headers().get(name).is_none(), "retained {name}"); + } + assert_eq!( + request.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn request_normalization_rejects_malformed_connection() { + for value in ["x-valid,,x-other", "bad name", ",x-valid", "x-valid,"] { + let mut request = OutboundRequest::get("https://example.com").expect("request"); + request.headers_mut().append( + "connection", + HeaderValue::from_str(value).expect("header value"), + ); + assert!( + normalize_for_dispatch(&mut request).is_err(), + "accepted {value}" + ); + } + + let mut request = OutboundRequest::get("https://example.com").expect("request"); + request.headers_mut().append( + "connection", + HeaderValue::from_bytes(b"x-private,\xff").expect("opaque header"), + ); + normalize_for_dispatch(&mut request).expect_err("opaque connection nomination"); + } + + #[test] + fn request_normalization_drops_non_utf8_content_encoding() { + let mut request = OutboundRequest::post("https://example.com").expect("request"); + request.headers_mut().append( + "content-encoding", + HeaderValue::from_bytes(&[0xff]).expect("opaque header value"), + ); + + normalize_for_dispatch(&mut request).expect("normalize"); + + assert!(request.headers().get("content-encoding").is_none()); + } + + #[test] + #[expect( + clippy::cognitive_complexity, + reason = "the round-trip contract intentionally verifies every independent outbound field" + )] + fn outbound_request_defaults_and_parts_round_trip() { + let deadline = Deadline::after(Duration::from_secs(9)); + let request = OutboundRequest::post("https://example.com/items") + .expect("request") + .body("payload") + .deadline(deadline) + .header("x-test", "one") + .expect("header") + .max_brotli_decoder_bytes(40 * 1024 * 1024) + .max_brotli_window_bits(23) + .max_chunk_bytes(NonZeroU64::new(4096).expect("nonzero")) + .max_decoded_response_bytes(2_000_000) + .max_encoded_response_bytes(1_000_000) + .max_request_body_bytes(3_000_000) + .max_response_bytes(4_000_000) + .max_response_header_bytes(32_000) + .max_response_header_count(80) + .timeout(Duration::from_secs(8)); + + let parts = request.into_parts(); + assert_eq!(parts.method, Method::POST); + assert_eq!(parts.uri, Uri::from_static("https://example.com/items")); + assert_eq!(parts.body.as_bytes(), Some(b"payload".as_slice())); + assert_eq!( + parts.deadline.expect("deadline").instant(), + deadline.instant() + ); + assert_eq!( + parts.headers.get("x-test"), + Some(&HeaderValue::from_static("one")) + ); + assert_eq!(parts.max_brotli_decoder_bytes, 40 * 1024 * 1024); + assert_eq!(parts.max_brotli_window_bits, 23); + assert_eq!(parts.max_chunk_bytes.map(NonZeroU64::get), Some(4096)); + assert_eq!(parts.max_decoded_response_bytes, Some(2_000_000)); + assert_eq!(parts.max_encoded_response_bytes, Some(1_000_000)); + assert_eq!(parts.max_request_body_bytes, 3_000_000); + assert_eq!(parts.max_response_header_bytes, Some(32_000)); + assert_eq!(parts.max_response_header_count, Some(80)); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: 4_000_000 + } + ); + assert_eq!(parts.timeout, Some(Duration::from_secs(8))); + + let request = OutboundRequest::from_parts(parts).expect("round trip"); + assert_eq!(request.method(), &Method::POST); + assert_eq!( + request.uri(), + &Uri::from_static("https://example.com/items") + ); + + let defaults = OutboundRequest::get("https://example.com") + .expect("defaults") + .into_parts(); + assert_eq!( + defaults.max_brotli_decoder_bytes, + DEFAULT_MAX_BROTLI_DECODER_BYTES + ); + assert_eq!(defaults.max_brotli_window_bits, 24); + assert_eq!( + defaults.max_request_body_bytes, + DEFAULT_OUTBOUND_REQUEST_BODY_BYTES + ); + assert_eq!( + defaults.response_mode, + ResponseMode::Buffered { + max_bytes: DEFAULT_MAX_RESPONSE_BYTES + } + ); + assert!(defaults.deadline.is_none()); + assert!(defaults.max_decoded_response_bytes.is_none()); + assert!(defaults.max_encoded_response_bytes.is_none()); + assert!(defaults.max_response_header_bytes.is_none()); + assert!(defaults.max_response_header_count.is_none()); + assert!(defaults.timeout.is_none()); + } + + #[test] + fn outbound_request_canonicalizes_url_table() { + let cases = [ + ("HTTPS://EXAMPLE.com:443/a/../b", "https://example.com/b"), + ("http://example.com:80", "http://example.com/"), + ("https://example.com:8443", "https://example.com:8443/"), + ("https://127.0.0.1", "https://127.0.0.1/"), + ("https://[::1]:443/a", "https://[::1]/a"), + ("https://caf%C3%A9.example/", "https://xn--caf-dma.example/"), + ]; + + for (input, expected) in cases { + let request = OutboundRequest::get(input).expect(input); + assert_eq!(request.uri().to_string(), expected, "{input}"); + } + + let dns = OutboundRequest::get("https://example.com").expect("dns"); + assert_eq!(dns.backend_target(), "example.com:443"); + assert_eq!(dns.cert_host(), Some("example.com")); + assert_eq!(dns.host_authority(), "example.com"); + assert_eq!(dns.host_name(), "example.com"); + assert_eq!(dns.sni_hostname(), Some("example.com")); + + let ip = OutboundRequest::get("https://[::1]:8443").expect("ip"); + assert_eq!(ip.backend_target(), "[::1]:8443"); + assert_eq!(ip.cert_host(), Some("::1")); + assert_eq!(ip.host_authority(), "[::1]:8443"); + assert_eq!(ip.host_name(), "::1"); + assert_eq!(ip.sni_hostname(), None); + } + + #[test] + fn outbound_request_rejects_invalid_target_table() { + for target in [ + "ftp://example.com", + "/relative", + "https:///missing", + "https://user:pass@example.com", + "https://example.com/path#fragment", + ] { + assert!(OutboundRequest::get(target).is_err(), "accepted {target}"); + } + } + + #[test] + fn outbound_request_rejects_empty_userinfo() { + OutboundRequest::get("https://@example.com/").expect_err("empty userinfo"); + } + + #[test] + fn outbound_request_rejects_backslash_authority_forms() { + for target in [ + r"https:\\@example.com/", + r"https:/\@example.com/", + r"https:\/\@example.com/", + r"https://example.com\path", + ] { + assert!(OutboundRequest::get(target).is_err(), "accepted {target}"); + } + } + + #[test] + fn outbound_request_preserves_percent_encoded_hash() { + let request = OutboundRequest::get("https://example.com/a%23b?q=%40user") + .expect("encoded delimiters"); + assert_eq!( + request.uri().to_string(), + "https://example.com/a%23b?q=%40user" + ); + } + + #[test] + fn outbound_request_from_request_normalizes_immediately() { + let request = request_builder() + .method(Method::POST) + .uri("/local") + .header("connection", "x-remove") + .header("x-remove", "gone") + .header("host", "local.invalid") + .header("content-length", "7") + .header("transfer-encoding", "chunked") + .header("x-keep", "yes") + .body(Body::from("payload")) + .expect("inbound request"); + + let outbound = + OutboundRequest::from_request(request, Uri::from_static("https://example.com/target")) + .expect("outbound request"); + assert_eq!(outbound.method(), &Method::POST); + assert_eq!( + outbound.into_parts().body.as_bytes(), + Some(b"payload".as_slice()) + ); + + let request = request_builder() + .method(Method::GET) + .uri("/local") + .header("x-keep", "yes") + .body(Body::empty()) + .expect("inbound request"); + let outbound = + OutboundRequest::from_request(request, Uri::from_static("https://example.com/target")) + .expect("outbound request"); + assert_eq!( + outbound.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn outbound_request_rejects_invalid_header_bytes() { + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"bad name", b"value") + .expect_err("invalid header name"); + + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"x-test", [0xff]) + .expect_err("invalid header value"); + + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"x-test", b"line\nbreak") + .expect_err("header value line break"); + } +} diff --git a/crates/edgezero-core/src/proxy.rs b/crates/edgezero-core/src/proxy.rs deleted file mode 100644 index 1a553777..00000000 --- a/crates/edgezero-core/src/proxy.rs +++ /dev/null @@ -1,656 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::body::Body; -use crate::error::EdgeError; -use crate::http::{ - Extensions, HeaderMap, Method, Request, Response, StatusCode, Uri, response_builder, -}; - -/// Header name attached to proxied responses to identify which adapter -/// forwarded the request (e.g. "fastly", "cloudflare", "spin"). -pub const PROXY_HEADER: &str = "x-edgezero-proxy"; - -#[async_trait(?Send)] -pub trait ProxyClient: Send + Sync { - async fn send(&self, request: ProxyRequest) -> Result; -} - -#[derive(Clone)] -pub struct ProxyHandle { - client: Arc, -} - -impl ProxyHandle { - #[must_use] - #[inline] - pub fn client(&self) -> Arc { - Arc::clone(&self.client) - } - - /// # Errors - /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the - /// response cannot be assembled. - #[inline] - pub async fn forward(&self, request: ProxyRequest) -> Result { - let response = self.client.send(request).await?; - response.into_response() - } - - #[inline] - pub fn new(client: Arc) -> Self { - Self { client } - } - - #[inline] - pub fn with_client(client: C) -> Self - where - C: ProxyClient + 'static, - { - Self { - client: Arc::new(client), - } - } -} - -/// Outbound request description for a proxy operation. -pub struct ProxyRequest { - body: Body, - extensions: Extensions, - headers: HeaderMap, - method: Method, - uri: Uri, -} - -impl fmt::Debug for ProxyRequest { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyRequest") - .field("method", &self.method) - .field("uri", &self.uri) - .field("headers", &self.headers) - .finish_non_exhaustive() - } -} - -impl ProxyRequest { - #[inline] - pub fn body(&self) -> &Body { - &self.body - } - - #[inline] - pub fn body_mut(&mut self) -> &mut Body { - &mut self.body - } - - #[inline] - pub fn extensions(&self) -> &Extensions { - &self.extensions - } - - #[inline] - pub fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } - - #[inline] - pub fn from_request(request: Request, uri: Uri) -> Self { - let (parts, body) = request.into_parts(); - Self { - body, - extensions: parts.extensions, - headers: parts.headers, - method: parts.method, - uri, - } - } - - #[inline] - pub fn headers(&self) -> &HeaderMap { - &self.headers - } - - #[inline] - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers - } - - #[inline] - pub fn into_parts(self) -> (Method, Uri, HeaderMap, Body, Extensions) { - ( - self.method, - self.uri, - self.headers, - self.body, - self.extensions, - ) - } - - #[inline] - pub fn method(&self) -> &Method { - &self.method - } - - #[inline] - pub fn new(method: Method, uri: Uri) -> Self { - Self { - body: Body::empty(), - extensions: Extensions::new(), - headers: HeaderMap::new(), - method, - uri, - } - } - - #[inline] - pub fn uri(&self) -> &Uri { - &self.uri - } -} - -pub struct ProxyResponse { - body: Body, - extensions: Extensions, - headers: HeaderMap, - status: StatusCode, -} - -impl fmt::Debug for ProxyResponse { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyResponse") - .field("status", &self.status) - .finish_non_exhaustive() - } -} - -impl ProxyResponse { - #[inline] - pub fn body(&self) -> &Body { - &self.body - } - - #[inline] - pub fn body_mut(&mut self) -> &mut Body { - &mut self.body - } - - #[inline] - pub fn extensions(&self) -> &Extensions { - &self.extensions - } - - #[inline] - pub fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } - - #[inline] - pub fn headers(&self) -> &HeaderMap { - &self.headers - } - - #[inline] - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers - } - - /// # Errors - /// Returns [`EdgeError::internal`] if the underlying `http::Response::builder()` - /// rejects a header — should be unreachable since we only store names/values - /// that were already validated, but propagation lets a faulty upstream stream - /// fail the request instead of crashing the worker. - #[inline] - pub fn into_response(self) -> Result { - let mut builder = response_builder().status(self.status); - for (name, value) in &self.headers { - builder = builder.header(name, value); - } - builder.body(self.body).map_err(EdgeError::internal) - } - - #[inline] - pub fn new(status: StatusCode, body: Body) -> Self { - Self { - body, - extensions: Extensions::new(), - headers: HeaderMap::new(), - status, - } - } - - #[inline] - pub fn status(&self) -> StatusCode { - self.status - } -} - -pub struct ProxyService { - client: C, -} - -impl ProxyService { - #[inline] - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl ProxyService -where - C: ProxyClient, -{ - /// # Errors - /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the - /// response cannot be assembled. - #[inline] - pub async fn forward(&self, request: ProxyRequest) -> Result { - let response = self.client.send(request).await?; - response.into_response() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::body::Body; - use crate::http::header::HeaderName; - use crate::http::{HeaderValue, Method, StatusCode, Uri, request_builder}; - use bytes::Bytes; - use futures::executor::block_on; - use futures_util::{StreamExt as _, stream}; - - struct EchoBodyClient; - - struct EchoHeadersClient; - - struct EchoMethodClient; - - struct ErrorClient; - - struct StreamingClient; - - struct TestClient; - - #[async_trait(?Send)] - impl ProxyClient for EchoBodyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_, _, _, body, _) = request.into_parts(); - Ok(ProxyResponse::new(StatusCode::OK, body)) - } - } - - #[async_trait(?Send)] - impl ProxyClient for EchoHeadersClient { - async fn send(&self, request: ProxyRequest) -> Result { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - // Echo back headers with x-echo- prefix - for (name, value) in request.headers() { - let echo_name = format!("x-echo-{}", name.as_str()); - if let Ok(header_name) = echo_name.parse::() { - resp.headers_mut().insert(header_name, value.clone()); - } - } - Ok(resp) - } - } - - #[async_trait(?Send)] - impl ProxyClient for EchoMethodClient { - async fn send(&self, request: ProxyRequest) -> Result { - let method_str = request.method().as_str(); - Ok(ProxyResponse::new( - StatusCode::OK, - Body::from(method_str.to_owned()), - )) - } - } - - #[async_trait(?Send)] - impl ProxyClient for ErrorClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Err(EdgeError::bad_request("connection failed")) - } - } - - #[async_trait(?Send)] - impl ProxyClient for StreamingClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, _uri, _headers, _body, _ext) = request.into_parts(); - let chunks = stream::iter(vec![ - Bytes::from_static(b"stream-one"), - Bytes::from_static(b"stream-two"), - ]); - Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) - } - } - - #[async_trait(?Send)] - impl ProxyClient for TestClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, _body, _) = request.into_parts(); - assert_eq!(method, Method::GET); - assert_eq!(uri, Uri::from_static("https://example.com")); - assert_eq!( - headers.get("x-demo"), - Some(&HeaderValue::from_static("true")) - ); - - let chunks = stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" world"), - ]); - Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) - } - } - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut data = Vec::new(); - while let Some(result) = stream.next().await { - let chunk = result.expect("chunk"); - data.extend_from_slice(&chunk); - } - data - }), - } - } - - #[test] - fn proxy_forward_preserves_streaming_body() { - let request = request_builder() - .method(Method::GET) - .uri("/local-stream") - .body(Body::empty()) - .expect("request"); - - let target = Uri::from_static("https://example.com/stream"); - let proxy_request = ProxyRequest::from_request(request, target); - let service = ProxyService::new(StreamingClient); - let response = block_on(service.forward(proxy_request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let body = response.into_body(); - let collected = collect_body(body); - assert_eq!(collected, b"stream-onestream-two"); - } - - #[test] - fn proxy_forward_roundtrips() { - let request = request_builder() - .method(Method::GET) - .uri("/local") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let target = Uri::from_static("https://example.com"); - let proxy_request = ProxyRequest::from_request(request, target); - let service = ProxyService::new(TestClient); - let response = block_on(service.forward(proxy_request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_forwards_request_body() { - let service = ProxyService::new(EchoBodyClient); - let request = request_builder() - .method(Method::POST) - .uri("/test") - .body(Body::from("request body content")) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - let body_bytes = collect_body(response.into_body()); - assert_eq!(body_bytes, b"request body content"); - } - - #[test] - fn proxy_forwards_request_headers() { - let service = ProxyService::new(EchoHeadersClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-custom-header", "custom-value") - .header("authorization", "Bearer token123") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - assert_eq!( - response - .headers() - .get("x-echo-x-custom-header") - .and_then(|value| value.to_str().ok()), - Some("custom-value") - ); - assert_eq!( - response - .headers() - .get("x-echo-authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer token123") - ); - } - - #[test] - fn proxy_forwards_various_methods() { - let service = ProxyService::new(EchoMethodClient); - - for method in [ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, - Method::HEAD, - Method::OPTIONS, - ] { - let req = ProxyRequest::new(method.clone(), Uri::from_static("https://example.com")); - let response = block_on(service.forward(req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - } - - #[test] - fn proxy_handle_forward_returns_response() { - let handle = ProxyHandle::with_client(TestClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(handle.forward(proxy_req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_handle_new_wraps_client() { - let client = Arc::new(TestClient); - let handle = ProxyHandle::new(client); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_handle_propagates_client_errors() { - let handle = ProxyHandle::with_client(ErrorClient); - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - block_on(handle.forward(req)).expect_err("ErrorClient propagates an error"); - } - - #[test] - fn proxy_handle_with_client_creates_arc() { - let handle = ProxyHandle::with_client(TestClient); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_request_body_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::POST, Uri::from_static("https://example.com")); - *req.body_mut() = Body::from("new body content"); - assert!(matches!( - req.body(), - Body::Once(bytes) if bytes.as_ref() == b"new body content" - )); - } - - #[test] - fn proxy_request_debug_format() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.headers_mut() - .insert("x-debug", HeaderValue::from_static("test")); - let debug = format!("{req:?}"); - assert!(debug.contains("ProxyRequest")); - assert!(debug.contains("GET")); - assert!(debug.contains("example.com")); - } - - #[test] - fn proxy_request_extensions_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.extensions_mut().insert("custom-data".to_owned()); - assert_eq!( - req.extensions().get::(), - Some(&"custom-data".to_owned()) - ); - } - - #[test] - fn proxy_request_from_request_preserves_all_parts() { - let request = request_builder() - .method(Method::POST) - .uri("/original") - .header("x-custom", "value") - .body(Body::from("request body")) - .expect("request"); - - let target = Uri::from_static("https://backend.example.com/api"); - let proxy_req = ProxyRequest::from_request(request, target.clone()); - - assert_eq!(proxy_req.method(), &Method::POST); - assert_eq!(proxy_req.uri(), &target); - assert_eq!( - proxy_req - .headers() - .get("x-custom") - .and_then(|value| value.to_str().ok()), - Some("value") - ); - } - - #[test] - fn proxy_request_headers_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.headers_mut() - .insert("authorization", HeaderValue::from_static("Bearer token")); - assert!(req.headers().get("authorization").is_some()); - } - - #[test] - fn proxy_request_into_parts_destructures() { - let mut req = ProxyRequest::new( - Method::DELETE, - Uri::from_static("https://example.com/resource"), - ); - req.headers_mut() - .insert("x-test", HeaderValue::from_static("value")); - *req.body_mut() = Body::from("body"); - - let (method, uri, headers, body, _extensions) = req.into_parts(); - assert_eq!(method, Method::DELETE); - assert_eq!(uri, Uri::from_static("https://example.com/resource")); - assert!(headers.get("x-test").is_some()); - assert!(matches!( - &body, - Body::Once(bytes) if bytes.as_ref() == b"body" - )); - } - - #[test] - fn proxy_request_new_creates_empty_request() { - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - assert_eq!(req.method(), &Method::GET); - assert_eq!(req.uri(), &Uri::from_static("https://example.com")); - assert!(req.headers().is_empty()); - assert!(matches!(req.body(), Body::Once(bytes) if bytes.is_empty())); - } - - #[test] - fn proxy_response_body_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - *resp.body_mut() = Body::from("updated body"); - assert!(matches!( - resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"updated body" - )); - } - - #[test] - fn proxy_response_debug_format() { - let resp = ProxyResponse::new(StatusCode::NOT_FOUND, Body::empty()); - let debug = format!("{resp:?}"); - assert!(debug.contains("ProxyResponse")); - assert!(debug.contains("404")); - } - - #[test] - fn proxy_response_extensions_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.extensions_mut().insert(42_i32); - assert_eq!(resp.extensions().get::(), Some(&42_i32)); - } - - #[test] - fn proxy_response_headers_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.headers_mut() - .insert("content-type", HeaderValue::from_static("application/json")); - assert!(resp.headers().get("content-type").is_some()); - } - - #[test] - fn proxy_response_into_response_converts() { - let mut resp = ProxyResponse::new(StatusCode::CREATED, Body::from("created")); - resp.headers_mut() - .insert("x-custom", HeaderValue::from_static("header")); - - let http_resp = resp.into_response().expect("response"); - assert_eq!(http_resp.status(), StatusCode::CREATED); - assert!(http_resp.headers().get("x-custom").is_some()); - } - - #[test] - fn proxy_response_new_creates_response() { - let resp = ProxyResponse::new(StatusCode::OK, Body::from("response body")); - assert_eq!(resp.status(), StatusCode::OK); - assert!(matches!( - resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"response body" - )); - } - - #[test] - fn proxy_service_propagates_client_errors() { - let service = ProxyService::new(ErrorClient); - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let result = block_on(service.forward(req)); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - } -} diff --git a/crates/edgezero-core/src/response_egress.rs b/crates/edgezero-core/src/response_egress.rs new file mode 100644 index 00000000..6edbdcaa --- /dev/null +++ b/crates/edgezero-core/src/response_egress.rs @@ -0,0 +1,811 @@ +//! Portable response-egress policy and exactly-once lifecycle reporting. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; +use std::time::Duration; + +use crate::http::{HeaderMap, Response, StatusCode, Version}; +use crate::router::RouteMetadata; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + +/// Portable response-write budget used when an application installs no policy. +pub const DEFAULT_RESPONSE_WRITE_BUDGET: Duration = Duration::from_secs(30); + +/// One finite absolute deadline selected for a response-conversion attempt. +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressPolicy { + pub write_deadline: Deadline, +} + +impl ResponseEgressPolicy { + /// Clamps this policy to the portable far-future bound relative to egress start. + /// + /// An already-expired deadline remains valid and is not moved forward. + /// + /// # Errors + /// Returns [`ResponseEgressOutcome::ConversionError`] if the clamp instant cannot be + /// represented by the monotonic clock. + #[inline] + pub fn normalize_at( + mut self, + egress_started_at: MonotonicInstant, + ) -> Result { + let maximum = egress_started_at + .checked_add(DEADLINE_FAR_FUTURE) + .ok_or(ResponseEgressOutcome::ConversionError)?; + if self.write_deadline.instant() > maximum { + self.write_deadline = Deadline::at_instant(maximum); + } + Ok(self) + } +} + +/// Immutable response metadata visible to the synchronous egress policy callback. +/// +/// This type deliberately has no response-body or mutation access. +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressHead<'head> { + headers: &'head HeaderMap, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + status: StatusCode, + version: Version, +} + +impl<'head> ResponseEgressHead<'head> { + #[must_use] + #[inline] + pub fn headers(&self) -> &'head HeaderMap { + self.headers + } + + /// Creates a body-blind response-head view for an adapter conversion attempt. + #[must_use] + #[inline] + pub fn new( + status: StatusCode, + version: Version, + headers: &'head HeaderMap, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + ) -> Self { + Self { + headers, + request_start, + route, + status, + version, + } + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + #[must_use] + #[inline] + pub fn route(&self) -> Option<&'head RouteMetadata> { + self.route + } + + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.version + } +} + +/// Synchronous, body-blind policy callback retained by an application. +pub type ResponseEgressPolicyCallback = Arc< + dyn for<'head> Fn(&ResponseEgressHead<'head>, MonotonicInstant) -> ResponseEgressPolicy + + Send + + Sync + + 'static, +>; + +/// Terminal result of one response-conversion attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressOutcome { + ClientDisconnected, + Completed, + ConversionError, + DeadlineExceeded, + HostHandoff, + ResponseReturned, + SourceError, + TransportError, + Unspecified, +} + +/// Bounded terminal report for one response-conversion attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResponseEgressReport { + pub bytes_written: u64, + pub elapsed: Duration, + pub outcome: ResponseEgressOutcome, + pub request_start: MonotonicInstant, + pub route: Option, +} + +/// Synchronous observer for terminal response-egress reports. +pub trait ResponseEgressObserver: Send + Sync + 'static { + fn complete(&self, report: &ResponseEgressReport); +} + +struct NoopResponseEgressObserver; + +impl ResponseEgressObserver for NoopResponseEgressObserver { + #[inline] + fn complete(&self, _report: &ResponseEgressReport) {} +} + +/// Cloneable handle to an application response-egress observer. +#[derive(Clone)] +pub struct ResponseEgressObserverHandle { + observer: Arc, +} + +/// Adapter-facing response plus immutable ingress metadata and app-owned egress hooks. +#[doc(hidden)] +pub struct ResponseEgressEnvelope { + clock: MonotonicClock, + observer: ResponseEgressObserverHandle, + policy: ResponseEgressPolicyCallback, + request_start: MonotonicInstant, + response: Response, + route: Option, +} + +impl ResponseEgressEnvelope { + /// Starts one response-conversion attempt and evaluates the app policy exactly once. + /// + /// # Errors + /// Returns `ConversionError` after reporting it when policy evaluation panics or deadline + /// normalization cannot represent the portable clamp. + #[inline] + pub fn begin( + self, + ) -> Result< + ( + Response, + ResponseEgressPolicy, + ResponseEgressAttempt, + MonotonicClock, + ), + ResponseEgressOutcome, + > { + let egress_started_at = self.clock.now(); + let head = ResponseEgressHead::new( + self.response.status(), + self.response.version(), + self.response.headers(), + self.request_start, + self.route.as_ref(), + ); + let mut attempt = ResponseEgressAttempt::new_with_clock( + &head, + egress_started_at, + self.observer, + self.clock.clone(), + ); + let Ok(selected_policy) = + catch_unwind(AssertUnwindSafe(|| (self.policy)(&head, egress_started_at))) + else { + log::error!("response-egress policy panicked before conversion"); + attempt.terminate(ResponseEgressOutcome::ConversionError, egress_started_at); + return Err(ResponseEgressOutcome::ConversionError); + }; + let normalized_policy = match selected_policy.normalize_at(egress_started_at) { + Ok(normalized) => normalized, + Err(outcome) => { + attempt.terminate(outcome, egress_started_at); + return Err(outcome); + } + }; + Ok((self.response, normalized_policy, attempt, self.clock)) + } + + /// Extracts the response for low-level callers that do not own a platform converter. + #[must_use] + #[inline] + pub fn into_response(self) -> Response { + self.response + } + + pub(crate) fn new( + response: Response, + request_start: MonotonicInstant, + route: Option, + policy: ResponseEgressPolicyCallback, + observer: ResponseEgressObserverHandle, + clock: MonotonicClock, + ) -> Self { + Self { + clock, + observer, + policy, + request_start, + response, + route, + } + } +} + +impl ResponseEgressObserverHandle { + fn complete(&self, report: &ResponseEgressReport) { + let result = catch_unwind(AssertUnwindSafe(|| self.observer.complete(report))); + if result.is_err() { + log::error!("response-egress observer panicked after terminal transition"); + } + } + + #[must_use] + #[inline] + pub fn new(observer: Observer) -> Self + where + Observer: ResponseEgressObserver, + { + Self { + observer: Arc::new(observer), + } + } +} + +impl Default for ResponseEgressObserverHandle { + #[inline] + fn default() -> Self { + Self::new(NoopResponseEgressObserver) + } +} + +enum AttemptState { + Initial, + Terminal(ResponseEgressReport), + Writing, +} + +/// Adapter-facing exactly-once response-egress completion guard. +/// +/// The guard is intentionally non-clone. Dropping it before a terminal signal reports a +/// conversion error from the initial state or a transport error from the writing state. +#[doc(hidden)] +pub struct ResponseEgressAttempt { + bytes_written: u64, + clock: MonotonicClock, + egress_started_at: MonotonicInstant, + observer: ResponseEgressObserverHandle, + request_start: MonotonicInstant, + route: Option, + state: AttemptState, +} + +impl ResponseEgressAttempt { + /// Accounts payload bytes accepted at the adapter's documented write boundary. + /// + /// Returns `false` outside the writing state. Overflow terminalizes the attempt as + /// [`ResponseEgressOutcome::TransportError`] and also returns `false`. + #[inline] + pub fn account_bytes(&mut self, bytes: u64, observed_at: MonotonicInstant) -> bool { + if !matches!(self.state, AttemptState::Writing) { + return false; + } + let Some(total) = self.bytes_written.checked_add(bytes) else { + self.transition(ResponseEgressOutcome::TransportError, observed_at); + return false; + }; + self.bytes_written = total; + true + } + + /// Moves an initial attempt into the writing state. + /// + /// Returns `false` after writing has already begun or the attempt is terminal. + #[inline] + pub fn begin_writing(&mut self) -> bool { + if !matches!(self.state, AttemptState::Initial) { + return false; + } + self.state = AttemptState::Writing; + true + } + + /// Successfully completes a writing attempt. + /// + /// Calling this in the initial state terminalizes as `ConversionError`; a converter must + /// enter writing even for an empty response. Later terminal signals return `false`. + #[inline] + pub fn complete(&mut self, observed_at: MonotonicInstant) -> bool { + match self.state { + AttemptState::Initial => { + self.transition(ResponseEgressOutcome::ConversionError, observed_at) + } + AttemptState::Writing => self.transition(ResponseEgressOutcome::Completed, observed_at), + AttemptState::Terminal(_) => false, + } + } + + #[must_use] + #[inline] + pub fn new( + head: &ResponseEgressHead<'_>, + egress_started_at: MonotonicInstant, + observer: ResponseEgressObserverHandle, + ) -> Self { + Self::new_with_clock(head, egress_started_at, observer, MonotonicClock::default()) + } + + fn new_with_clock( + head: &ResponseEgressHead<'_>, + egress_started_at: MonotonicInstant, + observer: ResponseEgressObserverHandle, + clock: MonotonicClock, + ) -> Self { + Self { + bytes_written: 0, + clock, + egress_started_at, + observer, + request_start: head.request_start(), + route: head.route().cloned(), + state: AttemptState::Initial, + } + } + + /// Terminalizes an attempt with the supplied outcome. + /// + /// `Completed` follows [`Self::complete`] state validation. `ResponseReturned` always + /// reports zero bytes because response-object construction is not a guest-visible write. + #[inline] + pub fn terminate( + &mut self, + outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, + ) -> bool { + if outcome == ResponseEgressOutcome::Completed { + self.complete(observed_at) + } else { + self.transition(outcome, observed_at) + } + } + + fn transition( + &mut self, + mut outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, + ) -> bool { + if matches!(self.state, AttemptState::Terminal(_)) { + return false; + } + + let force_zero_bytes = outcome == ResponseEgressOutcome::ResponseReturned; + let elapsed = + if let Some(elapsed) = observed_at.checked_duration_since(self.egress_started_at) { + elapsed + } else { + log::error!("response-egress monotonic clock moved backwards"); + outcome = ResponseEgressOutcome::Unspecified; + Duration::ZERO + }; + let bytes_written = if force_zero_bytes { + 0 + } else { + self.bytes_written + }; + let report = ResponseEgressReport { + bytes_written, + elapsed, + outcome, + request_start: self.request_start, + route: self.route.clone(), + }; + self.state = AttemptState::Terminal(report); + + if let AttemptState::Terminal(terminal_report) = &self.state { + self.observer.complete(terminal_report); + } + true + } +} + +impl Drop for ResponseEgressAttempt { + #[inline] + fn drop(&mut self) { + let outcome = match self.state { + AttemptState::Initial => ResponseEgressOutcome::ConversionError, + AttemptState::Writing => ResponseEgressOutcome::TransportError, + AttemptState::Terminal(_) => return, + }; + self.transition(outcome, self.clock.now()); + } +} + +/// Selects the portable default response-write policy. +#[must_use] +#[inline] +pub fn default_response_egress_policy( + _head: &ResponseEgressHead<'_>, + egress_started_at: MonotonicInstant, +) -> ResponseEgressPolicy { + let deadline = egress_started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .unwrap_or(egress_started_at); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + } +} + +#[cfg(test)] +mod tests { + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use super::*; + use crate::http::{HeaderMap, HeaderValue, Method, StatusCode, Version}; + use crate::router::RouteMetadata; + use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + + #[derive(Clone, Default)] + struct RecordingObserver { + reports: Arc>>, + } + + impl RecordingObserver { + fn reports(&self) -> Vec { + self.reports.lock().expect("reports lock").clone() + } + } + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.reports + .lock() + .expect("reports lock") + .push(report.clone()); + } + } + + struct PanickingObserver; + + impl ResponseEgressObserver for PanickingObserver { + fn complete(&self, _report: &ResponseEgressReport) { + panic!("observer panic"); + } + } + + fn head<'head>( + headers: &'head HeaderMap, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + ) -> ResponseEgressHead<'head> { + ResponseEgressHead::new( + StatusCode::CREATED, + Version::HTTP_2, + headers, + request_start, + route, + ) + } + + fn attempt( + observer: &RecordingObserver, + started_at: MonotonicInstant, + ) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressObserverHandle::new(observer.clone()), + ) + } + + #[test] + fn default_policy_is_finite_and_exactly_thirty_seconds() { + let headers = HeaderMap::new(); + let started_at = MonotonicInstant::now(); + let head = head(&headers, started_at, None); + let policy = default_response_egress_policy(&head, started_at); + + assert_eq!( + policy.write_deadline.instant(), + started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default deadline") + ); + assert_eq!(DEFAULT_RESPONSE_WRITE_BUDGET, Duration::from_secs(30)); + } + + #[test] + fn policy_normalization_clamps_far_future_and_preserves_expiry() { + let started_at = MonotonicInstant::now(); + let maximum = started_at + .checked_add(DEADLINE_FAR_FUTURE) + .expect("maximum deadline"); + let beyond = maximum + .checked_add(Duration::from_secs(1)) + .expect("deadline beyond maximum"); + let clamped = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(beyond), + } + .normalize_at(started_at) + .expect("normalization"); + assert_eq!(clamped.write_deadline.instant(), maximum); + + let expired = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at), + } + .normalize_at(started_at) + .expect("expired policy remains valid"); + assert_eq!(expired.write_deadline.instant(), started_at); + } + + #[test] + fn head_is_body_blind_and_exposes_only_immutable_metadata() { + let mut headers = HeaderMap::new(); + headers.insert("x-test", HeaderValue::from_static("visible")); + let request_start = MonotonicInstant::now(); + let route = RouteMetadata::new(Method::GET, "/items/{id}"); + let head = head(&headers, request_start, Some(&route)); + + assert_eq!(head.status(), StatusCode::CREATED); + assert_eq!(head.version(), Version::HTTP_2); + assert_eq!(head.headers(), &headers); + assert_eq!(head.request_start(), request_start); + assert_eq!(head.route(), Some(&route)); + } + + #[test] + fn observer_trait_is_object_safe_and_handle_has_a_noop_default() { + fn assert_object_safe(_observer: &dyn ResponseEgressObserver) {} + + let observer = RecordingObserver::default(); + assert_object_safe(&observer); + let _: ResponseEgressObserverHandle = ResponseEgressObserverHandle::default(); + } + + #[test] + fn every_outcome_is_reported_without_collapsing_variants_from_valid_states() { + let outcomes = [ + ResponseEgressOutcome::Completed, + ResponseEgressOutcome::ClientDisconnected, + ResponseEgressOutcome::ConversionError, + ResponseEgressOutcome::DeadlineExceeded, + ResponseEgressOutcome::HostHandoff, + ResponseEgressOutcome::ResponseReturned, + ResponseEgressOutcome::SourceError, + ResponseEgressOutcome::TransportError, + ResponseEgressOutcome::Unspecified, + ]; + + for outcome in outcomes { + let states = if outcome == ResponseEgressOutcome::Completed { + &[true][..] + } else { + &[false, true][..] + }; + for &writing in states { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + if writing { + assert!(attempt.begin_writing()); + } + if outcome == ResponseEgressOutcome::Completed { + assert!(attempt.complete(started_at)); + } else { + assert!(attempt.terminate(outcome, started_at)); + } + assert_eq!(observer.reports()[0].outcome, outcome); + } + } + } + + #[test] + fn completed_cannot_skip_the_writing_state() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + + assert!(attempt.complete(started_at)); + assert_eq!(observer.reports().len(), 1); + assert_eq!( + observer.reports()[0].outcome, + ResponseEgressOutcome::ConversionError + ); + } + + #[test] + fn writing_completion_records_metadata_bytes_and_elapsed_once() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let terminal_at = started_at + .checked_add(Duration::from_millis(25)) + .expect("terminal instant"); + let request_start = started_at + .checked_sub(Duration::from_millis(10)) + .expect("request start"); + let route = RouteMetadata::new(Method::POST, "/submit"); + let headers = HeaderMap::new(); + let head = head(&headers, request_start, Some(&route)); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressObserverHandle::new(observer.clone()), + ); + + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(0, started_at)); + assert!(attempt.account_bytes(7, started_at)); + assert!(attempt.complete(terminal_at)); + assert!(!attempt.complete(terminal_at)); + assert!(!attempt.terminate(ResponseEgressOutcome::TransportError, terminal_at)); + drop(attempt); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 7); + assert_eq!(reports[0].elapsed, Duration::from_millis(25)); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Completed); + assert_eq!(reports[0].request_start, request_start); + assert_eq!(reports[0].route, Some(route)); + } + + #[test] + fn drop_and_duplicate_signals_preserve_exactly_once_terminal_state() { + let initial_observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + drop(attempt(&initial_observer, started_at)); + assert_eq!(initial_observer.reports().len(), 1); + assert_eq!( + initial_observer.reports()[0].outcome, + ResponseEgressOutcome::ConversionError + ); + + let writing_observer = RecordingObserver::default(); + let mut writing = attempt(&writing_observer, started_at); + assert!(writing.begin_writing()); + assert!(!writing.begin_writing()); + drop(writing); + assert_eq!(writing_observer.reports().len(), 1); + assert_eq!( + writing_observer.reports()[0].outcome, + ResponseEgressOutcome::TransportError + ); + + let disconnected_observer = RecordingObserver::default(); + let mut disconnected = attempt(&disconnected_observer, started_at); + assert!(disconnected.begin_writing()); + assert!(disconnected.terminate(ResponseEgressOutcome::ClientDisconnected, started_at)); + drop(disconnected); + assert_eq!(disconnected_observer.reports().len(), 1); + } + + #[test] + fn dropped_attempt_uses_its_injected_clock() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let completed_at = started_at + .checked_add(Duration::from_millis(17)) + .expect("completed instant"); + let clock = MonotonicClock::new(move || completed_at); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + + drop(ResponseEgressAttempt::new_with_clock( + &head, + started_at, + ResponseEgressObserverHandle::new(observer.clone()), + clock, + )); + + assert_eq!(observer.reports()[0].elapsed, Duration::from_millis(17)); + } + + #[test] + fn byte_overflow_terminalizes_as_transport_error() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(u64::MAX, started_at)); + assert!(!attempt.account_bytes(1, started_at)); + assert!(!attempt.complete(started_at)); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, u64::MAX); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + } + + #[test] + fn zero_byte_completion_and_response_returned_have_zero_accounting() { + let completed_observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut completed = attempt(&completed_observer, started_at); + assert!(completed.begin_writing()); + assert!(completed.complete(started_at)); + assert_eq!(completed_observer.reports()[0].bytes_written, 0); + + let returned_observer = RecordingObserver::default(); + let mut returned = attempt(&returned_observer, started_at); + assert!(returned.begin_writing()); + assert!(returned.account_bytes(99, started_at)); + assert!(returned.terminate(ResponseEgressOutcome::ResponseReturned, started_at)); + assert_eq!(returned_observer.reports()[0].bytes_written, 0); + } + + #[test] + fn backwards_clock_is_zero_elapsed_unspecified_and_still_once() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let earlier = started_at + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let mut attempt = attempt(&observer, started_at); + assert!(attempt.begin_writing()); + assert!(attempt.complete(earlier)); + drop(attempt); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].elapsed, Duration::ZERO); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Unspecified); + } + + #[test] + fn backwards_clock_does_not_undo_response_returned_zero_accounting() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let earlier = started_at + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let mut attempt = attempt(&observer, started_at); + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(99, started_at)); + assert!(attempt.terminate(ResponseEgressOutcome::ResponseReturned, earlier)); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 0); + assert_eq!(reports[0].elapsed, Duration::ZERO); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Unspecified); + } + + #[test] + fn observer_panics_do_not_escape_terminal_or_drop_paths() { + let direct = catch_unwind(AssertUnwindSafe(|| { + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressObserverHandle::new(PanickingObserver), + ); + assert!(attempt.terminate(ResponseEgressOutcome::ConversionError, started_at)); + })); + direct.unwrap_or_else(|_| panic!("observer panic escaped direct completion")); + + let dropped = catch_unwind(AssertUnwindSafe(|| { + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + drop(ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressObserverHandle::new(PanickingObserver), + )); + })); + dropped.unwrap_or_else(|_| panic!("observer panic escaped guard drop")); + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index d20f35a8..006f3ef1 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -1,14 +1,15 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::task::{Context, Poll}; use matchit::Router as PathRouter; use tower_service::Service; -use crate::context::RequestContext; +use crate::context::{FallbackDrainOutcome, RequestContext, drain_body_discard}; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; +use crate::ingress::AdmittedIngress; use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -17,6 +18,7 @@ use crate::response::IntoResponse as _; struct RouteEntry { handler: BoxHandler, introspection_needs: IntrospectionNeeds, + metadata: RouteMetadata, } impl Clone for RouteEntry { @@ -24,46 +26,143 @@ impl Clone for RouteEntry { Self { handler: Arc::clone(&self.handler), introspection_needs: self.introspection_needs, + metadata: self.metadata.clone(), } } fn clone_from(&mut self, source: &Self) { self.handler = Arc::clone(&source.handler); self.introspection_needs = source.introspection_needs; + self.metadata.clone_from(&source.metadata); } } -#[derive(Clone, Debug)] -pub struct RouteInfo { +/// Stable identity for one registered route. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct RouteId { method: Method, - path: String, + pattern: String, } -impl RouteInfo { +impl RouteId { #[must_use] #[inline] pub fn method(&self) -> &Method { &self.method } - #[inline] - pub fn new>(method: Method, path: S) -> Self { + fn new>(method: Method, pattern: S) -> Self { Self { method, - path: path.into(), + pattern: pattern.into(), + } + } + + #[must_use] + #[inline] + pub fn pattern(&self) -> &str { + &self.pattern + } +} + +/// Canonical metadata for one registered route. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RouteMetadata { + class: Option>, + id: RouteId, +} + +impl RouteMetadata { + /// Opaque manifest-sourced route class used by admission policy. + #[must_use] + #[inline] + pub fn class(&self) -> Option<&str> { + self.class.as_deref() + } + + #[must_use] + #[inline] + pub fn id(&self) -> &RouteId { + &self.id + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + self.id.method() + } + + #[inline] + pub fn new>(method: Method, pattern: S) -> Self { + Self { + class: None, + id: RouteId::new(method, pattern), } } + fn new_with_class(method: Method, pattern: S, class: C) -> Self + where + C: Into>, + S: Into, + { + Self { + class: Some(class.into()), + id: RouteId::new(method, pattern), + } + } + + /// Registered route pattern. Preserved for existing route-introspection consumers. #[must_use] #[inline] pub fn path(&self) -> &str { - &self.path + self.pattern() + } + + #[must_use] + #[inline] + pub fn pattern(&self) -> &str { + self.id.pattern() + } +} + +/// Backward-compatible name for route-table entries; the value is canonical route metadata. +pub type RouteInfo = RouteMetadata; + +/// Stable result exposed to ingress admission before route dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RouteResolution { + Matched(RouteMetadata), + MethodNotAllowed { allowed: Arc<[RouteMetadata]> }, + NotFound, +} + +enum ResolvedTarget { + Found(RouteEntry, PathParams), + MethodNotAllowed(Vec), + NotFound, +} + +/// Opaque route-match token bound to the router that created it. +pub struct ResolvedDispatch { + method: Method, + owner: Arc, + path: String, + resolution: RouteResolution, + target: ResolvedTarget, +} + +impl ResolvedDispatch { + #[must_use] + #[inline] + pub fn resolution(&self) -> &RouteResolution { + &self.resolution } } enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), - MethodNotAllowed(Vec), + MethodNotAllowed(Vec), NotFound, } @@ -83,11 +182,15 @@ impl RouterBuilder { clippy::panic, reason = "duplicate route is a build-time programmer error, not a runtime condition" )] - fn add_route(&mut self, path: &str, method: Method, handler: H) + fn add_route(&mut self, path: &str, method: Method, class: Option>, handler: H) where H: IntoHandler, { - let router = self.routes.entry(method.clone()).or_default(); + let metadata = class.map_or_else( + || RouteMetadata::new(method.clone(), path), + |route_class| RouteMetadata::new_with_class(method.clone(), path, route_class), + ); + let router = self.routes.entry(method).or_default(); // The handler reports which introspection payloads its route needs; the // flag is read once here and consulted per request in `dispatch`. @@ -100,12 +203,12 @@ impl RouterBuilder { RouteEntry { handler: boxed, introspection_needs, + metadata: metadata.clone(), }, ) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - self.route_info - .push(RouteInfo::new(method, path.to_owned())); + self.route_info.push(metadata); } #[must_use] @@ -187,7 +290,25 @@ impl RouterBuilder { where H: IntoHandler, { - self.add_route(path, method, handler); + self.add_route(path, method, None, handler); + self + } + + /// Registers a route with opaque admission metadata from the manifest. + #[must_use] + #[inline] + pub fn route_with_class( + mut self, + path: &str, + method: Method, + class: C, + handler: H, + ) -> Self + where + C: Into>, + H: IntoHandler, + { + self.add_route(path, method, Some(class.into()), handler); self } @@ -227,45 +348,62 @@ struct RouterInner { } impl RouterInner { - async fn dispatch(&self, mut request: Request) -> Result { + async fn dispatch(&self, request: Request) -> Result { let method = request.method().clone(); let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { - // Inject only the introspection payloads this route asked for — - // nothing for the vast majority of routes that need none. - let needs = entry.introspection_needs; - if needs.manifest - && let Some(json) = &self.manifest_json - { - request - .extensions_mut() - .insert(ManifestJson(Arc::clone(json))); - } - if needs.routes { - request - .extensions_mut() - .insert(RouteTable(Arc::clone(&self.route_index))); - } - // App-owned state registered via RouterBuilder::with_state. - // Runs after introspection inserts; `extend` overwrites by - // TypeId, so app state wins last-write on any collision. - request - .extensions_mut() - .extend(self.state_extensions.clone()); - let ctx = RequestContext::new(request, params); - let next = Next::new(&self.middlewares, entry.handler.as_ref()); - next.run(ctx).await + self.dispatch_found(request, entry, params, None).await } - RouteMatch::MethodNotAllowed(mut allowed) => { - allowed.sort_by(|left, right| left.as_str().cmp(right.as_str())); - Err(EdgeError::method_not_allowed(&method, &allowed)) + RouteMatch::MethodNotAllowed(allowed) => { + let methods = allowed + .iter() + .map(|metadata| metadata.method().clone()) + .collect::>(); + Err(EdgeError::method_not_allowed(&method, &methods)) } RouteMatch::NotFound => Err(EdgeError::not_found(path)), } } + async fn dispatch_found( + &self, + mut request: Request, + entry: &RouteEntry, + params: PathParams, + ingress: Option, + ) -> Result { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest + && let Some(json) = &self.manifest_json + { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; `extend` overwrites by TypeId, so app state wins. + request + .extensions_mut() + .extend(self.state_extensions.clone()); + let ctx = match ingress { + Some(admitted) => { + RequestContext::new_routed(request, params, entry.metadata.clone(), admitted) + } + None => RequestContext::new(request, params), + }; + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + fn find_route(&self, method: &Method, path: &str) -> RouteMatch<'_> { if let Some(router) = self.routes.get(method) && let Ok(matched) = router.at(path) @@ -280,17 +418,24 @@ impl RouterInner { return RouteMatch::Found(matched.value, params); } - let allowed: HashSet = self + let mut allowed: Vec = self .routes - .iter() - .filter(|(_, router)| router.at(path).is_ok()) - .map(|(candidate_method, _)| candidate_method.clone()) + .values() + .filter_map(|router| router.at(path).ok()) + .map(|matched| matched.value.metadata.clone()) .collect(); + allowed.sort_by(|left, right| { + left.method() + .as_str() + .cmp(right.method().as_str()) + .then_with(|| left.pattern().cmp(right.pattern())) + }); + if allowed.is_empty() { RouteMatch::NotFound } else { - RouteMatch::MethodNotAllowed(allowed.into_iter().collect()) + RouteMatch::MethodNotAllowed(allowed) } } } @@ -324,6 +469,59 @@ impl RouterService { RouterBuilder::new() } + /// Dispatches the exact route selected before ingress admission without rematching. + /// + /// # Errors + /// Returns [`EdgeError::Internal`] if the token belongs to another router or the request + /// method/path changed after resolution. Normal handler and routing errors are preserved. + #[inline] + pub async fn dispatch_resolved( + &self, + resolved: ResolvedDispatch, + request: Request, + ingress: AdmittedIngress, + ) -> Result { + if !Arc::ptr_eq(&self.inner, &resolved.owner) { + return Err(EdgeError::internal(anyhow::anyhow!( + "resolved dispatch token belongs to another router" + ))); + } + if request.method() != resolved.method || request.uri().path() != resolved.path { + return Err(EdgeError::internal(anyhow::anyhow!( + "request method or path changed after ingress route resolution" + ))); + } + + match resolved.target { + ResolvedTarget::Found(entry, params) => { + if ingress.is_fallback() { + return Err(EdgeError::internal(anyhow::anyhow!( + "fallback body policy cannot dispatch a matched route" + ))); + } + self.inner + .dispatch_found(request, &entry, params, Some(ingress)) + .await + } + ResolvedTarget::MethodNotAllowed(allowed) => { + if let Some(response) = fallback_terminal_response(request, ingress).await? { + return Ok(response); + } + let methods = allowed + .iter() + .map(|metadata| metadata.method().clone()) + .collect::>(); + Err(EdgeError::method_not_allowed(&resolved.method, &methods)) + } + ResolvedTarget::NotFound => { + if let Some(response) = fallback_terminal_response(request, ingress).await? { + return Ok(response); + } + Err(EdgeError::not_found(resolved.path)) + } + } + } + fn new( routes: HashMap>, middlewares: Vec, @@ -354,6 +552,33 @@ impl RouterService { } } + /// Resolves a route without invoking middleware, handlers, or request-body code. + #[must_use] + #[inline] + pub fn resolve(&self, method: &Method, path: &str) -> ResolvedDispatch { + let (resolution, target) = match self.inner.find_route(method, path) { + RouteMatch::Found(entry, params) => ( + RouteResolution::Matched(entry.metadata.clone()), + ResolvedTarget::Found(entry.clone(), params), + ), + RouteMatch::MethodNotAllowed(allowed) => ( + RouteResolution::MethodNotAllowed { + allowed: Arc::from(allowed.clone()), + }, + ResolvedTarget::MethodNotAllowed(allowed), + ), + RouteMatch::NotFound => (RouteResolution::NotFound, ResolvedTarget::NotFound), + }; + + ResolvedDispatch { + method: method.clone(), + owner: Arc::clone(&self.inner), + path: path.to_owned(), + resolution, + target, + } + } + #[must_use] #[inline] pub fn routes(&self) -> Vec { @@ -361,6 +586,31 @@ impl RouterService { } } +async fn fallback_terminal_response( + request: Request, + ingress: AdmittedIngress, +) -> Result, EdgeError> { + let Some(fallback) = ingress.into_fallback() else { + return Ok(None); + }; + let (grant, max_body_bytes, read_deadline, monotonic_clock, on_exceeded, on_timeout) = + fallback.into_parts(); + let outcome = drain_body_discard( + request.into_body(), + max_body_bytes, + read_deadline, + &monotonic_clock, + ) + .await; + drop(grant); + + match outcome? { + FallbackDrainOutcome::Complete => Ok(None), + FallbackDrainOutcome::Exceeded => Ok(Some(on_exceeded.into_response())), + FallbackDrainOutcome::TimedOut => Ok(Some(on_timeout.into_response())), + } +} + #[cfg(test)] mod tests { /// Per-capability introspection injection: a route receives exactly the @@ -506,17 +756,170 @@ mod tests { } use super::*; + use crate::app::App; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::{Method, Request, Response, StatusCode, request_builder}; + use crate::ingress::{ + AdmissionDecision, IngressAdmissionOutcome, IngressFraming, IngressGrant, IngressHead, + IngressHeadAccounting, + }; use crate::params::PathParams; use crate::response::response_with_body; + use crate::time::{Deadline, MonotonicInstant}; use futures::executor::block_on; use futures::task::noop_waker_ref; use serde::Deserialize; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; + use std::time::Duration; + + #[test] + fn resolve_exposes_stable_route_identity_and_sorted_method_candidates() { + async fn handler(_ctx: RequestContext) -> Result { + response_with_body(StatusCode::OK, Body::empty()) + } + + let router = RouterService::builder() + .post("/users/{id}", handler) + .route_with_class("/users/{id}", Method::GET, "auction", handler) + .build(); + + let matched = router.resolve(&Method::GET, "/users/42"); + let RouteResolution::Matched(metadata) = matched.resolution() else { + panic!("expected matched route"); + }; + assert_eq!(metadata.method(), Method::GET); + assert_eq!(metadata.pattern(), "/users/{id}"); + assert_eq!(metadata.id().method(), Method::GET); + assert_eq!(metadata.id().pattern(), "/users/{id}"); + assert_eq!(metadata.class(), Some("auction")); + + let rejected = router.resolve(&Method::DELETE, "/users/99"); + let RouteResolution::MethodNotAllowed { allowed } = rejected.resolution() else { + panic!("expected method-not-allowed route"); + }; + assert_eq!(allowed.len(), 2); + assert_eq!(allowed[0].method(), Method::GET); + assert_eq!(allowed[1].method(), Method::POST); + assert_eq!(allowed[0].class(), Some("auction")); + assert_eq!(allowed[1].class(), None); + + assert!(matches!( + router.resolve(&Method::GET, "/missing").resolution(), + RouteResolution::NotFound + )); + } + + fn admitted_for(resolved: &ResolvedDispatch, method: Method, path: &str) -> AdmittedIngress { + let app = App::new(RouterService::builder().build()); + let request = request_builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + MonotonicInstant::now(), + resolved.resolution().clone(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressAdmissionOutcome::Admitted(admitted) = + app.admit_ingress(&head).expect("admission") + else { + panic!("expected admission"); + }; + admitted + } + + #[test] + fn dispatch_resolved_preserves_metadata_and_grant() { + async fn handler(ctx: RequestContext) -> Result { + let metadata = ctx.route_metadata().expect("route metadata"); + let grant = ctx.take_ingress_grant().expect("ingress grant"); + assert!(ctx.take_ingress_grant().is_none()); + let lease = grant.downcast::().expect("lease type"); + Ok(format!( + "{}:{}:{lease}", + metadata.pattern(), + metadata.class().expect("route class") + )) + } + + let router = RouterService::builder() + .route_with_class("/users/{id}", Method::GET, "auction", handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/users/42") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + resolved.resolution().clone(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let mut app = App::new(router.clone()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Admit { + grant: IngressGrant::new(String::from("lease")), + read_deadline: Deadline::after(Duration::from_secs(1)), + }); + let IngressAdmissionOutcome::Admitted(admitted) = + app.admit_ingress(&head).expect("admission") + else { + panic!("expected admission"); + }; + assert_eq!(admitted.request_start(), start); + let response = block_on(router.dispatch_resolved(resolved, request, admitted)) + .expect("resolved response"); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"/users/{id}:auction:lease" + ); + } + + #[test] + fn dispatch_resolved_rejects_foreign_router_token() { + let router = RouterService::builder() + .get("/users/{id}", ok_handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let admitted = admitted_for(&resolved, Method::GET, "/users/42"); + let request = request_builder() + .method(Method::GET) + .uri("/users/42") + .body(Body::empty()) + .expect("request"); + let foreign = RouterService::builder().build(); + assert!(matches!( + block_on(foreign.dispatch_resolved(resolved, request, admitted)), + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn dispatch_resolved_rejects_mutated_request_path() { + let router = RouterService::builder() + .get("/users/{id}", ok_handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let admitted = admitted_for(&resolved, Method::GET, "/users/42"); + let request = request_builder() + .method(Method::GET) + .uri("/users/43") + .body(Body::empty()) + .expect("request"); + assert!(matches!( + block_on(router.dispatch_resolved(resolved, request, admitted)), + Err(EdgeError::Internal { .. }) + )); + } async fn ok_handler(_ctx: RequestContext) -> Result { response_with_body(StatusCode::OK, Body::empty()) @@ -719,6 +1122,7 @@ mod tests { let entry = RouteEntry { handler: ok_handler.into_handler(), introspection_needs: IntrospectionNeeds::default(), + metadata: RouteMetadata::new(Method::GET, "/test"), }; let cloned = entry.clone(); diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index a4b0c34d..dbd1eedc 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -26,7 +26,9 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; +use crate::config_store::BoundedStoreRead; use crate::error::EdgeError; +use crate::time::Deadline; // --------------------------------------------------------------------------- // Contract test macro @@ -111,6 +113,10 @@ pub const MAX_NAME_LEN: usize = 512; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SecretError { + /// The absolute read deadline expired before a complete value was available. + #[error("secret store read deadline exceeded")] + DeadlineExceeded, + /// A general internal error. #[error("secret store error: {0}")] Internal(#[from] anyhow::Error), @@ -126,12 +132,19 @@ pub enum SecretError { /// A validation error (e.g., invalid secret name). #[error("validation error: {0}")] Validation(String), + + /// The value or guest-visible backend read exceeded its supplied allowance. + #[error("secret value exceeds configured byte limit")] + ValueTooLarge, } impl From for EdgeError { #[inline] fn from(err: SecretError) -> Self { match err { + SecretError::DeadlineExceeded => { + EdgeError::service_unavailable("secret store read deadline exceeded") + } SecretError::NotFound { .. } => { EdgeError::internal(anyhow::anyhow!("required secret is not configured")) } @@ -139,6 +152,9 @@ impl From for EdgeError { SecretError::Validation(..) => { EdgeError::internal(anyhow::anyhow!("secret lookup failed")) } + SecretError::ValueTooLarge => { + EdgeError::internal(anyhow::anyhow!("secret value too large")) + } SecretError::Internal(..) => { EdgeError::internal(anyhow::anyhow!("secret store operation failed")) } @@ -186,6 +202,31 @@ impl SecretStore for InMemorySecretStore { let compound = format!("{store_name}/{key}"); Ok(self.secrets.get(&compound).cloned()) } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + let value = self.get_bytes(store_name, key).await?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } // --------------------------------------------------------------------------- @@ -205,6 +246,24 @@ impl SecretStore for NoopSecretStore { async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { Ok(None) } + + #[inline] + async fn get_bytes_bounded( + &self, + _store_name: &str, + _key: &str, + deadline: Deadline, + _max_backend_bytes: u64, + _max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + } } // --------------------------------------------------------------------------- @@ -242,6 +301,32 @@ impl SecretHandle { self.provider.get_bytes(store_name, key).await } + /// Retrieve a secret under one absolute deadline and two byte limits. + /// + /// # Errors + /// Preserves validation and the provider's typed bounded-read errors. + #[inline] + pub async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + validate_name(store_name)?; + validate_name(key)?; + self.provider + .get_bytes_bounded( + store_name, + key, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + /// Create a new handle wrapping a multi-store provider. #[inline] pub fn new(provider: Arc) -> Self { @@ -292,6 +377,38 @@ impl SecretHandle { pub trait SecretStore: Send + Sync { /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; + + /// Retrieves one value under an absolute deadline and independent backend/value caps. + /// + /// The default checks cooperatively around the existing provider call and discards an + /// oversized materialized value. Providers must override it before claiming native bounds. + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + let value = self.get_bytes(store_name, key).await?; + if deadline.is_expired() { + return Err(SecretError::DeadlineExceeded); + } + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } // --------------------------------------------------------------------------- @@ -373,6 +490,35 @@ mod tests { }); } + #[test] + fn bounded_secret_exact_cap_succeeds_and_over_cap_discards_value() { + use std::time::Duration; + + use crate::time::Deadline; + + let handle = provider_handle_with(&[("signing-keys/current", "abc123")]); + let exact = block_on(handle.get_bytes_bounded( + "signing-keys", + "current", + Deadline::after(Duration::from_secs(1)), + 6, + 6, + )) + .expect("exact bounded read"); + assert_eq!(exact.backend_bytes, 6); + assert_eq!(exact.value, Some(Bytes::from_static(b"abc123"))); + + let error = block_on(handle.get_bytes_bounded( + "signing-keys", + "current", + Deadline::after(Duration::from_secs(1)), + 6, + 5, + )) + .expect_err("value cap"); + assert!(matches!(error, SecretError::ValueTooLarge)); + } + #[test] fn provider_handle_require_bytes_errors_for_missing() { let handle = provider_handle_with(&[]); diff --git a/crates/edgezero-core/src/store_registry.rs b/crates/edgezero-core/src/store_registry.rs index e0ffed59..ff3544d4 100644 --- a/crates/edgezero-core/src/store_registry.rs +++ b/crates/edgezero-core/src/store_registry.rs @@ -25,9 +25,10 @@ use std::collections::BTreeMap; use bytes::Bytes; -use crate::config_store::ConfigStoreHandle; +use crate::config_store::{BoundedStoreRead, ConfigStoreHandle}; use crate::key_value_store::KvHandle; use crate::secret_store::{SecretError, SecretHandle}; +use crate::time::Deadline; /// A per-bind KV handle, returned by [`KvRegistry::named`] / [`KvRegistry::default`]. pub type BoundKvStore = KvHandle; @@ -71,6 +72,29 @@ impl BoundSecretStore { self.handle.get_bytes(&self.store_name, key).await } + /// Retrieve a secret against the bound store under one absolute deadline and byte budget. + /// + /// # Errors + /// Preserves validation and the provider's typed bounded-read errors. + #[inline] + pub async fn get_bytes_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + self.handle + .get_bytes_bounded( + &self.store_name, + key, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + /// Underlying [`SecretHandle`] (escape hatch for callers that need the /// store-name argument explicitly). #[inline] diff --git a/crates/edgezero-core/src/time.rs b/crates/edgezero-core/src/time.rs new file mode 100644 index 00000000..cf5575db --- /dev/null +++ b/crates/edgezero-core/src/time.rs @@ -0,0 +1,297 @@ +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use crate::error::{BudgetSource, EdgeError}; +use crate::outbound::OutboundRequest; + +/// Max adapter overhead tolerated before a fan-out slot fails closed. +pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25); +/// Hard clamp on any caller-supplied duration, so construction cannot panic. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); +/// Budget applied when a request sets neither a timeout nor a deadline. +pub const DEFAULT_NO_DEADLINE_BUDGET: Duration = Duration::from_secs(30); + +/// An absolute, copyable monotonic deadline. A deadline at or before now is expired. +#[derive(Debug, Clone, Copy)] +pub struct Deadline(MonotonicInstant); + +/// Effective timeout selected for one outbound dispatch. +#[derive(Clone, Copy, Debug)] +pub struct DispatchBudget { + pub cause: BudgetSource, + pub deadline: Deadline, + pub duration: Duration, +} + +/// Portable monotonic clock instant used by `EdgeZero` timing APIs. +pub type MonotonicInstant = web_time::Instant; + +/// Cloneable monotonic clock source shared by one application and its admitted requests. +#[derive(Clone)] +pub struct MonotonicClock { + now: Arc MonotonicInstant + Send + Sync>, +} + +impl MonotonicClock { + #[must_use] + #[inline] + pub fn new(now: Now) -> Self + where + Now: Fn() -> MonotonicInstant + Send + Sync + 'static, + { + Self { now: Arc::new(now) } + } + + #[must_use] + #[inline] + pub fn now(&self) -> MonotonicInstant { + (self.now)() + } +} + +impl Default for MonotonicClock { + #[inline] + fn default() -> Self { + Self::new(MonotonicInstant::now) + } +} + +impl fmt::Debug for MonotonicClock { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MonotonicClock").finish_non_exhaustive() + } +} + +impl Deadline { + /// Returns a deadline `now + min(duration, DEADLINE_FAR_FUTURE)`; never panics. + #[inline] + #[must_use] + pub fn after(duration: Duration) -> Self { + let now = MonotonicInstant::now(); + let clamped = duration.min(DEADLINE_FAR_FUTURE); + Deadline(now.checked_add(clamped).unwrap_or(now)) + } + + /// Constructs a deadline from an absolute instant. + #[inline] + #[must_use] + pub fn at_instant(instant: MonotonicInstant) -> Self { + Deadline(instant) + } + + /// Returns the absolute deadline instant. + #[inline] + #[must_use] + pub fn instant(&self) -> MonotonicInstant { + self.0 + } + + /// Returns `true` once the deadline instant is at or before now. + #[inline] + #[must_use] + pub fn is_expired(&self) -> bool { + self.is_expired_at(MonotonicInstant::now()) + } + + /// Returns `true` when this deadline is at or before the supplied clock snapshot. + #[inline] + #[must_use] + pub fn is_expired_at(&self, now: MonotonicInstant) -> bool { + self.remaining_at(now).is_none() + } + + /// Returns the remaining time, or `None` once the deadline is reached or passed. + #[inline] + #[must_use] + pub fn remaining(&self) -> Option { + self.remaining_at(MonotonicInstant::now()) + } + + /// Returns time remaining at an explicit clock snapshot, or `None` at/past expiry. + #[inline] + #[must_use] + pub fn remaining_at(&self, now: MonotonicInstant) -> Option { + self.0 + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + } +} + +/// Computes one effective outbound deadline from a shared monotonic snapshot. +/// +/// # Errors +/// Returns [`EdgeError::GatewayTimeout`] when the selected budget is already exhausted. +#[inline] +pub fn dispatch_budget( + request: &OutboundRequest, + now: MonotonicInstant, +) -> Result { + let inputs = request.budget_inputs(); + let deadline_from_duration = |duration: Duration| { + let bounded_duration = duration.min(DEADLINE_FAR_FUTURE); + Deadline::at_instant(now.checked_add(bounded_duration).unwrap_or(now)) + }; + + let from_timeout = inputs.timeout.map(deadline_from_duration); + let from_caller = inputs.deadline.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_default = (inputs.timeout.is_none() && inputs.deadline.is_none()) + .then(|| deadline_from_duration(DEFAULT_NO_DEADLINE_BUDGET)); + + let (cause, deadline) = [ + from_timeout.map(|deadline| (BudgetSource::PerCallTimeout, deadline)), + from_caller.map(|deadline| (BudgetSource::BatchDeadline, deadline)), + from_default.map(|deadline| (BudgetSource::Default, deadline)), + ] + .into_iter() + .flatten() + .min_by_key(|(_, deadline)| deadline.instant()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "dispatch_budget: no deadline candidate; invariant violated" + )) + })?; + + let duration = deadline.instant().saturating_duration_since(now); + if duration.is_zero() { + return Err(EdgeError::gateway_timeout_caused( + "effective budget is zero", + cause, + )); + } + Ok(DispatchBudget { + cause, + deadline, + duration, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn monotonic_clock_uses_the_injected_source() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *observed_now.lock().expect("clock lock")); + + assert_eq!(clock.now(), start); + let advanced = start + .checked_add(Duration::from_secs(2)) + .expect("advanced instant"); + *now.lock().expect("clock lock") = advanced; + assert_eq!(clock.now(), advanced); + assert_eq!(clock.clone().now(), advanced); + } + + #[test] + fn monotonic_instant_is_public_clock_type() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant(start); + assert_eq!(deadline.instant(), start); + } + + #[test] + fn deadline_is_copy() { + fn assert_copy() {} + assert_copy::(); + } + + #[test] + fn constants_have_exact_values() { + assert_eq!(DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + assert_eq!(DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + } + + #[test] + fn deadline_before_now_is_expired() { + let base = MonotonicInstant::now(); + let past = Deadline::at_instant(base); + let now = base + .checked_add(Duration::from_secs(1)) + .expect("no overflow"); + assert!(past.is_expired_at(now)); + assert_eq!(past.remaining_at(now), None); + } + + #[test] + fn deadline_exactly_now_is_expired() { + let base = MonotonicInstant::now(); + let at_now = Deadline::at_instant(base); + assert_eq!( + at_now.remaining_at(base), + None, + "zero remaining is expired, not Some(0)" + ); + assert!( + at_now.is_expired_at(base), + "a deadline exactly at now is expired" + ); + } + + #[test] + fn deadline_in_future_has_exact_remaining() { + let base = MonotonicInstant::now(); + let future = Deadline::at_instant( + base.checked_add(Duration::from_mins(1)) + .expect("no overflow"), + ); + assert!(!future.is_expired_at(base)); + assert_eq!(future.remaining_at(base), Some(Duration::from_mins(1))); + } + + #[test] + fn after_clamps_duration_max_to_far_future() { + let before = MonotonicInstant::now(); + let deadline = Deadline::after(Duration::MAX); + let after = MonotonicInstant::now(); + let lower = before + .checked_add(DEADLINE_FAR_FUTURE) + .expect("no overflow"); + let upper = after.checked_add(DEADLINE_FAR_FUTURE).expect("no overflow"); + assert!(deadline.instant() >= lower, "clamped below the 7-day bound"); + assert!( + deadline.instant() <= upper, + "Duration::MAX was not clamped to 7 days" + ); + } + + #[test] + fn public_remaining_and_is_expired_smoke() { + let before = MonotonicInstant::now(); + let far = Deadline::after(Duration::from_hours(1)); + let after = MonotonicInstant::now(); + assert!(!far.is_expired()); + let lower = before + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + let upper = after + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + assert!( + far.instant() >= lower && far.instant() <= upper, + "after() must land exactly at now plus duration" + ); + assert!(far.remaining().is_some()); + + let now_deadline = Deadline::after(Duration::ZERO); + assert!(now_deadline.is_expired()); + assert_eq!(now_deadline.remaining(), None); + } + + #[test] + fn instant_round_trips() { + let base = MonotonicInstant::now() + .checked_add(Duration::from_secs(10)) + .expect("no overflow"); + assert_eq!(Deadline::at_instant(base).instant(), base); + } +} diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 1329991d..13e1315d 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -1,10 +1,10 @@ -use crate::manifest_definitions::{Manifest, StoreDeclaration}; +use crate::manifest_definitions::{Manifest, StoreDeclaration, reject_misplaced_capabilities}; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; use std::env; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use syn::parse::{Parse, ParseStream}; use syn::{Ident, LitStr, Token, parse_macro_input}; use validator::Validate as _; @@ -12,6 +12,7 @@ use validator::Validate as _; #[derive(Debug)] struct AppArgs { app_ident: Option, + configure: Option, owns_logging: Option, path: LitStr, state: Option, @@ -21,6 +22,7 @@ impl Parse for AppArgs { fn parse(input: ParseStream) -> syn::Result { let path: LitStr = input.parse()?; let mut app_ident: Option = None; + let mut configure: Option = None; let mut owns_logging: Option = None; let mut state: Option = None; let mut seen_keyword = false; @@ -34,6 +36,15 @@ impl Parse for AppArgs { input.parse::()?; seen_keyword = true; match key.to_string().as_str() { + "configure" => { + if configure.is_some() { + return Err(syn::Error::new( + key.span(), + "duplicate `configure` argument", + )); + } + configure = Some(input.parse::()?); + } "owns_logging" => { if owns_logging.is_some() { return Err(syn::Error::new( @@ -54,7 +65,7 @@ impl Parse for AppArgs { return Err(syn::Error::new( key.span(), format!( - "unknown `app!` argument `{other}`; expected `state` or `owns_logging`" + "unknown `app!` argument `{other}`; expected `configure`, `state`, or `owns_logging`" ), )); } @@ -81,6 +92,7 @@ impl Parse for AppArgs { } Ok(Self { app_ident, + configure, owns_logging, path, state, @@ -123,6 +135,19 @@ fn build_stores_tokens(manifest: &Manifest) -> TokenStream2 { } } +fn build_configure_tokens(callback: Option<&syn::Expr>) -> TokenStream2 { + callback.map_or_else( + || quote! { fn configure(_app: &mut edgezero_core::app::App) {} }, + |configure| { + quote! { + fn configure(app: &mut edgezero_core::app::App) { + (#configure)(app); + } + } + }, + ) +} + fn build_middleware_tokens(manifest: &Manifest) -> Result, String> { manifest .app @@ -145,9 +170,18 @@ fn build_route_tokens(manifest: &Manifest) -> Result, String> }; let handler_path = parse_handler_path(handler)?; let path_lit = LitStr::new(&trigger.path, Span::call_site()); + let class_lit = trigger + .class + .as_deref() + .map(|class| LitStr::new(class, Span::call_site())); for method in trigger.methods() { - tokens.push(route_for_method(method, &path_lit, &handler_path)); + tokens.push(route_for_method( + method, + &path_lit, + class_lit.as_ref(), + &handler_path, + )); } } Ok(tokens) @@ -165,18 +199,10 @@ pub fn expand_app(input: TokenStream) -> TokenStream { } }; - let mut manifest: Manifest = match toml::from_str(&manifest_source) { - Ok(parsed) => parsed, - Err(err) => { - let msg = format!("failed to parse {}: {err}", manifest_path.display()); - return quote!(compile_error!(#msg);).into(); - } + let manifest = match parse_manifest(&manifest_source, &manifest_path) { + Ok(manifest) => manifest, + Err(msg) => return quote!(compile_error!(#msg);).into(), }; - if let Err(err) = manifest.validate() { - let msg = format!("failed to validate {}: {err}", manifest_path.display()); - return quote!(compile_error!(#msg);).into(); - } - manifest.finalize(); let manifest_json = match serde_json::to_string(&manifest) { Ok(json) => json, @@ -209,6 +235,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); let owns_logging_lit = args.owns_logging.unwrap_or(false); + let configure_tokens = build_configure_tokens(args.configure.as_ref()); // Emitted only when `state = ` is given; `Option: ToTokens` // renders `None` as nothing, so an app without `state` is unchanged. let state_call = args.state.as_ref().map(|state_expr| { @@ -216,10 +243,11 @@ pub fn expand_app(input: TokenStream) -> TokenStream { }); // The emitted `Hooks` impl below explicitly defines `configure`, - // `owns_logging`, and `build_app` even though their bodies mirror the trait - // defaults. This is required because `missing_trait_methods` (restriction = - // deny) forbids relying on trait defaults in the impl. If those `Hooks` - // defaults change, update these emitted bodies to match. + // `owns_logging`, and `build_app`. `configure` invokes the supplied callback + // when present; the other bodies mirror the trait defaults. This is required + // because `missing_trait_methods` (restriction = deny) forbids relying on + // trait defaults in the impl. If those `Hooks` defaults change, update these + // emitted bodies to match. let output = quote! { // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). const _: &[u8] = include_bytes!(#manifest_path_lit); @@ -231,7 +259,25 @@ pub fn expand_app(input: TokenStream) -> TokenStream { build_router() } - fn configure(_app: &mut edgezero_core::app::App) {} + #configure_tokens + + fn manifest() -> ::edgezero_core::manifest::BakedManifest { + static CACHE: ::std::sync::OnceLock< + ::edgezero_core::manifest::BakedManifest, + > = ::std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + match ::manifest_json() { + None => ::edgezero_core::manifest::BakedManifest::Absent, + Some(json) => { + ::edgezero_core::manifest::Manifest::from_baked_json(json) + } + } + }) + } + + fn manifest_json() -> Option<&'static str> { + Some(#manifest_json_lit) + } fn owns_logging() -> bool { #owns_logging_lit @@ -263,6 +309,21 @@ pub fn expand_app(input: TokenStream) -> TokenStream { output.into() } +fn parse_manifest(source: &str, path: &Path) -> Result { + let value: toml::Value = toml::from_str(source) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + reject_misplaced_capabilities(&value) + .map_err(|error| format!("failed to validate {}: {error}", path.display()))?; + let mut manifest: Manifest = value + .try_into() + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + manifest + .validate() + .map_err(|error| format!("failed to validate {}: {error}", path.display()))?; + manifest.finalize(); + Ok(manifest) +} + /// Parses a handler reference like `crate::handlers::root` from `edgezero.toml` /// into the `syn::ExprPath` that the generated router code references. /// @@ -313,7 +374,32 @@ fn resolve_manifest_path(relative: String) -> PathBuf { PathBuf::from(manifest_dir).join(relative) } -fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> TokenStream2 { +fn route_for_method( + method: &str, + path: &LitStr, + class: Option<&LitStr>, + handler: &syn::ExprPath, +) -> TokenStream2 { + if let Some(route_class) = class { + let method_tokens = match method { + "GET" => quote! { edgezero_core::http::Method::GET }, + "POST" => quote! { edgezero_core::http::Method::POST }, + "PUT" => quote! { edgezero_core::http::Method::PUT }, + "DELETE" => quote! { edgezero_core::http::Method::DELETE }, + custom_method => { + let method_bytes = + syn::LitByteStr::new(custom_method.as_bytes(), Span::call_site()); + quote! { + edgezero_core::http::Method::from_bytes(#method_bytes) + .expect("invalid HTTP method in manifest") + } + } + }; + return quote! { + builder = builder.route_with_class(#path, #method_tokens, #route_class, #handler); + }; + } + match method { "GET" => quote! { builder = builder.get(#path, #handler); }, "POST" => quote! { builder = builder.post(#path, #handler); }, @@ -385,6 +471,25 @@ mod tests { assert_eq!(args.owns_logging, None); } + #[test] + fn app_args_parses_configure_expr() { + let args: AppArgs = + parse_str(r#""edgezero.toml", configure = crate::configure_app"#).expect("parse"); + let rendered = args.configure.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: configure_app".to_owned())); + } + + #[test] + fn app_args_parses_configure_with_other_keywords() { + let args: AppArgs = parse_str( + r#""edgezero.toml", MyApp, state = crate::app_state(), configure = crate::configure_app, owns_logging = true"#, + ) + .expect("parse"); + assert!(args.configure.is_some()); + assert!(args.state.is_some()); + assert_eq!(args.owns_logging, Some(true)); + } + #[test] fn app_args_parses_state_with_app_ident_and_owns_logging() { let args: AppArgs = @@ -405,6 +510,18 @@ mod tests { assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); } + #[test] + fn app_args_rejects_duplicate_configure() { + let err = parse_str::( + r#""edgezero.toml", configure = crate::first, configure = crate::second"#, + ) + .expect_err("duplicate configure"); + assert!( + err.to_string().contains("duplicate `configure`"), + "got: {err}" + ); + } + #[test] fn app_args_rejects_duplicate_key() { let err = @@ -435,6 +552,7 @@ mod tests { err.to_string().contains("unknown `app!` argument `bogus`"), "got: {err}" ); + assert!(err.to_string().contains("`configure`"), "got: {err}"); } #[test] @@ -503,6 +621,31 @@ handler = "crate::handlers::root" assert_eq!(tokens.len(), 3); } + #[test] + fn build_route_tokens_propagates_manifest_route_class() { + let manifest: Manifest = toml::from_str( + r#" +[app] +name = "demo" +entry = "crates/demo-core" + +[[triggers.http]] +class = "auction" +path = "/bid" +methods = ["GET", "POST"] +handler = "crate::handlers::bid" +"#, + ) + .expect("manifest TOML should parse"); + let tokens = build_route_tokens(&manifest).expect("valid manifest builds routes"); + assert_eq!(tokens.len(), 2); + for token in tokens { + let rendered = token.to_string(); + assert!(rendered.contains("route_with_class"), "{rendered}"); + assert!(rendered.contains("auction"), "{rendered}"); + } + } + #[test] fn build_route_tokens_skips_trigger_without_handler() { let manifest: Manifest = toml::from_str( diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs index 58185135..ff067209 100644 --- a/crates/edgezero-macros/tests/app_macro.rs +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -19,3 +19,47 @@ mod tests { assert!(super::OwnedLoggingApp::owns_logging()); } } + +#[cfg(test)] +mod configured_app { + use edgezero_core::app::{App, Hooks as _}; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, Uri, Version}; + use edgezero_core::ingress::{AdmissionDecision, IngressBeginOutcome, IngressHeadParts}; + use edgezero_core::time::MonotonicInstant; + + fn configure_app(app: &mut App) { + app.set_ingress_admission_policy(|_| { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::TOO_MANY_REQUESTS; + AdmissionDecision::Refuse(response) + }); + } + + edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + ConfiguredApp, + configure = configure_app + ); + + #[test] + fn app_macro_configure_callback_installs_ingress_policy() { + let app = ConfiguredApp::build_app(); + let head = IngressHeadParts::new( + Method::GET, + Uri::from_static("/"), + Version::HTTP_11, + HeaderMap::new(), + ); + let outcome = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress"); + let IngressBeginOutcome::Refused(response) = outcome else { + panic!("configured policy must refuse ingress"); + }; + assert_eq!( + response.into_response().status(), + StatusCode::TOO_MANY_REQUESTS + ); + } +} diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs index 5f8a7d1f..9d09d7a9 100644 --- a/crates/edgezero-macros/tests/nested_secrets_e2e.rs +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -79,6 +79,10 @@ struct Vaulted { struct BlobStore(String); #[async_trait(?Send)] +#[expect( + clippy::missing_trait_methods, + reason = "the legacy test provider intentionally exercises the bounded-read compatibility default" +)] impl ConfigStore for BlobStore { async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.clone())) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 81ebdb14..0a1bfcd7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -36,8 +36,9 @@ export default defineConfig({ { text: 'Routing', link: '/guide/routing' }, { text: 'Handlers & Extractors', link: '/guide/handlers' }, { text: 'Middleware', link: '/guide/middleware' }, + { text: 'Capabilities', link: '/guide/capabilities' }, { text: 'Streaming', link: '/guide/streaming' }, - { text: 'Proxying', link: '/guide/proxying' }, + { text: 'Outbound HTTP', link: '/guide/proxying' }, ], }, { diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 62813d79..753038f2 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -77,19 +77,16 @@ cargo build -p my-app-adapter-axum --release The binary is placed in `target/release/my-app-adapter-axum`. -## Proxy Client +## Outbound HTTP -The Axum adapter provides a native HTTP client for proxying: +The Axum adapter injects `AxumOutboundClient`, backed by `reqwest`. Application handlers use the +portable client from `RequestContext::http_client()`; direct wiring and tests can construct the +adapter client with `AxumOutboundClient::try_new()`. -```rust -use edgezero_adapter_axum::AxumProxyClient; -use edgezero_core::proxy::ProxyService; - -let client = AxumProxyClient::default(); -let response = ProxyService::new(client).forward(request).await?; -``` - -This uses `reqwest` under the hood for outbound HTTP requests. +Axum provides native total deadlines, elastic phase budgeting, batch slot isolation, header +fidelity, and streamed upload cancellation. A portable response stream is non-`Send`, while +Hyper requires a `Send` body, so downstream conversion collects it under the fixed 16 MiB +`AXUM_RESPONSE_STREAM_BUFFER_BYTES` cap. See [Capabilities](/guide/capabilities). ## Logging diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index c22e99e6..e2794607 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -95,17 +95,17 @@ wrangler deploy --cwd crates/my-app-adapter-cloudflare ## Fetch API -Cloudflare Workers use the global `fetch` API for outbound requests: - -```rust -use edgezero_adapter_cloudflare::CloudflareProxyClient; -use edgezero_core::proxy::ProxyService; - -let client = CloudflareProxyClient; -let response = ProxyService::new(client).forward(request).await?; -``` - -Unlike Fastly, there's no backend configuration needed - Workers can fetch any URL directly. +`CloudflareOutboundClient` uses the Workers global `fetch` API and is injected into core request +extensions. Workers needs no backend registration, but application manifests still declare +outbound hosts so the same portable contract validates on every target. + +The adapter requests raw encoded upstream bytes with manual fetch encoding, disables automatic +redirect following, and owns an abort signal for the absolute request deadline. When an encoded +body is passed through to the downstream response, the response converter separately selects +manual response-body encoding so Workers does not encode it again. These are two distinct +controls. Cloudflare is the only current adapter with Native lazy streamed response passthrough; +raw header octets and original non-`set-cookie` field boundaries remain unavailable, so header +fidelity is BestEffort. See [Capabilities](/guide/capabilities). ## Logging diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index da0185e9..42fb3bf8 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -147,16 +147,16 @@ fastly compute deploy ## Backends -EdgeZero's Fastly proxy client uses **dynamic backends** derived from the target URI (host + scheme). -You do not need to predeclare backends in `fastly.toml` for EdgeZero proxying. +`FastlyOutboundClient` uses deterministic **dynamic backends** derived from the canonical target, +TLS identity, and provider timer budget. You do not predeclare those destinations in +`fastly.toml`, but dynamic backends must be enabled on the deployed Fastly service. The local CLI +cannot prove that service entitlement, so `outbound-http` is BestEffort. A disabled service +returns a typed 502 with an enablement diagnostic. -```rust -use edgezero_adapter_fastly::FastlyProxyClient; -use edgezero_core::proxy::ProxyService; - -let client = FastlyProxyClient; -let response = ProxyService::new(client).forward(request).await?; -``` +Fastly also has documented deadline, upload, elastic-budget, batch-isolation, and lazy downstream +streaming limitations. The standard `#[fastly::main]` entrypoint buffers a portable response +stream under the fixed 16 MiB `FASTLY_RESPONSE_STREAM_BUFFER_BYTES` cap. See +[Capabilities](/guide/capabilities) before marking an outbound capability required. ## Logging @@ -264,9 +264,13 @@ async fn handler(ctx: RequestContext) -> Result { ## Streaming -Fastly supports native streaming via `stream_to_client`. The adapter automatically converts `Body::stream` to Fastly's streaming APIs. +Fastly provides `stream_to_client`, but that API is incompatible with the standard +`#[fastly::main]` entrypoint used by generated projects. The current response converter therefore +collects `Body::stream` under a fixed 16 MiB cap before returning the final response. Outbound +response streams retain typed read/decode/deadline errors during that collection. -See the [Streaming guide](/guide/streaming) for examples and patterns. +See the [Streaming guide](/guide/streaming) and +[capability matrix](/guide/capabilities#outbound-matrix) for the exact boundary. ## Testing diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 08745634..e71c4858 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -9,7 +9,7 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in - Preserve request semantics - Stream responses without buffering where the provider supports it - Expose provider context -- Offer a proxy bridge so handlers can forward traffic without knowing which platform they are on +- Inject the portable outbound HTTP client without exposing provider SDK types to handlers ## Request Conversion @@ -27,7 +27,7 @@ Adapters also expose `from_core_response` (or equivalent) to transform an `edgez - **Map HTTP status codes** verbatim - **Copy headers**, respecting casing rules enforced by the provider -- **Preserve streaming bodies** - `Body::Stream` should be written chunk-by-chunk to the provider output without buffering the entire payload +- **Apply the declared response boundary** - Cloudflare preserves lazy response streams; Axum, Fastly, and Spin collect portable response streams under a fixed 16 MiB conversion cap - **Handle encoding helpers** (`decode_gzip_stream`, `decode_brotli_stream`) where a provider requires transparent decompression ## Dispatch Helper @@ -52,15 +52,21 @@ the id-keyed `Kv` / `Secrets` / `Config` extractors or the matching `ctx.kv_store(id)` / `ctx.config_store(id)` / `ctx.secret_store(id)` accessors. The pre-rewrite `Hooks::config_store()` hook is gone. -## Proxy Integration +## Outbound HTTP Integration -Adapters implement `edgezero_core::proxy::ProxyClient` so handlers can forward outbound requests. The client must: +Adapters implement `edgezero_core::outbound::OutboundHttpClient` and inject an `HttpClient` into +each core request. Implementations must: -- Accept a `ProxyRequest` created with `ProxyRequest::from_request` -- Build and send an outbound provider request, reusing headers and streaming the body without buffering -- Convert the provider response into a `ProxyResponse`, again preserving streaming behaviour and normalising encodings -- Attach a diagnostic header (e.g., `x-edgezero-proxy`) identifying which adapter forwarded the call (Fastly and Cloudflare do this today) -- Surface provider errors as `EdgeError::internal` so applications can decide how to respond +- Implement both `send` and index-aligned `send_all`, including per-slot elapsed time +- Apply the shared request validation, hop-by-hop normalization, deadline, and response-body rules +- Enforce independent request, encoded response, decoded response, final buffer, header, Brotli, and chunk-shape controls +- Preserve typed deadline, transport, protocol, codec, and resource failures without message matching +- Attach `x-edgezero-proxy: ` to completed outbound responses +- Publish an exact static capability level for every outbound capability + +Provider limitations are part of the contract rather than hidden implementation details. See +[Capabilities](/guide/capabilities) for the support matrix, timing semantics, and accounting +exclusions. ## Logging Initialisation @@ -110,7 +116,7 @@ When bringing up another adapter: 1. **Implement request/response conversion functions** that follow the rules above 2. **Provide a context type** exposing the adapter's metadata and insert it in `into_core_request` 3. **Implement a `dispatch` wrapper** plus logging helper -4. **Wire up a `ProxyClient`** that streams bodies and normalises encodings +4. **Wire up an `OutboundHttpClient`** with limits, deadlines, batching, and typed errors 5. **Copy the contract test suite**, swapping in the new adapter types. Ensure the tests are gated to the target architecture if the adapter SDK does not compile for native hosts 6. **Register the adapter** with `edgezero-adapter::register_adapter` (typically in a `cli` module using the `ctor` crate) so the CLI can discover it dynamically diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index e9c0e57c..f0a32ba7 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -71,6 +71,19 @@ edgezero deploy --adapter spin spin deploy --from crates/my-app-adapter-spin ``` +## Outbound HTTP + +`SpinOutboundClient` uses the WASI HTTP 0.3 interfaces directly so request-body production, +response completion, limits, and the guest-visible deadline race share one owner. Generated +`spin.toml` files default `allowed_outbound_hosts` to `https://*:*`; cleartext requires an explicit +manifest declaration. + +Spin exposes raw header bytes and supports isolated concurrent `send_all` slots. Deadline and +streamed-upload cancellation remain BestEffort until host teardown has a documented observed +bound, and provider phase-timer defaults may prevent a fully elastic budget. The current +`SpinFullResponse` converter also buffers portable response streams under the fixed 16 MiB +`SPIN_RESPONSE_STREAM_BUFFER_BYTES` cap. See [Capabilities](/guide/capabilities). + ## KV Storage Spin KV is **label-backed and multi-store** — each logical id in diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 33241fac..1f6273e2 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -12,6 +12,7 @@ edgezero/ │ ├── edgezero-adapter/ # Shared adapter traits and registry │ ├── edgezero-adapter-fastly/ # Fastly Compute@Edge bridge │ ├── edgezero-adapter-cloudflare/ # Cloudflare Workers bridge +│ ├── edgezero-adapter-spin/ # Fermyon Spin bridge │ ├── edgezero-adapter-axum/ # Native Axum/Tokio bridge │ └── edgezero-cli/ # CLI for scaffolding and dev server └── examples/ @@ -29,6 +30,7 @@ edgezero/ - **Middleware** - Composable middleware chain with async support - **Manifest** - `edgezero.toml` parsing and validation - **Compression** - Shared gzip/brotli stream decoders +- **Outbound HTTP** - Typed requests, responses, limits, deadlines, batching, and capability contracts Handlers in your core crate only depend on `edgezero-core`, keeping them portable. @@ -57,20 +59,27 @@ Adapters translate between provider-specific types and the portable core model: - Converts Fastly `Request` to `edgezero_core::http::Request` - Maps core responses back to Fastly `Response` - Provides `FastlyRequestContext` for accessing Fastly-specific APIs -- Implements `FastlyProxyClient` for upstream requests +- Injects `FastlyOutboundClient` for upstream requests ### edgezero-adapter-cloudflare - Converts Workers `Request` to core request - Maps responses to Workers `Response` - Provides `CloudflareRequestContext` for Workers APIs -- Implements `CloudflareProxyClient` for fetch operations +- Injects `CloudflareOutboundClient` for fetch operations ### edgezero-adapter-axum - Wraps `RouterService` in Axum/Tokio services - Powers the local development server - Supports native container deployments +- Injects `AxumOutboundClient` backed by `reqwest` + +### edgezero-adapter-spin + +- Converts Spin/WASI HTTP requests and responses +- Resolves component-scoped KV, config, and secret bindings +- Injects `SpinOutboundClient` backed by WASI HTTP 0.3 ## CLI Crate @@ -86,7 +95,7 @@ Adapters translate between provider-specific types and the portable core model: ``` ┌─────────────────────────────────────────────────────────────┐ │ Provider Runtime │ -│ (Fastly Compute / Cloudflare Workers / Axum Server) │ +│ (Fastly Compute / Cloudflare Workers / Spin / Axum) │ └─────────────────────────────────────────────────────────────┘ │ ▼ diff --git a/docs/guide/capabilities.md b/docs/guide/capabilities.md new file mode 100644 index 00000000..6e56aa64 --- /dev/null +++ b/docs/guide/capabilities.md @@ -0,0 +1,225 @@ +# Capabilities + +Capabilities let an application state which portable runtime behaviors it depends on. +`edgezero build`, `serve`, and `deploy` compare the manifest declaration with the selected +adapter before starting the adapter command. + +## Declaration + +```toml +[capabilities] +required = ["outbound-deadlines"] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com", "https://*.example.net:*"] +``` + +A capability cannot appear more than once or in both lists. `required` accepts `Native` +and `BoundedCooperative`; it rejects `BestEffort` and `Unsupported`. `optional` never +blocks an operation, but degraded and unavailable support is logged. A missing or malformed +capability contract fails closed when it contains required entries. + +Support levels mean: + +- `Native`: fully supported without a documented deviation. +- `BoundedCooperative`: enforced with a documented deterministic bound. No current outbound + matrix cell uses this level. +- `BestEffort`: implemented, but with a documented behavioral deviation or an unverified + deployment prerequisite. +- `Unsupported`: unavailable on that adapter. + +## Ingress Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| -------------------------------- | ----------- | ----------- | ----------- | ----------- | +| `ingress-admission` | Native | Native | Native | Native | +| `inbound-read-deadlines` | Native | BestEffort | BestEffort | BestEffort | +| `raw-ingress-head-limits` | Unsupported | Unsupported | Unsupported | Unsupported | +| `raw-ingress-framing-validation` | Unsupported | Unsupported | Unsupported | Unsupported | + +All standard adapters resolve the route and run the application admission policy before +middleware, handler dispatch, or body polling. Axum can preempt an asynchronous body read. +Cloudflare and Spin enforce absolute checks around host reads but require deployed timing +probes before their cancellation latency can be bounded. Fastly cannot preempt a synchronous +host body read. Those limitations keep their deadline cells at `BestEffort`. + +All current adapters expose `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. They perform defensive checks on the normalized request +information they receive, but those checks do not prove parser-boundary accounting or +ambiguous HTTP/1 framing rejection. Neither raw-ingress capability is currently available on +any adapter. + +Admission is body-blind. An application may opt into `ReadBodyBeforeFallback` for pre-resolved +404/405 requests when it needs body-limit precedence: clean EOF at or below the selected cap +retains the canonical 404/405, the first byte over returns the application-selected buffered +`on_exceeded` response first, and expiration of the one absolute read deadline returns the +application-selected buffered `on_timeout` response first. The application-supplied ingress +grant remains live throughout the drain and is released before response conversion, when the +core drain future is dropped, or when the finite read deadline terminates the drain. This path +discards the body and invokes no route middleware, handler, or request context. `Refuse` remains +the zero-read overload path, and ordinary admission retains the immediate, no-poll 404/405 +behavior. Axum's current `block_in_place` bridge is not cancellable: aborting the outer Tower +service future does not promptly drop the inner fallback drain. The configured absolute read +deadline still terminates that drain and releases its grant and body source; tests pin this +deadline-bounded, rather than cancellation-bounded, release behavior. + +## Response Egress Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| ------------------------------ | ----------- | ----------- | ----------- | ----------- | +| `response-egress-abort` | Unsupported | Unsupported | Unsupported | Unsupported | +| `response-egress-backpressure` | Unsupported | Unsupported | Unsupported | Unsupported | +| `response-egress-completion` | Unsupported | Unsupported | Unsupported | Unsupported | +| `response-write-deadlines` | Unsupported | Unsupported | Unsupported | Unsupported | + +These cells describe transport-observable client-response delivery, not response conversion. +Axum currently emits `ResponseReturned` with zero written bytes after conversion and before +returning the response to Hyper. It does not observe Hyper acceptance, socket transmission, +disconnect, abort, or client completion. The other adapters likewise expose no proved +transport completion boundary. Converter caps and deadline checks remain useful, but they do +not satisfy these capabilities; whole-response-lifetime certification remains blocked. + +## Outbound Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --------------------------------------- | --------------------------------- | --------------------------------- | --------------------------------- | --------------------------------- | +| `outbound-http` | Native | Native | BestEffort⁹ | Native | +| `outbound-complete-resource-accounting` | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | +| `outbound-header-fidelity` | Native | BestEffort⁸ | Native | Native | +| `outbound-deadlines` | Native | Native | BestEffort¹ | BestEffort⁸ | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort⁵ | BestEffort⁵ | +| `send-all-slot-isolation` | Native | Native | BestEffort⁴ | Native | +| `streamed-upload-deadlines` | Native | Native | BestEffort² | BestEffort⁸ | +| `lazy-streamed-response-passthrough` | BestEffort³ | Native | BestEffort⁶ | BestEffort⁷ | + +[^resource-accounting]: + Complete accounting includes provider parsing and field-section + materialization, informational responses, final headers, trailers, native receive chunks, + guest buffers and spare capacity, decoder state, allocator metadata, and runtime copies. + Current provider APIs do not expose or bound every term, so all adapters report + `Unsupported`. The narrower EdgeZero-owned limits below still apply. + +¹ Fastly cannot preempt cold dynamic-backend registration or guest-to-origin writes. Its +receive timers and absolute checks still cover the documented warm, response-read portions, +but they are not one end-to-end wall-clock guarantee. + +² Fastly checks the absolute deadline between streamed upload chunks, but it cannot preempt a +stalled source pull or host write. + +³ Axum collects a portable non-`Send` response stream before passing it to Hyper. Collection +is capped at 16 MiB by `AXUM_RESPONSE_STREAM_BUFFER_BYTES`. + +⁴ Fastly dispatches slots sequentially and harvests response bodies in input order. Cold +backend registration can delay a later dispatch; unresolved uploads or an earlier body drain +can delay a sibling's terminal observation. + +⁵ Fastly divides a total budget among provider phase timers. Spin's host may reject phase +timer settings and retain opaque defaults. Neither can promise one fully elastic pool. + +⁶ The standard `#[fastly::main]` entrypoint cannot use Fastly's manual +`stream_to_client` response lifetime. EdgeZero therefore buffers the stream under +`FASTLY_RESPONSE_STREAM_BUFFER_BYTES`, currently 16 MiB. + +⁷ Spin's current `SpinFullResponse` boundary is buffered. EdgeZero collects the stream under +`SPIN_RESPONSE_STREAM_BUFFER_BYTES`, currently 16 MiB. + +⁸ Spin cancellation is cooperative until host teardown is observable within a documented +bound. Cloudflare exposes normalized header strings rather than original raw octets and field +boundaries, although it preserves the visible header semantics used by the client. + +⁹ Fastly outbound HTTP uses dynamic backends, which must be enabled on the deployed service. +The CLI cannot currently prove that entitlement. If disabled, dispatch returns a typed 502 +with the documented enablement diagnostic. + +## Host Declarations + +`[capabilities.outbound].hosts` configures platform host plumbing; it is not an application +authorization policy. Enforce user-controlled destination policy in application code. + +- Omitting `hosts` defaults to `https://*:*`. Cleartext is not granted implicitly. +- `"*"` explicitly expands to both `http://*:*` and `https://*:*`. +- A bare host or wildcard subdomain defaults to HTTPS and port 443. +- An explicit scheme may be `http` or `https`; ports are `1..=65535` or `*`. +- Paths, queries, fragments, user information, whitespace, non-ASCII names, and malformed + wildcards are rejected. + +Spin receives the canonicalized entries through `allowed_outbound_hosts`. Cloudflare does not +need a build-time destination list. Fastly creates deterministic dynamic backends from the +canonical target. Axum applies the same manifest validation even though the native client does +not require provider host registration. + +## Limits And Accounting + +Each `OutboundRequest` owns independent limits: + +| Control | Scope | Default | +| ---------------------------- | ------------------------------------------------------- | ------- | +| `max_request_body_bytes` | Buffered or streamed request bytes | 8 MiB | +| `max_encoded_response_bytes` | Upstream transport bytes before decoding | Unset | +| `max_decoded_response_bytes` | Identity or EdgeZero-decoded gzip/Brotli output | Unset | +| `max_response_bytes` | Final buffered response, including raw passthrough | 1 MiB | +| `max_response_header_bytes` | Cumulative guest-visible header name/value bytes | Unset | +| `max_response_header_count` | Cumulative guest-visible header fields | Unset | +| `max_brotli_window_bits` | Brotli stream header checked before decoder allocation | 24 | +| `max_brotli_decoder_bytes` | Pinned policy charge for Brotli decoder state | 32 MiB | +| `max_chunk_bytes` | Maximum emitted item size after decoding or passthrough | Unset | + +The encoded counter applies to every response path. The decoded counter applies to identity +and gzip/Brotli data decoded by EdgeZero, but not to unknown, stacked, parameterized, or other +raw passthrough encodings. The final buffered cap is independent of both. This separation lets +an application permit a larger raw body while keeping decoded expansion small. + +`max_chunk_bytes` is an item-shape guarantee, not a source-allocation limit: splitting a +`Bytes` value may retain its original allocation, and a provider may have already materialized +the source chunk. Header controls cover guest-visible fields, not opaque parser allocations, +informational blocks that the SDK hides, or provider-owned trailers. These exclusions are why +`outbound-complete-resource-accounting` is `Unsupported` on every current adapter. + +For `send_all`, bound both the number of requests and every per-slot cap. Core-retained payload +is approximately the sum of buffered request bodies, configured final response caps, and one +current chunk per actively draining slot. Adapter staging and host/runtime copies are outside +that formula. + +## Encoding Boundaries + +An application-provided request body is sent with its declared `Content-Encoding`; EdgeZero +does not silently recompress it. Response decoding is automatic only for a single bare `gzip` +or `br` coding. Multi-member gzip is drained through the final member and transport EOF. +Unknown or compound codings remain encoded and retain `Content-Encoding`. + +Cloudflare has two separate manual-encoding controls. The outbound fetch requests raw encoded +response bytes so EdgeZero can enforce transport and decode limits. When those bytes are later +passed through to the downstream response, the converter also selects manual response-body +encoding so Workers does not encode them again. The latter does not promise an exact streamed +wire `Content-Length`, because Workers owns final framing. + +## Batch Timing + +`HttpClient::send_all` returns one `OutboundSlotResult` per input in the same order. Each slot +contains its own `elapsed` and `outcome`; one failure does not erase sibling outcomes. +`elapsed` starts at the single method-entry monotonic snapshot and ends when that slot becomes +terminal, including preflight validation, adapter setup, provider queueing, upload, headers, +buffered body drain, and any delayed guest observation. It is not pure transport RTT. +Preflight failures are timed from the same batch start, and a same-tick result may be zero. + +Standard adapter wiring clones the application's monotonic clock into its outbound client, so +ingress timing, dispatch budgets, slot elapsed values, error precedence, and deferred body +streams remain in one clock domain. Explicit low-level outbound constructors use the default +clock. A backwards injected clock cannot enlarge the method-entry budget; backwards elapsed +sampling fails closed as an internal slot outcome with zero elapsed. + +Axum, Cloudflare, and Spin drive complete eligible exchanges concurrently. Fastly records each +slot when it is observed during sequential dispatch/harvest, so the value can include sibling +delay; the `send-all-slot-isolation` matrix row exposes that distinction. + +`send_all` accepts buffered request bodies and buffered response mode only. Use `send` for a +streamed upload or response, and consume a streamed response body inside the same concurrent +task that issued it. + +## Missing Client + +Adapters inject `HttpClient` into request extensions. A handler obtains it with +`RequestContext::http_client()`. The accessor returns `None` when custom/manual wiring omitted +the client; handlers should return an explicit 501 or another application-selected fallback. +The generated project demonstrates the 501 behavior. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f97681a6..7fe51af6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -62,6 +62,29 @@ Each item must be: - Either a unit struct or zero-argument constructor - Implementing `edgezero_core::middleware::Middleware` +## Capabilities Section + +Use `[capabilities]` to declare portable runtime behavior that the application requires or +explicitly treats as optional: + +```toml +[capabilities] +required = ["outbound-deadlines"] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com", "https://*.example.net:*"] +``` + +Required capabilities accept `Native` or `BoundedCooperative` support and fail before adapter +execution for `BestEffort` or `Unsupported`. Optional capabilities log degradation and proceed. +Unknown names, duplicates, and required/optional overlap are validation errors. + +Outbound hosts configure adapter plumbing rather than application authorization. Omitting +`hosts` defaults to `https://*:*`; `hosts = ["*"]` is an explicit grant for both HTTP and HTTPS. +See [Capabilities](/guide/capabilities) for the exact grammar, support matrix, and platform +limitations. + ## HTTP Triggers The `[[triggers.http]]` array defines routes: @@ -75,6 +98,7 @@ handler = "my_app_core::handlers::root" [[triggers.http]] id = "echo" +class = "interactive" path = "/echo/{name}" methods = ["GET", "POST"] handler = "my_app_core::handlers::echo" @@ -91,6 +115,11 @@ body-mode = "buffered" | `adapters` | No | Intended adapter filter (metadata; `app!` currently ignores) | | `description` | No | Human-readable description for docs or tooling | | `body-mode` | No | `buffered` or `stream` | +| `class` | No | Opaque route class exposed to ingress admission policy | + +`class` is application-defined metadata. It is available on matched and +method-not-allowed route metadata, but it is not interpreted by EdgeZero and does not change the +route's stable method-plus-pattern identity. ::: tip Adapter filters The `adapters` field is currently metadata for tooling; `app!` wires all triggers regardless of adapter. @@ -504,6 +533,12 @@ middleware = [ "my_app_core::middleware::Cors" ] +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com"] + [[triggers.http]] id = "root" path = "/" diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 630b3e91..39e48415 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -202,17 +202,17 @@ async fn inspect(ctx: RequestContext) -> Result, EdgeError> { `RequestContext` provides these methods: -| Method | Returns | -| ---------------- | ------------------------------------------ | -| `request()` | `&Request` - full HTTP request | -| `path_params()` | `&PathParams` - raw path parameters | -| `path::()` | Deserialize path params to `T` | -| `query::()` | Deserialize query string to `T` | -| `json::()` | Deserialize JSON body to `T` | -| `form::()` | Deserialize form body to `T` | -| `body()` | `&Body` - raw request body | -| `into_request()` | `Request` - consume context, take request | -| `proxy_handle()` | `Option` - adapter proxy hook | +| Method | Returns | +| ---------------- | --------------------------------------------------- | +| `request()` | `&Request` - full HTTP request | +| `path_params()` | `&PathParams` - raw path parameters | +| `path::()` | Deserialize path params to `T` | +| `query::()` | Deserialize query string to `T` | +| `json::()` | Deserialize JSON body to `T` | +| `form::()` | Deserialize form body to `T` | +| `body()` | `&Body` - raw request body | +| `into_request()` | `Request` - consume context, take request | +| `http_client()` | `Option` - adapter outbound HTTP client | ## Sharing app state @@ -301,6 +301,31 @@ app-state struct and register that. > hand out clones (e.g. a `OnceLock>` as above, or a `static`), and > let `T = Arc` so each call is just a refcount bump. Do **not** `Arc::new(HeavyThing::build())` directly in the `state` expression. +### Configuring the application lifecycle + +Macro-driven apps can customize the generated `App` through the existing +`Hooks::configure` seam without replacing manifest-driven routing: + +```rust +use edgezero_core::{AdmissionDecision, IngressGrant}; +use std::time::Duration; + +fn configure_app(app: &mut edgezero_core::app::App) { + app.set_ingress_admission_policy(|head| AdmissionDecision::Admit { + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + }); +} + +edgezero_core::app!("edgezero.toml", configure = crate::configure_app); +``` + +`configure = ` must evaluate to a callable that accepts `&mut App`. It is +invoked after the manifest router is built and before the app begins serving, so +it can install ingress admission, request-head limits, config extraction limits, +the monotonic clock, and response-egress policy or observation hooks. Keep the +callback cheap for the same adapter-lifecycle reasons as `state = ` above. + ## Response Types ### Text Responses diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index a038ac31..558bb04d 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -227,4 +227,4 @@ EdgeZero provides these middleware out of the box: ## Next Steps - Learn about [Streaming](/guide/streaming) for progressive responses -- Explore [Proxying](/guide/proxying) for upstream forwarding +- Explore [Outbound HTTP](/guide/proxying) for upstream requests diff --git a/docs/guide/proxying.md b/docs/guide/proxying.md index e6502f31..5534b741 100644 --- a/docs/guide/proxying.md +++ b/docs/guide/proxying.md @@ -1,63 +1,121 @@ -# Proxying +# Outbound HTTP -EdgeZero provides helpers for forwarding requests to upstream services while staying -provider-agnostic. +EdgeZero exposes one provider-neutral HTTP client to handlers. Adapters inject an `HttpClient` +into request extensions, while application code builds `OutboundRequest` values and receives +typed `OutboundResponse` or `EdgeError` outcomes. -## End-to-End Example +## Forward An Inbound Request -This example forwards the incoming request upstream, adjusts headers on the way in and out, and -returns a friendly 502 on proxy errors. It uses the adapter-provided proxy handle inserted by each -adapter. +This example preserves the inbound method and body, replaces the target URI, removes a private +header, and converts the outbound response back into a core response. ```rust use edgezero_core::action; -use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{HeaderValue, Response, Uri}; +use edgezero_core::outbound::OutboundRequest; +use std::num::NonZeroU64; +use std::time::Duration; #[action] -async fn proxy_with_auth(RequestContext(ctx): RequestContext) -> Result { - let target: Uri = "https://api.example.com".parse().unwrap(); +async fn forward_with_auth( + RequestContext(ctx): RequestContext, +) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::not_implemented("outbound HTTP client not available"))?; + let target: Uri = "https://api.example.com/v1/data" + .parse() + .map_err(|_| EdgeError::bad_request("invalid upstream URI"))?; - let handle = ctx - .proxy_handle() - .ok_or_else(|| EdgeError::internal("proxy client not configured"))?; - - let mut proxy_request = ProxyRequest::from_request(ctx.into_request(), target); - proxy_request.headers_mut().insert( + let mut request = OutboundRequest::from_request(ctx.into_request(), target)? + .timeout(Duration::from_secs(2)) + .max_request_body_bytes(256 * 1024) + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(4 * 1024 * 1024) + .max_response_bytes(4 * 1024 * 1024) + .max_response_header_bytes(64 * 1024) + .max_response_header_count(100) + .max_chunk_bytes(NonZeroU64::new(64 * 1024).unwrap_or(NonZeroU64::MIN)); + request.headers_mut().insert( "authorization", - "Bearer secret-token".parse().unwrap(), + HeaderValue::from_static("Bearer secret-token"), ); - proxy_request.headers_mut().remove("cookie"); - - match handle.forward(proxy_request).await { - Ok(mut response) => { - response - .headers_mut() - .insert("x-proxy-by", "edgezero".parse().unwrap()); - Ok(response) - } - Err(err) => { - tracing::error!("proxy failed: {}", err); - let response = Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("Bad Gateway")) - .map_err(EdgeError::internal)?; - Ok(response) - } - } + request.headers_mut().remove("cookie"); + + let mut response = client.send(request).await?; + response + .headers_mut() + .insert("x-forwarded-by", HeaderValue::from_static("edgezero")); + response.into_response() } ``` -## Notes +Outbound failures remain typed. Deadline expiry maps to `GatewayTimeout { cause }`; transport, +protocol, and codec failures map to inspectable `BadGatewayReason` values; resource limits use +`ResponseLimitReason`. Applications do not need to classify errors by matching messages. + +## Construct A Request + +Use `OutboundRequest::get`, `post`, or `new` for a new request. `from_request` is intended for +forwarding an inbound request and performs the same target validation and hop-by-hop header +normalization immediately. + +```rust +use edgezero_core::outbound::OutboundRequest; +use std::num::NonZeroU64; +use std::time::Duration; + +let request = OutboundRequest::post("https://api.example.com/events")? + .timeout(Duration::from_secs(2)) + .max_request_body_bytes(256 * 1024) + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(4 * 1024 * 1024) + .max_response_bytes(4 * 1024 * 1024) + .max_response_header_bytes(64 * 1024) + .max_response_header_count(100) + .max_chunk_bytes(NonZeroU64::new(64 * 1024).unwrap_or(NonZeroU64::MIN)) + .max_brotli_window_bits(24) + .max_brotli_decoder_bytes(32 * 1024 * 1024) + .header("accept", "application/json")? + .json(&payload)?; +let response = client.send(request).await?; +let decoded: ApiResponse = response.json()?; +``` + +Buffered response mode is the default. Call `stream_response()` for a streamed response and +consume `response.into_body()` while its absolute request deadline is still active. Use +`into_bytes_bounded`, `into_bytes_bounded_until`, or `json_bounded_until` when application code +performs an additional collection step. + +`max_chunk_bytes` opt-in rechunks guest-visible response items before they reach the application. +It bounds item shape and downstream per-item allocation; it does not bound a provider SDK's +source-chunk allocation before EdgeZero receives that chunk. + +## Batch Requests + +`send_all` accepts buffered request bodies and buffered response mode only. It returns an +index-aligned `Vec` rather than failing the whole batch: + +```rust +let slots = client.send_all(requests).await; +for slot in slots { + match slot.outcome { + Ok(response) => record_success(slot.elapsed, response.status()), + Err(error) => record_failure(slot.elapsed, error), + } +} +``` -- Fastly and Cloudflare preserve streaming bodies; Axum buffers outbound bodies before sending. -- Fastly and Cloudflare automatically decode `gzip`/`br` responses for you. -- If you need a direct client (for tests or custom wiring), use the adapter clients - (`FastlyProxyClient`, `CloudflareProxyClient`, `AxumProxyClient::default()`). +Every slot is timed from the batch method-entry snapshot through its own terminal observation, +including preflight and body buffering. It is not pure network RTT. Bound the request count and +every per-request body limit; EdgeZero intentionally has no global batch-concurrency or memory +cap. -## Next Steps +## Platform Behavior -- Learn about [Fastly](/guide/adapters/fastly) and [Cloudflare](/guide/adapters/cloudflare) adapter specifics +The API is portable, but not every runtime can provide identical timing, header, or lazy-stream +semantics. See [Capabilities](/guide/capabilities) for the exact support matrix, Fastly's dynamic +backend prerequisite, the 16 MiB Axum/Fastly/Spin response-conversion fallback, Cloudflare's +manual encoding boundaries, and complete resource-accounting exclusions. diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index 492d8ba1..024703bf 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -11,12 +11,8 @@ shift as the roadmap evolves. `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for `edgezero serve --adapter axum` (the local dev path; the standalone `dev` subcommand was reserved for a future dev-workflow command, see [CLI reference](./cli-reference#edgezero-demo)). -- Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and - where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging precedence, and introspection routes + body-mode behavior to reduce ambiguity. -- Spin support: add first-class Spin adapter support and document how EdgeZero manifests mirror - Spin-compatible deployments. - Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) using the stabilized adapter API to validate cross-provider abstractions. @@ -27,10 +23,12 @@ shift as the roadmap evolves. - Manifest ergonomics: established the `edgezero.toml` schema and CLI scaffolding for route triggers, env/secrets, and build targets. - Documentation baseline: published a single-source-of-truth docs set aligned with current APIs - (App::build_app entrypoints, adapter dispatch signatures, middleware signature, proxy handle - usage). -- Platform focus: Fastly Compute@Edge and Cloudflare Workers are the primary edge targets, with Axum - serving local development and native deployment needs. + (App::build_app entrypoints, adapter dispatch signatures, middleware signature, and outbound + HTTP capabilities). +- Outbound contract: shipped the portable client, typed limits/errors, per-slot timing, exact + capability matrix, and adapter-specific runtime implementations. +- Platform coverage: Fastly Compute, Cloudflare Workers, Fermyon Spin, and native Axum have + first-class adapters with executable contracts. - Core contracts: request/response mapping rules are now captured in the adapter contract docs. ## Open Design Questions (for later pickup) diff --git a/docs/guide/routing.md b/docs/guide/routing.md index ad5dea0b..8493ae8c 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -15,11 +15,16 @@ handler = "my_app_core::handlers::hello" [[triggers.http]] id = "echo" +class = "interactive" path = "/echo/{name}" methods = ["GET", "POST"] handler = "my_app_core::handlers::echo" ``` +An optional `class` is opaque application metadata for ingress admission. It is available through +`RouteMetadata::class()` for matched and method-not-allowed resolutions and does not change the +route's stable identity. + You can also build routes programmatically using convenience methods: ```rust @@ -40,7 +45,7 @@ use edgezero_core::http::Method; let router = RouterService::builder() .route("/hello", Method::GET, hello_handler) - .route("/echo/{name}", Method::GET, echo_handler) + .route_with_class("/echo/{name}", Method::GET, "interactive", echo_handler) .route("/echo", Method::POST, echo_json_handler) .build(); ``` diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index b0792a4d..1db2cf38 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -86,17 +86,24 @@ body-mode = "buffered" # or "stream" | `buffered` | Body is fully read into memory before handler runs | | `stream` | Body is passed as a stream for progressive processing | -## Transparent Decompression +## Outbound Response Decompression -EdgeZero automatically decompresses gzip and brotli responses from upstream services: +The outbound client decodes a single bare `gzip` or `br` content coding through shared +`edgezero-core` decoders. Unknown, parameterized, or stacked codings pass through unchanged. +Encoded transport bytes, decoded output, and final buffered bytes have independent limits; see +[Capabilities](/guide/capabilities#limits-and-accounting). ```rust -// Proxied response with Content-Encoding: gzip is automatically decoded -let response = proxy.forward(request).await?; -// response.body is now decompressed +let request = OutboundRequest::get("https://api.example.com/data")? + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(8 * 1024 * 1024) + .stream_response(); +let body = client.send(request).await?.into_body(); ``` -This happens transparently in the adapter layer using shared decoders from `edgezero-core`. +Multi-member gzip streams are decoded through every member and drained to transport EOF. +For Brotli, the stream window is checked before decoder allocation and decoder state is charged +against the configured policy limit. ## Memory Considerations @@ -131,5 +138,5 @@ async fn dynamic_content() -> Response { ## Next Steps -- Learn about [Proxying](/guide/proxying) for forwarding requests upstream +- Learn about [Outbound HTTP](/guide/proxying) for upstream requests - Explore adapter-specific streaming in [Fastly](/guide/adapters/fastly) and [Cloudflare](/guide/adapters/cloudflare) guides diff --git a/docs/guide/what-is-edgezero.md b/docs/guide/what-is-edgezero.md index 3583d69b..9b16aa9e 100644 --- a/docs/guide/what-is-edgezero.md +++ b/docs/guide/what-is-edgezero.md @@ -1,16 +1,16 @@ # What is EdgeZero? -EdgeZero is a production-ready toolkit for writing an HTTP workload once and deploying it across multiple edge providers. The core stays runtime-agnostic so it compiles cleanly to WebAssembly targets (Fastly Compute@Edge, Cloudflare Workers) and to native hosts (Axum/Tokio) without code changes. +EdgeZero is a production-ready toolkit for writing an HTTP workload once and deploying it across multiple edge providers. The core stays runtime-agnostic so it compiles cleanly to WebAssembly targets (Fastly Compute, Cloudflare Workers, Fermyon Spin) and to native hosts (Axum/Tokio) without code changes. ## Key Features EdgeZero provides developers with: - **Portable HTTP workloads** - Write your business logic once using the shared `edgezero-core` primitives, then compile to any supported target -- **Multiple deployment targets** - Deploy to Fastly Compute@Edge, Cloudflare Workers, or native Axum servers from the same codebase +- **Multiple deployment targets** - Deploy to Fastly Compute, Cloudflare Workers, Fermyon Spin, or native Axum servers from the same codebase - **Type-safe extractors** - Use ergonomic extractors like `Json`, `Path`, and `ValidatedQuery` for clean handler code - **Streaming support** - Stream responses progressively with `Body::stream` for long-lived or chunked responses -- **Proxy helpers** - Forward traffic upstream with built-in `ProxyRequest` and `ProxyService` abstractions +- **Outbound HTTP** - Send typed, bounded requests through one portable client with deadlines and partial-failure batching - **CLI tooling** - Scaffold projects, run dev servers, and deploy with the `edgezero` CLI ## How It Works @@ -33,6 +33,7 @@ This architecture means you can: | ------------------- | ------------------------ | ------ | | Fastly Compute@Edge | `wasm32-wasip1` | Stable | | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | +| Fermyon Spin | `wasm32-wasip2` | Stable | | Axum/Tokio (native) | Native host | Stable | ## Use Cases diff --git a/docs/superpowers/plans/2026-06-17-blob-app-config.md b/docs/superpowers/plans/2026-06-17-blob-app-config.md index 00c806f8..c68c9166 100644 --- a/docs/superpowers/plans/2026-06-17-blob-app-config.md +++ b/docs/superpowers/plans/2026-06-17-blob-app-config.md @@ -30,7 +30,7 @@ | Phase | Spec § | Commit type | Acceptance | | ----- | ----------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A | §4 | Pre-cutover infra | Canonical form + envelope + non-finite-float check ship in `edgezero-core`. No in-tree caller exercises them yet. Bisect-safe. | -| B | §3, §5, §6.3, §9.0 | Pre-cutover infra | `ConfigStoreBinding`, `EnvConfig::store_key`, manifest charset, `EdgeError::ConfigOutOfDate`, derive-macro extensions, read trait + per-adapter impls, and Fastly chunk-pointer helpers. None called yet. Bisect-safe. | +| B | §3, §5, §6.3, §9.0 | Pre-cutover infra | `ConfigStoreBinding`, `EnvConfig::store_key`, manifest charset, typed `StoreExtractionReason`, bounded read traits/policies, derive-macro extensions, per-adapter read impls, and Fastly chunk-pointer helpers. None called yet. Bisect-safe. | | C | §3.3, §8.2, §10.2, §10.2.1, §10.2.2 | **CUTOVER (not splittable)** | `AppConfig` extractor + `config push` rewrite + app-demo migration + scaffold templates + all three CI gates land together. Spec §10.1 forbids splitting. | | D | §8.1 | Post-cutover additive | `config diff` command, format renderers, `--exit-code`. Depends on Phase C's read flow + writers. | | E | §10 narrative | Post-cutover docs | Migration guide, smoke scripts, README updates. | @@ -998,7 +998,7 @@ default = "feature__flags" Run: `cargo test -p edgezero-core --lib manifest` Expected: 3 new tests pass. -## Task B2 — `EdgeError::ConfigOutOfDate` variant + two constructors +## Task B2 — `EdgeError::ConfigOutOfDate` variant + explicit-pair constructor **Files:** @@ -1010,7 +1010,6 @@ Expected: 3 new tests pass. - Produces: - `EdgeError::ConfigOutOfDate { message: String, field_path: String }` - `EdgeError::config_out_of_date(message: impl Into, field_path: impl Into) -> Self` - - `EdgeError::config_out_of_date_from_serde(err: serde_path_to_error::Error) -> Self` - [ ] **Step 1: Add the variant.** @@ -1045,18 +1044,13 @@ Edit `crates/edgezero-core/src/error.rs` near the existing enum. Insert: } } - /// Construct from a `serde_path_to_error` error returned by - /// the deserialise wrapper around the blob's `data` field. - pub fn config_out_of_date_from_serde( - serde_err: serde_path_to_error::Error, - ) -> Self { - Self::ConfigOutOfDate { - message: serde_err.inner().to_string(), - field_path: serde_err.path().to_string(), - } - } ``` +Typed store deserialization is added by Task B2.5 through +`store_deserialization_from_serde`, which carries the inspectable +`StoreExtractionReason::Deserialization`. The earlier compatibility constructor is +intentionally absent so consumers must migrate to the reason-bearing API. + - [ ] **Step 3: Add `serde_path_to_error` to `crates/edgezero-core/Cargo.toml`.** In `[dependencies]`: @@ -1118,186 +1112,123 @@ In the existing `mod tests` block: Run: `cargo test -p edgezero-core --lib error` Expected: pass; no existing test broken (the three exhaustive matches above — `inner()`, `message()`, `status()` — and the `thiserror` `#[error(...)]` attribute on the new variant cover every dispatch surface). -## Task B3 — `EdgeError` response body adds `kind` field + `Retry-After` on `ConfigOutOfDate` +## Task B2.5 — Typed store-extraction reasons (must precede extractor code) **Files:** -- Modify: `crates/edgezero-core/src/error.rs` — extend `IntoResponse` impl. -- Modify: `crates/edgezero-core/src/error.rs` — add per-variant `kind` constants. +- Modify: `crates/edgezero-core/src/error.rs`. +- Modify: every exhaustive `EdgeError` match identified by `rg -n 'EdgeError::' crates`. **Interfaces:** -- Produces: response body shape `{ "error": { "status": , "kind": "", "message": "<…>", "field_path"?: "<…>" } }`. `field_path` ONLY on `ConfigOutOfDate`. `Retry-After: 60` header ONLY on `ConfigOutOfDate`. Per spec §6.3.1. +- Produces: the exact `#[non_exhaustive] StoreExtractionReason` enum and + `EdgeError::StoreExtraction { reason, message, field_path }` contract from spec §6.3. +- Produces: `EdgeError::store_extraction`, + `EdgeError::store_deserialization_from_serde`, and + `EdgeError::store_extraction_reason`. -- [ ] **Step 1: Add a `kind_str` private method per variant.** +- [ ] **Step 1: Write the red error-classification matrix.** Enumerate every reason from + spec §6.3. For each, assert the exact status, kind, `Retry-After`, optional field path, + and absence of a JSON `reason` member. Add constructor/accessor tests and one + `#[non_exhaustive]` external-match compile fixture. -```rust -impl EdgeError { - fn kind_str(&self) -> &'static str { - match self { - EdgeError::BadRequest { .. } => "bad_request", - EdgeError::Internal { .. } => "internal", - EdgeError::MethodNotAllowed { .. } => "method_not_allowed", - EdgeError::NotFound { .. } => "not_found", - EdgeError::NotImplemented { .. } => "not_implemented", - EdgeError::ServiceUnavailable { .. } => "service_unavailable", - EdgeError::Validation { .. } => "validation", - EdgeError::ConfigOutOfDate { .. } => "config_out_of_date", - } - } -} -``` +- [ ] **Step 2: Run the focused tests and record the expected compile-red.** -- [ ] **Step 2: Rewrite the `IntoResponse` impl's body-building block.** +Run: `cargo test -p edgezero-core --lib error::tests::store_extraction` +Expected: compilation fails because `StoreExtractionReason` and the new variant do not +exist. A malformed test is not an acceptable red. -Locate the existing `IntoResponse` impl (around `error.rs:159` per spec). The current block writes `{ "error": { "status": ..., "message": ... } }`. Replace with code that: +- [ ] **Step 3: Add the enum, variant, constructors, accessor, and centralized mapping.** + Keep `ConfigOutOfDate` for non-store callers. `StoreExtraction` delegates status/kind and + response-header policy to its reason. Migrate typed extraction to + `store_deserialization_from_serde`; do not retain a compatibility constructor for typed + store deserialization. Do not leave a typed store path on `config_out_of_date`, + serialize the reason, or include an empty `field_path`. -1. Picks the right status code per variant. -2. Builds a `serde_json::Value::Object` map with `status`, `kind`, `message`, and (for `ConfigOutOfDate`) `field_path`. -3. Sets `Retry-After: 60` ONLY for `ConfigOutOfDate`. +- [ ] **Step 4: Update every exhaustive match.** This includes `StoredError` in the inbound + body design once that work lands; its captured form stores reason/message/path and + reconstructs the same variant. Do not add a wildcard to silence compiler coverage. -Pseudocode shape (adapt to the exact existing `IntoResponse` form): +- [ ] **Step 5: Run the focused tests, then all core tests.** -```rust -impl IntoResponse for EdgeError { - fn into_response(self) -> Response { - let kind = self.kind_str(); - let (status, message, field_path_opt): (StatusCode, String, Option) = match &self { - EdgeError::BadRequest { message } => (StatusCode::BAD_REQUEST, message.clone(), None), - EdgeError::Internal { source } => (StatusCode::INTERNAL_SERVER_ERROR, source.to_string(), None), - EdgeError::MethodNotAllowed { message } => (StatusCode::METHOD_NOT_ALLOWED, message.clone(), None), - EdgeError::NotFound { message } => (StatusCode::NOT_FOUND, message.clone(), None), - EdgeError::NotImplemented { message } => (StatusCode::NOT_IMPLEMENTED, message.clone(), None), - EdgeError::ServiceUnavailable { message } => (StatusCode::SERVICE_UNAVAILABLE, message.clone(), None), - EdgeError::Validation { message } => (StatusCode::UNPROCESSABLE_ENTITY, message.clone(), None), - EdgeError::ConfigOutOfDate { message, field_path } => ( - StatusCode::SERVICE_UNAVAILABLE, - message.clone(), - Some(field_path.clone()), - ), - }; - let mut error_obj = serde_json::Map::new(); - error_obj.insert("status".into(), serde_json::Value::from(status.as_u16())); - error_obj.insert("kind".into(), serde_json::Value::from(kind)); - error_obj.insert("message".into(), serde_json::Value::from(message)); - if let Some(fp) = field_path_opt { - error_obj.insert("field_path".into(), serde_json::Value::from(fp)); - } - let body = serde_json::json!({ "error": serde_json::Value::Object(error_obj) }); - let body_bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); - // Round-39 L-2: use the EdgeZero `crate::http` re-export, NOT - // direct `http::header::*` paths. Project convention at - // crates/edgezero-core/src/error.rs:8 imports through - // `crate::http::{header::CONTENT_TYPE, ...}`; new code follows - // the same pattern so application code never depends on the - // raw `http` crate. Implementation extends the existing `use` - // line to also bring in `RETRY_AFTER`. - let mut response = Response::builder() - .status(status) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body_bytes)) - .unwrap(); - if matches!(self, EdgeError::ConfigOutOfDate { .. }) { - response - .headers_mut() - .insert(RETRY_AFTER, HeaderValue::from_static("60")); - } - response - } -} +```bash +cargo test -p edgezero-core --lib error::tests::store_extraction +cargo test -p edgezero-core ``` -Use the actual `Response` / `Body` types as imported elsewhere in `error.rs`. - -- [ ] **Step 3: Run existing error tests to confirm nothing regressed (existing tests assert the old shape will fail — that's expected; we update them next).** +Expected: all pass. -Run: `cargo test -p edgezero-core --lib error` -Note which tests fail (they're asserting the old `{ status, message }` shape). Phase B Task B4 below adds the new tests. - -## Task B4 — Per-variant `kind` + `Retry-After` tests +## Task B3 — `EdgeError` response body adds `kind` and centralized header policy **Files:** -- Modify: `crates/edgezero-core/src/error.rs` (update existing IntoResponse tests + add new ones per spec §12.6.1). +- Modify: `crates/edgezero-core/src/error.rs` — extend `IntoResponse` impl. +- Modify: `crates/edgezero-core/src/error.rs` — add per-variant `kind` constants. **Interfaces:** -- Consumes: `EdgeError::kind_str` + `IntoResponse` impl from Task B3. +- Produces: response body shape `{ "error": { "status": , "kind": "", "message": "<…>", "field_path"?: "<…>" } }`. `field_path` is emitted only for field-anchored variants. `Retry-After: 60` is emitted only for the effective `config_out_of_date` kind, including the mapped store-extraction reasons. Per spec §6.3.1. -- [ ] **Step 1: Update existing tests that assert the old body shape.** Find tests asserting `body.error.status` + `body.error.message` only; extend them to also assert `body.error.kind`. Use the table from spec §12.6.1. +- [ ] **Step 1: Extend the existing `kind_str` match without replacing it.** Keep one + explicit arm for every `EdgeError` variant present after the outbound Phase 1a work and + Task B2.5. `StoreExtraction { reason, .. }` delegates to a total private + `StoreExtractionReason::wire_policy()` table. Do not add a wildcard: later additions such + as inbound `RequestTimeout` must produce a compiler error until their owner adds a kind. -- [ ] **Step 2: Add a new test per variant asserting the `kind` string.** +- [ ] **Step 2: Rewrite the `IntoResponse` impl's body-building block.** -```rust - #[test] - fn kind_strings_per_variant() { - let cases: &[(EdgeError, &str, u16)] = &[ - (EdgeError::BadRequest { message: "x".into() }, "bad_request", 400), - (EdgeError::Internal { source: anyhow::anyhow!("x").into() }, "internal", 500), - (EdgeError::MethodNotAllowed { message: "x".into() }, "method_not_allowed", 405), - (EdgeError::NotFound { message: "x".into() }, "not_found", 404), - (EdgeError::NotImplemented { message: "x".into() }, "not_implemented", 501), - (EdgeError::ServiceUnavailable { message: "x".into() }, "service_unavailable", 503), - (EdgeError::Validation { message: "x".into() }, "validation", 422), - (EdgeError::config_out_of_date("x", "f"), "config_out_of_date", 503), - ]; - for (err, expected_kind, expected_status) in cases { - // Clone the error if it doesn't impl Clone — work around with match. - let response = err.clone().into_response(); // adjust if not Clone - assert_eq!(response.status().as_u16(), *expected_status); - let body_bytes = ...; // collect body - let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - assert_eq!(body["error"]["kind"], serde_json::Value::from(*expected_kind)); - } - } -``` +Locate the existing `IntoResponse` impl (around `error.rs:159` per spec). The current block writes `{ "error": { "status": ..., "message": ... } }`. Replace with code that: -(If `EdgeError` is not `Clone`, restructure to build each case in turn rather than collecting in a slice.) +1. Picks the right status code per variant. +2. Builds a `serde_json::Value::Object` map with `status`, `kind`, `message`, and a + non-empty `field_path` from either `ConfigOutOfDate` or `StoreExtraction`. +3. Sets `Retry-After: 60` only when centralized policy marks the effective kind + `config_out_of_date`; do not use a variant-only `matches!` expression. + +Preserve the actual `IntoResponse` signature, +`fn into_response(self) -> Result`, and the existing +`response_with_body`/JSON fallback path. Compute status, kind, message, optional non-empty +field path, and retry policy before consuming `self`. `ConfigOutOfDate` keeps its current +wire behavior. `StoreExtraction` derives all four decisions from its reason table, but the +reason itself is never serialized. Use `crate::http::header::{CONTENT_TYPE, RETRY_AFTER}`; +do not introduce a direct `http` dependency or invent message fields on structured variants. -- [ ] **Step 3: Add `Retry-After` presence assertion.** +- [ ] **Step 3: Run existing error tests to confirm nothing regressed (existing tests assert the old shape will fail — that's expected; we update them next).** -```rust - #[test] - fn retry_after_only_on_config_out_of_date() { - for (err, expected_retry_after) in [ - (EdgeError::BadRequest { message: "x".into() }, false), - (EdgeError::Internal { source: anyhow::anyhow!("x").into() }, false), - (EdgeError::ServiceUnavailable { message: "x".into() }, false), // round-10 H-3 narrowing - (EdgeError::config_out_of_date("x", "f"), true), - ] { - let response = err.into_response(); - // Round-39 L-2: same `crate::http` re-export rule as the - // production code above — test bodies use the same import. - let header = response.headers().get(RETRY_AFTER); - if expected_retry_after { - assert_eq!(header.unwrap().to_str().unwrap(), "60"); - } else { - assert!(header.is_none(), "unexpected Retry-After header"); - } - } - } -``` +Run: `cargo test -p edgezero-core --lib error` +Expected red before Task B4: only assertions that intentionally pin the old response shape +fail; compilation and unrelated behavior remain intact. -- [ ] **Step 4: Add `field_path` presence assertion.** +## Task B4 — Per-variant/reason `kind` + `Retry-After` tests -```rust - #[test] - fn field_path_only_on_config_out_of_date() { - let err = EdgeError::BadRequest { message: "x".into() }; - let body: serde_json::Value = parse_body(err.into_response()); - assert!(body["error"].get("field_path").is_none()); - - let err = EdgeError::config_out_of_date("x", "feature.new_checkout"); - let body: serde_json::Value = parse_body(err.into_response()); - assert_eq!(body["error"]["field_path"], "feature.new_checkout"); - } +**Files:** - fn parse_body(response: Response) -> serde_json::Value { - // Helper: collect body bytes synchronously. If body is async, - // use futures::executor::block_on; that's the in-tree pattern. - ... - } -``` +- Modify: `crates/edgezero-core/src/error.rs` (update existing IntoResponse tests + add new ones per spec §12.6.1). + +**Interfaces:** + +- Consumes: `EdgeError::kind_str` + `IntoResponse` impl from Task B3. + +- [ ] **Step 1: Update existing tests that assert the old body shape.** Find tests asserting `body.error.status` + `body.error.message` only; extend them to also assert `body.error.kind`. Use the table from spec §12.6.1. + +- [ ] **Step 2: Add one consumed case per variant and per reason.** `EdgeError` is not + `Clone`, so build an owned `Vec<(EdgeError, expected_kind, expected_status)>` and consume + it, or use a case factory. Construct structured variants with their real fields or public + constructors. Include all variants listed by the final §12.6.1 matrix and every known + `StoreExtractionReason`; no fake `message` fields and no wildcard matches. + +- [ ] **Step 3: Add `Retry-After` assertions.** Assert `60` for + `ConfigOutOfDate` and store reasons `Deserialization`, `MissingBlob`, `MissingSecret`, + and `Validation`. + Assert absence for every other reason and ordinary variant, including plain + `ServiceUnavailable` and `DeadlineExceeded`. + +- [ ] **Step 4: Add `field_path` assertions.** Assert omission for every ordinary variant + except a non-empty `ConfigOutOfDate` path. For `StoreExtraction`, table-drive every reason + with both `None` and a supplied path: emit it only where §6.3 permits a field-anchored + path, and never emit an empty string. Parse the existing `Result` + return through the in-tree body collection helper rather than assuming a synchronous + response type. - [ ] **Step 5: Run, confirm all pass.** @@ -2589,6 +2520,77 @@ This branch logic is intentionally large because spec §9.0 says "read-back uses Run: `cargo test --workspace --all-targets` Expected: pass. +## Task B13.5 — Bounded config/secret reads and capability cells + +**Files:** + +- Modify: `crates/edgezero-core/src/config_store.rs`. +- Modify: `crates/edgezero-core/src/secret_store.rs`. +- Modify: `crates/edgezero-core/src/app.rs` and `context.rs`. +- Modify: `crates/edgezero-core/src/manifest.rs` and + `crates/edgezero-adapter/src/registry.rs`. +- Modify: all four adapter config/secret-store implementations and contract tests. + +**Interfaces:** + +- Produces: `ConfigExtractionLimits`, its five defaults, `BoundedStoreRead`, lower-level + deadline/size errors, the bounded trait methods, and matching `ConfigStoreHandle`, + `SecretHandle`, and `BoundSecretStore` forwarding methods from spec §6.3.2. +- Produces: `config-read-allocation-bounds` and `config-read-deadlines` capability cells. +- Preserves: unbounded `ConfigStore::get` and `SecretStore::get_bytes` for hand-managed + callers. `AppConfig` must not call them. + +- [ ] **Step 1: Write red core contract tests.** Test default constants and validation; + exact-cap success; first-byte-over failure; checked cumulative accounting overflow; one + absolute deadline shared across blob, Fastly chunks, and secrets; expiry winning a + simultaneous source result; backend-byte reports below returned value length or above + the supplied allowance; and typed + `ValueTooLarge`/`DeadlineExceeded` mappings. A scripted store records every passed + deadline and decreasing backend allowance and proves the deadline never changes. + +- [ ] **Step 2: Run the focused tests and record the expected compile-red.** + +Run: `cargo test -p edgezero-core --lib config_store::tests::bounded` +Expected: compilation fails on the missing limits and bounded trait methods. + +- [ ] **Step 3: Add the core policy and object-safe bounded methods.** Validate limits when + `App` is finalized, copy them into each `RequestContext`, and keep extractor-private + `ConfigExtractionBudget` state with separate checked backend-byte and retained-value + charging. All helper methods take the same `Deadline` and remaining backend allowance; + none accepts an optional or relative timeout. + +- [ ] **Step 4: Implement Axum's Native paths.** Read local files incrementally with + `max_bytes + 1` bounded allocation and race async reads against the absolute deadline. + Drop/cancel the read on expiry. Add fake-time tests for first-byte/inter-read expiry and a + filesystem test proving an oversized value is rejected without allocating the whole file. + Return exact guest-visible `backend_bytes` for every logical read. + +- [ ] **Step 5: Implement Cloudflare, Fastly, and Spin honestly.** Pass the absolute + deadline and remaining backend allowance through all helper layers, check before host + entry and after return, and discard over-cap materialized values. Fastly counts its root + pointer plus every fetched chunk in `BoundedStoreRead::backend_bytes`; direct reads count + the exposed value bytes. Use native cancellation only where the SDK exposes it. Do not + label a post-return length check as an allocation bound or a synchronous host call as + deadline-preemptible. + +- [ ] **Step 6: Add capability parsing, display, build/deploy enforcement, and matrix tests.** + Pin the initial cells exactly as in spec §6.3.2. Tests fail closed for unknown values and + for a manifest requiring Native on a weaker adapter. + +- [ ] **Step 7: Add deployed timing probes.** Each non-Axum probe uses a delayed backend, + records requested deadline versus observed return/cancellation, and emits a machine-readable + artifact. A passing probe is necessary but not sufficient to upgrade allocation support; + host API documentation must also establish pre-materialization behavior. + +- [ ] **Step 8: Run focused and workspace tests.** + +```bash +cargo test -p edgezero-core --lib config_store::tests::bounded +cargo test --workspace --all-targets +``` + +Expected: all pass. + ## Task B14 — Commit Phase B - [ ] **Step 1: Run the four CI gates.** @@ -2639,10 +2641,12 @@ docs/superpowers/specs/2026-06-16-blob-app-config.md. Config::default() still returns ConfigStoreHandle (unwraps binding.handle); new default_binding() accessor returns the binding for the typed extractor. -- §6.3.1 EdgeError gains the ConfigOutOfDate variant + two - constructors (config_out_of_date / config_out_of_date_from_serde). +- §6.3 EdgeError gains non-exhaustive StoreExtractionReason and + the typed StoreExtraction variant before extractor code lands. Response body adds a stable `kind` field on every variant; - Retry-After: 60 fires only on ConfigOutOfDate. + Retry-After: 60 fires only for the effective config_out_of_date + kind. Typed extraction uses bounded config/secret reads under one + absolute deadline and cumulative byte budget. - §3.3.1 SecretKind::KeyInNamedStore { store_ref_field } added; the #[derive(AppConfig)] macro accepts #[secret(store_ref = \"field\")], rejects #[serde(skip_serializing / skip_serializing_if @@ -2702,40 +2706,15 @@ where { async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) - })?; - let key = &binding.default_key; - let raw = binding - .handle - .get(key) - .await - .map_err(|e| EdgeError::internal(anyhow::anyhow!(e)))? - .ok_or_else(|| EdgeError::config_out_of_date( - format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), - ))?; - let envelope: BlobEnvelope = serde_json::from_str(&raw) - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; - envelope - .verify() - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")))?; - let mut data = envelope.into_data(); - // Secret walk per §3.3.3. - secret_walk::(ctx, &mut data).await?; - // Deserialise via serde_path_to_error to preserve field_path - // for ConfigOutOfDate per §4.3. - use serde::de::IntoDeserializer as _; - let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; - cfg.validate().map_err(|err| { - EdgeError::config_out_of_date( - err.to_string(), - first_violating_field(&err).unwrap_or_default(), + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, ) })?; - Ok(AppConfig(cfg)) + extract_from_handle::(ctx, &binding.handle, &binding.default_key) + .await + .map(AppConfig) } } @@ -2745,7 +2724,7 @@ where /// just `"service"`). Round-34 M-1: earlier draft only looked /// at top-level keys, which collapsed /// `service.timeout_ms` → `"service"` and made -/// `EdgeError::ConfigOutOfDate.field_path` useless for any +/// the typed store-extraction `field_path` useless for any /// `#[validate(nested)]` failure (which is the common case in /// app-demo + scaffold templates). fn first_violating_field(errors: &validator::ValidationErrors) -> Option { @@ -2805,32 +2784,39 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option async fn secret_walk( ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, data: &mut serde_json::Value, ) -> Result<(), EdgeError> where C: AppConfigMeta, { let data_obj = data.as_object_mut().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")) + EdgeError::store_extraction( + StoreExtractionReason::Deserialization, + "blob `data` is not a JSON object", + None, + ) })?; for field in C::SECRET_FIELDS { let key_name = data_obj .get(field.name) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, format!( "secret field `{}` has kind KeyInDefault but no default secret store is registered", field.name, ), - field.name.to_owned(), + Some(field.name.to_owned()), ) })?; let id = bound.store_name().to_owned(); @@ -2841,22 +2827,51 @@ where let store_id_str = data_obj .get(store_ref_field) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing store_ref `{store_ref_field}` for secret field `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { - EdgeError::config_out_of_date( - format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), - field.name.to_owned(), + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!( + "secret field `{}` references an unregistered store (identifier redacted)", + field.name, + ), + Some(field.name.to_owned()), ) })?; (bound, store_id_str) } }; - let secret = bound.require_str(&key_name).await.map_err(|err| { - map_secret_error(err, field.name, &resolved_store_id, &key_name) + let read = bound + .get_bytes_bounded( + &key_name, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) + .await + .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; + budget.charge_backend(read.backend_bytes)?; + let secret_bytes = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{}` was not found in its store (identifier redacted)", + field.name, + ), + Some(field.name.to_owned()), + ))?; + budget.charge_value(secret_bytes.len())?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::InvalidSecretValue, + format!("secret for field `{}` is not UTF-8", field.name), + Some(field.name.to_owned()), + ) })?; data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); } @@ -2866,25 +2881,77 @@ where fn map_secret_error( err: crate::secret_store::SecretError, field_name: &str, - store_id: &str, - key_name: &str, + _store_id: &str, + _key_name: &str, ) -> EdgeError { use crate::secret_store::SecretError; match err { - SecretError::NotFound { name } => EdgeError::config_out_of_date( - format!("secret `{name}` in store `{store_id}` not found"), - field_name.to_owned(), + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), ), - SecretError::Validation(msg) => EdgeError::config_out_of_date( - format!("secret `{key_name}` in store `{store_id}` rejected: {msg}"), - field_name.to_owned(), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{field_name}` was not found in its store (identifier redacted)" + ), + Some(field_name.to_owned()), + ), + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, + format!( + "the secret referenced by `{field_name}` was rejected by its store (details redacted)" + ), + Some(field_name.to_owned()), + ), + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!( + "the secret referenced by `{field_name}` exceeds its configured byte limit" + ), + Some(field_name.to_owned()), + ), + } +} + +fn map_config_store_error(err: ConfigStoreError) -> EdgeError { + match err { + ConfigStoreError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + "typed app-config store read deadline exceeded", + None, + ), + ConfigStoreError::InvalidKey { .. } => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, + "typed app-config store rejected the requested key (details redacted)", + None, + ), + ConfigStoreError::Unavailable { .. } => EdgeError::store_extraction( + StoreExtractionReason::BackendUnavailable, + "typed app-config store is unavailable", + None, + ), + ConfigStoreError::Internal { .. } => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + "typed app-config store read failed (details redacted)", + None, + ), + ConfigStoreError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + "typed app-config store value exceeded its read limit", + None, ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "secret store `{store_id}` unreachable" - )), - SecretError::Internal(source) => EdgeError::internal(anyhow::anyhow!( - "secret `{key_name}` in store `{store_id}` produced unexpected store error: {source}" - )), } } ```` @@ -2906,9 +2973,11 @@ where /// store and prefer the bare `C` over wrapping/unwrapping. pub async fn named(ctx: &RequestContext, key: &str) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, + ) })?; extract_from_handle::(ctx, &binding.handle, key).await } @@ -2922,9 +2991,11 @@ where key: Option<&str>, ) -> Result { let binding = ctx.config_store_binding(store_id).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no config store registered for id `{store_id}`" - )) + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!("no config store registered for id `{store_id}`"), + None, + ) })?; let key = key.unwrap_or(&binding.default_key); extract_from_handle::(ctx, &binding.handle, key).await @@ -2942,36 +3013,48 @@ async fn extract_from_handle( where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // ConfigStoreError → EdgeError uses the existing `impl - // From for EdgeError` at - // `crates/edgezero-core/src/error.rs:148`, which maps - // Unavailable → ServiceUnavailable (503), InvalidKey → - // BadRequest (400), Internal → Internal (500). Per spec §6.3's - // mapping table. NEVER `map_err(EdgeError::internal)` here — - // that collapses backpressure / bad-key signals into 500s and - // dashboards lose the distinction. - let raw = handle - .get(key) + let mut budget = ConfigExtractionBudget::start(ctx.config_extraction_limits())?; + let read = handle + .get_bounded( + key, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) .await - .map_err(EdgeError::from)? - .ok_or_else(|| EdgeError::config_out_of_date( + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), + None, ))?; + budget.charge_value(raw.len())?; let envelope: BlobEnvelope = serde_json::from_str(&raw) - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; + .map_err(|_| EdgeError::store_extraction( + StoreExtractionReason::MalformedEnvelope, + "typed app-config envelope is invalid", + None, + ))?; envelope.verify().map_err(|e| { - EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")) + EdgeError::store_extraction( + StoreExtractionReason::IntegrityMismatch, + format!("typed app-config integrity check failed: {e}"), + None, + ) })?; let mut data = envelope.into_data(); - secret_walk::(ctx, &mut data).await?; + secret_walk::(ctx, &mut budget, &mut data).await?; use serde::de::IntoDeserializer as _; let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; + .map_err(EdgeError::store_deserialization_from_serde)?; cfg.validate().map_err(|err| { - EdgeError::config_out_of_date( - err.to_string(), - first_violating_field(&err).unwrap_or_default(), + let field = first_violating_field(&err).unwrap_or_default(); + EdgeError::store_extraction( + StoreExtractionReason::Validation, + "typed app-config failed validation", + (!field.is_empty()).then_some(field), ) })?; Ok(cfg) @@ -2988,9 +3071,11 @@ where { async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, + ) })?; let key = binding.default_key.clone(); extract_from_handle::(ctx, &binding.handle, &key).await.map(AppConfig) @@ -3002,47 +3087,30 @@ where Per spec §12.1 "AppConfig extractor" bullet list: "`named(key)` reads a different key from the same store" — assert it. Plus add a test for `from_store` reading a non-default `[stores.config]` id. -- [ ] **Step 4: Write four extractor error-path tests covering the full §6.3 mapping table.** - -The `ConfigStoreError → EdgeError` path is delegated to the existing `From` impl, but the extractor is the contract surface — tests need to fail loudly if a future refactor accidentally re-introduces `map_err(EdgeError::internal)`. - -```rust - #[test] - fn app_config_extractor_returns_config_out_of_date_on_missing_blob() { - // Mock a ConfigStore whose get() returns Ok(None). - struct EmptyStore; - #[async_trait(?Send)] - impl ConfigStore for EmptyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(None) } - } - // ... build a request context with that store via a binding ... - // Run extract; assert Err(EdgeError::ConfigOutOfDate { .. }). - // Assert message contains "missing typed app-config blob" + "run ` config push`". - } - - #[test] - fn app_config_extractor_maps_config_store_unavailable_to_service_unavailable() { - // Mock store returning Err(ConfigStoreError::Unavailable { ... }). - // Run extract. - // Assert Err(EdgeError::ServiceUnavailable { .. }) — NOT - // EdgeError::Internal. Per §6.3 mapping table. - } - - #[test] - fn app_config_extractor_maps_config_store_invalid_key_to_bad_request() { - // Mock store returning Err(ConfigStoreError::InvalidKey { ... }). - // Run extract. - // Assert Err(EdgeError::BadRequest { .. }). - } - - #[test] - fn app_config_extractor_maps_config_store_internal_to_internal() { - // Mock store returning Err(ConfigStoreError::Internal { source }). - // Run extract. - // Assert Err(EdgeError::Internal { .. }) and that the source - // chain still carries the original anyhow::Error. - } -``` +- [ ] **Step 4: Write extractor error-path tests covering the complete §6.3 reason table.** + +Use scripted config/secret stores and malformed envelope/schema fixtures to produce every +known `StoreExtractionReason`: backend failure/unavailability, deadline, integrity, +envelope/key/secret validity, missing blob/registry/secret, schema mismatch, secret backend +unavailability, unknown store, and byte overflow. Assert the returned variant and reason +directly, then assert its wire status/kind/header/path. Include these boundary cases: + +- `get_bounded` receives the one captured deadline and root cap; the secret walk receives + the same deadline and per-secret cap. +- An exactly-at-cap blob/secret and cumulative total succeed; one byte above each fails as + `ValueTooLarge` before parse or insertion. +- A deadline that expires while the host result becomes ready yields `DeadlineExceeded`. +- `ConfigStoreError::{InvalidKey,Unavailable,Internal}` map to + `InvalidKey`, `BackendUnavailable`, and `BackendFailure`, respectively. +- A missing root returns `MissingBlob`; missing/unknown registries are distinct; a missing + secret returns `MissingSecret`; non-UTF-8 secret bytes return `InvalidSecretValue`. +- Envelope parse, unsupported version/discriminator, and SHA mismatch map to + `MalformedEnvelope`, `UnsupportedVersion`, and `IntegrityMismatch`; deserialize and + validator failures map independently to `Deserialization` and `Validation`, with + redacted paths and messages that never include stored or resolved secret values. + +Do not use the generic `From for EdgeError` in this extractor: that impl is +for hand-managed reads and intentionally lacks extraction context. - [ ] **Step 5: Run.** Expected: pass. @@ -3054,7 +3122,9 @@ Run: `cargo test -p edgezero-core --lib extractor::tests::app_config` - Modify: `crates/edgezero-cli/src/config.rs` — `run_config_push_typed` builds ONE `BlobEnvelope`, serialises it, resolves the target key, and calls the EXISTING `adapter.push_config_entries(...)` writer with exactly one logical `(key, envelope_json)` entry. - Modify: each adapter under `crates/edgezero-adapter-*/src/cli.rs` (and `push_sqlite.rs` / `push_cloud.rs` for Spin) ONLY for: (a) any pre-blob-model "flatten + per-leaf loop" preprocessing the writer did, (b) per-platform cap checks on the single logical entry, and (c) Fastly's adapter-private expansion of an oversized logical entry into physical chunk entries plus a root pointer. Adapters do NOT construct `BlobEnvelope` from `C` — the `(String, String)` writer surface stays exactly as it is at `crates/edgezero-adapter/src/registry.rs:277`. -- Modify: `crates/edgezero-adapter-fastly/src/config_store.rs` — runtime `FastlyConfigStore::get` resolves direct-or-pointer values through the same Fastly chunk helper. +- Modify: `crates/edgezero-adapter-fastly/src/config_store.rs` — runtime + `FastlyConfigStore::{get,get_bounded}` resolve direct-or-pointer values through the same + Fastly chunk helper; the bounded path counts pointer plus chunk bytes. **Ownership rationale (round-26 H-2, updated for Fastly chunking):** the existing `push_config_entries` trait method takes `entries: &[(String, String)]` — a sequence of key/value pairs. Adapters don't see `C` or `cfg`. Earlier draft of this task told each adapter to build the envelope from `cfg`, which would have required widening the trait to take `C` (breaking the adapter abstraction) OR duplicating envelope-construction code across all four adapters. The clean ownership remains: **CLI builds the envelope once, then hands the writer ONE logical entry: `(resolved_key, envelope_json)`.** Fastly may expand that one logical entry into multiple physical config-store entries internally; every other adapter writes the logical entry directly. @@ -3144,7 +3214,10 @@ The existing cap check at line 90 (`if pair.len() >= MAX_ARGV_BYTES_PER_INVOCATI - Fastly local push writes root pointer plus literal dotted chunk keys under `[local_server.config_stores..contents]`. - Fastly local dry-run reports chunking and does not edit `fastly.toml`. - Simulated Fastly failure before the pointer write leaves the previous root active; this is tested at helper/writer level by asserting the root write is last. -- Runtime `FastlyConfigStore::get` resolves a direct value unchanged and reconstructs a pointer value by fetching chunks from the same store. +- Runtime `FastlyConfigStore::{get,get_bounded}` resolve a direct value unchanged and + reconstruct a pointer value by fetching chunks from the same store. The bounded path + threads one deadline/remaining allowance through every fetch and reports the full + guest-visible byte count. - [ ] **Step 5: Run.** @@ -3947,7 +4020,7 @@ app-demo handlers in the same commit). secret walk per Model A (#[secret] / #[secret(store_ref)] / #[secret(store_ref = \"field\")]), serde_path_to_error deserialise, validator::Validate::validate. Missing blob → - EdgeError::ConfigOutOfDate per Q3 (d). + EdgeError::StoreExtraction with reason MissingBlob per Q3 (d). - §8.2 config push rewrite: single logical envelope per [stores.config] root key per adapter. Per-adapter writers (Axum file map / Cloudflare bulk put with namespace-id+remote / @@ -4507,7 +4580,11 @@ trap - EXIT assert_contains "$result" "large-fastly-blob" "fastly chunk-pointer runtime read" ``` -The actual fixture should generate the repeated text programmatically rather than checking in an enormous TOML file. The important assertion is that runtime `FastlyConfigStore::get` follows the pointer and returns the reconstructed normal envelope to the core extractor. +The actual fixture should generate the repeated text programmatically rather than checking +in an enormous TOML file. Assert that runtime `FastlyConfigStore::get_bounded` follows the +pointer, reports pointer plus chunk bytes, and returns the reconstructed normal envelope to +the core extractor; the hand-managed `get` compatibility path returns the same logical +value. **Spin Cloud exception.** Spin Cloud's `Unsupported` read-back per spec §8.3 means the smoke script can't `config diff` against Cloud, but the push + extractor path is the same shape; the smoke loop's `spin:--local` row exercises Spin's SQLite read path (per round-29 H-1's path-resolver fixtures, the SQLite resolution already has unit-test coverage; the smoke just adds the end-to-end composition check). The Spin Cloud branch is exercised by the SEPARATE block below. @@ -4577,7 +4654,7 @@ Mapping spec §s → tasks: | §3.3.2 (writer behaviour + structural checks) | B11 | | §3.3.3 (extractor secret walk) | C1 | | §3.3.4 (blob layout) | C2 (envelope creation) | -| §3.3.6 (SecretError → EdgeError mapping) | C1 (`map_secret_error`) | +| §3.3.6 (SecretError → EdgeError mapping) | B2.5, B13.5, C1 (`map_secret_error`) | | §3.3.7 (sha + secret-key interaction) | A2 + C1 | | §3.3.8 (push vs runtime validation) | B9 + C4 | | §4.1 (envelope shape) | A4 | @@ -4592,10 +4669,11 @@ Mapping spec §s → tasks: | §6.2 (explicit-key `named(key)` form) | C1 (Step 2 inherent impl) | | §6.2.1 (cross-store `from_store(id, key)` form) | C1 (Step 2 inherent impl) | | §6.2.2 (runtime validation) | C1 (`cfg.validate()`) | -| §6.3 (errors — bullet list) | B2, B3, C1 | -| §6.3.1 (`ConfigOutOfDate` body shape) | B2, B3, B4 | +| §6.3 (typed extraction errors) | B2, B2.5, B3, B4, C1 | +| §6.3.1 (`config_out_of_date` wire shape) | B2, B2.5, B3, B4 | +| §6.3.2 (bounded/cancellable reads) | B13.5, C1 | | §6.4 (no caching) | C1 (re-reads every call) | -| §6.5 (existing `ConfigStore` trait stays) | (no code work — kept) | +| §6.5 (existing unbounded store methods stay) | B13.5 (preserved beside bounded methods) | | §7.x (SHA discussion) | A2 (canonical form) | | §8.1 (config diff) | D1, D2 | | §8.2 (config push + consent) | C2, C3, C4 | diff --git a/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md b/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md new file mode 100644 index 00000000..a99dcf6e --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md @@ -0,0 +1,150 @@ +# Outbound HTTP Implementation Index + +> **Status:** Phase index, not an executable implementation plan. +> +> The authoritative contract is +> [`2026-05-21-outbound-http-design.md`](../specs/2026-05-21-outbound-http-design.md). +> This file deliberately does not duplicate adapter algorithms, capability matrices, or +> migration details from that specification. Read the relevant spec sections against the +> current tree before authoring each phase plan. + +## Current Baseline + +- Rust 1.95, edition 2024, resolver 2. +- Four runtime adapters: Axum, Cloudflare, Fastly, and Spin SDK 6 / WASI HTTP 0.3. +- Adapter dispatch has both `execute(..)` and `execute_capture(..)` entry points. +- The action set includes staged deploy, version emission, healthcheck, and rollback in + addition to build/serve/deploy/auth. +- The outbound design declares exactly seven **outbound** capabilities. The matrix and its + footnotes in spec §3.5.2 are the only authority for those support levels; other specs may + add non-outbound cells to the shared enum. + +## Locked Scope + +- The public `proxy` API becomes `outbound` without compatibility aliases. Templates, + public docs, generated projects, and `examples/app-demo` migrate in the same implementation + series. +- Core remains runtime-independent and WASM-compatible: no Tokio, reqwest, Fastly, worker, + or Spin SDK dependency enters `edgezero-core`. +- `EdgeError` gains `BadGateway { reason: BadGatewayReason }`, attributed + `GatewayTimeout`, and the distinct + `ResponseTooLarge { reason: ResponseLimitReason }` outcome. Response overflow is not + collapsed into `BadGateway`, and reason/provenance fields remain outside the JSON wire + envelope. +- `DispatchBudget` and `dispatch_budget` land together after `OutboundRequest` exposes its + private budget-input carrier. They are not part of Phase 1a. +- Capability types are owned by `edgezero-core::manifest`. Consequently, + `edgezero-adapter` adds a direct dependency on `edgezero-core` for the registry trait's + public capability signature. +- Outbound capability enforcement applies to construction/deployment of the current + runtime: build, serve, deploy, staged deploy, and demo. New outbound-scoped + `execute_runtime(..)` and `execute_capture_runtime(..)` entry points accept an owned + runtime and no separate adapter/action identity; they derive both values from that + runtime and gate exactly once before shell or registry dispatch. Existing operational + dispatch remains unchanged for auth, version emission, healthcheck, and rollback; + provision and config are also outside this gate. +- Spin request-component setters and request-option setters have different error types. + `RequestOptionsError::NotSupported` retains the outer monotonic deadline race and logs a + BestEffort degradation; `Immutable` and `Other(..)` are internal setup failures. +- The inbound `RequestContext` body-state migration is owned by + [`2026-08-22-inbound-body-design.md`](../specs/2026-08-22-inbound-body-design.md). The + outbound spec depends only on its `into_request()` contract. Ingress admission, + request-start/read deadlines, and raw framing rejection are not prerequisites for Phase + 1a and must not be implemented opportunistically in an outbound phase. +- The downstream response-write lifetime starts after a core `Response` reaches an adapter + converter. Adapter phases may implement the bounded fallback required by this outbound + design, but must not claim a general write deadline, native abort, or exactly-once egress + completion until a dedicated response-egress contract is reviewed. +- GitHub accepts `workflow_dispatch` events only for workflow files already present on the + default branch ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow_dispatch)). Before Phase 4 begins, land a standalone infrastructure bootstrap PR that + adds the inert, protected, exact-SHA dispatcher shells + `.github/workflows/outbound-cloudflare-deployed.yml` and + `.github/workflows/outbound-fastly-characterization.yml`. Those default-branch workflows + check out only a maintainer-reviewed `commit_sha` and execute the probe driver from that + tree. This bootstrap is the sole exception to the branch-only no-intermediate-merge rule: + it publishes no API, capability cell, runtime behavior, or secret-bearing automatic trigger. + +## Phase Sequence + +| Phase | Scope | Plan status | +| --- | --- | --- | +| 0 | Bootstrap inert default-branch exact-SHA probe dispatchers and provision protected disposable environments/origin | Executable and required before Phase 4: [`2026-09-06-outbound-http-phase0-probe-bootstrap.md`](2026-09-06-outbound-http-phase0-probe-bootstrap.md) | +| 1a | Add `BadGatewayReason`, `BudgetSource`, typed `BadGateway`, attributed `GatewayTimeout`, `Deadline`, and the three deadline constants | Implemented; plan: [`2026-07-10-outbound-http-phase1a-error-time.md`](2026-07-10-outbound-http-phase1a-error-time.md) | +| 1b | Add the pinned direct `url` dependency, outbound request/response types, one-time canonical URI construction, resource-limit builders, `DispatchBudget`, and `dispatch_budget` in a buildable dependency order | Executable: [`2026-09-06-outbound-http-phase1b-core-types-budget.md`](2026-09-06-outbound-http-phase1b-core-types-budget.md) | +| 2 | Typed body errors, typed response-limit errors, encoded/header/Brotli/chunk controls, bounded/deadline-aware drains, normalization, and shared decoder primitives | Executable: [`2026-09-06-outbound-http-phase2-body-response-limits.md`](2026-09-06-outbound-http-phase2-body-response-limits.md) | +| 3 | Manifest capability declarations, fail-closed adapter metadata contract, paired target/contract resolver, CLI gates, and selected-component Spin host-drift validation | Executable: [`2026-09-06-outbound-http-phase3-capabilities-cli.md`](2026-09-06-outbound-http-phase3-capabilities-cli.md) | +| 4 | Axum and Cloudflare outbound implementations, response-converter scheduling, host-event fairness, and contract tests | Executable: [`2026-09-06-outbound-http-phase4-axum-cloudflare.md`](2026-09-06-outbound-http-phase4-axum-cloudflare.md) | +| 5 | Spin hand-built WASI HTTP request/response state machines, completion-error mapping, and cooperative raw/decoded-input fairness | Task 0 executable characterization; Tasks 1-6 blocked until it passes: [`2026-09-06-outbound-http-phase5-spin.md`](2026-09-06-outbound-http-phase5-spin.md) | +| 6 | Fastly dispatch/harvest engine, dynamic backends, timers, and test seams | Authored; execute only after Phase 5's blocker is cleared: [`2026-09-06-outbound-http-phase6-fastly.md`](2026-09-06-outbound-http-phase6-fastly.md) | +| 7 | Templates, `app-demo`, public docs, generated-project checks, hard legacy-API removal, and remaining live-host characterization | Authored; execute only after Phases 5-6 pass: [`2026-09-06-outbound-http-phase7-migration-docs.md`](2026-09-06-outbound-http-phase7-migration-docs.md) | + +After the inert workflow bootstrap above, the phase numbers after 1a are organizational +guidance, not permission to split an +invariant across unbuildable commits. Phases 1b and 2 stage the new core module alongside +the old one; Phases 4-6 migrate adapters; Phase 7 migrates external consumers and deletes +the old module. That temporary dual surface is branch-only scaffolding: no intermediate +state is releasable, there are no aliases between old and new names, and the series is not +mergeable until Phase 7's scoped legacy-symbol gate passes. Each task still leaves its +current repository/worktree buildable. + +Phase 3 leaves each in-tree adapter on the registry method's `Unsupported` default. The +exact Axum/Cloudflare, Spin, and Fastly matrix rows are published only in Phases 4, 5, and +6 respectively, in the same commit series as their passing behavior contracts and any +required host evidence. This prevents the CLI from accepting a capability before the +runtime implements it. + +Phases 4-6 include each adapter's direct dependencies, independent `test-utils` feature, +native contract tests, and explicit native/WASM CI activation from spec §5.5. These gates +land with their adapter implementation rather than waiting for phase 7. Cloudflare's raw +subrequest encoding bridge and host-observed cancellation/encoding tests belong to phase 4; +Workers-only response conversion (including `Headers.getAll("set-cookie")`) runs in the +locked workerd fixture, while browser WASM covers only portable bridge behavior. +Phase 2 includes the shared decoder's gzip-member/native-completion contract, transport-side +encoded cap, opt-in rechunker, response-header caps, and pre-allocation Brotli-window check +(§3.4.1/§3.4.5). +Phase 4 includes Axum's single `block_in_place` + `Handle::block_on` response-conversion +boundary and Cloudflare's deployed-proven host-event yield quotas, frozen-clock regressions, +manual response-body encoding, and null-body 205 branch (§4.1/§4.2). +Phase 5 Task 0 must verify the Spin SDK-resource runner/harness compatibility gate in spec +§5.5; HTTP/Preview 3 registration flags alone are not execution evidence. Tasks 1-6 remain +blocked until the committed crate-local runner executes the named real-SDK resource tests +with a nonzero count. After that proof, the implementation must route `client::send`, +`request_done`, and response completion through the same typed classifier and make +continuously-ready raw/decoded input yield cooperatively. Phase 6 +includes known-`SendErrorCause` boundary tests and the buffered-batch versus streamed-upload +cleanup distinction (§4.3). These requirements do not change Phase 1a. + +## Plan-Authoring Gate + +Before writing any later phase plan: + +1. Re-read the relevant spec sections and inspect the current implementations and pinned + SDK source. +2. Enumerate every touched file, including Cargo manifests and generated/template surfaces. +3. Keep platform behavior at adapter boundaries and portable value/arithmetic layers in + core. +4. Define executable test seams, their Cargo features, target gates, and CI commands using + spec §5.5. Confirm each command executes tests rather than reporting zero tests behind + a whole-file platform gate, and validate SDK-resource runner/harness compatibility. + Test constructible known SDK enum variants directly; do not fabricate nonexistent variants of + `#[non_exhaustive]` enums with unsafe code or test-only public variants. +5. Separate no-runtime contract tests from live-host characterization. A mock must not be + used to claim host cancellation or wire behavior. +6. Include the resource-limit precedence matrix, every typed reason value, multi-member + gzip drain-to-native-EOF cases, and the platform's exact abort/completion owner. A + rechunker test may claim bounded emitted item size, not bounded transport allocation or + process RSS. +7. Run the repository gates required by `CLAUDE.md`, plus generated-project, + `examples/app-demo`, adapter-target, and documentation checks for phases that touch those + surfaces. Generated-project verification explicitly runs + `cargo test -p scaffold-probe-core --lib`; a build-only command is insufficient. + +## Readiness + +Phase 1a is implemented. Phase 0 and Plans 1b-3 are executable; Phase 0 must merge before +Phase 4's protected host-evidence task. Phase 5 Task 0 is an executable characterization; +the remainder of Phase 5 and all downstream phases stay +blocked until its mandatory real WASI HTTP SDK-resource runner proof passes with the named +nonzero tests. The master design remains the normative behavior contract; these plans +define implementation order, red/green tests, commits, and verification commands without +replacing it. diff --git a/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md b/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md new file mode 100644 index 00000000..eee7a8f7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md @@ -0,0 +1,813 @@ +# Outbound HTTP — Phase 1a: typed `EdgeError` 502/504 + `time.rs` primitives + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Land the **additive, no-new-dependency on the current baseline** core primitives from the outbound-HTTP spec ([`2026-05-21-outbound-http-design.md`](../specs/2026-05-21-outbound-http-design.md)): `EdgeError::BadGateway { reason: BadGatewayReason }`, `GatewayTimeout { cause: BudgetSource }`, and the `edgezero-core::time` module's `Deadline` + budget constants. `BadGatewayDecodeReason`, `BadGatewayReason`, and `BudgetSource` land in `error.rs`, Task 1, because the variants name them; `dispatch_budget` remains Phase 1b. Neither task touches the `proxy → outbound` rename or `Body`, so each task keeps `cargo test --workspace` green. **Scope caveat:** per-task verification is a deliberate local subset (Task 3); generated projects, `app-demo`, and expanded adapter-specific WASM matrices remain CI backstops. + +**Architecture:** `edgezero-core` only. Additive: two new `EdgeError` variants, three non-exhaustive reason/provenance enums, and a new `time` module. The `EdgeError` enum is `#[non_exhaustive]`, but matches inside its defining crate remain exhaustive and must gain both arms. `BadGatewayReason` distinguishes pre-response unreachability from later transport/protocol failures, while `BadGatewayDecodeReason` preserves the EdgeZero-owned codec identity. No reason/cause field is serialized. `message()` preserves Rust-side diagnostics, while HTTP conversion emits fixed `"bad gateway"` / `"gateway timeout"` category strings. No adapter, CLI, or app-demo change. **`DispatchBudget` and `dispatch_budget` are both deferred to Phase 1b**; Phase 1a lands `Deadline` + constants only. + +**Round-59 scope alignment:** the master spec's response resource limits, canonical URL +parser, adapter scheduling/completion rules, ingress ownership, and response-egress gate are +later work. The corrected `Body::Stream` constructors, +Fastly buffered-upload caveat, Axum/Cloudflare upload-pull boundary checks, and Spin +exchange state machine are later outbound/adapter work. They do not alter any Phase 1a +task, file, API, or verification command. In particular, this plan must not opportunistically +change `Body`, `proxy`, or an adapter while landing the error/time primitives. + +**Tech Stack:** Rust 1.95 (edition 2024), `thiserror`, `serde_json`, `web-time` (behind the public `MonotonicInstant` alias), `futures::executor::block_on` for async tests. + +> **Post-Phase 1a note:** later ingress/outbound hardening added the EdgeZero-owned +> `MonotonicClock` handle and made `Deadline::{is_expired_at,remaining_at}` public so one +> application clock can be paired across deferred work. The implementation excerpt below records +> the smaller Phase 1a landing shape; the current API is normative in the outbound design. + +## Global Constraints (from the master design and phase index) + +- **WASM-first:** no `tokio`/runtime deps; public APIs use + `edgezero_core::time::MonotonicInstant`, backed internally by `web_time::Instant`, not + `std::time::Instant`. Core stays `default-features = false`. +- **Colocated tests** (`#[cfg(test)]` same file); async tests use `futures::executor::block_on`. +- **Verbatim constants:** `DEFAULT_NO_DEADLINE_BUDGET = 30 s`, `DEADLINE_FAR_FUTURE = 7 days`, `BATCH_DISPATCH_SLACK_MAX = 25 ms`. +- **CI gates must stay green:** `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`; `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- **Verified against the current worktree's `crates/edgezero-core/src/error.rs`** (re-confirm with the compiler-driven Step 6 rather than trusting line numbers): `EdgeError` today has variants `BadRequest, ConfigOutOfDate, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`. The two new arms must be added to **nine** exhaustive matches: five implementation matches (`inner`, `kind_str`, `message`, `status`, and `IntoResponse`'s `field_path_opt`) plus four explicit `ConfigOutOfDate` test matches. The compiler remains the source of truth. Three per-variant tests gain rows for both variants. Reason/provenance tests additionally cover every known enum value and wire-shape isolation. `web-time` presence is confirmed in Task 0. +- **`cargo test` accepts only ONE positional filter** — `cargo test -p X a b` fails with `unexpected argument 'b'` (verified). Use a single common substring or two separate commands. +- **The Clippy gate is STRICT — read this before writing any code.** The root `Cargo.toml` sets `restriction = { level = "deny", priority = -1 }`, and the following are **not** allow-listed, so they are hard errors in **production** code: + - `missing_inline_in_public_items` → **every public fn needs `#[inline]`** (error.rs already carries 14). + - `min_ident_chars` → no single-char idents (`d` → `duration`). + - `arithmetic_side_effects` → **no bare `+` / `-`** on `Instant`/`Duration`; use `checked_add` / `checked_duration_since`. + - `expect_used`, `unwrap_used`, `as_conversions` → forbidden in production; use `?`/`ok_or`/`From`/`TryFrom`. + - **`unseparated_literal_suffix`** → integer suffixes need an underscore: `502_u16`, not `502u16` (verified: `502u16` errors; the opposing `separated_literal_suffix` is allow-listed, so the underscore form is the one that passes). + - **`arbitrary_source_item_ordering`** → **items must be ALPHABETICAL** — consts, enum **variants**, and impl fns alike (verified). This is why `error.rs`'s variants and methods are already alphabetized. Insert new items **in place**; never append. + - **`duration_suboptimal_units`** (pedantic; CI runs `-D warnings`, so it *fails the build*) → use the largest readable unit: `Duration::from_hours(168)` not `from_secs(7*24*60*60)`; `Duration::from_mins(1)` not `from_secs(60)`. + - **Verified end-to-end:** this exact `Deadline` code + these constants compile **clean** under the repo's full lint table (`restriction = deny` + `pedantic` + the real allow-list). `std_instead_of_core`/`std_instead_of_alloc` **are** allow-listed, so `use std::time::Duration;` is fine. + - **In TESTS**, the root `clippy.toml` sets `allow-expect-in-tests = true`, `allow-unwrap-in-tests = true`, `allow-panic-in-tests = true`, `allow-indexing-slicing-in-tests = true` — so `.expect(..)` in tests is fine. **`arithmetic_side_effects` is NOT test-exempt**, which is why the tests below use `checked_add(..).expect("no overflow")` rather than `base + dur`. + +--- + +### Task 0: Confirm the `web-time` dependency + +**Files:** Inspect `crates/edgezero-core/Cargo.toml` + +- [ ] **Step 1: Check whether `web-time` is already a dependency** + +Run: `rg -n 'web-time|web_time' crates/edgezero-core/Cargo.toml` +Expected: a line like `web-time = { workspace = true }`. + +- [ ] **Step 2: If absent, STOP and update/re-review this plan** + +Do not add the dependency as part of this task. The plan's locked premise is that +`web-time` already exists in both the workspace and `edgezero-core`; silently adding it +would contradict the no-new-dependency goal and turn Task 0 from verification into +implementation. + +- [ ] **Step 3: Verify it compiles** — Run: `cargo check -p edgezero-core` — Expected: `Finished`. + +--- + +### Task 1: typed `EdgeError::BadGateway` (502) + `GatewayTimeout` (504) + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` (enum + constructors + **9 exhaustive matches: 5 impl + 4 test panic-arms**) +- Test: `crates/edgezero-core/src/error.rs` (colocated `#[cfg(test)]`) + +**Interfaces:** +- Produces `bad_gateway(msg)` with `reason: BadGatewayReason::Unspecified`, + `bad_gateway_with_reason(msg, reason)`, `gateway_timeout(msg)` with + `cause: BudgetSource::Unspecified`, and `gateway_timeout_caused(msg, cause)`. + `BadGatewayReason::{Decode(BadGatewayDecodeReason), Protocol, Transport, Unreachable, + Unspecified}`, `BadGatewayDecodeReason::{Brotli, Gzip, Json}`, and + `BudgetSource::{BatchDeadline, Default, PerCallTimeout, Unspecified}` are + `#[non_exhaustive]`, `Clone + Copy + Debug + Eq`. Consumers inspect fields by matching the + variant. The JSON envelope remains `{ "error": { "status", "kind", "message" } }` with + no `field_path`, `reason`, or `cause`. +- `BudgetSource::Unspecified` means no EdgeZero budget source was proven. It covers both + timeout APIs outside a dispatch budget and an independently controlled provider timeout + observed before the selected absolute dispatch deadline expired. A provider phase timer + explicitly configured from the selected budget may retain that budget's source even when + it intentionally fires before the total deadline; an unconfigured DNS/write/host timeout + may not. Adapters must not stamp a configured source onto an early provider event merely + because its SDK error name contains `Timeout`; the outbound adapter phases own the exact + per-variant classification rule. + +- [ ] **Step 1: Write the failing tests (surface, typed fields, and wire isolation)** + +The existing `#[cfg(test)] mod tests` already imports `StatusCode`, `CONTENT_TYPE`, `HeaderValue`, `str` and does `use super::*;`, and has a `parse_body(response) -> serde_json::Value` helper (`tests::parse_body`). Add — **no new imports** (re-importing under `-D warnings` fails): + +This code is **pre-wrapped to rustfmt's canonical form at the final nesting depth** (inside `mod tests` → `fn` → `for`). Written as one-liners, the array-of-tuples rows and the message-bearing `assert!` exceed `max_width = 100` once indented into the test module and rustfmt rewraps them — which would surface as a diff at the Task 3 `cargo fmt --all -- --check` gate. (Step 8's `cargo fmt` would rewrap them for you, but the plan shows the landed form.) + +```rust +#[test] +fn bad_gateway_and_gateway_timeout_surface() { + for (err, code, msg) in [ + ( + EdgeError::bad_gateway("upstream refused"), + StatusCode::BAD_GATEWAY, + "upstream refused", + ), + ( + EdgeError::gateway_timeout("deadline expired"), + StatusCode::GATEWAY_TIMEOUT, + "deadline expired", + ), + ] { + assert_eq!(err.status(), code); + assert_eq!(err.message(), msg); + assert!(err.inner().is_none()); + // Display must render the new variants (not just the pre-existing ones). Assert + // the message is present rather than pinning an exact format, so this survives a + // format tweak while still proving `Display` covers `BadGateway`/`GatewayTimeout`. + assert!(err.to_string().contains(msg)); + } +} + +#[test] +fn bad_gateway_and_gateway_timeout_json_shape() { + for (err, code, kind, msg) in [ + ( + EdgeError::bad_gateway("nope"), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Protocol), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Transport), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Unreachable), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::gateway_timeout("late"), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + // Every OTHER cause too — wire-isolation must hold for ALL FOUR BudgetSource + // values, so a conditional serializer cannot leak `cause` for any of them + // (BatchDeadline / Default / PerCallTimeout / Unspecified are all covered). + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::PerCallTimeout), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::BatchDeadline), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::Default), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ] { + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), code); + let body_json = parse_body(response); // existing helper -> serde_json::Value + assert_eq!(body_json["error"]["status"], code); + assert_eq!(body_json["error"]["kind"], serde_json::Value::from(kind)); + assert_eq!(body_json["error"]["message"], serde_json::Value::from(msg)); + assert!( + body_json["error"].get("field_path").is_none(), + "502/504 carry no field_path" + ); + // Typed classification is Rust-side only; the JSON must not leak either field. + assert!( + body_json["error"].get("reason").is_none(), + "reason is not part of the wire shape" + ); + assert!( + body_json["error"].get("cause").is_none(), + "cause is not part of the wire shape" + ); + } +} + +#[test] +fn bad_gateway_decode_reason_is_not_serialized() { + for reason in [ + BadGatewayDecodeReason::Brotli, + BadGatewayDecodeReason::Gzip, + BadGatewayDecodeReason::Json, + ] { + let err = EdgeError::bad_gateway_with_reason( + "nope", + BadGatewayReason::Decode(reason), + ); + let response = err.into_response().expect("response"); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], 502_u16); + assert_eq!(body_json["error"]["kind"], "bad_gateway"); + assert_eq!(body_json["error"]["message"], "bad gateway"); + assert!(body_json["error"].get("reason").is_none()); + } +} + +#[test] +fn bad_gateway_reason_is_typed() { + let EdgeError::BadGateway { reason, .. } = EdgeError::bad_gateway("x") else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, BadGatewayReason::Unspecified); + + for expected in [ + BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + BadGatewayReason::Protocol, + BadGatewayReason::Transport, + BadGatewayReason::Unreachable, + BadGatewayReason::Unspecified, + ] { + let EdgeError::BadGateway { reason, .. } = + EdgeError::bad_gateway_with_reason("x", expected) + else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, expected); + } +} + +// The typed timeout-attribution contract (§3.3.2 / §3.4.3). WITHOUT these, a mis-wired +// constructor that dropped the cause, or that always stored the wrong variant, would pass +// the suite. These are the exact assertions compile-verified in the errsurface scaffold. +#[test] +fn bare_gateway_timeout_is_unspecified() { + // `let-else`, NOT `match … { other => panic! }`: a catch-all arm over the + // multi-variant `EdgeError` trips the denied `clippy::wildcard_enum_match_arm` + // (compile-verified against the real enum shape under edition 2024). `let-else` + // has no wildcard arm. + let EdgeError::GatewayTimeout { cause, .. } = EdgeError::gateway_timeout("x") else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, BudgetSource::Unspecified); +} +#[test] +fn gateway_timeout_caused_preserves_cause() { + // Loop var is `expected` (NOT a single char — `min_ident_chars`). **Includes + // `Unspecified`** so an impl that special-cased the bare constructor's cause is + // caught. `let-else` again (no `wildcard_enum_match_arm`). rustfmt-canonical + // (edition 2024) wrapped form, verified. + for expected in [ + BudgetSource::BatchDeadline, + BudgetSource::Default, + BudgetSource::PerCallTimeout, + BudgetSource::Unspecified, + ] { + let EdgeError::GatewayTimeout { cause, .. } = + EdgeError::gateway_timeout_caused("x", expected) + else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, expected); + } +} +``` + +In the **same red test edit**, extend all three existing per-variant matrices. These rows +reference the not-yet-created constructors, just like the focused tests above, so they belong +in the same compile-red state rather than being deferred until after implementation. The +following are three labeled insertion fragments for their named existing test functions, +not one contiguous block to paste at a single location: + +```rust + // `kind_strings_per_variant` + assert_kind!(EdgeError::bad_gateway("x"), "bad_gateway", 502_u16); + assert_kind!(EdgeError::gateway_timeout("x"), "gateway_timeout", 504_u16); + + // `retry_after_only_on_config_out_of_date` + assert_retry_after!(EdgeError::bad_gateway("x"), false); + assert_retry_after!(EdgeError::gateway_timeout("x"), false); + + // `field_path_only_on_config_out_of_date` (there is no helper macro here) + for err in [ + EdgeError::bad_gateway("x"), + EdgeError::gateway_timeout("x"), + ] { + let body = parse_body(err.into_response().expect("response")); + assert!( + body["error"].get("field_path").is_none(), + "field_path should be absent for gateway errors" + ); + } +``` + +Only `kind_strings_per_variant` is exhaustive today; the other two are subset checks. Add +the rows to all three anyway so 502/504 are pinned for kind/status, absence of +`Retry-After`, and absence of `field_path` from the first red run onward. + +- [ ] **Step 2: Run to verify it fails** — Run both focused commands: + `cargo test -p edgezero-core bad_gateway` (matches the two shared surface/wire tests plus + `bad_gateway_reason_is_typed`) and `cargo test -p edgezero-core gateway_timeout` (matches + the two shared tests plus both timeout-attribution tests). Expected: FAIL to compile + (`no variant or associated item named bad_gateway`/`gateway_timeout`). A single + `gateway_timeout` filter does **not** match `bad_gateway_reason_is_typed` and therefore + cannot prove the new 502 classification went red. + +- [ ] **Step 3: Add the two variants** in `pub enum EdgeError` — **ALPHABETICALLY, not appended.** + +`clippy::arbitrary_source_item_ordering` is a denied restriction lint and it **does police enum-variant order** (verified: appending `BadGateway` after `Validation` errors with *"incorrect ordering of items (must be alphabetically ordered)"*). The existing variants are already alphabetical (`BadRequest, ConfigOutOfDate, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`), so insert **in place**: +- `BadGateway` goes **before** `BadRequest` (BadG < BadR), +- `GatewayTimeout` goes **between** `ConfigOutOfDate` and `Internal` (C < G < I). + +Resulting order: `BadGateway, BadRequest, ConfigOutOfDate, GatewayTimeout, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`. + +```rust + /// Upstream or transport failure (DNS, TLS, connect, unreachable, or a + /// non-timeout send failure). HTTP 502. + #[error("{message}")] + BadGateway { + message: String, + reason: BadGatewayReason, + }, + /// A wall-clock deadline or per-request timeout fired. HTTP 504. + /// Carries typed provenance naming which configured budget input selected the + /// effective deadline. This is not the physical timer phase, proof that the named + /// deadline elapsed, or a retry/batch-abandonment decision. + #[error("{message}")] + GatewayTimeout { message: String, cause: BudgetSource }, +``` + +`BadGatewayDecodeReason`, `BadGatewayReason`, and `BudgetSource` are defined **in `error.rs` +in THIS task (Task 1)**, NOT in `time.rs` (Task 2). That ordering is load-bearing: Task 1 +lands/commits/builds **before** Task 2, and the two variants name these enums, so a +`time`-module home would make the standalone Task-1 commit fail to compile. Define all three +immediately **before `EdgeError`** in alphabetical item order (`BadGatewayDecodeReason`, +`BadGatewayReason`, `BudgetSource`, `EdgeError`); +`time.rs` (Task 2) and `dispatch_budget` (Phase 1b) later +`use crate::error::BudgetSource;`. + +The **derives and variant order are compile-verified** (a throwaway crate under the repo's +`arbitrary_source_item_ordering` deny + `cargo check`): +```rust +// Debug: EdgeError derives Debug and contains both reason enums. +// Clone + Copy: error carriers pass classification/provenance by value. +// PartialEq + Eq: contract tests assert exact values. +// Variants ALPHABETICAL: `arbitrary_source_item_ordering` (denied) rejects any other order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { + Brotli, + Gzip, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// non_exhaustive: public enum that must be able to gain a future budget-input source +// without a breaking change. Verified: intra-crate exhaustive matches +// still compile clean under the denied `wildcard_enum_match_arm`. +#[non_exhaustive] +pub enum BudgetSource { + BatchDeadline, + Default, + PerCallTimeout, + Unspecified, +} +``` + +- [ ] **Step 4: Add the constructors** in `impl EdgeError` — **also alphabetically** (the impl's fns are already ordered `bad_request, config_out_of_date, inner, internal, kind_str, message, status, validation`): put `bad_gateway` **before** `bad_request`, and `gateway_timeout` **between** `config_out_of_date` and `inner`. + +```rust + #[inline] + pub fn bad_gateway>(message: S) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason: BadGatewayReason::Unspecified, + } + } + #[inline] + pub fn bad_gateway_with_reason>( + message: S, + reason: BadGatewayReason, + ) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason, + } + } + #[inline] + pub fn gateway_timeout>(message: S) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause: BudgetSource::Unspecified, + } + } + #[inline] + pub fn gateway_timeout_caused>(message: S, cause: BudgetSource) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause, + } + } +``` + +(All four literals are shown in rustfmt's canonical split form, verified with +`rustfmt --edition 2024`.) + +- [ ] **Step 5: Update ALL nine exhaustive matches (crate won't compile until every one is done)** + +`impl` sites: +- `kind_str()` — add `EdgeError::BadGateway { .. } => "bad_gateway",` and `EdgeError::GatewayTimeout { .. } => "gateway_timeout",` +- `status()` — add `EdgeError::BadGateway { .. } => StatusCode::BAD_GATEWAY,` and `EdgeError::GatewayTimeout { .. } => StatusCode::GATEWAY_TIMEOUT,` +- `message()` — add **`EdgeError::BadGateway { message, .. }`** and + **`EdgeError::GatewayTimeout { message, .. }`** to the "clone the `message`" arm. The + `..` is required because both variants carry a second typed field. +- `inner()` — add both variants to the `=> None` arm list. +- `IntoResponse::into_response`'s `field_path_opt` match — add both variants to the `=> None` arm list. + +**Test-module sites (these have explicit panic-arms listing every non-`ConfigOutOfDate` variant, NO `_`):** in each of the **four** `match err { … }` blocks (the fourth is the root-error sentinel test; Step 6's compiler-driven `E0004` sweep is the source of truth for their exact locations), add `| EdgeError::BadGateway { .. } | EdgeError::GatewayTimeout { .. }` to the `=> panic!("expected ConfigOutOfDate")` arm. + +- [ ] **Step 6: Compiler-driven catch — build and fix any remaining non-exhaustive match** + +Run: `cargo build -p edgezero-core --tests` +If it reports `E0004 non-exhaustive patterns` anywhere, add the two arms at that exact site (the compiler prints the file:line). Repeat until it builds. Expected end state: builds clean. + +- [ ] **Step 7: Run the new + matrix tests to verify they pass** + +Run: `cargo test -p edgezero-core bad_gateway`, then +`cargo test -p edgezero-core gateway_timeout` (the **same two filters as red Step 2**), then +`cargo test -p edgezero-core kind_strings_per_variant`, then +`cargo test -p edgezero-core only_on_config_out_of_date` (one filter matches both the +retry_after_* and field_path_* matrices). +Expected: PASS. + +- [ ] **Step 8: Format, lint, full-crate test** + +Run: `cargo fmt -p edgezero-core && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings && cargo test -p edgezero-core` +Expected: clean, all green. + +- [ ] **Step 9: Commit** + +```bash +git add crates/edgezero-core/src/error.rs +git commit -m "feat(core): add typed gateway errors" +``` + +--- + +### Task 2: `time` module — constants + `Deadline` + +**Files:** +- Create: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/lib.rs` (add `pub mod time;`) +- Test: `crates/edgezero-core/src/time.rs` (colocated) + +**Interfaces:** +- Produces (for Phase 1b `dispatch_budget` + all adapters): public + `type MonotonicInstant = web_time::Instant`, `Deadline` (`Copy`), + `Deadline::after(Duration) -> Self`, `::at_instant(MonotonicInstant) -> Self`, + `::instant(&self) -> MonotonicInstant`, `::remaining(&self) -> Option`, + `::is_expired(&self) -> bool`; consts `DEFAULT_NO_DEADLINE_BUDGET` (30 s), + `DEADLINE_FAR_FUTURE` (7 days), `BATCH_DISPATCH_SLACK_MAX` (25 ms). + **`DispatchBudget` ships in Phase 1b with `dispatch_budget`.** Downstream crates can name, + construct, and compare public timing values without declaring a direct `web-time` + dependency. + +**Deadline semantics (matches spec §3.3.2 `deadline <= now => expired`):** a deadline whose instant is **exactly now** is **expired** — `is_expired()` is `true` and `remaining()` is `None` at equality, not `Some(0)`. A naive `checked_duration_since(now).is_none()` gets this wrong (it returns `Some(ZERO)` at equality), so the impl below uses `checked_duration_since(..).filter(|r| !r.is_zero())` — the zero case is filtered explicitly. + +- [ ] **Step 1: Write the failing tests (deterministic — bounded by explicit instants, no wall-clock tolerance windows)** + +Create `crates/edgezero-core/src/time.rs` with only the test module + `use`: + +```rust +use std::time::Duration; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monotonic_instant_is_public_clock_type() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant(start); + let _: MonotonicInstant = deadline.instant(); + } + + // The public API promises `Deadline: Copy` (adapters copy it into per-slot budgets + // rather than borrowing). Pin it at COMPILE time — a later `#[derive]` edit that + // drops `Copy` must fail the build, not silently change the API. + #[test] + fn deadline_is_copy() { + fn assert_copy() {} + assert_copy::(); + } + + #[test] + fn constants_have_exact_values() { + assert_eq!(DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + assert_eq!(DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + } + + // EXACT + deterministic: every assertion pins BOTH the deadline instant and the + // `now` it is compared against, via the pure `*_at(now)` helpers. No wall-clock + // tolerance windows, no assumption about how fast the test resumes. + + #[test] + fn deadline_before_now_is_expired() { + let base = MonotonicInstant::now(); + let past = Deadline::at_instant(base); + let now = base + .checked_add(Duration::from_secs(1)) + .expect("no overflow"); + assert!(past.is_expired_at(now)); + assert_eq!(past.remaining_at(now), None); + } + + #[test] + fn deadline_exactly_now_is_expired() { + // The equality boundary: deadline instant == now. `deadline <= now` is + // expired, but `checked_duration_since` returns Some(ZERO) here, so a naive + // impl would wrongly report NOT expired. + let base = MonotonicInstant::now(); + let at_now = Deadline::at_instant(base); + assert_eq!( + at_now.remaining_at(base), + None, + "zero remaining is expired, not Some(0)" + ); + assert!( + at_now.is_expired_at(base), + "a deadline exactly at now is expired" + ); + } + + #[test] + fn deadline_in_future_has_exact_remaining() { + let base = MonotonicInstant::now(); + let future = Deadline::at_instant( + base.checked_add(Duration::from_mins(1)) + .expect("no overflow"), + ); + assert!(!future.is_expired_at(base)); + // EXACT equality — both instants are explicit, so there is no elapsed-time slop. + assert_eq!(future.remaining_at(base), Some(Duration::from_mins(1))); + } + + #[test] + fn after_clamps_duration_max_to_far_future() { + // Prove the 7-DAY CLAMP via bounds on the resulting INSTANT (no second + // now()-snapshot to race against). + let before = MonotonicInstant::now(); + let deadline = Deadline::after(Duration::MAX); + let after = MonotonicInstant::now(); + // `after()` computed `t0 + FAR_FUTURE` for some t0 in [before, after], + // so the instant must land within [before+FAR_FUTURE, after+FAR_FUTURE]. + let lower = before + .checked_add(DEADLINE_FAR_FUTURE) + .expect("no overflow"); + let upper = after.checked_add(DEADLINE_FAR_FUTURE).expect("no overflow"); + assert!(deadline.instant() >= lower, "clamped below the 7-day bound"); + assert!( + deadline.instant() <= upper, + "Duration::MAX was NOT clamped to 7 days" + ); + } + + // Public smoke tests: the live-clock wrappers (which call MonotonicInstant::now()) actually + // delegate to the pure *_at helpers. The *_at tests above cover exact arithmetic; + // these guard the public surface AND that `after` honours its duration argument. + #[test] + fn public_remaining_and_is_expired_smoke() { + // Tight instant-bracket around `after`: the resulting deadline MUST land in + // [before + 1h, after + 1h]. Any mutation that perturbs the duration — dropping + // 30s, or clamping to DEADLINE_FAR_FUTURE = 7 days — falls outside the bracket. + // (A loose "remaining is (59 min, 1 h]" check would survive a 30s-off mutant.) + let before = MonotonicInstant::now(); + let far = Deadline::after(Duration::from_hours(1)); + let after = MonotonicInstant::now(); + assert!(!far.is_expired()); + // rustfmt-canonical SPLIT form (verified with `rustfmt --edition 2024`): the + // `.checked_add(..).expect(..)` chain exceeds `chain_width = 60`, so rustfmt + // breaks it across lines — a one-liner here would fail `cargo fmt --check`. + let lo = before + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + let hi = after + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + assert!( + far.instant() >= lo && far.instant() <= hi, + "after() must land exactly `now + duration`" + ); + assert!(far.remaining().is_some()); + // `after(ZERO)` yields `now`; by the time is_expired()/remaining() read a later + // MonotonicInstant::now(), it is at-or-past — no `checked_sub` (would underflow a fresh + // WASM Instant near its epoch). + let now_deadline = Deadline::after(Duration::ZERO); + assert!(now_deadline.is_expired()); + assert_eq!(now_deadline.remaining(), None); + } + + #[test] + fn instant_round_trips() { + let base = MonotonicInstant::now() + .checked_add(Duration::from_secs(10)) + .expect("no overflow"); + assert_eq!(Deadline::at_instant(base).instant(), base); + } +} +``` + +> The test snippet above is **pre-wrapped to rustfmt's canonical form** — verified by running `rustfmt --edition 2024` on it, not by eyeballing width. The `.checked_add(..).expect(..)` chains split because they exceed **`chain_width = 60`** (NOT `max_width = 100` — an earlier note wrongly cited the 100 limit; the chain heuristic is the binding one, which is why `lo`/`hi` are multi-line here even though they'd fit on one line width-wise). Copy it verbatim and the Task 3 `cargo fmt --all -- --check` gate stays a no-op. + +- [ ] **Step 2: Wire the module in and run to verify failure** + +Add `pub mod time;` to `crates/edgezero-core/src/lib.rs` (alphabetical position among the `pub mod` lines). +Run: `cargo test -p edgezero-core --lib time::` +Expected: FAIL to compile (`cannot find value DEFAULT_NO_DEADLINE_BUDGET`, `cannot find type Deadline`). + +- [ ] **Step 3: Implement constants + `Deadline`** + +Insert the implementation between the existing `use std::time::Duration;` and +`#[cfg(test)]`. Define the public alias in this module; do not import `web_time::Instant` +under its dependency-owned name: + +> **This code is written to pass the repo's strict Clippy gate.** The workspace sets +> `restriction = { level = "deny", priority = -1 }` (root `Cargo.toml`), and **none** of +> `missing_inline_in_public_items`, `min_ident_chars`, `arithmetic_side_effects`, +> `expect_used`, or `as_conversions` is allow-listed. Therefore: every public method +> carries **`#[inline]`** (matching the 14 existing `#[inline]`s in `error.rs`); no +> single-char idents (`d` → `duration`); and **no bare `-`/`+`** — all arithmetic is +> `checked_*`. The private `*_at(now)` helpers additionally make the logic **pure and +> deterministically testable** (no hidden `MonotonicInstant::now()` inside the assertion). + +```rust + +/// Max adapter overhead tolerated before a fan-out slot fails closed. +pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25); +/// Hard clamp on any caller-supplied duration, so construction cannot panic. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); +/// Budget applied when a request sets neither a timeout nor a deadline. +pub const DEFAULT_NO_DEADLINE_BUDGET: Duration = Duration::from_secs(30); + +/// An absolute, copyable monotonic deadline. A deadline at or before now is expired. +#[derive(Debug, Clone, Copy)] +pub struct Deadline(MonotonicInstant); + +/// Portable monotonic clock instant used by EdgeZero timing APIs. +pub type MonotonicInstant = web_time::Instant; + +impl Deadline { + /// Returns a deadline `now + min(duration, DEADLINE_FAR_FUTURE)`; never panics. + #[inline] + #[must_use] + pub fn after(duration: Duration) -> Self { + let now = MonotonicInstant::now(); + let clamped = duration.min(DEADLINE_FAR_FUTURE); + Deadline(now.checked_add(clamped).unwrap_or(now)) + } + + /// Constructs a deadline from an absolute instant. + #[inline] + #[must_use] + pub fn at_instant(instant: MonotonicInstant) -> Self { + Deadline(instant) + } + + /// Returns the absolute deadline instant. + #[inline] + #[must_use] + pub fn instant(&self) -> MonotonicInstant { + self.0 + } + + /// Returns `true` once the deadline instant is at or before now. + #[inline] + #[must_use] + pub fn is_expired(&self) -> bool { + self.is_expired_at(MonotonicInstant::now()) + } + + fn is_expired_at(&self, now: MonotonicInstant) -> bool { + self.remaining_at(now).is_none() + } + + /// Returns the remaining time, or `None` once the deadline is reached or passed. + #[inline] + #[must_use] + pub fn remaining(&self) -> Option { + self.remaining_at(MonotonicInstant::now()) + } + + fn remaining_at(&self, now: MonotonicInstant) -> Option { + self.0 + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** — Run: `cargo test -p edgezero-core --lib time::` — Expected: PASS (all eight). + +- [ ] **Step 5: Format, lint, full-crate test** + +Run: `cargo fmt -p edgezero-core && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings && cargo test -p edgezero-core` +Expected: clean, all green. + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/time.rs crates/edgezero-core/src/lib.rs +git commit -m "feat(core): add time module (Deadline + budget constants)" +``` + +--- + +### Task 3: Core CI-gate verification (the five CLAUDE.md gates) + +**Files:** none (verification only). Run from the repo root. + +**Scope:** Phase 1a is **additive, core-only** (new `EdgeError` variants and typed +reason/provenance enums plus a new `time` module; no adapter, CLI, template, or `app-demo` +change), so this task runs the **five CLAUDE.md gates** over the workspace **plus one +core-only wasm32-unknown-unknown check** (Step 5). It deliberately does **not** run the +generated-project build, the `examples/app-demo` build, or expanded per-adapter WASM +test/clippy matrices beyond the required Spin check. **This is a risk-reduced local subset, NOT a proof those are +unaffected:** every one of them compiles `edgezero-core`, so a core change *could* in +principle break them (an unexpected new export collision, a feature-gate interaction). The +judgement is that this purely additive core surface is very unlikely to, and **full CI runs +all of them on the PR regardless** — so Task 3 is a fast local gate, and CI is the actual +backstop. If local certainty is required, run the full workspace + `examples/app-demo` +builds too; otherwise rely on CI. Phases that touch adapters/templates/app-demo add those +locally. The one WASM target that is *not* redundant here is +`wasm32-unknown-unknown`: `web-time::Instant` resolves to its JS +`Date`/`performance.now()` path there, whereas the Spin `wasm32-wasip2` gate uses the WASI +clock — so the new `time` module's `web-time` dependency must be compiled on that target +too. + +- [ ] **Step 1: Format check + workspace test** + +Run: `cargo fmt --all -- --check && cargo test --workspace --all-targets` +Expected: no diff; all green. (Confirms the additive changes broke no crate.) + +- [ ] **Step 2: Clippy (all targets, all features)** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: clean. + +- [ ] **Step 3: Feature-combo check** + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +Expected: `Finished`. + +- [ ] **Step 4: Spin wasm target check** + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +Expected: `Finished`. + +- [ ] **Step 5: Core wasm32-unknown-unknown check (web-time JS path)** + +Run: `cargo check -p edgezero-core --target wasm32-unknown-unknown` +Expected: `Finished`. (Compiles the new `time` module against `web-time`'s browser/JS clock backend — the Cloudflare target — which the Spin `wasip2` gate does not exercise. Core-only, so no adapter is pulled in.) + +(Steps 1–4 collectively execute all five repo CI commands from `CLAUDE.md`; Step 5 adds the `wasm32-unknown-unknown` core check. Do not skip the wasm targets — they are the ones most likely to catch an accidental `std::time` / non-WASM import.) + +--- + +## Self-Review + +- **Spec coverage:** Task 1 = §3.4.3/§7 `error.rs` (both variants, both typed enums, + constructors, full match surface, Rust-side classification, and JSON isolation); Task 2 = + §§3.3.1/3.3.4 (`Deadline` and all three constants). `DispatchBudget` + + `dispatch_budget()` (§3.3.2) are deferred **together** to Phase 1b — a stated sequencing + boundary, not a gap. +- **Compile-safety (the class of bug a prior review caught):** the nine exhaustive matches (5 impl + 4 test panic-arms) are enumerated *and* backed by a compiler-driven catch step; focused tests and all six matrix rows enter the same compile-red edit; the `cargo test` single-filter rule is applied; `is_expired_at` treats a **zero** remaining as expired (`remaining_at` filters out a zero `Duration`), so a deadline exactly at now reads as expired. +- **No placeholders / no flaky tests:** every step has exact code, paths, single-filter commands, expected output; timing tests are bounded by explicit `at_instant` instants (no `now() - 1s` underflow, no wide tolerance windows), and the clamp test proves the 7-day bound. + +## Next (not this plan; each is its own plan, NOT one atomic step) + +Phase 1b must respect the producer's type dependency: `dispatch_budget(&OutboundRequest, ..)` +cannot land before `OutboundRequest` and its private `budget_inputs()` accessor exist. The +next plan must add the pinned direct workspace/`edgezero-core` `url` dependency and either +(1) land `OutboundRequest`/`ResponseMode`, one-time canonical URI construction, +resource-limit builders, `validate_for_dispatch`, `BudgetInputs`, `DispatchBudget`, and +`dispatch_budget` in one buildable slice, or (2) land the request type/accessor in an +earlier buildable slice and the budget carrier/producer immediately after it. +`OutboundResponse`, the typed `ResponseLimitReason` work, the `Body::Stream` error change, +and the `proxy → outbound` rename can then be sequenced around the four-adapter atomic +migration, but no slice may name a type that does not yet exist. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md new file mode 100644 index 00000000..364a1370 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md @@ -0,0 +1,159 @@ +# Outbound HTTP Phase 0: Protected Probe Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement +> this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put inert exact-SHA Cloudflare and Fastly probe dispatchers on the default branch +and provision their disposable protected environments before any implementation phase needs +host evidence. + +**Architecture:** GitHub delivers `workflow_dispatch` only for workflow files already on the +default branch. Two manually dispatched workflows validate a reviewed SHA without secrets, +then run only that SHA's probe driver in a protected disposable environment. They have no +automatic trigger and publish no outbound API or capability behavior. + +**Tech Stack:** GitHub Actions, actionlint 1.7.7, `gh`, disposable Cloudflare/Fastly accounts, +and one authenticated observable probe origin. + +--- + +## Preconditions + +- [ ] The outbound design and implementation index are approved. +- [ ] A repository administrator can create protected environments, environment secrets, + required-reviewer rules, and the fixed `refs/heads/outbound-probe-reviewed` branch. +- [ ] No Phase 4 or Phase 6 capability publication has started. This bootstrap is the sole + intermediate merge allowed by the implementation index. + +## Task Protocol + +For each task, make the stated change, run every exact local command, inspect the workflow +permissions and secret boundaries, and commit only the listed files. A skipped job, absent +secret, missing driver, zero probe count, or mismatched SHA is never success. + +### Task 1: Add inert default-branch dispatchers + +**Files:** +- Modify: `.tool-versions` +- Create: `.github/workflows/outbound-cloudflare-deployed.yml` +- Create: `.github/workflows/outbound-fastly-characterization.yml` + +- [ ] Pin actionlint 1.7.7 in `.tool-versions`. Require + `test "$(actionlint -version | head -n 1)" = "1.7.7"` before validation. +- [ ] Give each workflow only `workflow_dispatch` with one required lowercase 40-hex + `commit_sha` string input and top-level `permissions: contents: read`. Do not add + `pull_request_target`, `pull_request`, `push`, `schedule`, reusable-workflow inputs, or a + user-selectable ref/command/path. Put the exact input SHA in the workflow `run-name` and + protected job name so an environment reviewer can compare it with the reviewed ref before + approving secret access. +- [ ] Pin every third-party action to a reviewed full commit SHA and annotate its release name; + floating major-version tags are forbidden in these secret-bearing workflows. Give both jobs + finite `timeout-minutes`, disable checkout credential persistence, and do not cache any path + that candidate code can write across protected runs. +- [ ] Give each provider workflow a fixed repository-wide `concurrency` group with + `cancel-in-progress: false`. Fastly runs must serialize because they deploy into the same two + disposable services; a queued run may not cancel an active run before its cleanup trap + restores prior service state. +- [ ] Add an unprivileged `validate` job with no environment and no secret references. It + fetches only `refs/heads/outbound-probe-reviewed`, rejects a non-lowercase/full SHA, + requires that commit to be reachable from the fetched ref, checks out the exact object, + and requires `git rev-parse HEAD` to equal the input byte-for-byte. Export only that + validated SHA as a job output. +- [ ] Add a `probe` job that `needs: validate`, binds only its named protected environment, + checks out the validated SHA, and repeats the byte-for-byte HEAD check. Dependency setup, + metadata checks, and fixture builds receive no secrets. Reference disposable secrets only + in the final driver step's `env` block. +- [ ] The Cloudflare driver is exactly + `npm --prefix crates/edgezero-adapter-cloudflare run test:deployed-timing` and receives only + `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, + `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`. The Fastly driver is exactly + `bash crates/edgezero-adapter-fastly/tests/host/deployed.sh` and receives only + `FASTLY_DYNAMIC_SERVICE_ID`, `FASTLY_DYNAMIC_SERVICE_URL`, `FASTLY_DISABLED_SERVICE_ID`, + `FASTLY_DISABLED_SERVICE_URL`, `FASTLY_API_TOKEN`, `OUTBOUND_PROBE_ORIGIN_URL`, and + `OUTBOUND_PROBE_ORIGIN_TOKEN`. +- [ ] Before the Cloudflare secret-bearing driver step, install the exact Rust and Node + versions from `.tool-versions`, add `wasm32-unknown-unknown`, run + `npm ci --prefix crates/edgezero-adapter-cloudflare`, run the fixture's locked Cargo + metadata and Worker 0.8.3 tree assertions, install + `worker-build 0.8.3 --locked`, assert `worker-build --version` plus locked Wrangler's + version, assert fixture/template compatibility settings, and run + `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture`. None of these + setup/build steps receives provider or origin secrets. +- [ ] Before the Fastly secret-bearing driver step, install the exact Rust and Fastly CLI + versions from `.tool-versions`, add `wasm32-wasip1`, require + `fastly version` to report 15.1.0, assert both locked dependency graphs select Fastly SDK + 0.12.1, run the standalone fixture's locked metadata/tree checks, and build it without + secrets using + `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly`. + Require the fixed + `tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` artifact. The final + driver may deploy only that prebuilt package; it must not compile, download candidate + dependencies, or select a different artifact after secrets enter its environment. +- [ ] Run `actionlint .github/workflows/outbound-cloudflare-deployed.yml + .github/workflows/outbound-fastly-characterization.yml` and `git diff --check`. Then run + the following negative trigger audit; status 1 is success, a match or scan error is failure: + +```sh +if git grep -nE 'pull_request_target|pull_request:|push:|schedule:' -- \ + .github/workflows/outbound-cloudflare-deployed.yml \ + .github/workflows/outbound-fastly-characterization.yml; then + exit 1 +else + status=$? + test "$status" -eq 1 +fi +``` +- [ ] Commit: `ci: bootstrap protected outbound probes`. + +### Task 2: Provision disposable protected resources + +**Repository/environment state, no source files:** + +- [ ] Create `outbound-cloudflare-probe` and `outbound-fastly-probe` as protected + environments with required maintainers, no self-approval, no production secrets, and no + deployment branches except the default-branch workflow. Populate only the exact secrets + listed in Task 1. +- [ ] Create or verify `refs/heads/outbound-probe-reviewed` as a repository-owned protected + branch with deletion and force pushes disabled. A maintainer reproduces a reviewed tree on + this ref with a fast-forward-only push before requesting environment approval; workflows + reject any SHA not reachable from it. Never dispatch a fork SHA directly. The environment + reviewer compares the SHA shown in the run/job name with the reviewed commit before approval. +- [ ] Provision a disposable Workers account/subdomain and a least-privilege token limited to + Workers Scripts edit in that account. Provision two disposable Fastly services: one with + dynamic backends enabled and one with them disabled, plus a token limited to those services. +- [ ] Freeze the observable origin protocol: authenticated `POST /v1/probes/{run_id}/arm` + creates isolated state; requests use `/v1/probes/{run_id}/{case_id}` for raw bytes, delayed + headers/chunks, stalled upload reads, and cancellation; authenticated + `GET /v1/probes/{run_id}/observations` returns timestamped bytes/EOF/disconnect facts; + authenticated `DELETE /v1/probes/{run_id}` removes the state. Run IDs and tokens are + unguessable, state is isolated per run, and stale state has a bounded retention policy. +- [ ] Verify the origin arm/observe/delete control plane with a unique smoke run and delete + it. Verify both provider tokens can inspect only their disposable resources. Record the + environment settings and resource identifiers in the repository's private operational + record, never in committed logs or docs. + +### Task 3: Merge and prove dispatch availability + +- [ ] Open and review the bootstrap PR independently of the implementation branch. Confirm + its diff contains only `.tool-versions` and the two inert workflow files. +- [ ] Merge it to the default branch. Run + `gh workflow view outbound-cloudflare-deployed.yml --ref main` and + `gh workflow view outbound-fastly-characterization.yml --ref main`; both must identify the + merged workflow and `workflow_dispatch` trigger. +- [ ] Record the bootstrap merge SHA in the outbound implementation PR. Do not dispatch a + probe until its owning phase has added a reviewed nonzero driver; a missing driver must + fail rather than skip. + +## Phase Verification + +- [ ] `test "$(actionlint -version | head -n 1)" = "1.7.7"` +- [ ] `actionlint .github/workflows/outbound-cloudflare-deployed.yml .github/workflows/outbound-fastly-characterization.yml` +- [ ] Repeat Task 1's negative trigger audit and require exit 0 with no matches. +- [ ] `git diff --check` +- [ ] Both workflows are manually visible from the default branch. +- [ ] Both protected environments require reviewer approval and contain only disposable + resources/secrets. +- [ ] The observable origin smoke state was deleted. + +Expected result: later phases can dispatch immutable reviewed SHAs through protected +default-branch workflows without merging any partial outbound implementation. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md new file mode 100644 index 00000000..672aef42 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md @@ -0,0 +1,261 @@ +# Outbound HTTP Phase 1b: Core Types and Budget Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the runtime-independent outbound request, response, client, URL, and dispatch-budget value layer specified in §§3.1-3.3 of the outbound design. + +**Architecture:** Build the new `edgezero_core::outbound` module alongside the legacy `proxy` module so the repository compiles while adapters migrate in Phases 4-6. This coexistence is branch-only scaffolding, not a compatibility promise or a releasable state. Phase 7 deletes `proxy` and proves that no public alias remains. Core canonicalizes a URL once, owns all policy values, and exposes only provider-neutral data to adapters. + +**Tech Stack:** Rust 1.95, `http`, `bytes`, `serde_json`, `url = "=2.5.8"`, `web-time`, `async-trait`, `futures`. + +--- + +## Preconditions and Merge Rule + +- [ ] Confirm Phase 1a tests pass: `cargo test --offline --locked -p edgezero-core --lib`. +- [ ] Read spec §§3.1-3.3, §5.1, §6, and the core entries in §7. +- [ ] Do not publish or merge the series until Phase 7 removes `edgezero_core::proxy`, `ProxyService`, and every legacy public name. Do not add aliases between old and new names. +- [ ] Keep `BudgetInputs` private and limited to `deadline` and `timeout`; response size does not participate in budget selection. + +## Task Protocol + +For every task below: add only the named tests first; run the exact focused command and require a nonzero failure caused by the missing behavior; implement the smallest listed surface; rerun the same command to zero failures; run `cargo test --offline --locked -p edgezero-core --lib` and `git diff --check`; then stage only the task's files and make the stated commit. A compile error for a not-yet-added public item is an acceptable red result. An already-passing new test is not. The contract declarations below are in the repository's required alphabetical item order; keep every actual module item, struct field, enum variant, and impl method in that order, and add `#[inline]` to every public function as required by the denied workspace lints. + +**Required exact test names:** `outbound_request_defaults_and_parts_round_trip`, `outbound_request_canonicalizes_url_table`, `outbound_request_rejects_invalid_target_table`, `outbound_request_rejects_empty_userinfo`, `outbound_request_rejects_backslash_authority_forms`, `outbound_request_preserves_percent_encoded_hash`, `outbound_request_from_request_normalizes_immediately`, `dispatch_validation_precedence_table`, `request_normalization_strips_connection_nominations`, `request_normalization_is_idempotent`, `dispatch_budget_selects_and_attributes_minimum`, `dispatch_budget_rejects_expired_or_zero`, `http_client_delegates_send_and_send_all`, `http_client_preserves_per_slot_elapsed`, and `outbound_response_parts_preserve_method_headers_and_body`. + +**Expected red:** Task 2 initially fails to resolve `edgezero_core::outbound`; Task 3 initially observes nominated/hop-by-hop headers or accepts an invalid request; Task 4 fails to resolve `dispatch_budget`; Task 5 fails to resolve `HttpClient`/`OutboundResponse`. Dependency Task 1 is a lock/dependency precondition and is green when the exact tree assertion passes. + +### Task 1: Pin the canonical URL parser + +**Files:** +- Modify: `Cargo.toml` +- Modify: `crates/edgezero-core/Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add `url = "=2.5.8"` to workspace dependencies and `url = { workspace = true }` to core. +- [ ] Refresh both independent lock graphs with `cargo check --offline -p edgezero-core` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-core`; these commands are intentionally unlocked in this dependency task. +- [ ] Run `cargo tree --offline --locked -p edgezero-core -e normal | rg 'url v2\.5\.8'`; expect exactly the pinned normal dependency. +- [ ] Run `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core -e normal | rg 'url v2\.5\.8'`; expect the same exact version in the excluded demo graph. +- [ ] Commit: `build(core): pin outbound URL parser`. + +### Task 2: Add canonical outbound requests + +**Files:** +- Create: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +pub struct OutboundRequest { + body: Body, + deadline: Option, + headers: HeaderMap, + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_request_body_bytes: u64, + max_response_header_bytes: Option, + max_response_header_count: Option, + method: Method, + response_mode: ResponseMode, + timeout: Option, + uri: Uri, +} + +pub struct OutboundRequestParts { + pub body: Body, + pub deadline: Option, + pub headers: HeaderMap, + pub max_brotli_decoder_bytes: u64, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +pub enum ResponseMode { Buffered { max_bytes: u64 }, Streamed } + +impl OutboundRequest { + pub fn body(self, body: impl Into) -> Self; + pub fn deadline(self, deadline: Deadline) -> Self; + pub fn from_parts(parts: OutboundRequestParts) -> Result; + pub fn from_request(request: Request, target: Uri) -> Result; + pub fn get(uri: impl AsRef) -> Result; + pub fn header, V: AsRef<[u8]>>( + self, + name: N, + value: V, + ) -> Result; + pub fn headers(&self) -> &HeaderMap; + pub fn headers_mut(&mut self) -> &mut HeaderMap; + pub fn into_parts(self) -> OutboundRequestParts; + pub fn json(self, value: &T) -> Result; + pub fn max_brotli_decoder_bytes(self, bytes: u64) -> Self; + pub fn max_brotli_window_bits(self, bits: u8) -> Self; + pub fn max_chunk_bytes(self, bytes: NonZeroU64) -> Self; + pub fn max_decoded_response_bytes(self, bytes: u64) -> Self; + pub fn max_encoded_response_bytes(self, bytes: u64) -> Self; + pub fn max_request_body_bytes(self, bytes: u64) -> Self; + pub fn max_response_bytes(self, bytes: u64) -> Self; + pub fn max_response_header_bytes(self, bytes: u64) -> Self; + pub fn max_response_header_count(self, count: u64) -> Self; + pub fn method(&self) -> &Method; + pub fn new(method: Method, uri: Uri) -> Result; + pub fn post(uri: impl AsRef) -> Result; + pub fn stream_response(self) -> Self; + pub fn timeout(self, timeout: Duration) -> Self; + pub fn uri(&self) -> &Uri; +} +``` + +- [ ] Add failing `outbound_request_*` tests for defaults, every builder/accessor, `into_parts`/`from_parts`, JSON content type, and header byte validation. `from_request` must preserve the exact method/body while immediately stripping hop-by-hop, nominated, `Host`, `Content-Length`, and `Transfer-Encoding` headers. +- [ ] Add URL table tests for scheme/host case, default ports, DNS, IPv4, bracketed IPv6, path/query WHATWG normalization, and exact canonical serialization. +- [ ] Add rejection rows for non-HTTP schemes, relative/no-authority targets, nonempty and empty userinfo, raw `#` fragments, every raw-backslash special-URL separator form (`//`, `\\`, `/\\`, `\\/` around an empty-userinfo authority), and malformed UTF-8/header bytes. Add positive rows proving percent-encoded `%23` and `%40` remain path/query data rather than being rejected as fragment/userinfo delimiters. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound::tests::outbound_request_`; expect compile/test failure. +- [ ] Implement the types and builders from spec §3.1. Store the canonical `Uri`; adapters must not parse the caller's source string again. +- [ ] Implement raw-string construction in this order: reject a literal `#`; reject every raw `\\` byte before WHATWG parsing; isolate the raw authority between literal `://` and the first `/`, `?`, or `#` and reject any literal `@` (including `https://@example.com/`); `Url::parse`; reject scheme/authority/remaining userinfo violations; clear default ports; serialize once; parse that serialization into `http::Uri`. Rejecting backslashes is the exact pre-parser rule because WHATWG special URLs normalize them as separators; do not add a second ad hoc authority parser. `new(Uri)` runs the same post-parse validation but cannot recover syntax discarded before it received the typed URI. +- [ ] Implement `from_request` by preserving method/body, replacing only the target, and applying the same request normalizer immediately. Adapters still reapply normalization immediately before SDK construction because `headers_mut` can introduce unsafe fields later. +- [ ] Add and test provider accessors `backend_target`, `cert_host`, `host_authority`, `host_name`, and `sni_hostname`. `host_name` is the host-only value required by Fastly backend identity and must not include IPv6 brackets or a port. +- [ ] Set exact defaults: final Buffered response 1 MiB, request body 8 MiB, Brotli window bits 24, and Brotli decoder-requested heap 32 MiB. Encoded and decoded response caps default to unset; all byte caps and counters are `u64`. +- [ ] Rerun the focused test and `cargo test --offline --locked -p edgezero-core --lib`; expect success. +- [ ] Commit: `feat(core): add canonical outbound request types`. + +### Task 3: Add dispatch validation and request normalization + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `dispatch_validation_*` tests for the portable method set, GET/HEAD body rules, streamed-body detection, streamed-response detection, invalid policy combinations, and configured Brotli window values below 10 or above 30. The infallible builder stores any `u8`; only dispatch validation rejects the invalid range. +- [ ] Add failing `request_normalization_*` tests for standard hop-by-hop fields, every valid `Connection` nomination, repeated `Connection` fields, malformed tokens, `Host`, `Content-Length`, `Transfer-Encoding`, and idempotence. +- [ ] Run both filters separately; expect failures: + - `cargo test --offline --locked -p edgezero-core --lib dispatch_validation_` + - `cargo test --offline --locked -p edgezero-core --lib request_normalization_` +- [ ] Implement `validate_for_dispatch`, `normalize_for_dispatch`, `is_stream_body`, `is_stream_response`, and private `budget_inputs`. Validation must happen before body polling; normalization must be repeatable at adapter boundaries. +- [ ] Rerun both filters and the core library suite; expect success. +- [ ] Commit: `feat(core): validate outbound dispatch requests`. + +### Task 4: Add dispatch budget arithmetic + +**Files:** +- Modify: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +pub struct DispatchBudget { + pub cause: BudgetSource, + pub deadline: Deadline, + pub duration: Duration, +} + +pub fn dispatch_budget( + request: &OutboundRequest, + now: Instant, +) -> Result; +``` + +- [ ] Add failing `dispatch_budget_*` tests for default, timeout only, deadline only, both orderings, equal values favoring `PerCallTimeout`, zero/expired inputs returning attributed 504, seven-day clamping, and one shared `now` across a batch. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib dispatch_budget_`; expect failure. +- [ ] Implement only checked `Instant`/`Duration` arithmetic. Preserve the winning `BudgetSource` through every clamp. +- [ ] Rerun the focused and core suites; expect success. +- [ ] Commit: `feat(core): compute outbound dispatch budgets`. + +### Task 5: Add client and response value surfaces + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundSlotResult { + pub elapsed: Duration, + pub outcome: Result, +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + async fn send(&self, request: OutboundRequest) -> Result; + async fn send_all( + &self, + requests: Vec, + ) -> Vec; +} + +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +pub struct OutboundResponse { + body: Body, + headers: HeaderMap, + request_method: Method, + status: StatusCode, +} + +impl HttpClient { + pub fn new(client: Arc) -> Self; + pub async fn send(&self, request: OutboundRequest) + -> Result; + pub async fn send_all(&self, requests: Vec) + -> Vec; + pub fn with_client(client: C) -> Self; +} + +impl OutboundResponse { + pub fn body(&self) -> &Body; + pub fn headers(&self) -> &HeaderMap; + pub fn headers_mut(&mut self) -> &mut HeaderMap; + pub fn into_body(self) -> Body; + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body); + pub fn is_success(&self) -> bool; + pub fn new( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + ) -> Self; + pub fn status(&self) -> StatusCode; +} +``` + +- [ ] Add failing `http_client_*` tests proving `send`/`send_all` delegation, empty-batch forwarding, index alignment, and preservation of each mock slot's distinct `elapsed` plus mixed outcome. Core delegates these values unchanged; complete preflight and real clock ownership belong to each adapter contract in Phases 4-6. +- [ ] Add failing `outbound_response_*` tests for originating method, status/success, immutable and adapter-facing mutable header access, repeated headers, borrowed/consuming body access, and `into_parts`. `into_response` is owned by Phase 2 Task 3 because it depends on response normalization. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib http_client_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_`; expect a nonzero failure. +- [ ] Implement `HttpClient::send` and `send_all` only. Do not expose the inner client and do not recreate `ProxyService::forward`. +- [ ] Implement `OutboundResponse::new`, `headers_mut`, `status`, `is_success`, `headers`, `body`, `into_body`, and `into_parts() -> (Method, StatusCode, HeaderMap, Body)`. Bounded drains, JSON, and `into_response` remain Phase 2. +- [ ] Add `RequestContext::http_client()` using `self.request.extensions()` directly. Keep `proxy_handle()` only as temporary branch scaffolding and add no alias between the two client types. +- [ ] Rerun focused tests, `cargo test --offline --locked -p edgezero-core --lib`, and `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [ ] Commit: `feat(core): add outbound HTTP client surface`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-core --target wasm32-unknown-unknown` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: both core modules compile temporarily, the new outbound API and budget layer are fully tested, and no adapter behavior has changed. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md new file mode 100644 index 00000000..14cec718 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md @@ -0,0 +1,309 @@ +# Outbound HTTP Phase 2: Typed Bodies, Decoding, and Response Limits Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the portable response pipeline: exact typed stream errors, response-limit errors, header/body normalization, encoded and decoded caps, Brotli preflight, gzip/Brotli completion, optional rechunking, and deadline-aware drains. + +**Architecture:** Keep all transport-independent byte accounting and decoding in core. Adapters retain timers, abort handles, trailer/completion protocols, and native body ownership. The pipeline order is fixed: cumulative visible-field accounting, normalization, body disposition and sound `Content-Length` checks, encoded limit, lazy Brotli WBITS/decoder-charge gate, decoder/native EOF, decoded limit for identity or EdgeZero-decoded bodies only, rechunker, outer deadline/cancellation wrapper, then either the independent final Buffered collection cap or a lazy Streamed body. + +**Tech Stack:** Rust 1.95, `bytes`, `futures`, exact manifest-pinned `async-compression = 0.4.43`, `http`, `serde`, `serde_json`. + +--- + +## Preconditions + +- [ ] Phase 1b is complete and `cargo test --offline --locked -p edgezero-core --lib` passes. +- [ ] Re-read spec §§3.1.4, 3.3.3-3.4.5, §5.1, and the core file summary in §7. +- [ ] Preserve `Body::into_bytes_bounded(usize)` as the inbound 400 helper. Outbound response drains are separate `u64`/502 APIs. +- [ ] Header-limit ties resolve `HeaderCount` before `HeaderBytes`, matching the specified per-entry algorithm. A malformed or conflicting payload-bearing `Content-Length` is `BadGatewayReason::Protocol`; HEAD/304 representation metadata is preserved without body interpretation. + +## Task Protocol + +For every task: add only the named tests; run the exact focused filter and require failure from the missing behavior; implement the listed API/algorithm; rerun that filter and the full core library suite; run `git diff --check`; then stage only the task's files and make the stated commit. For stream tests, use poll-counting scripted sources so early-stop, order, and EOF claims are executable assertions rather than comments. + +**Required exact test names:** `response_too_large_preserves_every_reason_without_serializing_it`, `response_too_large_constructors_preserve_unspecified_and_specific_reason`, `body_from_stream_preserves_edge_error`, `body_from_external_stream_maps_to_internal`, `body_into_bytes_bounded_preserves_edge_error`, `body_stream_accepts_infallible_bytes`, `body_bounded_checks_before_append`, `response_normalization_precedence_table`, `response_header_limiter_accumulates_field_sections`, `response_resource_header_limit_count_wins_tie`, `content_encoding_classifier_covers_every_visible_shape`, `payload_content_length_rejects_before_body_poll`, `payload_content_length_explicit_identity_rejects_before_body_poll`, `payload_content_length_passthrough_uses_buffered_not_decoded_cap`, `payload_content_length_skips_output_caps_for_compressed_body`, `outbound_response_into_response_reapplies_normalization`, `encoded_limit_stops_before_decoder`, `decoded_limit_bypasses_raw_passthrough`, `buffered_collection_limit_is_independent`, `rechunk_stream_is_lazy_and_ordered`, `outbound_response_until_deadline_wins_ready_result`, `outbound_response_json_error_classification`, `decoder_carrier_restores_exact_edge_error`, `decode_gzip_drains_every_member_to_native_eof`, `decode_brotli_rejects_trailing_data`, `brotli_memory_charge_is_pinned_and_checked`, and `brotli_window_rejects_before_decoder_allocation`. + +**Expected red:** each task either fails to resolve its new type/helper or observes the old behavior: 500/untyped limit, erased stream error, retained unsafe header, over-limit poll/append, cap winning an expired deadline, decoder stopping at its first end marker, or decoder construction before WBITS rejection. Do not accept a panic as the intended red result. + +**Portable pipeline skeleton:** adapters compose the helpers in this exact nesting; an implementation with a different nesting fails the order tests. + +```rust +let encoding = classify_content_encoding(&headers); +let max_buffered_response_bytes = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, +}; +enforce_payload_content_length( + &headers, + encoding, + max_buffered_response_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, +)?; +let raw = limit_encoded_stream(native_body, max_encoded_response_bytes); +let decoded = match encoding { + ContentEncoding::Brotli => decode_brotli_stream( + raw, + max_brotli_window_bits, + max_brotli_decoder_bytes, + ), + ContentEncoding::Gzip => decode_gzip_stream(raw), + ContentEncoding::Identity => raw, + ContentEncoding::Passthrough => raw, +}; +if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); +} +let output_limited = match encoding { + ContentEncoding::Brotli | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough => decoded, +}; +let shaped = rechunk_stream(output_limited, max_chunk_bytes); +let timed = adapter_deadline_wrapper(shaped, budget, native_completion); +let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(timed, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(timed), +}; +``` + +`adapter_deadline_wrapper` and `native_completion` above are adapter-owned placeholders, not core APIs; they show why rechunking must remain inside the outer timer/cancellation wrapper. +The wrapper rechecks the absolute deadline whenever an inner resource error becomes ready, +so an already-expired adapter budget retains the spec's 504 precedence. The decoded limiter +must remain inside that wrapper, while the final collection helper remains outside it and +preserves the narrower generic-consumer precedence documented in §3.4.5. + +Every transport-independent stream helper accepts and returns the same concrete erased type; +none returns a branch-specific opaque `impl Stream`: + +```rust +pub type BodyStream = LocalBoxStream<'static, Result>; + +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result; +pub fn decode_brotli_stream( + stream: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream; +pub fn decode_gzip_stream(stream: BodyStream) -> BodyStream; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContentEncoding { Brotli, Gzip, Identity, Passthrough } +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding; +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result; +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn rechunk_stream(stream: BodyStream, max: Option) -> BodyStream; + +pub struct ResponseHeaderLimiter { /* private checked-u64 counters and limits */ } +impl ResponseHeaderLimiter { + pub fn new(max_bytes: Option, max_count: Option) -> Self; + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError>; +} +``` + +Ownership is exact: `BodyStream` lives at `edgezero_core::body::BodyStream`; +`BROTLI_DECODER_FIXED_CHARGE_BYTES`, `ContentEncoding`, +`brotli_decoder_memory_charge`, `classify_content_encoding`, `decode_brotli_stream`, and +`decode_gzip_stream` live in `edgezero_core::compression`; `ResponseHeaderLimiter`, +`collect_response_stream`, `enforce_payload_content_length`, `limit_decoded_stream`, +`limit_encoded_stream`, and `rechunk_stream` live in `edgezero_core::outbound`. Core re-exports +every one at the `edgezero_core` root, and adapter crates use that single stable import +surface. Core owns typed source restoration and codec/accounting errors; adapters own the +outer deadline, cancellation guard, and native-completion protocol. + +### Task 1: Add typed response-limit errors + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] Add failing `response_too_large_*` tests for all reasons: `BrotliWindow`, `BufferedBody`, `DecodedBody`, `DecoderMemory`, `EncodedBody`, `HeaderBytes`, `HeaderCount`, and `Unspecified`. +- [ ] Assert status 502, kind `response_too_large`, no `reason`, `field_path`, or `Retry-After` in the wire response, and preservation of the Rust-side reason. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib response_too_large_`; expect failure. +- [ ] Add `ResponseLimitReason` and `EdgeError::ResponseTooLarge`; update every exhaustive match in production and tests in alphabetical order. +- [ ] Add and test both required constructors: `response_too_large(message)` stores `Unspecified`; `response_too_large_with_reason(message, reason)` stores the supplied reason. +- [ ] Rerun the focused test and core suite; expect success. +- [ ] Commit: `feat(core): add typed outbound response limits`. + +### Task 2: Make body stream errors exact + +**Files:** +- Modify: `crates/edgezero-core/src/body.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/src/proxy.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/response.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +**Contract:** + +```rust +pub type BodyStream = LocalBoxStream<'static, Result>; + +pub enum Body { + Once(Bytes), + Stream(BodyStream), +} + +pub fn from_external_stream(stream: S) -> Self; // E -> Internal +pub fn from_stream(stream: S) -> Self; // exact EdgeError +pub async fn into_bytes_bounded(self, max_size: usize) -> Result; +pub fn into_stream(self) -> Option; +pub fn stream(stream: S) -> Self; // infallible Bytes +``` + +- [ ] Add failing tests for typed error identity, external error conversion, infallible input, `From`, exact-limit success, first-byte-over-limit failure, checked overflow, and no poll after failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib body::tests::`; expect failure. +- [ ] Implement `BodyStream`, all three constructors, `into_stream`, `From`, and pre-append checked accounting. `into_bytes_bounded` must propagate an existing `EdgeError` unchanged. Add one typed-source test for every public error family and a foreign sentinel proving `from_external_stream` sanitizes before collection. Use `from_external_stream` only at actual platform/foreign-error inputs; a platform inbound producer either uses that compatibility boundary or deliberately maps to a public `EdgeError` before `from_stream`. Remove redundant `map_err(EdgeError::internal)` where it would erase an existing `EdgeError`. +- [ ] Run `rg -n 'Body::from_stream|Body::from_external_stream|Body::Stream|Self::Stream|\.into_stream\(' crates examples/app-demo --glob '*.rs' --glob '*.hbs'`. Classify construction/consumption sites, ignoring definitions, comments, and introspection-only matches. Add error-identity regressions at EdgeZero-owned error-producing boundaries; inbound platform streams receive only compile-required `from_external_stream` migration that preserves their existing Internal mapping. +- [ ] Run the core suite and `cargo test --workspace --all-targets`; expect success. +- [ ] Commit: `refactor(core): preserve typed body stream errors`. + +### Task 3: Normalize response metadata and body disposition + +**Files:** +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `response_normalization_*` tests for repeated and malformed `Connection`, nominated `content-encoding`/`content-length`, UTF-8 filtering, standard hop-by-hop fields, and idempotence. +- [ ] Add body-disposition cases for HEAD, every 1xx class, 204, positive/zero/absent 205, 304, and ordinary payload-bearing responses. +- [ ] Add explicit malformed, duplicate-conflicting, and comma-list `Content-Length` cases. Payload-bearing/205 ambiguity must fail as protocol; HEAD/304 metadata is retained without parsing it as a body promise. +- [ ] Add `outbound_response_into_response_reapplies_normalization`: mutate headers through `headers_mut`, then prove `into_response` strips hop-by-hop and nominated fields, preserves repeated end-to-end fields, keeps the body lazy, and returns typed protocol/internal errors rather than an infallible or generic conversion. +- [ ] Run both focused commands; each must execute a nonzero matching test count and fail for missing behavior: + - `cargo test --offline --locked -p edgezero-core --lib response_normalization_` + - `cargo test --offline --locked -p edgezero-core --lib outbound_response_into_response_` +- [ ] Implement `normalize_response_headers` returning `ResponseBodyDisposition`, a private checked `parse_content_length`, plus `OutboundResponse::into_response`. Preserve HEAD/304 representation metadata; strip prohibited 1xx/204 framing; validate payload/205 length syntax; rewrite successful 205 to `Content-Length: 0` only after capturing `declared_body`. `into_response` must reapply the same helper idempotently after any `headers_mut` use. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): normalize outbound response metadata`. + +### Task 4: Implement response header, encoded-byte, and chunk wrappers + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/Cargo.toml` +- Modify: `crates/edgezero-core/src/outbound.rs` + +**Pre-poll length contract:** adapters call this only after response normalization and only +for `ResponseBodyDisposition::Payload`: + +```rust +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; +``` + +- [ ] Add failing `response_resource_*` tests for exact and over-limit header count/bytes, repeated values, checked overflow, count-before-bytes precedence, and cumulative accounting across successive informational/final/trailer `ResponseHeaderLimiter::observe` calls. Prove a limit cannot reset at a field-section boundary. Adapters still document whether those sections are exposed before host allocation. +- [ ] Add classifier tables for absent and exactly one bare `identity`, `gzip`, or `br`; ASCII case and surrounding space/tab; repeated fields; comma-stacked, parameterized, empty, malformed/non-UTF-8, and unknown values. The alphabetically declared results are `Brotli`, `Gzip`, `Identity`, and `Passthrough`; every adapter consumes this helper rather than inspecting the header itself. +- [ ] Add payload-length tables for absent/zero/exact/over values; malformed, comma-list, and conflicting values; effective identity (absent or one bare `identity`) versus gzip/br/passthrough coding; encoded-only, decoded-only, final-buffer-only, and combined caps. Encoded applies to every coding. Decoded rejects early only for effective identity. The final Buffered cap rejects early for identity and passthrough, where wire bytes are final bytes, but never for gzip/Brotli that EdgeZero decodes. Include explicit-identity and passthrough final-buffer overages that reject before the scripted body is polled, plus a passthrough body larger than the decoded cap that succeeds when the independent final cap permits it. HEAD/1xx/204/304 never call this helper; 205 uses its disposition protocol. +- [ ] Add encoded-limit tests for exact/over limit, empty chunks counting as zero but remaining observable, cumulative gzip-member input, early stop, and typed source-error preservation. +- [ ] Add decoded-limit tests for exact/over cumulative identity, gzip, and Brotli output; checked overflow; empty chunks; early stop; and typed source-error preservation. A pipeline table proves passthrough never enters this wrapper. +- [ ] Add `rechunk_*` tests for lazy pulls, order preservation, exact maximum item size, empty items, source error ordering, early drop, and no claim about backing allocation/RSS. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib response_resource_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib content_encoding_classifier_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib payload_content_length_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib encoded_limit_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib rechunk_`; expect a nonzero failure. +- [ ] Implement `ContentEncoding` and `classify_content_encoding` in `compression.rs`; + implement `ResponseHeaderLimiter`, `enforce_payload_content_length`, + `limit_decoded_stream`, `limit_encoded_stream`, and `rechunk_stream` in `outbound.rs`, with the exact shared + signatures above. Re-export every new public item from `lib.rs`. The classifier uses + `HeaderMap::get_all`; only one bare visible field can select Identity/Gzip/Brotli, and + every repeated/stacked/parameterized/malformed/non-UTF-8/unknown shape is Passthrough. No + helper may poll after a terminal error or cap result. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): enforce outbound response resources`. + +### Task 5: Add deadline-aware response drains and JSON + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `outbound_response_drain_*` tests for empty and nonempty `Once` plus `Stream`; exact/over final collection limits; typed source failures; pre-append accounting; and `ResponseLimitReason::BufferedBody`. Exercise the same `collect_response_stream` helper adapters use before constructing a Buffered response. +- [ ] Add `_until` tests for entry, pre-poll, post-ready chunk, source error, cap result, and EOF checks. If expired when a decision is ready, 504 wins with `BudgetSource::Unspecified`, because this API receives only `Deadline`. Separately prove an adapter-style wrapper preserves its supplied `DispatchBudget::cause`. +- [ ] Add `outbound_response_json_*` tests for valid/malformed JSON, buffered/streamed modes, and streamed `json` invalid-state mapping to protocol 502. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_drain_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_until_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_json_`; expect a nonzero failure. +- [ ] Implement `collect_response_stream` plus synchronous `json(&self)` for `Body::Once`, returning protocol 502 for `Body::Stream`; implement consuming `into_bytes_bounded`, `into_bytes_bounded_until`, `json_bounded`, and `json_bounded_until`. These helpers own a final collection cap and report `BufferedBody`; the request's independent decoded-output policy has already wrapped eligible streams. Malformed upstream JSON is 502 with `BadGatewayReason::Decode(BadGatewayDecodeReason::Json)`. Do not delegate to the inbound body helper. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): add bounded outbound response drains`. + +### Task 6: Replace decoder error plumbing and completion semantics + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add failing `decoder_carrier_*` tests proving all `EdgeError` variants survive the `io::Error` bridge unchanged and decoder-originated errors become `BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip)` or `Decode(BadGatewayDecodeReason::Brotli)` with diagnostics. +- [ ] Change the workspace requirements to exact `async-compression = "=0.4.43"`, `brotli = "=8.0.4"`, and `brotli-decompressor = "=5.0.1"`; add the decompressor as a core dev dependency so Cargo constrains the same implementation used transitively. Refresh both lockfiles with exact `cargo update --offline --precise` commands for all three packages. +- [ ] Assert both graphs resolve exactly 0.4.43, 8.0.4, and 5.0.1 with no duplicate Brotli decoder version: + - `cargo tree --offline --locked -p edgezero-core -e normal | rg 'async-compression v0\.4\.43'` + - `cargo tree --offline --locked -p edgezero-core | rg 'brotli v8\.0\.4|brotli-decompressor v5\.0\.1'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core -e normal | rg 'async-compression v0\.4\.43'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core | rg 'brotli v8\.0\.4|brotli-decompressor v5\.0\.1'` +- [ ] Add `decode_gzip_*` tests for one member, concatenated/empty/split members, cumulative output, corrupt/truncated later members, trailing garbage, late source errors, empty chunks, and drain to native EOF. +- [ ] Add `decode_brotli_*` tests for one stream, buffered read-ahead recovery, trailing bytes, a second stream, late source errors, empty chunks, and native EOF. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decoder_carrier_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decode_gzip_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decode_brotli_`; expect a nonzero failure. +- [ ] Implement the private exact carrier and the concrete `BodyStream -> BodyStream` decoder signatures; enable `GzipDecoder::multiple_members(true)`; recover and inspect Brotli unread input and continue to native EOF. Migrate the existing Cloudflare/Fastly shared-decoder call sites in this same task so the commit leaves the workspace buildable and does not erase `EdgeError`. Never drain after cap/error/timeout. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `fix(core): preserve decoder completion and errors`. + +### Task 7: Gate Brotli allocation from the stream prefix + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `brotli_window_*` tests for all WBITS values 10 through 30, standard and two-byte large-window forms, every split prefix boundary, exact/over configured window limits, malformed/truncated prefixes, replay fidelity, and proof that both window and decoder-charge rejection occur before decoder construction. Add `brotli_decoder_memory_charge_*` tables asserting `16_777_216 + 2^WBITS` with checked `u64` arithmetic, exact/one-byte-under policy boundaries, default WBITS 24 fitting the 32 MiB default, and no decoder/source poll after a failed preconstruction gate. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib brotli_window_`; expect failure. +- [ ] Audit the exact locked `brotli 8.0.4 -> brotli-decompressor 5.0.1` source graph. Record in a code comment every decoder allocation family covered by `BROTLI_DECODER_FIXED_CHARGE_BYTES = 16_777_216` and why its grammar maximum fits; separately account for the maximum `2^WBITS` ring window. This source proof owns the constant. Corpus/allocation measurements are regression evidence only and may not substitute for the audit. Add exact `cargo tree --locked` assertions so a dependency update cannot retain the old charge silently. +- [ ] Implement one lazy Brotli state machine in `decode_brotli_stream(stream, max_window_bits, max_decoder_bytes)`: read only the fixed-size prefix, compute the source-audited charge, return `BrotliWindow` or `DecoderMemory` before decoder construction, replay the prefix, then construct and drive the decoder. Invalid syntax is `Decode(Brotli)`. Because construction happens on first poll, adapter polling places prefix reads and allocation inside the outer absolute deadline/cancellation wrapper. +- [ ] Compose only the **core-owned segment** in a private core test helper: classify -> payload-length checks -> encoded counter -> decoder (including the lazy Brotli prefix/charge gate) -> decoded counter only for Identity/Gzip/Brotli -> optional rechunker -> final collection cap. Add table tests for absent/identity, bare case-insensitive gzip/br, unknown, parameterized, stacked, and repeated encodings. Give decoded and final caps unequal values in both directions; prove passthrough bypasses only the decoded cap while every Buffered disposition still receives `BufferedBody` at its final cap. The helper mutates test headers to assert the contract that known decoded layers remove visible `content-encoding` and `content-length`, while Identity and Passthrough preserve them. Do not add a production response orchestrator, adapter deadline/cancellation wrapper, native-completion source, or platform response constructor in core. Phases 4-6 compose these public helpers at each native boundary and perform the specified header mutation before `OutboundResponse` construction. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): gate brotli decoder allocation`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-core --target wasm32-unknown-unknown` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `rg -n 'map_err\(EdgeError::internal\)' crates/edgezero-*` and inspect every remaining foreign-error boundary. +- [ ] `git diff --check` + +Expected result: core exposes one typed, ordered response-processing pipeline. Platform timers and cancellation remain adapter work in Phases 4-6. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md new file mode 100644 index 00000000..3ee99254 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md @@ -0,0 +1,187 @@ +# Outbound HTTP Phase 3: Capabilities and CLI Enforcement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make outbound requirements declarative and enforce them against one pinned app/runtime pair before build, serve, deploy, staged deploy, or demo execution. + +**Architecture:** Core owns the manifest schema and host grammar; macros bake the validated contract; the adapter registry reports support; the CLI resolves one target and its associated contract once, then gates before shell or registry dispatch. Spin platform-host validation compares canonical atomic host sets for only the selected component. + +**Tech Stack:** Rust 1.95, `serde`, `toml`, `validator`, proc macros/`trybuild`, adapter registry, CLI integration tests. + +--- + +## Preconditions + +- [ ] Phase 2 passes all repository gates. +- [ ] Read spec §3.5 in full, CLI portions of §§5-7, and current adapter discovery paths. +- [ ] Do not edit generated templates, app-demo, or public guide content in this phase; Phase 7 owns consumer declarations and docs. + +## Task Protocol + +For each task: write only the named unit/integration/trybuild cases; run the exact package/filter command and require a nonzero compile/assertion failure; implement the listed surface; rerun focused and package tests; run `git diff --check`; then stage only the task's files and make the stated commit. Resolver tests receive explicit directories/environment maps and never mutate process cwd/env concurrently. + +**Required exact test names:** `capability_manifest_rejects_unknown_duplicate_and_overlap`, `capability_manifest_rejects_reserved_key_at_every_depth_and_case`, `outbound_host_grammar_table`, `outbound_host_default_is_https_only`, `baked_manifest_states_are_distinct`, `baked_manifest_rejects_reserved_key_at_every_depth_and_case`, `app_macro_manifest_caches_are_per_implementation`, `adapter_capability_default_is_unsupported`, `explicit_manifest_is_authoritative`, `runtime_resolver_rejects_cross_app_pair`, `runtime_resolver_rejects_equal_distance_ambiguity`, `spin_selected_component_hosts_match_as_atomic_sets`, `execute_runtime_gates_each_runtime_action_once`, and `operational_actions_keep_existing_dispatch_path`. + +**Expected red:** schema types are initially unresolved; macro fixtures initially compile or share state when they must not; the registry lacks `capability`; resolver tests select/fall back instead of rejecting; Spin drift passes incorrectly; dispatch counters show zero or duplicate gate calls. + +### Task 1: Add strict capability and host schema + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Public surface:** `Capability`, `CapabilitySupport`, `ManifestCapabilities`, `ManifestOutboundCapability`, `AtomicHost`, `HostParseError`, `HostPat`, `Port`, `Scheme`, and `canonicalize_outbound_host` exactly as specified in §3.5.1. + +- [ ] Add failing manifest tests for all eight kebab-case outbound capabilities, including `outbound-complete-resource-accounting`, unknown keys, duplicate required/optional entries, required/optional overlap, and serialization round trips. Add a table that rejects any nested key equal to `capabilities` ignoring ASCII case, rejects non-lowercase top-level spellings, and accepts only exact lowercase top-level `capabilities`; recurse through tables and arrays. +- [ ] Add table tests for every accepted/rejected host grammar row from §3.5.1, including IPv4/IPv6, wildcard expansion, ports, LDH limits, punycode, raw Unicode, userinfo, paths, queries, fragments, whitespace, and malformed brackets. +- [ ] Map every rejected host row to the exact non-exhaustive `HostParseError` variant and stable nonempty Display text specified in §3.5.1; messages must not echo caller input. Use `Port::Any`, never a nonexistent wildcard variant. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib manifest::tests::`; expect failure. +- [ ] Implement strict `deny_unknown_fields` schema plus the runtime TOML reserved-key walker. Keep the shared `is_reserved_capabilities_key` policy and host parsing inside `manifest.rs`, because that file is textually included by the macro crate. +- [ ] Make absent hosts canonicalize to `https://*:*`; explicit `"*"` expands to both HTTP and HTTPS atomic entries. Validation, rendering, and drift checks must call the same parser. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): add outbound capability manifest schema`. + +### Task 2: Add baked-manifest state and macro isolation + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` +- Create: `crates/edgezero-macros/tests/ui/app_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/trigger_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/environment_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/adapter_build_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/array_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/app_capabilities_mixed_case.rs` and `.stderr` +- Create matching TOML inputs under `crates/edgezero-macros/tests/fixtures/` with the same stems + +**Contract:** `BakedManifest::{Absent, Malformed, Present}`, `ManifestContract::{Malformed, None, Present}`, and `Hooks::{manifest_json, manifest}`. + +- [ ] Add failing tests for absent, malformed, and present baked manifests; runtime/baked validation parity; reserved keys at every depth/case in both raw TOML and baked JSON; and two app implementations whose caches cannot leak into each other. +- [ ] Add trybuild failures for `capabilities` misplaced under nested tables and arrays at every depth listed by §3.5.3, plus a mixed-case nested spelling. +- [ ] Run `cargo test --offline --locked -p edgezero-macros`; expect failure. +- [ ] In `expand_app`, parse the source manifest to `toml::Value`, run the recursive reserved-key scan before typed deserialization can discard misplaced tables, then deserialize, validate, finalize, and serialize. Generate one `OnceLock` per app implementation, never in a shared default trait method. At runtime, parse baked JSON to `serde_json::Value`, run the equivalent JSON walker using the same case-insensitive key predicate and array recursion, then deserialize, validate, and finalize. Any scan/parse/validation failure is `BakedManifest::Malformed`, never `Absent` or an empty `Present` contract. +- [ ] Rerun macro and core tests; expect success. +- [ ] Commit: `feat(macros): bake outbound capability contracts`. + +### Task 3: Add fail-closed adapter capability metadata + +**Files:** +- Modify: `crates/edgezero-adapter/Cargo.toml` +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `Cargo.lock`, `examples/app-demo/Cargo.lock` + +- [ ] Add failing registry tests for the default `Unsupported` result and a fixture adapter that returns each support level. Do not publish in-tree matrix cells before the owning adapter behavior and evidence land. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter --features cli adapter_capability_`; expect a nonzero failure. +- [ ] Add the direct `edgezero-core` dependency and defaulted `Adapter::capability`. Until Phases 4-6, in-tree adapters inherit `Unsupported`; each owning phase adds its exhaustive override atomically with passing contracts and any required host proof. +- [ ] Refresh both independent lock graphs with `cargo check --offline -p edgezero-adapter --features cli` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-cli --tests`; these dependency-refresh commands are intentionally unlocked. The demo command must select a package in the demo workspace; Cargo rejects feature selection for the root workspace's `edgezero-adapter` package through the demo manifest. +- [ ] Assert the new edge with `cargo tree --offline --locked -p edgezero-adapter -e normal | rg 'edgezero-core'` and `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p edgezero-adapter -e normal | rg 'edgezero-core'`. +- [ ] Rerun `cargo test --offline --locked -p edgezero-adapter --features cli adapter_capability_`; expect a nonzero test count and success. +- [ ] Commit: `feat(adapter): add fail-closed capability metadata`. + +### Task 4: Build the paired runtime resolver + +**Files:** +- Modify: `crates/edgezero-adapter/src/lib.rs` +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Create: `crates/edgezero-cli/src/manifest_source.rs` +- Modify: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/adapter.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` + +**Contract:** declare the exact spec §3.5.3 types in lint order. In +`edgezero-adapter::registry`, add private-field `AdapterExecutionTarget { app_root, +component, platform_manifest }` with public `new` plus read-only accessors, and add +`Adapter::execute_target(action, &target, args)`. Its default returns an explicit pinned-target +unsupported error; it must not call the cwd-discovering `execute`. In `manifest_source`, add +`ResolvedAdapterTarget::{Registered(AdapterExecutionTarget), Shell}`, `ResolvedManifest { +loader, path }`, owned `ResolvedRuntime { action, adapter, contract, target }`, and +`ResolvedShellTarget { bind_host, bind_port, command, environment, root }`. Keep the latter +fields private to `manifest_source`; `ResolvedRuntime` exposes only crate-private read-only +`action()`, `adapter_name()`, `manifest()`, and `target()` accessors, and the contained types +expose read-only accessors for their listed fields. No `ResolvedRuntime` constructor or +mutator is exposed outside the resolver, and module items/struct fields/enum variants/impl +methods must satisfy `arbitrary_source_item_ordering`. + +- [ ] Add pure failing tests for explicit `EDGEZERO_MANIFEST`, captured invocation-directory resolution, ancestor precedence, bounded workspace descendants, equal-distance ambiguity, target/contract mismatch, symlink containment, malformed contracts, and the narrow valid `contract: None` case. Assert app/shell roots are absolute canonical directories, contract/platform manifests are absolute canonical regular files contained by their corresponding root, and file/directory type swaps fail closed. +- [ ] Add tests proving shell-command and registry-backed runtime-producing actions receive + the same pinned target and cannot rediscover a different manifest. Prove the canonical + adapter identity and requested action are stored in each resolved runtime. For every + in-tree adapter, run from an unrelated cwd with conflicting discoverable manifests and + assert `execute_target` uses only `app_root`, `platform_manifest`, and `component`; assert + the default fixture adapter fails instead of entering its cwd-discovering `execute`. + Assert the resolver rejects operational actions so auth/version/healthcheck/rollback + cannot accidentally enter this outbound-only path. +- [ ] Run `cargo test --offline --locked -p edgezero-cli manifest_source`; expect failure. +- [ ] Extract target-argument parsing and discovery into pure helpers. Explicit env selection is authoritative and never falls back. Canonical platform files must remain under the canonical app root. Resolve and pin exactly one adapter identity, manifest contract, target, and runtime-producing action; no later dispatcher step may read environment variables or scan ancestors/workspace descendants. +- [ ] Preserve direct adapter API discovery for callers outside the CLI. Refactor each + in-tree adapter's build/serve/deploy implementation into target-aware internal helpers; + direct `execute` discovers once and delegates, while the paired CLI path calls + `execute_target` with the cross-crate typed target and performs no discovery. Do not use + process-wide cwd changes or synthetic path/component entries in `adapter_args`. +- [ ] Rerun focused CLI tests; expect success. +- [ ] Commit: `refactor(cli): resolve paired runtime contracts`. + +### Task 5: Validate selected Spin component host drift + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `crates/edgezero-cli/src/manifest_source.rs` + +- [ ] Add failing tests for explicit component, single inferred component, missing/ambiguous components, absent hosts, wildcard expansion, scheme case normalization, malformed platform entries, set-order independence, and drift diagnostics containing manifest path/component/expected list. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli spin_selected_component_`; expect a nonzero failure. +- [ ] Implement one pure component selector shared by adapter validation and CLI enforcement. Compare only selected-component canonical atomic sets. +- [ ] Rerun `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli spin_selected_component_`; expect success. +- [ ] Run `cargo test --offline --locked -p edgezero-cli --no-default-features --features cli spin_selected_component_`; expect success with the Spin registry adapter genuinely unlinked. +- [ ] Separately run `cargo test --offline --locked -p edgezero-cli spin_selected_component_`; expect success with the default linked-adapter set. +- [ ] Commit: `feat(cli): reject Spin outbound host drift`. + +### Task 6: Gate actions exactly once + +**Files:** +- Modify: `crates/edgezero-cli/src/adapter.rs` +- Modify: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/demo_server.rs` +- Modify: `crates/edgezero-cli/Cargo.toml` + +**Dispatch signatures:** add two outbound-scoped dispatch functions that accept only the owned `ResolvedRuntime` and adapter arguments. They call private `ensure_action_capabilities(&runtime)` exactly once before branching. That helper derives the canonical adapter name, runtime-producing action, and manifest through read-only accessors and delegates to `ensure_capabilities(runtime.adapter_name(), ManifestContract::from_opt(runtime.manifest()))`. The private post-gate dispatcher also accepts the complete runtime and never gates again. There is no duplicate adapter/action argument that can diverge between admission and the eventual side effect. Keep the existing `execute(..)` / `execute_capture(..)` APIs for exempt operational actions unchanged. + +```rust +pub fn execute_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String>; + +pub fn execute_capture_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result, String>; +``` + +- [ ] Add failing tests with fixture adapters for required versus optional capabilities at all four support levels, missing registry behavior, and malformed/absent contracts. +- [ ] Add a counter seam proving `Build`, `Serve`, `Deploy`, and `DeployStaged` gate once in both `execute_runtime` and `execute_capture_runtime` before any shell/registry side effect. Use two fixture adapters and distinct actions to prove capability lookup, shell diagnostics/registry lookup, and final `AdapterAction` conversion all observe the adapter/action stored by the resolver; the outbound-scoped dispatch API must offer no override arguments. +- [ ] Add `operational_actions_keep_existing_dispatch_path`: all three auth actions, emit-version, healthcheck, and rollback continue to call the existing dispatcher, remain exempt from capability gating, and preserve registered no-project behavior. The test must fail if any of them constructs `ResolvedRuntime`, invokes paired manifest discovery, or acquires a manifest requirement. Provision and config are outside this outbound specification and receive no requirements here. Add a demo test proving Axum checks the baked manifest before startup. +- [ ] Run `cargo test --offline --locked -p edgezero-cli`; expect failure. +- [ ] Implement one private post-gate dispatcher so `execute_capture_runtime` cannot re-enter public `execute_runtime`. Required accepts only Native/BoundedCooperative; optional warns for BestEffort/Unsupported. Derive adapter/action exclusively from `ResolvedRuntime` in every outbound-gated branch. Leave `auth.rs` and the existing emit-version/healthcheck/rollback dispatch path unchanged; after a captured Deploy lacks a version, retain today's registered `EmitVersion` call rather than broadening this resolver. +- [ ] Rerun CLI tests; expect success. Adapter-specific capability values remain Unsupported until their implementation phase. +- [ ] Commit: `feat(cli): enforce outbound capabilities`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `cargo test -p edgezero-adapter --all-targets` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: every execution path evaluates one manifest/target pair once, before side effects, while platform support claims remain explicit and conservative. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md new file mode 100644 index 00000000..924443d0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md @@ -0,0 +1,298 @@ +# Outbound HTTP Phase 4: Axum and Cloudflare Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the full outbound contract for Axum and Cloudflare, including batch behavior, typed cleanup, adapter scheduling, and executable native/WASM/host contracts. + +**Architecture:** Both clients consume the same core request and response pipeline. Axum uses Reqwest and bounded conversion at one Tokio blocking boundary. Cloudflare uses a target-neutral orchestration driver plus a small Worker WASM bridge that owns abort, timer, native body, and host-event yield resources through terminal completion. + +**Tech Stack:** Reqwest 0.13.4, Axum/Tokio, Worker 0.8.3, Web APIs, `web-time`, `futures`, wasm-bindgen-test, workerd/deployed probes. + +--- + +## Preconditions and Owned Files + +- [ ] Phases 1b-3 pass all gates. +- [ ] The implementation index's inert workflow-bootstrap PR is merged to the default + branch, so `.github/workflows/outbound-cloudflare-deployed.yml` can receive + `workflow_dispatch` for an exact reviewed implementation SHA. Verify the default-branch + workflow has only the protected-environment manual trigger; do not attempt to dispatch a + workflow definition that exists only on this implementation branch. +- [ ] Phase 0 has also provisioned the protected `outbound-cloudflare-probe` environment, + required reviewers, exact disposable secrets/account, fixed reviewed probe ref, and the + authenticated observable-origin arm/case/observe/delete protocol. Task 4 cannot begin on + workflow presence alone. +- [ ] Re-read spec §§3.1-3.5, 4.1, 4.2, 5.2-5.5, and the Axum/Cloudflare rows in §7. +- [ ] Add `web-time` and independent `test-utils = []` to both adapter manifests. `test-utils` must not enable a runtime feature. +- [ ] Modify only Axum/Cloudflare adapters, root `Cargo.toml`/`Cargo.lock`, `examples/app-demo/Cargo.toml`/`Cargo.lock`, their host fixtures, the shared nonzero-test gate, and `.github/workflows/test.yml`. The protected Cloudflare dispatcher remains the inert default-branch bootstrap workflow; no core semantics or Phase 7 templates/docs. + +## Task Protocol + +For each adapter task: add only the named contract cases; run its exact Task 1 command and require nonzero failing tests; implement through the production driver; rerun to zero failures; run that adapter's target check and `git diff --check`; then stage only listed files and make the stated commit. Mock transport proves orchestration only. Any cancellation, raw-wire, or host-yield claim marked host-observed requires the workerd/deployed fixture before its capability cell is published. Task 4 is the exception to the one-commit task protocol: its protected red/green proof requires two immutable commits with the exact messages named below. Keep both commits reachable and do not amend, rebase, or squash either one after recording its workflow evidence. + +**Required exact test names:** `send_all_preflight_precedence_and_indices`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `send_all_starts_every_eligible_exchange`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `redirect_response_is_not_followed`, `streamed_tasks_consume_fast_body_before_slow_headers`, `request_deadline_checks_before_and_after_source_ready`, `response_pipeline_preserves_typed_limits`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `axum_205_reads_at_most_once`, `axum_response_conversion_keeps_reactor_live`, `cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect`, `cloudflare_raw_fetch_rejects_failed_option_set_without_dispatch`, `cloudflare_origin_observes_canonical_host`, `cloudflare_stream_delivers_first_chunk_before_source_eof`, `cloudflare_frozen_clock_forces_host_yield`, `cloudflare_205_reads_at_most_once`, `cloudflare_invalid_utf8_request_header_returns_error_not_panic`, `cloudflare_valid_non_ascii_request_header_survives`, `cloudflare_abort_guard_fires_exactly_once`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** contract smoke tests first fail their sentinel assertion; behavior tests then expose serial starts, followed redirects, timeout/cap misclassification, reactor starvation, transformed fetch bytes, absent abort, extra 205 reads, or panic. Host-yield proof fails by timer starvation, not by a fabricated clock advance. + +### Task 1: Establish executable contract seams + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `examples/app-demo/Cargo.lock` +- Modify: `crates/edgezero-adapter-{axum,cloudflare}/Cargo.toml` +- Modify: `crates/edgezero-adapter-cloudflare/.cargo/config.toml` +- Create: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify/Create: both adapters' `src/test_utils.rs` +- Modify: `.github/workflows/test.yml` + +- [ ] Pin Reqwest and Worker before compiling either bridge. Set root `reqwest = "=0.13.4"`. Set root and app-demo Worker requirements to exact `=0.8.3`, both with `default-features = false` and `features = ["http"]`; make the Cloudflare adapter inherit that workspace dependency while retaining `optional = true`. +- [ ] Refresh both independent lock graphs with `cargo update --offline -p reqwest --precise 0.13.4`, `cargo update --offline -p worker --precise 0.8.3`, `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p reqwest --precise 0.13.4`, and `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p worker --precise 0.8.3`. +- [ ] Assert both graphs before adding bridge code: + - `cargo tree --offline --locked -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown | rg 'worker v0\.8\.3'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown | rg 'worker v0\.8\.3'` +- [ ] Assert both Reqwest graphs resolve exactly 0.13.4: + - `cargo tree --offline --locked -p edgezero-adapter-axum --features axum -e normal | rg 'reqwest v0\.13\.4'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-axum -e normal | rg 'reqwest v0\.13\.4'` +- [ ] Add one deliberately failing smoke test to each native contract module. +- [ ] Remove Cloudflare's whole-file `cfg(all(feature = "cloudflare", target_arch = "wasm32"))`; place it only on the browser SDK module and add a native `test-utils` module, so the native command cannot succeed with zero tests. +- [ ] Replace the Cloudflare crate's stale Preview-1 Cargo config with `[build] target = "wasm32-unknown-unknown"` and an exact `wasm-bindgen-test-runner` for that target. CI installs the runner and executes one crate-local contract command without `--target` or `CARGO_TARGET_*_RUNNER`; a nonzero passing sentinel proves local target resolution consumes the committed config. +- [ ] Run: + - `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` + - `cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] Confirm each command executes a nonzero test count and fails at the assertion, not at linking/imports. +- [ ] Replace smoke failures with feature-gated seams for clocks, timers, transport/body handles, abort events, host yields, and stage delays. Production and fake transports must call the same driver. +- [ ] Commit: `test(adapters): add outbound contract seams`. + +### Task 2: Implement Axum dispatch and batch behavior + +**Files:** +- Create: `crates/edgezero-adapter-axum/src/outbound.rs` beside the temporary legacy `src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [ ] Add failing tests for complete batch preflight, empty batch, index alignment, partial failure, one shared `batch_started_at`, distinct per-slot elapsed values, all valid slots started before completion, duplicate request headers, methods, canonical target use, and a 302 response returned without following its `Location`. Advance the clock during normalization/preflight/builder preparation and prove `send`/`send_all` budgets and elapsed values use the method-entry snapshot rather than a post-preflight re-anchor. Preflight failures receive measured elapsed time; an early completed slot retains its own terminal elapsed when a sibling finishes later; backwards injected time yields zero plus Internal, while a legitimate same-tick result may also be zero with its ordinary outcome. A valid one-slot buffered `send_all` must match `send` outcome semantics while its elapsed field is asserted separately. Include GET/HEAD body invalidity beating batch-only stream-mode errors and prove a rejected body source is never polled. +- [ ] In the production Reqwest request captured by the seam, assert exact core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and query preservation. The adapter must never rebuild these values from URL components. +- [ ] Add upload tests for exact/over request cap, source error, stalled pull, timeout before/after readiness, and no further polling after failure. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: mutate `Connection` nominations, `Host`, `Content-Length`, and `Transfer-Encoding` through `headers_mut` after construction, then inspect the production Reqwest request and prove the adapter called `normalize_for_dispatch` immediately before SDK construction. Only adapter-owned framing and the canonical host may remain. +- [ ] Run the Axum contract command; expect failures. +- [ ] Implement `AxumOutboundClient::{send, send_all}`. Capture the monotonic snapshot as the first operation in each public method, before normalization/preflight, and pass it through without re-anchoring. Configure Reqwest with `redirect(Policy::none())`, `no_gzip()`, `no_brotli()`, `no_deflate()`, and `no_zstd()`, and remove the client-wide 30-second timeout. Assert compressed loopback bytes reach only the shared EdgeZero decoder. Insert `Accept-Encoding: identity` only when the normalized request has no caller-supplied `Accept-Encoding`; preserve any caller value for the shared response decoder. +- [ ] Run `validate_for_dispatch` exactly once per request immediately after the method-entry snapshot and before batch-only mode checks, budget selection, body polling, normalization, or Reqwest construction. Batch survivors enter a private already-validated helper so they are not validated twice. +- [ ] Apply `RequestBuilder::timeout(remaining)` immediately before `send`. Race every streamed upload pull against the same absolute budget and buffer only within `max_request_body_bytes`. +- [ ] Use `join_all` after preflight, preserving slot order and independent results. Construct each `OutboundSlotResult` inside that slot's future at its terminal point; never measure after `join_all` returns or copy batch elapsed into all slots. +- [ ] Compile/export the new module for contract tests, but leave production request injection on the legacy client until Task 3 is green. +- [ ] Rerun the contract command; expect success. +- [ ] Commit: `feat(axum): implement outbound dispatch`. + +### Task 3: Implement Axum response pipeline and conversion schedule + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Delete: `crates/edgezero-adapter-axum/src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [ ] Add loopback-origin tests for repeated headers including multiple `Set-Cookie` values, raw malformed nomination/encoding lines, all content-encoding policy rows, cumulative visible header count/bytes, encoded bytes, decoded output, Brotli window/decoder-state charge, and independent final Buffered limits, gzip members/native EOF, bodyless/205 cases, timeout races, future/source drop on cancellation/error/cap, and non-2xx success. Use unequal decoded/final caps in both directions and prove raw passthrough bypasses only the decoded cap. Exercise HTTP/1 and HTTP/2 separately: HTTP/1 may close its connection, while HTTP/2 resets only the request stream and may retain the pooled connection. Do not assert a generic connection drop, source allocation bound from `max_chunk_bytes`, or bounded origin observation. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving the shared pre-poll helper runs after normalization: encoded caps apply to every coding; decoded and final Buffered caps reject early for effective identity; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. Every rejection leaves the loopback body unread. Include an explicit `Content-Encoding: identity` overage. +- [ ] Classify loopback DNS/connect/TLS establishment failures before a response head as `BadGatewayReason::Unreachable`, failures after connection progress as `Transport`, framing/completion as `Protocol`, and EdgeZero-owned JSON/gzip/Brotli failures with their exact `BadGatewayDecodeReason`. Never infer a reason by parsing diagnostic text; unavailable phase evidence falls back to `Unspecified`. +- [ ] For Axum 205, prove positive visible length aborts without reading and absent/zero length performs at most one native read: EOF succeeds; an empty or nonempty item aborts without a second read. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Every case must return the attributed 504, retain typed late source/completion errors, and keep cleanup armed until native EOF. +- [ ] Add the streamed fan-out regression: join tasks that each perform `send` and immediately consume the body; the fast body must finish before a sibling's delayed headers. A control that joins header-only sends then delays consumption must fail. +- [ ] Add exact and one-byte-over `AXUM_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB` conversion cases, preserving the original `EdgeError` status/kind. +- [ ] Add a reactor-progress regression proving no nested runtime/blocking deadlock. +- [ ] Add optional live HTTP/2 characterization through the deployed proxy path and HTTP/3 only when that production client feature is enabled. Record observed stream reset/connection reuse behavior without making it a blocking portable guarantee; the deterministic contract is guest-visible timeout plus request/body/source future drop. +- [ ] Run the Axum contract command; expect failures. +- [ ] Feed Reqwest `Response::chunk()` lazily through the core pipeline; retain method/mode/all policy fields before consuming request parts. +- [ ] Make response conversion async. Await routing and conversion inside exactly one `block_in_place(|| Handle::block_on(async { ... }))` boundary. +- [ ] On conversion failure, emit `EdgeError::into_response()`; never replace typed 502/504 errors with a generic 500. +- [ ] Switch request extensions to `HttpClient`, remove the adapter's legacy module, and do not leave a module/type alias. Do not change inbound buffering except compile-required typed stream conversion. +- [ ] Rerun Axum contract and crate tests; expect success. +- [ ] Commit: `feat(axum): enforce outbound response contracts`. + +### Task 4: Prove a Cloudflare host-event yield primitive + +**Files:** +- Create/Modify: `crates/edgezero-adapter-cloudflare/tests/host/outbound.mjs` +- Create: `crates/edgezero-adapter-cloudflare/tests/host/deployed.mjs` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.lock` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/wrangler.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/src/lib.rs` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/README.md` +- Modify: `crates/edgezero-adapter-cloudflare/package.json` +- Create: `crates/edgezero-adapter-cloudflare/package-lock.json` + +- [ ] Put an empty `[workspace]` table in the fixture `Cargo.toml` so Cargo treats it as a standalone nested workspace rather than an undeclared member of the repository workspace. +- [ ] Pin the fixture's Worker dependency to exact `=0.8.3`. Generate and commit its independent Rust lock with `cargo check --offline --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml`; then require `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` and `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.3'` before `worker-build`. +- [ ] Pin fixture `compatibility_date = "2023-05-01"` and no additional compatibility flags, exactly matching the generated production `wrangler.toml.hbs`. Add a fixture assertion that parses both files and fails if their date/flags diverge. +- [ ] Pin `worker-build = 0.8.3` in the fixture instructions/CI setup and add package script + `build:outbound-fixture` running + `worker-build --release tests/fixtures/outbound-worker`. The positional path selects the + nested fixture crate; do not pass `--manifest-path`, which is only a forwarded Cargo option + after `worker-build` has already selected a crate. Record `worker-build --version` and + `wrangler --version` in the probe output. +- [ ] Add exact package scripts: `test:workerd` invokes `node tests/host/outbound.mjs --mode workerd`; `test:deployed-timing` invokes `node tests/host/deployed.mjs`. Neither script may turn absent credentials, zero probes, or unsupported host behavior into success. +- [ ] Generate and commit the adapter's first npm lockfile with `npm --prefix crates/edgezero-adapter-cloudflare install --package-lock-only --ignore-scripts`; subsequent host jobs use `npm ci` and may not float Wrangler independently of that lock. +- [ ] Build a deployed timing probe with a frozen guest clock and a continuously-ready stream. Compare candidate host-event yields; microtask/self-wake/immediately-ready futures are invalid. +- [ ] Establish an executable red in commit `test(cloudflare): add failing host-yield candidate`: + use a microtask/self-wake/immediately-ready yield, set `RED_SHA=$(git rev-parse HEAD)`, and + push that exact tree with + `git push origin HEAD:refs/heads/outbound-probe-reviewed`; the protected ref must accept it + only as a fast-forward. Dispatch the + default-branch workflow with + `RED_RUN_URL="$(gh workflow run outbound-cloudflare-deployed.yml --ref main -f commit_sha="$RED_SHA")"`, + derive `RED_RUN_ID="${RED_RUN_URL##*/}"`, reject an empty/non-URL result, and run + `gh run watch "$RED_RUN_ID" --exit-status`. Require that command to return nonzero because + the named frozen-clock timer probe starves while every + setup and positive-control probe executes. Missing credentials, zero probes, build failure, + or an unrelated assertion is not the expected red. Record the workflow URL and `RED_SHA` + before changing the reviewed ref. +- [ ] Select one candidate host-event primitive and freeze one quota in `1..=64` in commit + `test(cloudflare): characterize outbound host yielding`. Set + `GREEN_SHA=$(git rev-parse HEAD)`, push that exact tree to the same reviewed ref with + `git push origin HEAD:refs/heads/outbound-probe-reviewed`, require the update to be a + fast-forward from the red commit, and dispatch with + `GREEN_RUN_URL="$(gh workflow run outbound-cloudflare-deployed.yml --ref main -f commit_sha="$GREEN_SHA")"`, + derive `GREEN_RUN_ID="${GREEN_RUN_URL##*/}"`, reject an empty/non-URL result, and require + `gh run watch "$GREEN_RUN_ID" --exit-status` to succeed. Require every named probe plus a + positive count to pass, including timer and abort delivery under the frozen guest clock. + Record the passing workflow URL, `GREEN_SHA`, primitive, quota, runtime, and observed bound + before starting Task 5. The green commit is Task 4's final commit; do not create a third + summary commit or rewrite either evidence-bearing SHA. +- [ ] If no candidate passes on the pinned Worker runtime, STOP Phase 4 and downgrade/re-review the Cloudflare deadline capability before implementation. + +### Task 5: Implement the Cloudflare target-neutral driver + +**Files:** +- Create: `crates/edgezero-adapter-cloudflare/src/outbound.rs` beside the temporary legacy `src/proxy.rs` +- Create: `crates/edgezero-adapter-cloudflare/src/outbound/worker.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [ ] Add failing native tests for full batch preflight/order/isolation, one-slot `send_all`/`send` outcome equivalence, one method-entry `batch_started_at`, distinct per-slot elapsed values including preflight failures, upload caps/errors/timeouts, send/body timeout races, encoded/decoded/final-cap fairness, frozen-clock terminal decisions, abort ownership, every early-return/drop path, and 3xx visibility without redirect dispatch. Advance the injected clock during request preparation and prove that elapsed time consumes the original `send`/`send_all` budget. Complete one slot early and another late, then prove the first elapsed value was captured at its own terminal point rather than batch return. Include backwards-clock zero-plus-Internal and legitimate same-tick zero cases, GET/HEAD body errors beating batch-only errors, and no rejected-source polls. +- [ ] Through the target-neutral `NativeBody`/header seam, cover every content-encoding row, effective-identity and raw-passthrough pre-poll rejection including explicit `identity`, unequal encoded/decoded/final caps, Brotli decoder-state charge, cumulative guest-visible header sections, repeated `Set-Cookie`, 205 settlement, first-chunk-before-EOF, typed completion, and decoder/cap/deadline ownership. These tests own orchestration; Task 7 repeats Workers-only SDK response boundaries under workerd. +- [ ] Prove typed 502 classification at each seam: provider-confirmed DNS/connect/TLS establishment before a response head is `Unreachable`, later body/completion transport is `Transport`, framing is `Protocol`, and shared JSON/gzip/Brotli decoding preserves exact coding identity. A generic Promise rejection without phase evidence is `Unspecified`, not guessed from its message. +- [ ] Capture the exact canonical string supplied to `worker::Request` and assert core + serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA + input, empty paths, and queries; no bridge-side URL reconstruction is allowed. Do not call + this the final wire URL: the Web API may perform a second WHATWG parse/serialization. + Task 7 separately asserts the effective origin-observed target and Host against the + corresponding canonical semantics without requiring byte equality to the input string. +- [ ] Run the Cloudflare native contract command; expect failure. +- [ ] Implement one driver parameterized by `Clock`, `Timer`, `HostYield`, `RawFetch`, `NativeBody`, and `AbortHandle`. Capture the clock as the first operation in each public send method, before normalization/preflight, and never re-anchor. Count ready items including empty chunks; preserve quota state across polls and reset only after the selected host event completes. +- [ ] Run `validate_for_dispatch` exactly once per request immediately after the method-entry snapshot and before batch-only checks, budget selection, body polling, normalization, or Worker request construction. Batch survivors enter a private already-validated helper. +- [ ] Keep the owning abort guard armed through decoder completion and native EOF. Disarm only on full success. +- [ ] Implement concurrent eligible `send_all` exchanges with complete preflight and stable indices. Each slot constructs its timed result at preflight failure or inside its exchange future immediately when terminal; vector assembly performs no timing measurement. +- [ ] Compile/export the new module for contracts, but leave production request injection on the legacy client until Task 6 is green. +- [ ] Rerun native contract tests; expect success. +- [ ] Commit: `feat(cloudflare): add outbound exchange driver`. + +### Task 6: Add Worker raw fetch and response bridges + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound/worker.rs` +- Delete: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [ ] Add browser WASM tests that inspect `RequestRedirect::Manual` on the constructed `request.inner()` separately from `signal` plus `encodeResponseBody: "manual"` on the final fetch initializer; the latter must not reset redirect. Preserve request header list semantics and compile/exercise only portable Web API bridge construction. +- [ ] Do not call `worker::Headers::get_all` or claim real Worker SDK response conversion in the browser runner: Worker 0.8.3 binds the Workers-only `Headers.getAll()` API without a catch boundary. Compile the production response bridge here, but execute its real header/body conversion, repeated `Set-Cookie`, and checked completion assertions in Task 7's workerd fixture. +- [ ] Keep payload-length/decode/cap/body-disposition behavior in the target-neutral native suite. The workerd suite repeats the SDK boundary cases proving malformed/conflicting lengths and encoded/effective-identity overages reject before a Worker body stream is polled. +- [ ] Ensure the production bridge composes the same native-tested pipeline in both Buffered and Streamed modes. Task 7 runs real Worker cases stalled before decoded output, midstream, and after codec EOF but before native EOF; those cases must retain attributed 504, typed late source/completion errors, and exactly-once abort ownership. +- [ ] Add the same joined send-plus-immediate-body-consumption regression as Axum to the native driver suite. The first response chunk before source EOF is repeated through the real Worker response bridge in Task 7; collecting the whole body is not sufficient evidence. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization` at the raw-fetch boundary with post-construction `Connection` nominations, stale `Host`, `Content-Length`, and `Transfer-Encoding`. Assert stale Host is removed, no replacement Host header is inserted, and the exact canonical URL string is supplied when constructing the Web request. For headers, exercise request list semantics, preserve valid non-ASCII UTF-8 strings such as `café`, reject a raw invalid-UTF-8 value with an error rather than a panic, and assert only Cloudflare's visible normalized response-string baseline without claiming unavailable raw octets or final wire-URL byte equality. +- [ ] Inject raw-JS option-set failures and prove both a thrown `Reflect::set` and `Ok(false)` return typed Internal without invoking fetch. Also test checked JS-response conversion failure. +- [ ] The browser command must list and execute a nonzero portable bridge sentinel, but it is compile evidence only for the Workers-specific response path. Task 7's workerd suite is the required execution gate for that path. +- [ ] Run the WASM contract command; expect failure before implementation and nonzero success afterward. +- [ ] Implement lazy `Body::Stream` output and the raw JS/Web fetch bridge. Do not add direct Web dependencies unless the pinned Worker re-exports prove insufficient. +- [ ] Switch request extensions to `HttpClient`, delete the legacy module, and leave no module/type alias. +- [ ] Remove production `brotli`/`flate2` only after no production callsite remains. +- [ ] Commit: `feat(cloudflare): bridge raw outbound fetches`. + +### Task 7: Land host evidence and CI gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/host/outbound.mjs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/host/deployed.mjs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/wrangler.toml` +- Modify: `crates/edgezero-adapter-cloudflare/package.json` +- Modify: `crates/edgezero-adapter-cloudflare/package-lock.json` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/README.md` +- Create: `scripts/run_test_nonzero.sh` + +- [ ] Run workerd and deployed-origin tests for real Worker SDK response conversion, Workers-only `Headers.getAll("set-cookie")`, repeated `Set-Cookie`, origin-observed effective URL/Host agreement after the Web API's WHATWG normalization, send-future/body cancellation, early consumer drop, cap/decode failure, first chunk before source EOF, gzip/br decoding exactly once in Buffered and Streamed modes, effective-identity `Content-Length` rejection before body polling, the complete passthrough encoding matrix, raw upstream receipt under manual fetch encoding, downstream `EncodeBody::Manual`, 205 settlement, and selected host-yield/frozen-clock timing. Include dot-segment, numeric-IPv4, IDNA, percent-encoding, empty-path, and query rows. No browser-only result substitutes for any of these cases. +- [ ] Make `tests/host/outbound.mjs` require every exact probe identifier, print `PASS ` for each, print a positive final count, and fail on missing/duplicate/zero probes. Include `repeated_set_cookie_survives_response_conversion`, `cloudflare_origin_observes_canonical_host`, `cloudflare_stream_delivers_first_chunk_before_source_eof`, `response_content_length_explicit_identity_rejects_before_body_poll`, `cloudflare_205_reads_at_most_once`, and `cloudflare_frozen_clock_forces_host_yield`. +- [ ] Record runtime/tool versions and observed bounds in fixture output or adjacent README comments. Mocks do not satisfy this gate. +- [ ] Before `worker-build`, CI runs the fixture's locked metadata and Worker tree assertions from Task 4. Install the fixture builder with `cargo install worker-build --version 0.8.3 --locked`; verify `worker-build --version` before invoking `build:outbound-fixture`. Assert the fixture's compatibility date/flags match the production template. The README local command and CI use those same exact versions/settings. +- [ ] Keep deterministic workerd coverage in the normal untrusted `pull_request` workflow. + Use the already-merged default-branch + `.github/workflows/outbound-cloudflare-deployed.yml` bootstrap dispatcher with **only** + `workflow_dispatch`, `contents: read`, and the protected `outbound-cloudflare-probe` + environment; never use `pull_request_target`. It requires `commit_sha`, checks out that + exact full SHA, and fails unless `git rev-parse HEAD` is byte-for-byte equal before any + probe command receives secrets. Do not modify the workflow on this implementation branch + and mistake that branch-only file for executable dispatch infrastructure. +- [ ] The protected job uses only a disposable Workers account and independently observable disposable origin. Define exact secrets `CLOUDFLARE_API_TOKEN` (Workers Scripts edit limited to that account), `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`; production account tokens, zones, routes, and origins are forbidden. Install Node/Rust from `.tool-versions`, run `npm ci`, assert both lock graphs and Worker 0.8.3, install `worker-build 0.8.3 --locked`, assert tool versions, build the fixture, then run `npm --prefix crates/edgezero-adapter-cloudflare run test:deployed-timing`. +- [ ] Implement the Phase 0-frozen origin protocol in the fixture README and driver: + authenticated `POST /v1/probes/{run_id}/arm` creates isolated state; Worker requests use + `/v1/probes/{run_id}/{case_id}` for raw bytes, delayed headers/chunks, stalled upload reads, + and cancellation; authenticated `GET /v1/probes/{run_id}/observations` returns timestamped + bytes/EOF/disconnect facts; authenticated `DELETE /v1/probes/{run_id}` removes them. Every + request carries an unguessable run ID and token. The driver creates a unique Worker name + from `GITHUB_RUN_ID` + `GITHUB_RUN_ATTEMPT`, polls its `workers.dev` URL, requires every + exact probe ID plus a positive final count, and verifies cancellation/timing from origin + observations rather than client timing alone. Changing that protocol requires updating and + re-reviewing Phase 0 before dispatch. +- [ ] Put Worker deletion and origin-state deletion in `deployed.mjs` `finally` blocks. A failed assertion must still attempt both cleanups; a cleanup failure is reported and fails the job. The script must reject missing variables before deployment and must never reuse a fixed Worker name or observation namespace. +- [ ] Create executable POSIX `scripts/run_test_nonzero.sh `. It first invokes the command with `-- --list`, requires the sentinel as the exact terminal test-name component (an optional Rust module prefix ending in `::` is allowed) and at least one listed test, then invokes the command normally; list or execution failure propagates. Match a listed test identifier, not arbitrary diagnostic text. Use it for every native, browser-WASM, and capability-table Cargo test added to CI so cfg/feature drift cannot turn a gate into zero-test success. +- [ ] Add native Axum/Cloudflare commands and browser-WASM `test-utils` activation to CI through that helper. Add and execute explicit capability-table unit commands so feature-gated `cli.rs` tests run rather than merely compile under clippy: + - `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` + - `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] Add the generated Cloudflare adapter target check to CI: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown)`. +- [ ] Keep the workerd host suite and the trusted-environment deployed cancellation/timing probe as named CI jobs. Capability publication is blocked unless the recorded deployed job passes; native mocks or a credential-skipped zero-test result cannot substitute. +- [ ] For fork PRs, run only non-secret native/browser/workerd jobs automatically. A maintainer reviews the exact commit, reproduces and pushes that tree to `refs/heads/outbound-probe-reviewed`, dispatches the protected workflow for its SHA, and records the successful workflow URL and SHA on the PR. A skipped or mismatched-SHA run cannot publish Cloudflare's Native timing/upload/lazy-stream cells. +- [ ] Add table-driven capability tests and publish the overrides only now. Axum: Native for HTTP, header fidelity, deadlines, flexible phase budget, slot isolation, and upload deadlines; BestEffort for lazy response passthrough; Unsupported for complete resource accounting. Cloudflare: Native for HTTP, deadlines, flexible phase budget, slot isolation, upload deadlines, and lazy response passthrough; BestEffort for header fidelity; Unsupported for complete resource accounting. Both wildcard future capabilities to Unsupported. +- [ ] Commit: `ci: enforce axum and cloudflare outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `env CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` +- [ ] `(cd crates/edgezero-adapter-cloudflare && ../../scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked --no-default-features --features cloudflare,test-utils --test contract)`; no target or runner override is permitted. +- [ ] `cargo check --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.3'` +- [ ] `npm ci --prefix crates/edgezero-adapter-cloudflare` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run test:workerd` +- [ ] Push the reviewed phase HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-cloudflare-deployed.yml` with + `commit_sha=$(git rev-parse HEAD)`, and require a successful workflow URL whose checked-out + SHA matches exactly. Directly running the credentialed npm script is not a substitute for + protected-environment, exact-SHA, and cleanup-path evidence. +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: Axum and Cloudflare no longer expose adapter `proxy` modules, publish their reviewed capability rows atomically with passing contracts, and Cloudflare's Native timing claim is backed by host-observed evidence. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md new file mode 100644 index 00000000..b09d13cb --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md @@ -0,0 +1,197 @@ +# Outbound HTTP Phase 5: Spin WASI HTTP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Spin outbound HTTP with owned WASI resources, exact request/response completion, typed error classification, cooperative fairness, and a real executable SDK-resource test gate. + +**Architecture:** A target-neutral state-machine driver is exercised natively; a thin Spin SDK layer supplies real WASI HTTP resources. Upload, send, request completion, response body, trailers, and caller-result handles remain owned until their specified terminal branch. One outer monotonic race covers the complete buffered exchange; streamed bodies retain the original deadline. + +**Tech Stack:** exact manifest-pinned Spin SDK 6.0.0, WASI HTTP 0.3, `web-time`, `futures`, wasm32-wasip2, validated Wasmtime runner. + +> **Readiness:** Only Task 0 is executable. Tasks 1-6 are blocked until Task 0 records a nonzero passing real SDK-resource run and freezes the exact runner in the crate-local Cargo configuration and CI. + +--- + +## Preconditions + +- [ ] Phases 1b-4 pass all gates. +- [ ] Re-read spec §4.4, Spin rows in §§5.2-5.5, and Spin file summary in §7. +- [ ] Keep capability support BestEffort for deadlines, flexible phase budgets, streamed-upload deadlines, and lazy response passthrough. No fake-resource test may promote these cells. + +## Task Protocol + +For each task: add only the named native or SDK-resource tests; run the exact native or pinned WASM command and require a nonzero failure from missing behavior; implement through the same production state machine; rerun focused and package tests; run `git diff --check`; then stage only listed files and make the stated commit. Resource ownership assertions use counters/defaulted completion writers and simultaneous-readiness scripts, never prose-only reasoning. + +After Task 0, "run native contracts" means: +`scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract`. +"Run SDK resources" and "run WASM contracts" mean invoking Cargo from +`crates/edgezero-adapter-spin` so its validated `.cargo/config.toml` is loaded: +`../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources` +and +`../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract`. +Every command must execute a nonzero test count; a target/linker error or zero tests is failure. + +**Required exact test names:** `sdk_fields_preserve_duplicate_values`, `sdk_request_options_execute_all_setters`, `spin_error_code_table_is_exhaustive`, `spin_timeout_provenance_before_and_after_deadline`, `send_all_preflight_precedence_and_indices`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `upload_failure_wins_simultaneous_send`, `request_done_error_wins_retained_send`, `reader_gone_never_polls_request_done`, `request_trailers_succeed_only_on_clean_eof_or_reader_gone`, `response_caller_result_succeeds_only_after_native_eof`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `raw_and_decoded_quotas_yield_before_item_65`, `streamed_tasks_consume_fast_body_before_slow_headers`, `streamed_body_races_each_pull_against_remaining_budget`, `downstream_fallback_preserves_typed_error_envelope`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** Task 0 fails at WASI import/resource execution until the runner is correct; later tests expose wrong poll priority, dropped/early-success completion handles, item 65 without Pending, unbounded streamed pull, or generic response errors. A trap caused by missing imports is a Task 0 failure, never a skipped/pass result. + +**Exchange skeleton:** implement these states directly; do not replace them with an unbiased `select`. + +```rust +enum ExchangeState { + AwaitingRequestDone { request_done: RequestDone, send: StoredSend }, + ReaderGone { request_done: RequestDone, send: SendFuture }, + Uploading { pump: UploadPump, request_done: RequestDone, send: SendFuture }, +} +``` + +`Uploading` polls one pump step first and polls send only after that step returns Pending. `AwaitingRequestDone` polls completion first while retaining ready send. `ReaderGone` never polls completion, waits for send, then drops completion before response conversion. + +### Task 0: Prove the SDK-resource runner or STOP + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `examples/app-demo/Cargo.lock` +- Create: `crates/edgezero-adapter-spin/tests/sdk_resources.rs` +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Modify after proof: `crates/edgezero-adapter-spin/.cargo/config.toml`, `.tool-versions`, `.github/workflows/test.yml` + +- [ ] Pin root and app-demo `spin-sdk` requirements to exact `=6.0.0`. Add root workspace `wasip3 = "=0.6.0"` and make it an optional direct dependency of `edgezero-adapter-spin` enabled by `spin`, so that path dependency constrains the enum re-export in both the root and excluded app-demo graphs rather than relying on Spin SDK's compatible range. +- [ ] Refresh both independent locks with the exact manifest requirements using `cargo check --offline -p edgezero-adapter-spin --features spin --target wasm32-wasip2` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-spin --features spin --target wasm32-wasip2`; these dependency-refresh commands are intentionally unlocked. Assert both locked trees report exactly `spin-sdk v6.0.0` and `wasip3 v0.6.0+wasi-0.3.0-rc-2026-03-15`. +- [ ] Add the independent `test-utils = []` feature without enabling `spin`; Task 1 adds the driver dependencies. +- [ ] Add nonzero WASM tests that construct real `Fields`, append duplicate fields, construct `RequestOptions`, call all three timeout setters, construct `Request`, and exercise request/response completion resources without invoking an external origin. +- [ ] Start with the pinned Wasmtime 44.0.1 candidate: + +```sh +CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' \ + scripts/run_test_nonzero.sh sdk_fields_preserve_duplicate_values \ + cargo test --offline --locked -p edgezero-adapter-spin --no-default-features \ + --features spin,test-utils --target wasm32-wasip2 --test sdk_resources +``` + +- [ ] Record the exact runtime version, flags, Rust target/component setup, test count, and passing output. Put the verified runner in the crate-local `.cargo/config.toml`; CI must use the same value. `-S http=y -S p3=y` is necessary evidence, not assumed sufficient evidence. +- [ ] From `crates/edgezero-adapter-spin`, rerun without a runner environment override: `../../scripts/run_test_nonzero.sh sdk_fields_preserve_duplicate_values cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources`; require the same nonzero pass. This proves the committed crate-local Cargo configuration is the configuration later tasks and CI consume. +- [ ] If the pinned runtime cannot execute these resources, STOP. Select and review a compatible exact runtime pin before changing `.tool-versions` and CI together. Compilation, native fakes, and zero-test success do not pass this gate. +- [ ] Commit after proof: `test(spin): execute WASI HTTP SDK resources`. + +### Task 1: Establish the native driver seam and error classifier + +**Files:** +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Create: `crates/edgezero-adapter-spin/src/outbound.rs` beside temporary `src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/sdk_resources.rs` + +- [ ] Add direct `web-time`; keep `test-utils` and `spin` independent. +- [ ] Remove the whole-file WASM gate from `tests/contract.rs`; create native and SDK modules. Confirm the native command executes a nonzero deliberately failing test. +- [ ] In the native contract, test only the target-neutral classifier policy and driver seam; do not claim that a local mirror proves the SDK enum boundary. +- [ ] In `sdk_resources.rs`, add the exhaustive table over all 39 real pinned `spin_sdk::wasip3::http::types::ErrorCode` variants with no wildcard. At or after the absolute deadline, every simultaneous SDK error maps to an attributed 504. Before expiry, all five provider timeout variants map to 504 with `BudgetSource::Unspecified`; caller request policy/size maps to 400; local invariants to 500; pre-response DNS/connect/TLS establishment to `Unreachable`; later connection/read/write failure to `Transport`; response framing/protocol to `Protocol`; and host internal to unspecified 502. Shared decoder tests preserve exact JSON/gzip/Brotli identity. Exercise the entire error table before and after deadline expiry. Invoke all three real request-option setters and record/assert the actual pinned Wasmtime outcome; this suite must not claim it can force host-selected `NotSupported` results. +- [ ] Test the real SDK conversion through `client::send`, `request_done`, and response completion paths under `spin,test-utils`. Non-2xx HTTP statuses remain successful responses. +- [ ] Run native contracts and require the deliberate assertion failure. From `crates/edgezero-adapter-spin`, run `cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources spin_error_code_table_is_exhaustive`; expect a nonzero compile/assertion failure from the missing classifier. +- [ ] Implement target-neutral traits/resources and `map_spin_send_err(err, deadline, cause)`. It checks absolute expiry before classifying the SDK variant and has no wildcard for the pinned exhaustive enum; any dependency update must revise the mapping and tests in the same change. +- [ ] Run native contracts; expect success, then run SDK resources filtered to `spin_error_code_table_is_exhaustive`; expect a nonzero pass against the real SDK type. +- [ ] Commit: `feat(spin): add outbound driver and error classifier`. + +### Task 2: Implement preflight, request conversion, and batch execution + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing tests for empty batch, complete preflight, streamed request/response slot rejection, stable indices, partial errors, one-slot `send_all`/`send` outcome equivalence, one method-entry `batch_started_at`, distinct per-slot elapsed values including measured preflight time, concurrent eligible exchange progress, and sibling isolation. Advance the clock during normalization/preflight/builder preparation and prove that time consumes the original `send`/`send_all` budget. Complete one slot early and another late and prove the early elapsed value is retained; backwards injected time yields zero plus Internal, while legitimate same-tick completion may also be zero with its ordinary outcome. Include GET/HEAD body errors beating batch-only errors and no polls of rejected source streams. +- [ ] In the target-neutral injected conversion suite, add tests for methods, repeated fields via append, request options order, nonzero nanosecond rounding, and every synthetic setter outcome (`NotSupported`, `Immutable`, `Other`). The real SDK-resource suite only executes the setters and asserts the pinned host's actual result; a deployed-host probe is required before claiming a different host accepts or rejects any setter. Capture the final WASI scheme/authority/path-with-query and assert exact core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and queries. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: after `headers_mut` introduces `Connection` nominations plus stale `Host`, `Content-Length`, and `Transfer-Encoding`, inspect the final WASI `Fields` and prove normalization ran immediately before SDK request construction. Only canonical host and adapter-owned framing may remain. +- [ ] Run native contracts; expect failure. +- [ ] Implement `SpinOutboundClient::{send, send_all}` for contract tests. Capture the monotonic snapshot as the first operation in each public method, then run `validate_for_dispatch` exactly once per request before batch-only mode checks, budget selection, body polling, normalization, or SDK construction; never re-anchor. Retain method, mode, and all policy fields before consuming request parts; production injection switches only after Task 5 is green. +- [ ] Treat `RequestOptionsError::NotSupported` as logged BestEffort degradation while retaining the outer deadline race; `Immutable`/`Other` are internal setup failures. +- [ ] Use `join_all` for eligible complete batch slots after full preflight. Construct each `OutboundSlotResult` when its own preflight/exchange becomes terminal; vector assembly must not sample time. +- [ ] Rerun native and SDK-resource tests; expect success. +- [ ] Commit: `feat(spin): dispatch outbound requests`. + +### Task 3: Implement the biased upload/send state machine + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing transition tests for `Uploading`, `AwaitingRequestDone`, and `ReaderGone`; source/cap/deadline failures, partial writes, cancellation during `write_all`, clean EOF, reader-gone, early response, writer/reader drop, trailer-completion failure, and future/resource drop counts. A successful request-trailers `FutureWriter::write(Ok(None))` is the only path from clean EOF to `AwaitingRequestDone`; `FutureWriteError` means the host dropped its reader, transitions to `ReaderGone`, and never polls `request_done`. WASI HTTP 0.3's component-stream writer has no flush operation, so no test or implementation may invent one. +- [ ] Add simultaneous-readiness tests: upload failure beats send; request completion beats retained send; send is observed only after an upload step returns Pending. +- [ ] Prove request trailers attempt `Ok(None)` only for clean EOF or reader-gone and retain the default failure otherwise. Reader-gone ignores a rejected redundant completion write; clean EOF must inspect the write result and follow the transition above. +- [ ] Implement the state machine exactly as §4.4 specifies. Never poll `request_done` in `ReaderGone`; never lose an already-ready send result while awaiting request completion. +- [ ] Yield once after every accepted upload chunk and recheck the absolute deadline before accepting every terminal or data result. +- [ ] Rerun native contracts; expect success. +- [ ] Commit: `feat(spin): own outbound upload completion`. + +### Task 4: Implement response completion, decoding, and fairness + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Delete: `crates/edgezero-adapter-spin/src/decompress.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing cases for cumulative guest-visible header limits, repeated request fields, repeated response `Set-Cookie`, raw malformed response nomination/encoding, 3xx returned without a follow-up request, HEAD/1xx/204/304, every 205 branch, encoded bytes, decoded output, Brotli window/decoder-state charge, independent final Buffered limits, gzip members, Brotli trailing data, typed source/completion errors, and timeout precedence. Unequal decoded/final caps prove raw passthrough bypasses only decoded accounting; `max_chunk_bytes` is tested only as emitted-item shaping. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving malformed/conflicting values and encoded/effective-identity decoded overages reject before reading the WASI response body; include exactly one bare `Content-Encoding: identity`. Effective identity compares encoded, decoded, and final Buffered caps; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Assert attributed 504, no early caller-result success, and preservation of a late typed source/completion error when it wins before expiry. +- [ ] Add the joined send-plus-immediate-body-consumption regression used by Axum/Cloudflare; a fast body must complete while sibling headers remain pending. +- [ ] Add caller-result tests proving the default is failure and `Ok(())` is written only after native EOF, trailers, decoder completion, caps, and deadlines succeed. Separate the actual shapes: the body is a component `stream`; only the response trailers/completion future carries an `ErrorCode` into `map_spin_send_err`; caller-result `write(Ok(()))` returns `FutureWriteError` when the host dropped its reader. Assert protocol 502 for that failed completion handshake while a consumer waits and cleanup-only behavior during wrapper drop. +- [ ] Add continuously-ready raw and decoded streams. Freeze separate quotas at 64; empty items count; state survives polls/`next()`; item 65 is preceded by a cooperative Pending. +- [ ] Replace adapter-local buffered decompression with core's shared streaming pipeline. Remove production `brotli`/`flate2` only after no production callsite remains. +- [ ] For streamed mode, retain body/trailer/caller-result ownership in `Body::Stream`; late errors must surface as error items, never false EOF. Add pre-pull, timer-ready, item-ready, post-ready, and terminal EOF/error tests for the streamed-only monotonic race. +- [ ] Rerun native and SDK tests; expect success. +- [ ] Commit: `feat(spin): complete outbound response streams`. + +### Task 5: Add the outer deadline race and downstream fallback + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Delete: `crates/edgezero-adapter-spin/src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/response.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing tests for send/upload/buffered-drain timeout, simultaneous exchange/timer readiness, post-ready absolute checks, preservation of all four `BudgetSource` values, and the streamed continuation using only `budget.deadline.remaining()` after headers. +- [ ] Race the whole buffered exchange against `spin_sdk::time::sleep(remaining)`. Poll exchange first but check absolute expiry in both branches so expiry wins simultaneous results. +- [ ] For Streamed mode only, read remaining time after header exchange. If expired, drop native response resources and return attributed 504; otherwise install a fresh per-chunk sleep race bounded by that remaining duration while retaining the original absolute deadline for post-ready checks. Do not add a second race to Buffered mode. +- [ ] Rename/freeze `SPIN_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB`. Test exact/over limit and source failure; convert the original typed error envelope before platform headers are committed. +- [ ] Switch request extensions to `HttpClient`, delete the legacy module, and leave no module/type alias. +- [ ] Rerun native contracts and WASM checks; expect success. +- [ ] Commit: `feat(spin): enforce outbound exchange budgets`. + +### Task 6: Land CI execution gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` +- Modify if validated in Task 0: `.tool-versions` + +- [ ] Add the native contract command. +- [ ] Add the exact validated SDK-resource and WASM contract runner commands, with `spin,test-utils`, through `scripts/run_test_nonzero.sh` using exact sentinels; a successful zero-test command is failure. +- [ ] Keep ordinary Spin WASM compile checks. +- [ ] Add the generated Spin adapter target check: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-spin --no-default-features --features spin --target wasm32-wasip2)`. +- [ ] Add and execute `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec`; clippy compilation alone does not execute the feature-gated `cli.rs` table. +- [ ] Add table-driven capability tests and publish Spin's override only now: Native for outbound HTTP, header fidelity, and slot isolation; BestEffort for deadlines, flexible phase budget, streamed-upload deadlines, and lazy response passthrough; Unsupported for complete resource accounting; future capabilities -> Unsupported. +- [ ] Commit: `ci: execute Spin outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract` +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract)` using the validated crate-local runner. +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources)` using the validated crate-local runner. +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `cargo check --offline --locked -p edgezero-adapter-spin --no-default-features --features spin --target wasm32-wasip2` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-spin --no-default-features --features spin --target wasm32-wasip2)` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: Spin's deterministic contract and real SDK bindings execute in CI, and its conservative capability row lands with them. Host cancellation remains documented BestEffort until a separate live characterization proves a finite bound. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md new file mode 100644 index 00000000..d7e4bf18 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md @@ -0,0 +1,245 @@ +# Outbound HTTP Phase 6: Fastly Dispatch and Harvest Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Fastly outbound HTTP with dispatch-all-before-wait fan-out, deterministic dynamic backends, host timer derivation, typed errors, streamed upload ownership, and bounded downstream conversion. + +**Architecture:** A target-neutral synchronous engine owns slots, backend identity/cache behavior, timing arithmetic, and cleanup. The SDK layer supplies `PendingRequest`, backend registration, and body handles. Batch dispatch is a distinct first phase; ordered harvest may opportunistically poll later slots but never loses a result. + +**Tech Stack:** Fastly SDK 0.12.1, `web-time`, `sha2`, core typed streams/decoders, native `test-utils`, wasm32-wasip1/Viceroy. + +--- + +## Preconditions + +- [ ] Phases 1b-5 pass all gates. +- [ ] The implementation index's inert workflow-bootstrap PR is merged to the default + branch, so `.github/workflows/outbound-fastly-characterization.yml` can receive + `workflow_dispatch` for an exact reviewed implementation SHA. Do not rely on a workflow + definition introduced only on this implementation branch. +- [ ] Phase 0 has also provisioned the protected `outbound-fastly-probe` environment, + required reviewers, exact disposable secrets, enabled/disabled services, fixed reviewed + probe ref, and authenticated observable-origin protocol. Workflow presence alone is not a + runnable host gate. +- [ ] Re-read spec §4.3, Fastly rows in §§5.2-5.5, and the Fastly file summary in §7. +- [ ] Keep outbound HTTP, deadlines, flexible phase budgets, slot isolation, streamed-upload deadlines, and lazy response passthrough at the exact reviewed BestEffort levels. Dynamic-backend service enablement is not statically provable, so `outbound-http` cannot be published as Native. +- [ ] Confirm `OutboundRequest::host_name()` exists from Phase 1b; never reparse the URI to construct backend identity. +- [ ] Require `viceroy --version` to print exactly the repository-pinned `0.17.0`. Run WASM tests from `crates/edgezero-adapter-fastly` so its committed `.cargo/config.toml` supplies both `wasm32-wasip1` and `viceroy run -C ../../examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml --`; no CI environment override may replace that service configuration. + +## Task Protocol + +For each task: add only the named native or SDK-gated cases; run the exact native/WASM command and require a nonzero failure; implement through the shared production engine; rerun focused and package tests; run `git diff --check`; then stage only listed files and make the stated commit. Fake handles establish stage order and cleanup, not real host cancellation or a finite write bound. + +"Run native contracts" means +`scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract`. +"Run Fastly WASM contracts/library tests" means the two crate-local `wasm32-wasip1` +commands in Phase Verification with `fastly,test-utils`. Every command must execute a nonzero test count; a +target/linker error or a zero-test result is failure. + +**Required exact test names:** `backend_identity_uses_every_canonical_property`, `backend_creation_error_table_is_exhaustive`, `backend_builder_keeps_pooling_for_identical_identity_and_settings`, `dispatch_guard_checks_expiry_before_slack`, `send_all_dispatches_every_slot_before_wait`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `send_all_reverse_completion_preserves_order`, `done_error_survives_poll_sweep`, `send_error_cause_table_preserves_timeout_source`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `response_read_checks_deadline_after_eof`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `streamed_upload_finishes_exactly_once`, `streamed_upload_failure_never_waits`, `downstream_fallback_preserves_typed_error_envelope`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** the old client waits during dispatch, backend names/settings collide, error variants map to generic 500, an EOF read escapes deadline checks, or failed uploads finish/wait. SDK-gated tests must fail assertions, not report zero tests. + +**Engine skeleton:** + +```rust +enum Slot { + Done(OutboundSlotResult), + Pending(PendingSlot), + Taken, +} + +struct BackendIdentity { + budget_ms: u32, + host: String, + port: u16, + scheme: Scheme, + tls_mode: TlsMode, +} +``` + +Phase one converts every valid slot to `Pending` or `Done`; no wait occurs. Phase two waits in input order, polls later pending slots opportunistically into `Done`, and replaces an unresolved `Taken` with internal error rather than panicking. + +### Task 1: Establish the engine and executable seams + +**Files:** +- Modify: `Cargo.toml` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `crates/edgezero-adapter-fastly/Cargo.toml` +- Create: `crates/edgezero-adapter-fastly/src/outbound.rs` beside temporary `src/proxy.rs` +- Create: `crates/edgezero-adapter-fastly/src/outbound/{backend,engine,sdk,test_utils}.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `Cargo.lock`, `examples/app-demo/Cargo.lock` + +- [ ] Pin root and app-demo `fastly` requirements to exact `=0.12.1`; refresh both locks with `cargo update --offline -p fastly --precise 0.12.1` and `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p fastly --precise 0.12.1`. Assert both `cargo tree --offline --locked` graphs resolve exactly `fastly v0.12.1` before writing exhaustive SDK mappings. +- [ ] Add direct `web-time` and independent `test-utils = []`; do not enable Fastly through test-utils. +- [ ] Move whole-file WASM gating to the SDK test module. Add a native deliberately failing smoke test and prove the native command executes it. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract`; expect a nonzero test count and failure at the smoke assertion, not at linking/imports. +- [ ] Define injectable clock, dispatch-stage callback, backend registrar, synchronous pending/body handles, and hash function. Public retained callbacks must remain `Send + Sync`. +- [ ] Production and tests must call the same generic engine; test code may supply effects, not copy algorithms. +- [ ] Replace the sentinel with a passing seam-construction test and rerun the same native command; require a nonzero test count and success before committing. +- [ ] Commit: `test(fastly): add outbound engine seams`. + +### Task 2: Implement backend identity and host timers + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/backend.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing native tests for DNS/IP/IPv6/plain/TLS identities, default/explicit ports, exact ceil-to-ms budgets, SHA-256 first-128-bit names, dedup, forced hash collision, externally occupied `NameInUse`, and concurrent lookup. Add a production-builder assertion that no `.enable_pooling(false)` override is applied: identical names/settings may use the SDK default pool, while distinct budgets/settings produce distinct identities. +- [ ] Add timer tests: for total >= 4 ms, connect = total/4, first byte = remainder, between = total; below 4 ms, connect/first each equal total. +- [ ] In the Fastly-featured SDK test module, add an exhaustive no-wildcard table over the real `fastly::backend::BackendCreationError`: `Disallowed` -> actionable unspecified 502; `NameInUse` -> cache/collision protocol; all three `*TimeoutTooLarge`, `NameTooLong`, and `EncodingError` -> internal 500; `HostError` -> unspecified 502. Native tests may exercise an adapter-owned policy model but cannot claim coverage of the SDK enum. +- [ ] Add guard-order tests immediately before dispatch: absolute expiry -> attributed 504; live-deadline setup over `BATCH_DISPATCH_SLACK_MAX` -> internal 500; no deadline uses the default budget without the live-deadline slack failure. +- [ ] Run native contracts; expect a nonzero failure. From `crates/edgezero-adapter-fastly`, run `../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib`; expect a nonzero compile/assertion failure from the missing real-SDK classifier. Do not override the committed target/runner. +- [ ] Implement `BackendIdentity(scheme, host_name, resolved_port, tls_mode, budget_ms)` and `ez_` names. Cache name -> `(identity, backend)` behind `Mutex`; never hold the lock across `finish()`. Leave SDK connection pooling at its default: Fastly reuses only same-name, exact-same-settings backends, and the identity/settings already separate budgets and TLS modes. +- [ ] A cache miss followed by `NameInUse` fails closed. Do not recover with string matching or `Backend::from_name`. +- [ ] Implement the complete creation-error mapping separately from `SendErrorCause`; DNS/TLS/connect failures belong only to the send-stage table. +- [ ] Configure SNI only from `sni_hostname`; certificate checking from `cert_host`, including HTTPS IP literals. +- [ ] Rerun native contracts and the filtered Fastly WASM library test; each must execute a nonzero test count and succeed. +- [ ] Commit: `feat(fastly): configure deterministic outbound backends`. + +### Task 3: Implement two-phase send_all + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing cases for empty batch, complete preflight and index alignment, every valid dispatch before first wait, one-slot `send_all`/`send` outcome equivalence, reverse completion with stable order, partial dispatch/wait/body errors, preservation of pre-existing `Done` results during poll sweeps, one method-entry `batch_started_at`, distinct per-slot elapsed values, and unresolved-slot internal error. Advance the clock during normalization/preflight/backend preparation and prove the original `send`/`send_all` snapshot owns the budget and elapsed measurement. Preflight/dispatch failures sample immediately; an opportunistically polled early completion keeps its own elapsed value while an earlier index blocks later vector return; backwards injected time yields zero plus Internal, while legitimate same-tick completion may also be zero with its ordinary outcome. Include GET/HEAD body errors beating batch-only errors and no polls of rejected source streams. +- [ ] Capture the exact SDK request target and assert it matches core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and queries; Fastly may derive backend identity from accessors but must not reconstruct the request URI. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: mutate `Connection` nominations plus stale `Host`, `Content-Length`, and `Transfer-Encoding` through `headers_mut`, then inspect the final Fastly request and prove normalization ran immediately before SDK construction. Only canonical host override and adapter-owned framing may remain. +- [ ] Add serial-harvest cases showing a later slot can expire while waiting behind an earlier slot without cancellation or Native slot-isolation claims. +- [ ] Run native contracts; expect failure. +- [ ] Implement `Slot::{Done, Pending, Taken}`. Capture the monotonic snapshot as the first operation in each public send method, then run `validate_for_dispatch` exactly once per request before batch-only checks, budget selection, body polling, normalization, backend construction, or SDK request construction; never re-anchor. Construct `OutboundSlotResult` at each slot's actual terminal point, including preflight/dispatch errors and immediately after wait/poll harvest; final vector assembly performs no clock read. Issue every valid `send_async` sequentially before the first wait; pinned Fastly 0.12.1 returns when transmission begins and continues buffered upload in the background, so a stalled buffered upload may leave its slot unresolved but must not be modelled as synchronously preventing later dispatch calls. Then wait in input order and opportunistically poll later pending slots. +- [ ] Preserve all policy/method/mode fields in `PendingSlot`. Resolve every output index without panic. Do not use SDK `select()`, which cannot preserve stable slot identity. +- [ ] Harvest every successfully dispatched buffered batch request even when siblings fail. +- [ ] Rerun native contracts; expect success. +- [ ] Commit: `feat(fastly): dispatch fanout before ordered harvest`. + +### Task 4: Map SDK errors and process responses + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add SDK-gated construction tests for every known public `SendErrorCause` listed in + §4.3, with a mandatory future `_` classifier arm. Split timeout policy into + `BudgetedTimeout` (`ConnectionTimeout`, `HttpResponseTimeout`) and `ProviderTimeout` + (`DnsTimeout`). Pass the absolute deadline, selected cause, and injected observation + instant into the pure classifier; test all four `BudgetSource` values before, exactly at, + and after expiry. Configured phase timers stay attributed; pre-deadline DNS timeout is 504 + with `BudgetSource::Unspecified`; at/after expiry the selected cause wins for every result. +- [ ] Add native response cases for cumulative guest-visible headers, repeated request fields, repeated response `Set-Cookie`, raw malformed response nomination/encoding, 3xx returned without a follow-up request, bodyless/205, encoded bytes, decoded output, Brotli window/decoder-state charge, independent final Buffered limits, encoding policy, gzip members/native EOF, errors, deadlines before/after each blocking read including EOF, and drop-on-early-termination. Unequal decoded/final caps prove raw passthrough bypasses only decoded accounting; `max_chunk_bytes` remains an emitted-item guarantee. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving malformed/conflicting values and encoded/effective-identity decoded overages reject before the first Fastly body read; include exactly one bare `Content-Encoding: identity`. Effective identity compares encoded, decoded, and final Buffered caps; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Assert attributed 504 after the blocking read returns, no decoder-end success before native EOF, and late typed source/completion preservation when observed in budget. +- [ ] Run native and Fastly WASM library/contract tests; expect failures. +- [ ] Map configured phase timeout -> attributed 504; early unconfigured DNS timeout -> + unattributed 504; any result at/after absolute expiry -> attributed 504; + DNS/destination/connect/TLS establishment before a response head -> typed 502 + `Unreachable`; later connection/I/O -> `Transport`; response framing -> `Protocol`; + shared JSON/gzip/Brotli failures preserve exact coding identity; local invariant/platform internal -> 500; unspecified + known/future -> unspecified 502 as specified. Define one + `DYNAMIC_BACKENDS_DISABLED_MESSAGE` constant with the exact spec diagnostic and use it for + `BackendCreationError::Disallowed` and the deployed disabled-service assertion. +- [ ] Apply the core response pipeline in order. Drop the owned native body on cap/decode/deadline/consumer termination; do not drain after failure and do not claim finite origin cancellation. +- [ ] Rerun native/WASM tests; expect success. +- [ ] Commit: `feat(fastly): classify and harvest outbound responses`. + +### Task 5: Implement streamed uploads + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing tests for source/cap/pre-read/post-read/write/flush failures, future drop, clean EOF, `finish` exactly once, finish failure, post-finish expiry, and no wait after failed finish. +- [ ] Retain writer and pending handles together. Every failure drops both without `finish`/`wait`; clean EOF finishes once and only then permits wait. +- [ ] Document that source pulls and host writes can stall beyond the cooperative check and that partial bytes may already be delivered. +- [ ] Rerun native/WASM tests; expect success. +- [ ] Commit: `feat(fastly): own streamed outbound uploads`. + +### Task 6: Migrate request injection and downstream conversion + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Delete: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Inject one `HttpClient::with_client(FastlyOutboundClient::new())` per Fastly session so the backend cache lifetime mirrors the SDK namespace. +- [ ] Delete the legacy module during this switch and leave no module/type alias. +- [ ] Add exact/over `FASTLY_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB` tests and typed stream-error envelope tests. +- [ ] Implement bounded fallback before platform headers are committed. Preserve original status/kind on failure. +- [ ] Rerun adapter and workspace tests; expect success. +- [ ] Commit: `feat(fastly): wire outbound client and response fallback`. + +### Task 7: Land CI gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Create: `crates/edgezero-adapter-fastly/tests/host/deployed.sh` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.lock` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/fastly.toml` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/src/main.rs` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/README.md` + +- [ ] Add the native contract command and enable `test-utils` in Fastly WASM contract/library runs. Execute every test target through `scripts/run_test_nonzero.sh` with one exact sentinel; retain production target checks. +- [ ] Assert `viceroy --version` is exactly `0.17.0`, then execute both WASM suites from the adapter crate without `CARGO_TARGET_*_RUNNER`; this proves the checked-in runner and demo `fastly.toml` are used locally and in CI. +- [ ] Add the generated Fastly adapter target check: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1)`. +- [ ] Add a standalone deployed-probe crate with an empty `[workspace]`, exact + `fastly = "=0.12.1"`, path dependencies on the production adapter/core, and its own + committed lock. The binary directly exercises `FastlyOutboundClient` for every named + probe case; it must not depend on the not-yet-migrated app-demo handler. Pin the package + name and output to + `tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz`. Generate the fixture + lock once with + `cargo check --offline --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml --target wasm32-wasip1`; + subsequent metadata, tree, and build commands are locked. Its `fastly.toml` build script + runs Cargo with `--offline --locked --profile release --target wasm32-wasip1`. +- [ ] Before any protected dispatch, run locked metadata/tree checks for the fixture, require + exactly Fastly SDK 0.12.1, and build without secrets using + `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly`. + Require the fixed package path to exist. `deployed.sh` accepts only that prebuilt package; + it exits before deployment if the artifact is absent and contains no build, dependency + download, or package-selection fallback while secrets are present. +- [ ] Confirm each command lists and executes nonzero tests. +- [ ] Add and execute `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec`; clippy compilation alone does not execute feature-gated capability tests. +- [ ] Add table-driven capability tests and publish Fastly's override only now: Native for header fidelity; BestEffort for outbound HTTP, deadlines, flexible phase budget, slot isolation, streamed-upload deadlines, and lazy response passthrough; Unsupported for complete resource accounting; future capabilities -> Unsupported. +- [ ] Use the already-merged default-branch trusted characterization dispatcher with the protected `outbound-fastly-probe` environment. It accepts an exact commit SHA, checks out and verifies that SHA, installs Fastly CLI `15.1.0`, and uses only disposable `FASTLY_DYNAMIC_SERVICE_ID`, `FASTLY_DYNAMIC_SERVICE_URL`, `FASTLY_DISABLED_SERVICE_ID`, `FASTLY_DISABLED_SERVICE_URL`, `FASTLY_API_TOKEN`, `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN` secrets. Never use `pull_request_target`, expose secrets to a fork checkout, or modify a branch-only workflow and assume it is dispatchable. +- [ ] `tests/host/deployed.sh` records each service's prior active version, deploys the fixed prebuilt probe package to both disposable services, sends a unique run ID to an authenticated observable origin, and requires exact positive probe IDs for canonical arbitrary destinations, request method/body, timeout phases, upload cancellation characterization, and response streaming. Characterize which `SendErrorCause` each configured connect/response timer and the provider DNS timeout produces. The enabled service must succeed; the disabled service must return the exact `DYNAMIC_BACKENDS_DISABLED_MESSAGE` typed 502 and must not contact the origin. In a trap, reactivate each recorded prior version, delete only versions created by this run, and delete this run's origin observations; never delete a pre-existing version. Any cleanup failure fails the job. Record the workflow URL, commit SHA, tool versions, and observed bounds in the PR. This evidence characterizes the BestEffort row; it does not promote it to Native. +- [ ] For a fork PR, run only non-secret native/Viceroy checks automatically. A maintainer first reviews, reproduces, and pushes the exact tree to `refs/heads/outbound-probe-reviewed`, then dispatches the protected workflow for that SHA. Missing live evidence does not silently skip a probe or report zero tests. +- [ ] Commit: `ci: execute Fastly outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract` +- [ ] `test "$(viceroy --version)" = "viceroy 0.17.0"` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked --no-default-features --features fastly,test-utils --test contract)` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib)` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `cargo check --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml | rg 'fastly v0\.12\.1'` +- [ ] `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly` +- [ ] `test -f crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] Push the reviewed phase HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-fastly-characterization.yml` with + `commit_sha=$(git rev-parse HEAD)`, and attach a successful workflow URL whose checked-out + SHA matches exactly; this remains characterization of the BestEffort service prerequisite. +- [ ] `git diff --check` + +Expected result: Fastly dispatches all eligible fan-out slots before harvest, preserves typed per-slot outcomes, and documents rather than overclaims its unavoidable host-blocking gaps. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md new file mode 100644 index 00000000..8afd45b2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md @@ -0,0 +1,237 @@ +# Outbound HTTP Phase 7: Public Migration, Documentation, and Final Gates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate all remaining consumers, delete the legacy proxy API without aliases, publish capability/outbound documentation, and make every generated, example, adapter, WASM, host, and docs gate executable in CI. + +**Architecture:** Migrate non-workspace consumers before deleting core's temporary legacy module. Then enforce the hard public cutoff with a scoped symbol scan. Deterministic merge gates stay separate from the required protected Fastly characterization and the optional Spin teardown characterization; Cloudflare host evidence is already mandatory in Phase 4. + +**Tech Stack:** Rust workspace and generated workspaces, Handlebars templates, VitePress/npm, GitHub Actions, ripgrep structural gates. + +--- + +## Preconditions + +- [ ] Phases 1b-6 pass all their deterministic and required host gates. +- [ ] All four adapters already use `outbound` modules and `HttpClient`; only core scaffolding, generated/template consumers, app-demo, and prose may retain legacy names. +- [ ] Re-read spec §§5-7 and the final capability matrix. Do not promote BestEffort cells during documentation cleanup. + +## Task Protocol + +For each task: first add or run the named structural/build test and capture the expected stale-symbol, missing-capability, or compile failure; perform only that migration slice; rerun it to success plus `git diff --check`; then stage only listed files and make the stated commit. Do not combine the hard core deletion with unrelated prose cleanup. + +**Required exact test/gate names:** `generated_workspace_compiles`, `generated_manifest_declares_outbound_http_optional`, `generated_spin_hosts_default_to_https_only`, `generated_outbound_http_smoke`, `generated_core_tests_execute`, `generated_core_test_gate_rejects_zero_tests`, `app_demo_outbound_client_preserves_method_and_uri`, `app_demo_manifest_declares_outbound_http_optional`, `typed_body_stream_boundaries_preserve_edge_error`, `capabilities_page_lists_exact_matrix`, `docs_sidebar_links_capabilities`, and `outbound_docs_contract`. The source-wide legacy-symbol check is owned by `scripts/check_outbound_legacy_api.sh`, not by a Rust test that would need to embed the forbidden strings. + +**Expected red:** generated/template tests find missing capabilities or stale behavior; app-demo fails against the new API; the hard-cut scan reports exact remaining source/test/template paths; docs checks find missing matrix/sidebar content. Each red result must disappear in its owning task, not be added to an exclusion. + +### Task 1: Migrate scaffolds and strengthen generated-project tests + +**Files:** +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/README.md.hbs` +- Modify: `crates/edgezero-adapter-spin/src/templates/spin.toml.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `crates/edgezero-cli/tests/generated_project_builds.rs` + +- [ ] Add failing structural tests for `optional = ["outbound-http"]`, outbound API names, Spin absent-host rendering as HTTPS-only, explicit wildcard HTTP+HTTPS expansion, and no implicit cleartext grant. The generated project is portable across all four adapters, so it cannot require a capability Fastly truthfully reports BestEffort. Generated README text states that `optional` explicitly accepts both documented behavioral deviations and runtime failure when an unverified deployment prerequisite is absent; an app whose behavior depends on outbound success must promote the declaration to `required` and select an adapter/deployment whose matrix result passes. Do not add a Rust assertion containing legacy API spellings; Task 3's source-wide negative gate owns their absence after all code/tests/templates migrate. +- [ ] First add only the harness regression + `generated_core_test_gate_rejects_zero_tests`. Run + `cargo test -p edgezero-cli --test generated_project_builds generated_core_test_gate_rejects_zero_tests` + and require a nonzero assertion failure showing the current harness accepts an + otherwise-successful zero-test fixture; dependency/build failure or a filtered zero-test + command is not the expected red. +- [ ] Add a generated core test named `generated_outbound_http_smoke`. Extend the generated-project harness to run `cargo test -p scaffold-probe-core --lib -- --list`, parse listed test identifiers, and require at least one test plus exactly one identifier whose terminal component is `generated_outbound_http_smoke` (the current full name is `handlers::tests::generated_outbound_http_smoke`). Match an optional Rust module prefix ending in `::`, not arbitrary diagnostic text or an impossible bare output line. Then run `cargo test -p scaffold-probe-core --lib`. Rerun the exact `generated_core_test_gate_rejects_zero_tests` command and require a nonzero passing test, then run the `generated_workspace_compiles` sentinel gate below. +- [ ] Run `cargo test -p edgezero-cli generator`; expect failure, then migrate templates/renderers and rerun. +- [ ] Run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds`; expect generated compilation and nonzero core tests. +- [ ] Commit: `feat(scaffold): generate outbound HTTP clients`. + +### Task 2: Migrate app-demo and its independent lockfile + +**Files:** +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` +- Modify: `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add/update tests for URI merge/query preservation, injected client success, no-client 501, original method/URI propagation, `OutboundResponse::into_response`, and shipped capability parsing. +- [ ] Before migration, run `(cd examples/app-demo && cargo test --offline --locked -p app-demo-core app_demo_outbound_client_preserves_method_and_uri)` and require a nonzero failure from missing/stale outbound behavior, not dependency resolution or a zero-test filter. +- [ ] Update the mock to implement both required trait methods and preserve partial batch results. +- [ ] Declare outbound HTTP optional so the multi-adapter demo remains deployable to Fastly + with the standard BestEffort warning; make selected Spin hosts match the canonical expected + set. The demo handler must render the typed outbound failure, including the exact + dynamic-backend-disabled 502, rather than assuming optional means success. The test must + still prove the capability is present and not silently omitted. +- [ ] Run these exact independent-workspace gates and require success: `(cd examples/app-demo && cargo fmt --all -- --check)`, `(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings)`, `(cd examples/app-demo && cargo test --locked --workspace --all-targets)`, `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare)`, `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly)`, and `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin)`. +- [ ] Commit: `refactor(app-demo): migrate to outbound HTTP API`. + +### Task 3: Perform the hard core cutoff + +**Files:** +- Delete: `crates/edgezero-core/src/proxy.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Create: `scripts/check_outbound_legacy_api.sh` +- Modify: each remaining runtime/template path printed by the first red run of that script; adding an exclusion instead is not permitted + +- [ ] Create `scripts/check_outbound_legacy_api.sh` as the deterministic negative gate for code and generated templates in this task. It initially scans `crates` and `examples/app-demo` for only the legacy API symbols below. Task 4 expands its roots to active documentation after that task owns the prose migration. Implement exit handling explicitly: ripgrep status 0 prints matches and exits 1; status 1 exits 0; status greater than 1 propagates as a scan failure. + +```sh +#!/usr/bin/env bash +set -uo pipefail + +if matches="$(rg -n 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ + crates examples/app-demo --glob '*.rs' --glob '*.hbs')"; then + printf '%s\n' "$matches" + exit 1 +else + status=$? + if [ "$status" -eq 1 ]; then exit 0; fi + exit "$status" +fi +``` + +- [ ] Run `bash scripts/check_outbound_legacy_api.sh`; expect failure with every remaining source/test/template match. Migrate each match; do not preserve forbidden spellings in Rust string-literal assertions because this raw-source gate intentionally rejects them too. + +```sh +bash scripts/check_outbound_legacy_api.sh +``` + +- [ ] Delete the legacy module and `proxy_handle`; export only outbound names. Add no deprecated aliases, forwarding traits, or feature flags. +- [ ] Preserve the wire/header constant `PROXY_HEADER`, `x-edgezero-proxy`, route names such as `/proxy/{*rest}`, and ordinary reverse-proxy terminology where semantically correct. +- [ ] Rerun `bash scripts/check_outbound_legacy_api.sh`; expect exit 0 and no output. Run workspace and app-demo tests. +- [ ] Separately inventory every typed-stream boundary; this audit is not satisfied by the legacy-symbol scan: + +```sh +rg -n 'Body::from_stream|Body::from_external_stream|Body::Stream|Self::Stream|\.into_stream\(' \ + crates examples/app-demo --glob '*.rs' --glob '*.hbs' +``` + +- [ ] This reruns the Phase 2 audit rather than creating a new adjacent test for comments, definitions, or unchanged inbound introspection. Classify construction/consumption matches only: EdgeZero-owned outbound/decoder/deadline paths must carry `Result`; genuine platform/foreign inputs alone use `from_external_stream`. Existing Phase 2 and adapter error-identity tests must cover every error-producing owned boundary introduced by the migration. +- [ ] Rerun the inventory after migration and review every remaining construction/consumption line. Then run `cargo test --workspace --all-targets`, `cargo check --workspace --all-targets --features "fastly cloudflare spin"`, the Cloudflare WASM contract command, both Fastly WASM contract/library commands, and both Spin WASM contract/SDK-resource commands from Task 6. Compilation plus the named Phase 2/adapter error-identity tests is the `typed_body_stream_boundaries_preserve_edge_error` gate. +- [ ] Commit: `refactor(core): remove legacy proxy API`. + +### Task 4: Publish outbound and capability documentation + +**Files:** +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/handlers.md` +- Modify: `docs/guide/architecture.md` +- Modify: `docs/guide/streaming.md` +- Modify: `docs/guide/what-is-edgezero.md` +- Modify: `docs/guide/configuration.md` +- Modify: `docs/guide/adapters/{overview,axum,cloudflare,fastly,spin}.md` +- Create: `docs/guide/capabilities.md` +- Modify: `docs/.vitepress/config.mts` +- Modify: `scripts/check_outbound_legacy_api.sh` +- Create: `scripts/check_outbound_docs_contract.mjs` +- Modify: `CLAUDE.md`, `.claude/agents/code-architect.md`, `Cargo.toml`, `TODO.md` where they describe the active API + +- [ ] Document all eight outbound capability names, support ladder semantics, exact matrix, every BestEffort footnote including Fastly's dynamic-backend service prerequisite, host grammar/defaults, request/response limits, batch memory model, and no-client behavior. The exact rows are: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-http` | Native | Native | BestEffort | Native | +| `outbound-complete-resource-accounting` | Unsupported | Unsupported | Unsupported | Unsupported | +| `outbound-header-fidelity` | Native | BestEffort | Native | Native | +| `outbound-deadlines` | Native | Native | BestEffort | BestEffort | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort | BestEffort | +| `send-all-slot-isolation` | Native | Native | BestEffort | Native | +| `streamed-upload-deadlines` | Native | Native | BestEffort | BestEffort | +| `lazy-streamed-response-passthrough` | BestEffort | Native | BestEffort | BestEffort | + +- [ ] Explain that Cloudflare manual upstream fetch encoding and downstream encoded passthrough are distinct controls. +- [ ] Explain 16 MiB downstream conversion fallback on Axum/Fastly/Spin and Native lazy passthrough only on Cloudflare. +- [ ] Explain that the body/header/window/decoder-state controls bound named guest-visible terms, `max_chunk_bytes` shapes emitted items without limiting source allocation, and complete pre-admission process/isolate accounting is Unsupported on every current adapter because provider parser, field-section, native chunk, allocator, and host-copy terms remain opaque. +- [ ] Before editing prose, expand `scripts/check_outbound_legacy_api.sh` to scan `docs/guide`, `README.md`, `CLAUDE.md`, `TODO.md`, root `Cargo.toml`, and `.claude/agents/code-architect.md` in addition to its existing code/template roots, adding `*.md` and `*.toml` globs. Using explicit active-source roots excludes internal history under `docs/superpowers/specs` and `docs/superpowers/plans`. Run it and require the expected stale-document failure. +- [ ] Add exactly one sidebar item whose link is `/guide/capabilities`; verify all internal links/anchors. +- [ ] Create `scripts/check_outbound_docs_contract.mjs`. It reads `docs/guide/capabilities.md` and `docs/.vitepress/config.mts`, locates exactly one Markdown table with the exact header `Capability | Axum | Cloudflare | Fastly | Spin`, and parses every contiguous data row in that table rather than filtering by a name prefix. It strips backticks around the first cell and only a trailing superscript footnote reference (`¹` through `⁹`) or Markdown footnote reference (`[^…]`) from support cells, then deep-compares the resulting ordered entries to the literal eight-row matrix above. It fails on a missing/duplicate matrix table or any duplicate, missing, extra, reordered, misspelled, or differently supported row, including the two valid names that do not begin `outbound-`. It also requires exactly one `/guide/capabilities` sidebar link. These assertions own `capabilities_page_lists_exact_matrix`, `docs_sidebar_links_capabilities`, and `outbound_docs_contract`. +- [ ] Run `node scripts/check_outbound_docs_contract.mjs`; expect failure before the page/sidebar are complete, then exit 0 after the documentation change. +- [ ] Run `npm --prefix docs run lint`, `npm --prefix docs run format`, and `npm --prefix docs run build`; expect success. +- [ ] Run `bash scripts/check_outbound_legacy_api.sh` over the now-updated active docs; expect no stale API references. Legitimate protocol terminology is outside the script's exact symbol pattern and needs no broad exclusion. +- [ ] Commit: `docs: publish outbound HTTP and capabilities guide`. + +### Task 5: Make the completion matrix a CI contract + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/format.yml` +- Modify: `scripts/run_tests.sh` +- Modify: `scripts/run_test_nonzero.sh` + +- [ ] Add/confirm native contract commands for all four adapters and `test-utils` in each supported WASM contract run. Route every native, browser-WASM, WASI SDK-resource, Fastly SDK, and CLI capability suite through `scripts/run_test_nonzero.sh [--ignored] `. The helper first runs the command with harness `--list` (and `--ignored` when requested), requires the sentinel as the exact terminal test-name component with only an optional Rust module prefix, and requires at least one listed test, then runs the same selected suite; cfg/feature drift to zero tests fails CI. The generated-project integration target uses this helper, while its nested generated-core command keeps Task 1's equivalent in-harness list/sentinel/count assertion because it runs from a generated directory. +- [ ] Add generated-project core tests, app-demo fmt/clippy/workspace-test and three WASM checks, and PR-time docs lint/format/build. Retain the existing explicit `examples/app-demo` format and clippy steps in `.github/workflows/format.yml`; the root workspace excludes that directory, so root gates cannot replace them. +- [ ] Retain Cloudflare workerd/deployed host-observed cancellation and timing jobs from Phase 4. +- [ ] Keep pinned Spin teardown characterization clearly non-blocking for its existing BestEffort claims. Retain Phase 6's required Fastly enabled/disabled entitlement characterization as evidence that the adapter actually issues requests and maps disabled-service failure; that evidence does not promote the static `outbound-http` cell. Any future Native promotion requires a deployment-aware prerequisite, new evidence, and a spec change. +- [ ] Add `bash scripts/check_outbound_legacy_api.sh` and `node scripts/check_outbound_docs_contract.mjs` as deterministic nonzero-on-violation gates, with the legacy scan roots documented beside its workflow step. +- [ ] Add the four CLI capability suites as explicit CI commands so the declarations and action-specific gate paths cannot disappear behind broad workspace success: + +```sh +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +``` + +- [ ] Make the generated-project job run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds`. The integration harness then performs Task 1's `generated_outbound_http_smoke` list/count assertion inside the generated workspace before executing its core suite. A successful zero-test command at either level is a CI failure. +- [ ] Commit: `ci: enforce outbound HTTP completion matrix`. + +### Task 6: Final full-system verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `env CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` +- [ ] `(cd crates/edgezero-adapter-cloudflare && ../../scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked --no-default-features --features cloudflare,test-utils --test contract)`; this separate run has no target/runner override and proves the committed crate-local Cargo config selects `wasm32-unknown-unknown` plus `wasm-bindgen-test-runner`. +- [ ] `test "$(viceroy --version)" = "viceroy 0.17.0"` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked --no-default-features --features fastly,test-utils --test contract)` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml | rg 'fastly v0\.12\.1'` +- [ ] `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly` +- [ ] `test -f crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract)`; the crate-local runner must contain the exact Phase 5 validated command. +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.3'` +- [ ] `npm ci --prefix crates/edgezero-adapter-cloudflare` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run test:workerd`; the host driver requires every exact probe ID and a positive count. +- [ ] Push the reviewed final HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-cloudflare-deployed.yml` with + `commit_sha=$(git rev-parse HEAD)`, and require a successful workflow URL whose recorded + checkout SHA matches exactly and whose driver reports every exact probe ID plus a positive + count. A direct credentialed npm invocation does not prove protected-environment or + default-branch workflow behavior. +- [ ] Dispatch the default-branch + `.github/workflows/outbound-fastly-characterization.yml` for the same exact reviewed SHA + and require its successful workflow URL, exact checkout SHA, positive probe count, and + cleanup result. +- [ ] `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds` +- [ ] `(cd examples/app-demo && cargo fmt --all -- --check)` +- [ ] `(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings)` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin)` +- [ ] `bash scripts/check_outbound_legacy_api.sh` +- [ ] `node scripts/check_outbound_docs_contract.mjs` +- [ ] `npm --prefix docs ci` +- [ ] `npm --prefix docs run lint` +- [ ] `npm --prefix docs run format` +- [ ] `npm --prefix docs run build` +- [ ] Rerun the Task 3 typed-stream inventory. Every construction/consumption match must retain its reviewed owned/external classification; compilation plus the named Phase 2 and adapter tests must cover each error-producing owned boundary introduced by the migration. Then run `git diff --check`. +- [ ] Review `git status --short`; only intended implementation/docs/lock/CI changes may remain. + +Expected result: the repository has one public outbound HTTP API, generated consumers and app-demo exercise it, documentation matches the exact capability contract, and CI covers every deterministic acceptance surface. diff --git a/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md new file mode 100644 index 00000000..34ced52d --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md @@ -0,0 +1,206 @@ +# Inbound Ingress Admission Implementation Plan + +> **Status:** Partially implemented in PR #275. Core route resolution, admission, grants, +> request timing, lazy bounded body state, adapter entry seams, and the opt-in bounded body +> drain with a transferable admission grant and application-selected overflow/timeout responses +> before canonical 404/405 responses are implemented. The Axum raw-parser boundary and +> deployed cancellation probes remain open; raw framing/head-limit capabilities stay +> `Unsupported`, and every current adapter reports host-managed head accounting and framing. +> This plan is owned by the +> [inbound-body design](../specs/2026-08-22-inbound-body-design.md), not the outbound HTTP +> implementation phases. +> +> Checkboxes record implementation status. Mixed adapter/certification tasks remain unchecked +> until every assertion in that task has named evidence. + +**Goal:** Add stable pre-dispatch route resolution, one app-owned ingress admission gate, +request-owned grants, absolute body-read deadlines, parser-level request-target/header +limits and raw HTTP/1 framing rejection on Axum, and lazy bounded inbound-body consumption +across all adapters. + +**Architecture:** Each adapter stamps one monotonic request start and validates raw framing +where possible. Core resolves the route once without dispatching, admission sees that stable +resolution, and admitted requests dispatch through an opaque single-use token. The router +constructs `RequestContext` with the original start, route metadata, one-shot grant, and a +lazy deadline-bound body. Core owns the body cache/poison state machine; adapters own native +read cancellation and honest capability claims. + +**Dependencies:** The outbound Phase 1a `Deadline`, `BudgetSource`, and `BadGatewayReason` +work, plus the Phase 2 `ResponseLimitReason` work, must be present before the total +`StoredError` match lands. No outbound adapter client phase is otherwise required. + +## Global Constraints + +- Do not add Tokio to core or shared adapter crates. +- Route resolution may run before admission; middleware, handlers, extractors, and body + polling may not. +- A resolved token is consumed once and cannot be replayed against another router or a + changed method/path. +- Every admitted body has one finite absolute deadline. Relative per-read timeout resets are + forbidden. +- Raw head limits and framing validation must use a pre-normalization parser boundary. A + normalized target or `HeaderMap` check cannot claim parser-allocation or + request-smuggling protection. +- Preserve `RequestContext::new(Request, PathParams)` for low-level callers; it records no + route or admission grant. +- Follow red-green-refactor for every behavioral task and run the focused test after each + production edit. + +## Task 1: Stable route identity and single-use resolved dispatch + +**Files:** `crates/edgezero-core/src/router.rs`, route tests, public re-exports. + +- [x] Write red tests for structural `RouteId(method, registered_pattern)`, dynamic path + independence, optional manifest route class on matched/405 metadata, deterministic 405 + candidates, and `NotFound`. +- [x] Write red tests proving `resolve` executes no middleware/handler/body poll and captures + exact path params. +- [x] Write red tests proving `dispatch_resolved` does not rematch, consumes its token, and + rejects a foreign-router token or changed method/path as `Internal`. +- [x] Add `RouteId`, class-bearing `RouteMetadata`, `RouteResolution`, opaque + `ResolvedDispatch`, `RouterService::resolve`, `RouterBuilder::route_with_class`, and + `dispatch_resolved` exactly as specified. Route class must not enter `RouteId`. +- [x] Run `cargo test -p edgezero-core --lib router`. + +## Task 2: App admission policy, parser limits, and normalized ingress head + +**Files:** `crates/edgezero-core/src/app.rs`, app tests, macro/configure tests. + +- [x] Write red tests for validated nonzero `IngressHeadLimits`, `IngressHeadAccounting`, + `IngressFraming`, immutable/body-blind `IngressHead`, `AdmissionDecision`, and non-clone + opaque `IngressGrant` downcast behavior. +- [x] Write red ordering tests: framing, resolution, admission, then dispatch; refusal skips + middleware/handler/body polling and retains the chosen response. +- [x] Add the synchronous policy and immutable head-limit setters on `App`; preserve both + through complete app-to-service construction rather than cloning only `app.router()`. +- [x] Add the cloneable app-owned `MonotonicClock`, its setter/snapshot accessors, and tests + proving admission retains that exact handle rather than resnapshotting a global clock. +- [x] Add the default decision: empty grant plus + `request_start + DEFAULT_INBOUND_READ_BUDGET` (30 seconds). Clamp every policy deadline + to `request_start + DEADLINE_FAR_FUTURE` with checked arithmetic; reject overflow or an + invalid policy result before body ownership moves. +- [x] Run `cargo test -p edgezero-core --lib app`. + +## Task 3: RequestContext ingress metadata and grant lifetime + +**Files:** `crates/edgezero-core/src/context.rs`, router/context tests. + +- [x] Write red tests proving `IngressHead` and routed `RequestContext` expose the identical + `MonotonicInstant`, paired `MonotonicClock`, and class-bearing `RouteMetadata` values. +- [x] Write red drop-count tests for taken, untaken, refused, 404, and 405 grants. A second + `take_ingress_grant()` returns `None`; no grant type is clonable or serialized. +- [x] Preserve `RequestContext::new(Request, PathParams)`: snapshot start at construction, + expose no route, return no grant, and make no admission claim. +- [x] Add a crate-private routed constructor consuming `AdmittedIngress`; initialize all + metadata and the admitted clock before middleware starts. +- [x] Run `cargo test -p edgezero-core --lib context`. + +## Task 4: Lazy body cell, bounded extractors, and absolute deadline + +**Files:** `crates/edgezero-core/src/body.rs`, `context.rs`, `extractor.rs`, `error.rs`. + +- [x] Write red state-machine tests for `Initial`, `Draining`, `Cached`, `Poisoned`, and + `Taken`, including reentrancy and dropped-drain poison. +- [x] Write red deadline races for first byte, inter-chunk waits, EOF, source error, and + simultaneous readiness using the admitted clock. Expiry wins and remains sticky as + `RequestTimeout` (408). +- [x] Write red cap tests for exact limit, first byte over, checked accounting overflow, and + stricter re-check of already cached bytes without poisoning the cache. +- [x] Implement private `BodyCell`/`StoredError`, no borrow across await, and fallible + `take_body`/`into_request`. Keep the total error match wildcard-free. +- [x] Migrate JSON/form extractors to the documented defaults and explicit `Within` forms. +- [x] Run `cargo test -p edgezero-core`. + +## Task 5: Future Axum raw request-head and framing certification + +**Files:** pinned Hyper patch/upstream hook, workspace dependency patch, Axum connection +setup/request conversion, raw-socket integration tests. + +Current Axum ingress uses `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. Every item in this task is future promotion evidence; none is a +claim about the current adapter. + +- [ ] Add red raw-socket tests for exact/over-limit request-target bytes, raw header bytes, + and field-line count; checked accounting overflow; CL+TE in both orders; duplicate + equal/unequal and comma-list CL; signed/malformed/overflow CL; + repeated/non-final/unsupported TE; HTTP/2 TE; and valid controls. +- [ ] Assert target overflow is 414, header byte/count overflow is 431, diagnostics echo no + request data, and every head rejection happens before resolution/admission/body polling. + Exact limits pass and expose the exact `IngressHeadAccounting::RawValidated` totals. +- [ ] Assert rejection is 400, closes HTTP/1 or resets only the multiplexed stream, invokes + no admission policy, and polls no body. +- [ ] Characterize pinned Hyper 1.10.1 first: preserve source references and tests proving a + `Content-Length` after `Transfer-Encoding` is skipped and equal duplicate lengths are + accepted. These are the red cases the patch must change. +- [ ] Add the smallest audited parser patch (or consume an upstream equivalent) in Hyper's + existing request-line/ordered `httparse` header path. Bound parser reads so the raw head is + rejected rather than accumulated past the installed target/header policy; count duplicate + field lines and exact syntax bytes. In the same boundary reject any CL+TE presence in + either order; any second or comma-list CL; and malformed, repeated/non-final `chunked`, or + unsupported transfer codings before normalization. Keep Hyper as the only HTTP/1 parser; + do not add an independent socket pre-parser that could disagree on pipelined boundaries. +- [ ] Pin the exact patched source/revision and add an upgrade gate that fails when the + patch no longer applies or its source assertions change. Document the upstream issue/PR + and removal condition. +- [ ] Only after raw acceptance, derive the normalized `IngressFraming` summary from the + surviving headers and HTTP version and attach the raw accounting totals. Do not claim the + summaries themselves performed parser enforcement or smuggling validation. +- [ ] Convert accepted bodies lazily, wrap native cancellation under the admitted absolute + deadline, and dispatch with the exact resolved token. +- [ ] Run Axum unit, integration, and raw-socket suites. + +## Task 6: Cloudflare, Fastly, and Spin ingress implementations + +**Files:** each adapter request/service entry point and contract tests. + +Axum shares the current `HostManaged` baseline described in Task 5. This task records the +equivalent baseline and target-specific deadline behavior for the other three adapters. + +- [ ] Write per-adapter ordering tests with an observable first body poll. +- [ ] Stamp `request_start` from `App::monotonic_now()` at earliest guest entry, pass + `HostManaged` framing, attach + `IngressHeadAccounting::HostManaged`, apply only documented post-materialization + defense-in-depth limits, invoke policy once, and preserve the resolved token through + dispatch. +- [ ] Implement pre-read/post-ready deadline checks against the admitted app clock and the + strongest available drop/abort primitive. Do not claim preemption around synchronous + host calls. +- [ ] Prove buffered provider values enter as `Body::Once` only after admission and within a + documented platform bound; all other paths use lazy `Body::Stream`. +- [ ] Run each adapter's contract suite, including its WASM target where applicable. + +## Task 7: Capabilities, generators, and deployed probes + +**Files:** manifest schema/parser, adapter registry, CLI checks, scaffold templates, docs. + +- [ ] Add separate `ingress-admission`, `inbound-read-deadlines`, + `raw-ingress-head-limits`, and `raw-ingress-framing-validation` cells with the exact + initial matrix in the spec. +- [ ] Test parse/display/round-trip, unknown value rejection, and fail-closed build/serve/ + deploy/demo behavior for Native requirements. +- [ ] Update manifest-generated and hand-written app construction so both retain admission + policy; keep low-level `into_router()` explicit about its lack of adapter admission. +- [ ] Keep Cloudflare's pre-select cooperative zero-delay yield, document the frozen-clock + caveat, add Cloudflare and Spin deployed cancellation probes, and retain Fastly + cooperative timing evidence. Store machine-readable target/runtime/version/tolerance + results. +- [ ] Update adapter capability docs without upgrading a cell from local mocks alone. + +## Task 8: Final verification and integration boundary + +- [ ] Audit for eager request-body collection, post-materialization parser-limit claims, + normalized-header framing claims, rematching after admission, relative timeout resets, + wildcard `EdgeError` matches, and leaked grants. +- [ ] Run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +- [ ] Record deployed-probe results separately from deterministic local tests. +- [ ] Commit ingress work independently from outbound adapter phases so each contract can be + reviewed and reverted without changing the other. diff --git a/docs/superpowers/plans/2026-09-08-response-egress-implementation.md b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md new file mode 100644 index 00000000..9eb3a66f --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md @@ -0,0 +1,169 @@ +# Response Egress Implementation Plan + +> **Status:** Partially implemented in PR #275. Core policy, reports, completion guards, +> dispatch metadata, converter-level deadlines/fallback reporting, and capability cells are +> implemented. Axum currently emits `ResponseReturned` after conversion but before returning +> the response to Hyper, so it is not transport acceptance or completion evidence. +> Transport-observable abort, backpressure, completion, write deadlines, and deployed probes +> remain open; all four capability cells stay `Unsupported`. Source of truth: +> [response-egress design](../specs/2026-09-08-response-egress-design.md). +> +> Checkboxes record implementation status. Mixed converter/transport-certification tasks stay +> unchecked until every assertion in that task has named evidence. + +**Goal:** Make client-response conversion and delivery observable and deadline-bounded where +platform APIs permit, with backpressure, native abort, and one terminal report. + +**Architecture:** Core defines policy, metadata, terminal outcome/report, and a non-clone +completion guard. Adapter services retain request start/route metadata across router dispatch, +start one guarded egress attempt, and hand it to response converters. Streaming converters +poll under platform demand and one absolute timer. Buffered-only hosts enforce a converter +cap but remain Unsupported for delivery guarantees they cannot observe. + +**Dependency:** Stable route metadata/request-start reuse depends on Tasks 1-3 of the +[inbound ingress plan](2026-09-08-inbound-ingress-admission.md). Response-egress work may be +developed earlier with `route: None`, but no adapter claims Native completion until the +service handoff preserves the original request metadata. + +## Global Constraints + +- This work does not alter outbound request/response deadlines. +- Do not add Tokio to core or shared adapter crates. +- No terminal path may call the observer twice. +- Do not reset the deadline per chunk, flush, or platform call. +- Do not rewrite status or append an error body after the adapter's response commit point. +- Do not claim client completion from response-object construction. +- Apply TDD per task; record the red failure before production edits. + +## Task 1: Core policy, report, and completion guard + +**Files:** new `crates/edgezero-core/src/response_egress.rs`, `lib.rs`, app configuration. + +- [x] Write red tests for the 30-second default, immediate expiry, checked + `egress_started_at + DEADLINE_FAR_FUTURE` clamping, checked-add overflow failure, all + `ResponseEgressOutcome` variants, report accessors, and observer object safety. +- [x] Write red state tests for `Initial -> Writing -> Completed`, every failure from both + nonterminal states, terminal-signal races, guard drop, disarm, and exactly one callback. +- [x] Write red accounting tests for zero/exact bytes and checked `u64` overflow. Inject a + backwards clock and assert zero elapsed plus `Unspecified`, one notification, and a log. +- [x] Add `ResponseEgressPolicy`, body-blind immutable head accessors, outcome/report, + observer handle, + and a public, non-clone, adapter-facing completion guard (optionally `#[doc(hidden)]`). Keep + observer payload bounded and body/header-free. +- [x] Run `cargo test -p edgezero-core --lib response_egress`. + +## Task 2: Preserve request metadata through dispatch + +**Files:** core router/service result, adapter request services, context tests. + +- [x] Write red tests proving response policy sees the ingress-captured request start and + canonical registered route pattern, never a dynamic path or a newly sampled start. +- [x] Add an internal dispatch envelope carrying `Response`, request start, optional route + metadata, and the app's policy/observer handles to the adapter converter boundary. +- [x] Keep public handler return types unchanged; the envelope is service plumbing, not an + application response type. +- [x] Ensure 404/405 and converted `EdgeError` responses also create one egress attempt. +- [x] Run core router/service tests. + +## Task 3: Shared converter contract and capability cells + +**Files:** `edgezero-adapter` registry/contracts, core manifest, CLI enforcement, docs. + +- [x] Write red parse/display/round-trip tests for `response-egress-abort`, + `response-egress-backpressure`, `response-egress-completion`, and + `response-write-deadlines`. +- [x] Write red build/serve/deploy/demo tests that fail closed when an app requires Native + and the selected target advertises BestEffort/Unsupported. +- [x] Add the exact initial matrix from the spec. Keep cells separate so one target can + expose pull backpressure without claiming observable finish. +- [ ] Define adapter contract fixtures for source, sink, timer, commit, abort, disconnect, + and completion observation without introducing a runtime into core. +- [ ] Run manifest, registry, and CLI capability tests. + +## Task 4: Future Axum transport-egress certification + +**Files:** `edgezero-adapter-axum/src/response.rs`, service wiring, integration tests. + +Current Axum behavior stops at `ResponseReturned` after conversion and before returning the +response to Hyper. The work below is required before any of the four capability cells can be +promoted from `Unsupported`. + +- [ ] Write red body-wrapper tests: no second source poll while one chunk is pending; + independent timer wake; first-byte/inter-chunk/finish deadline; exact byte count; source + error; sink error; body drop; normal EOF; and competing drop/deadline notification. +- [ ] Write red raw-socket tests for a client that does not read, disconnects before body, + disconnects mid-body, and consumes normally. Assert reset/close and exactly one report. +- [ ] Add a bounded local-executor/channel bridge from core's non-`Send` stream to Axum's + `Send` body requirement. Retain the guard through that bridge and race source progress + against an adapter-owned Tokio sleep created from the same absolute deadline. +- [ ] Own the Hyper connection so deadline expiry can reset/close it even when Hyper accepted + a frame and never polls the body again. A body-wrapper timer by itself is not + response-write-deadline evidence. +- [ ] Define and test the header commit point. Conversion/deadline failure before commit may + use the minimal fallback response; after commit it emits a body error/resets instead. +- [ ] Promote each Axum capability separately only after its connection-level behavior and + named raw-socket evidence satisfy the corresponding row in the design. Local conversion + tests alone cannot promote any cell. +- [ ] Run `cargo test -p edgezero-adapter-axum --all-targets`. + +## Task 5: Cloudflare stream wrapper and deployed probe + +**Files:** Cloudflare response converter, WASM tests/harness, deployed probe script/docs. + +- [ ] Write red wrapper tests for pull-driven source polling, cancel, source error, absolute + deadline, exact byte count, and exactly-once finish/drop behavior. +- [ ] Add a frozen-clock fixture where the source continuously returns ready chunks. Assert + the wrapper cooperatively yields and its independent Worker timer can win. +- [ ] Replace simple stream mapping with a wrapper owning source, timer, cancellation, and + completion guard. Recheck deadline after every ready source/platform result. +- [ ] Add a deployed probe covering slow client/backpressure, first-byte and midstream + timeout, explicit disconnect, normal finish, elapsed tolerance, and runtime version. +- [ ] Keep all four cells Unsupported until deployed evidence demonstrates the corresponding + host behavior; a local WASM mock cannot upgrade them. +- [ ] Run Cloudflare unit/contract/WASM checks and archive the probe artifact. + +## Task 6: Fastly and Spin bounded converter fallback + +**Files:** Fastly/Spin response converters and tests, capability docs. + +- [ ] Write red exact-cap and one-byte-over tests for both `Body::Once` and streamed bodies. + The same finite collection cap applies before conversion; checked accounting prevents + overflow. +- [ ] Write red tests for source errors and pre-return deadline checks. Assert reports use + `ConversionError`, `SourceError`, or `DeadlineExceeded` as appropriate and fire once. +- [ ] Refactor collection into shared per-adapter helpers with one absolute converter + deadline. Drop partial buffers/source on failure. +- [ ] Keep `response-egress-abort`, `response-egress-backpressure`, + `response-egress-completion`, and `response-write-deadlines` Unsupported. Document that + successful host response construction emits `ResponseReturned` with zero bytes exactly once, + never `HostHandoff` or `Completed`; this preserves observer cardinality without claiming host + acceptance or an unobservable client finish. +- [ ] Run Fastly and Spin unit/contract suites plus Spin's WASM build. + +## Task 7: Cross-adapter lifecycle tests and documentation + +- [ ] Run the shared matrix for empty, buffered, streaming, source failure, transport failure, + deadline, disconnect, converter failure, and terminal races on every supporting target. +- [ ] Verify reports never contain body bytes, header values, dynamic paths, or raw source + error strings. +- [ ] Update adapter guides with commit points, byte-count boundary, abort primitive, + provider-buffer exclusions, and capability/evidence links. +- [ ] Audit all response converters with `rg` for `block_on`, unbounded `Vec` growth, stream + mapping without cancel/drop observation, deadline resets, and variant-only completion. + +## Task 8: Final verification + +- [ ] Run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +- [ ] Run Axum raw-socket and Cloudflare deployed timing probes separately; record versions + and tolerances. +- [ ] Re-read the source spec and account for every normative statement before marking the + plan complete. diff --git a/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md b/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md new file mode 100644 index 00000000..c70c9386 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md @@ -0,0 +1,78 @@ +# Demo and Generator Lifecycle Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the checked-in demo and newly generated projects exercise the current outbound HTTP and ingress-admission contracts instead of relying on compatibility defaults. + +**Architecture:** Extend `app!` with one optional `configure = ` callback that implements the existing `Hooks::configure(&mut App)` seam. The demo and core template use that callback to install a finite, route-aware admission policy and opaque request grant. Their outbound examples share explicit encoded, decoded, final-buffer, request-body, header, Brotli, and timeout policy, while a batch endpoint exposes positional per-slot elapsed time. + +**Tech Stack:** Rust, proc macros (`syn`/`quote`), Handlebars templates, TOML manifests, EdgeZero core/adapters, native and WASM contract tests. + +--- + +### Task 1: Add the macro configuration callback + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` + +- [x] Add parser tests for `configure = crate::configure_app`, mixed keyword ordering, duplicate rejection, and the updated unknown-key diagnostic. +- [x] Run the focused macro tests and confirm they fail because `configure` is not accepted. +- [x] Parse one optional configure expression and emit `Hooks::configure(app)` as a call to it; preserve the empty default for existing macro invocations. +- [x] Add an integration test proving `Hooks::build_app()` invokes the emitted callback and installs its policy. +- [x] Run all macro tests and strict macro clippy. + +### Task 2: Align ingress behavior in the demo and scaffold + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` + +- [x] Add failing demo tests proving manifest route classes reach resolution, the configured callback supplies finite class-aware deadlines, and the opaque grant can be consumed exactly once by a handler. +- [x] Add route classes for health, diagnostic, and outbound routes in both manifests. +- [x] Define a small application-owned admission lease and configure callback in both core crates/templates; outbound routes receive a tighter read budget and every request receives a finite deadline. +- [x] Add an admission diagnostic handler that consumes the typed grant and confirms its route class without exposing provider data. +- [x] Run the demo core tests and generated-source assertions. + +### Task 3: Align outbound examples with current limits and timing + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/README.md.hbs` + +- [x] Extend outbound mock tests first to require explicit per-call timeout, request-body limit, independent encoded/decoded/final limits, header byte/count caps, and Brotli policy. +- [x] Apply one shared outbound policy helper to proxy and batch requests in both demo and template. +- [x] Add a positional batch endpoint that computes one absolute deadline, calls `send_all`, and returns each slot's index, elapsed milliseconds, and typed success/failure category. +- [x] Add tests for positional ordering, empty batches, and distinct per-slot elapsed values. +- [x] Document the generated routes, configured limits, timing semantics, and adapter timing-quality caveat. + +### Task 4: Lock generator/demo parity + +**Files:** +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `.github/workflows/test.yml` +- Test: generated project under a temporary directory + +- [x] Add generator assertions for the configure callback, ingress policy/grant, route classes, independent outbound caps, and per-slot timing endpoint. +- [x] Run focused generator tests and confirm the new assertions fail before template changes, then pass after them. +- [x] Generate a fresh all-adapter project and run its tests and strict clippy. +- [x] Run the checked-in demo tests, strict clippy, and all three adapter WASM checks. + +### Task 5: Full verification and PR update + +**Files:** +- Modify: PR 275 description through `gh` + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `scripts/run_tests.sh` and documentation checks. +- [x] Update PR metadata, commit, push, wait for all checks, and verify a clean synchronized branch. diff --git a/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md b/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md new file mode 100644 index 00000000..d6ffd6e2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md @@ -0,0 +1,104 @@ +# Ingress Fallback Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let applications hold an admission lease while enforcing a finite body cap and absolute read deadline before EdgeZero emits a pre-resolved 404 or 405, with application-controlled overflow and timeout responses. + +**Architecture:** Extend the synchronous, body-blind `ReadBodyBeforeFallback` disposition with a transferable `IngressGrant` and buffered terminal responses. Core retains the exact resolved route token and grant for the full drain lifetime, adapters install the existing deadline-bound lazy body, and resolved dispatch maps typed drain outcomes to either the application response or canonical 404/405 without middleware, handlers, rematching, or string classification. Response-egress capability levels remain unchanged and explicitly document the current certification boundary. + +**Tech Stack:** Rust 2024, `edgezero-core`, Axum/Hyper adapter integration tests, Handlebars project templates, VitePress Markdown, Cargo workspace CI. + +--- + +## Task 1: Add the bounded fallback admission contract + +**Files:** +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-core/src/app.rs` + +- [x] Add failing core tests proving `ReadBodyBeforeFallback` is accepted only for `NotFound` and `MethodNotAllowed` and is rejected for matched routes before body polling. +- [x] Add failing dispatch tests proving an exact-cap lengthless body produces the canonical 404/405, the first byte over produces 400 before 404/405, and an expired absolute deadline produces 408. +- [x] Run the focused core tests and record the expected failures caused by the missing variant and dispatch behavior. +- [x] Add the initial bounded fallback disposition. Task 6 hardens its final contract with a transferable grant and exact buffered terminal responses while retaining the no-context/no-handler path. +- [x] Reuse the existing deadline checks in a bounded count-and-discard drain for fallback dispatch. Do not rematch, invoke middleware, construct a routed request context, or poll bodies for ordinary `Admit`/`Refuse` outcomes. +- [x] Run `cargo test -p edgezero-core --lib ingress`, `cargo test -p edgezero-core --lib app`, and `cargo test -p edgezero-core --lib router` until green. + +## Task 2: Prove adapter-level 400-before-404/405 behavior + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/service.rs` + +- [x] Add failing Axum service tests with body streams that omit `Content-Length`: exact-cap unmatched and wrong-method requests retain 404/405, while one-byte-over requests return 400. +- [x] Add a deadline test proving fallback draining uses the admitted absolute read deadline and releases the native body without middleware or handler execution. +- [x] Run the focused tests before production changes to confirm the missing core behavior is the failure source. +- [x] Make only the adapter wiring changes required by the public contract; standard adapters must continue to wrap the unread native body before `dispatch_admitted`. +- [x] Run `cargo test -p edgezero-adapter-axum --all-targets`. + +## Task 3: Keep the demo and generated project current + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` + +- [x] Add failing demo/template assertions for a 4 KiB unmatched/wrong-method fallback cap and finite fallback deadline. +- [x] Update both admission callbacks to return `ReadBodyBeforeFallback` for `NotFound` and `MethodNotAllowed`; matched routes retain route-class grants and class-aware deadlines. +- [x] Add demo lifecycle tests for under-cap 404/405 and overflow 400 precedence. +- [x] Extend generator source assertions so future templates cannot silently drop the fallback policy. +- [x] Run demo core tests, generator tests, and the ignored generated-workspace integration test. + +## Task 4: Correct evidence and certification documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md` +- Modify: `docs/superpowers/plans/2026-09-08-response-egress-implementation.md` + +- [x] Specify the bounded fallback decision, ordering, cap/deadline errors, route-token preservation, and no-handler/no-middleware behavior. +- [x] Mark parser-level head-limit and Axum raw-socket framing rows as future acceptance criteria. State that every current adapter reports `HostManaged` and both raw ingress capabilities remain `Unsupported`. +- [x] State that Axum currently reports `ResponseReturned` after conversion but before Hyper transmission. Keep abort, backpressure, completion, and write-deadline capabilities `Unsupported` and identify them as certification blockers, not implemented delivery guarantees. +- [x] Update implementation-plan status notes without claiming unfinished raw-boundary or transport-egress work. +- [x] Run documentation format, lint, build, and contract checks. + +## Task 5: Full verification and PR update + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- [x] Run `scripts/run_tests.sh`, generated-project validation, demo target checks, and `git diff --check`. +- [x] Self-review the complete diff for precedence, cancellation, grant lifetime, error redaction, capability honesty, and demo/template parity. +- [ ] Update PR 275 metadata, commit, push, wait for all checks, and verify a clean synchronized branch. + +## Task 6: Transfer the fallback grant and terminal responses + +**Files:** +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: adapter contract tests under `crates/edgezero-adapter-*/tests/contract.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/guide/capabilities.md` + +- [x] Add failing core tests proving a non-empty fallback grant remains live during every body poll and drops exactly once after EOF, overflow, timeout, source failure, or cancellation. +- [x] Add failing core tests proving saturated admission returns an application 503 without polling the body, and fallback terminal responses preserve exact application-selected status, headers, and buffered bytes. +- [x] Add a cloneable buffered ingress-response value and extend `ReadBodyBeforeFallback` with `grant`, `on_exceeded`, and `on_timeout`. Keep `Refuse` as the zero-read overload path. +- [x] Replace fallback drain message classification with private `Complete`, `Exceeded`, and `TimedOut` outcomes. Hold the grant through the drain, release it before response conversion, preserve source errors, and retain canonical 404/405 only after clean EOF. +- [x] Extend Axum, Cloudflare, Fastly, and Spin contract suites so each adapter exercises exact-cap `NotFound` and `MethodNotAllowed` requests, first-byte-over precedence, absolute deadline behavior, zero middleware/handler calls, source release, zero-read refusal, and grant lifecycle at the strongest capability each host exposes. Axum additionally pins deadline-bounded release after an outer abort request because its blocking bridge is not promptly cancellable. +- [x] In every adapter contract suite, assert the exact application-selected overflow and timeout status, headers, and buffered body bytes so adapter conversion cannot silently restore the generic JSON 400/408 renderer. +- [x] Update the demo and generated app policy to carry a fallback lease plus explicit plain-text overflow and timeout responses; extend generator assertions and lifecycle tests so the two remain identical. +- [x] Update the ingress specification, capability guide, and this plan to remove the empty-grant and generic-error claims and state the exact lifecycle and wire-response contract. +- [x] Run focused core, adapter, demo, generator, documentation, full workspace, strict Clippy, feature, and WASM target checks before committing and pushing. diff --git a/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md b/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md new file mode 100644 index 00000000..6b14102f --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md @@ -0,0 +1,171 @@ +# Outbound HTTP Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining current-head review gaps by preventing provider diagnostics from reaching HTTP responses, splitting typed config failure reasons, and carrying manifest route class plus one injectable monotonic clock through ingress and outbound HTTP. + +**Architecture:** Keep detailed errors available inside `EdgeError`, but render fixed category-only messages at the wire boundary and stop adapters from embedding provider values where no internal diagnostic is needed. Hard-cut the ambiguous config reasons so exhaustive downstream matches must migrate to the precise taxonomy. Route identity remains method plus pattern, while route class is optional metadata. Use one `App`-owned portable clock handle backed by `web_time` by default; the same handle captures ingress start, evaluates admitted body deadlines, and is cloned into the standard outbound client installed by every adapter. + +**Tech Stack:** Rust 2024, `web-time`, `http`, Axum/Tokio, Cloudflare Workers, Fastly Compute, Fermyon Spin, Cargo workspace and WASM target checks. + +--- + +### Task 1: Wire-Safe Server Error Messages + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Test: colocated unit tests in the files above + +- [x] Add token-bearing tests proving `BadGateway`, `GatewayTimeout`, `Internal`, and `ResponseTooLarge` preserve their internal diagnostics while `IntoResponse` emits only fixed category messages and never serializes the token, URL, query, or provider text. +- [x] Run `cargo test -p edgezero-core error` and verify the new tests fail on the current `self.message()` wire rendering. +- [x] Add a private exhaustive `wire_message()` policy and use it only in `IntoResponse`; keep `message()`, `Display`, typed reasons, and causes available for internal inspection. +- [x] Replace Axum, Cloudflare, and Fastly outbound provider-error interpolation with fixed diagnostics. Keep Spin's already category-only mappings unchanged. +- [x] Add token-bearing wire regression coverage and adapter-facing secret-error assertions; scan every outbound provider mapping to verify rendered responses cannot contain provider values. +- [x] Run `cargo test -p edgezero-core` and focused adapter tests. + +### Task 2: Split Typed Config Extraction Reasons + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `docs/superpowers/plans/2026-06-17-blob-app-config.md` + +- [x] Add failing tests for four independently inspectable reasons: `Deserialization`, `Validation`, `MalformedEnvelope`, and `UnsupportedVersion`. +- [x] Run the focused extractor and error tests and verify the pre-split reason assertions fail. +- [x] Add the four variants to the non-exhaustive enum and its exhaustive wire status/kind policy. Remove the ambiguous predecessor variants; this is an intentional compile-time migration break. +- [x] Map malformed envelope parsing, unsupported envelope version/discriminator, typed `data` deserialization, validator failures, and structural secret-walk failures to their exact new reasons. +- [x] Update stored-error round trips and exhaustive reason tests. +- [x] Run `cargo test -p edgezero-core`. + +### Task 3: Manifest Route Class Metadata + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md` + +- [x] Add failing manifest, macro-token, router-resolution, admission-head, and request-context tests for optional `class = "auction"` metadata. +- [x] Run focused core and macro tests and verify the field/API are absent. +- [x] Add optional validated `class` to `ManifestHttpTrigger`; do not overload the existing unique trigger `id`. +- [x] Add optional class to `RouteMetadata` without changing `RouteId`; add `RouteMetadata::class()` and a builder path for classed routes while preserving all existing route methods. +- [x] Make `app!` propagate each trigger's class for every generated method. Ensure 404/405 behavior and deterministic allowed-route ordering remain unchanged. +- [x] Run `cargo test -p edgezero-core` and `cargo test -p edgezero-macros`. + +### Task 4: Paired Injectable Ingress Clock + +**Files:** +- Modify: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Test: colocated core and adapter tests + +- [x] Add failing tests with a manually advanced clock proving the same source captures `request_start`, normalizes the admission deadline, wins simultaneous body readiness/expiry, and poisons repeated body reads exactly once. +- [x] Add a cloneable `MonotonicClock` handle wrapping `Fn() -> MonotonicInstant + Send + Sync`, with a `web_time` default. Make `Deadline::remaining_at` and `is_expired_at` public clock-paired operations. +- [x] Store the clock on `App`, expose a setter and snapshot method, attach it to admitted ingress, and carry it into `RequestContext`. +- [x] Change all standard adapter entry points to capture start from `App`'s clock as their first EdgeZero-owned operation. Pass the admitted clock into each lazy body wrapper and use it for pre-poll/post-ready deadline decisions; retain each platform's strongest available timer/cancellation primitive. +- [x] Keep low-level conversion APIs explicitly non-admitting and preserve their existing default-clock behavior. +- [x] Run focused core and all four adapter request/contract tests. + +### Task 5: Documentation, Metadata, and Full Verification + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: relevant implementation plans under `docs/superpowers/plans/` +- Modify: PR 275 title/body through `gh` + +- [x] Document fixed wire messages, split config reasons, route class semantics, and the paired clock contract. +- [x] Preserve explicit `Unsupported` declarations for raw parser accounting, provider-side config allocation/cancellation, and transport-observed response abort/backpressure/completion/write deadlines. +- [x] Update the PR title from design-only wording and refresh the body without claiming unsupported guarantees. +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Compile and lint Cloudflare, Fastly, and Spin on their target-specific WASM triples; run the exact Fastly and Spin CI sentinels locally. +- [x] Make the Fastly outbound concurrency sentinel part of the WASM contract binary, as required by the existing CI matrix, and prove it under Viceroy. +- [x] Confirm the Cloudflare browser-runtime contract through the PR's Linux CI job; local Safari WebDriver cannot start in this environment. +- [x] Run documentation contract, formatting, lint, and build checks. +- [x] Commit, push, wait for PR checks, and verify the branch is clean and synchronized. + +### Task 6: Final Self-Review Corrections + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `.github/workflows/test.yml` + +- [x] Add red regressions for escaped JSON discriminators, config provider-message redaction, + generic Cloudflare fetch classification, paired relative admission deadlines, and native source + release when a terminal body error is emitted. +- [x] Replace textual discriminator detection with structural JSON inspection and keep malformed + envelopes distinct from unsupported versions. +- [x] Redact direct config-store provider failures at conversion and classify an opaque Cloudflare + fetch rejection as `Unspecified` rather than claiming connection-phase evidence. +- [x] Add a request-start-relative deadline helper, stamp ingress before store resolution on every + adapter path, and release body sources at the terminal item rather than retaining them until the + wrapper is dropped. +- [x] Correct BestEffort cancellation wording, stale Spin request-conversion docs, and unsafe + config-plan examples; add explicit Fastly WASM concurrency and public ingress-capability checks. +- [x] Remove the superseded serde constructor and verify no active code depends on it. +- [x] Run focused red/green tests after each correction, then repeat every Task 5 verification gate. + +### Task 7: Pair outbound clients with the application clock + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: adapter contract tests under `crates/edgezero-adapter-*/tests/contract.rs` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/guide/capabilities.md` + +- [x] Add failing per-adapter tests proving method-entry anchoring, preflight elapsed timing, post-ready expiry, backwards-clock handling, and clock retention by streamed upload/response paths. Cloudflare and Spin deferred-path probes run through their hosted contract binaries rather than unexecuted library-only tests. +- [x] Store a `MonotonicClock` on every native outbound client. Preserve default-clock constructors for low-level use and add explicit clock constructors for standard adapter wiring and tests. +- [x] Install each standard request's outbound client with the exact `App::monotonic_clock()` clone used by ingress; keep standalone low-level request converters explicitly default-clocked. +- [x] Replace every production outbound `MonotonicInstant::now`, `Deadline::remaining`, and `Deadline::is_expired` call with the client clock plus explicit `remaining_at` or `is_expired_at`; carry clock clones into all deferred body streams and Fastly pending slots. +- [x] Keep `DispatchBudget` as the copyable result of pure budget selection while ensuring its start snapshot, all later deadline checks, error precedence, dispatch-slack checks, and slot completion observations use one clock domain. Fastly backend identity uses the method-entry `budget.duration`; preparation-time clock samples cannot fragment a homogeneous batch's dynamic-backend identity. +- [x] Update the outbound specification and capability guide with constructor semantics, app-clock propagation, low-level defaults, and the full injected-clock acceptance matrix. +- [x] Run focused outbound adapter tests, source scans for global-clock bypasses, full workspace tests, strict Clippy, feature builds, and all three WASM target checks before committing and pushing. + +### Task 8: Final implementation-review corrections + +**Files:** +- Modify: `crates/edgezero-core/src/{app,extractor,lib,outbound,response_egress}.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{outbound,response}.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `docs/guide/proxying.md` +- Modify: outbound and response-egress specifications + +- [x] Validate HEAD/304 representation `Content-Length` as one consistent `u64`, reject malformed/conflicting/overflow values before body polling, retain valid metadata, and continue stripping the field on 1xx/204. +- [x] Carry the application clock through `OutboundResponse`, cooperative bounded-until collection, typed config extraction, `ResponseEgressEnvelope`, and every adapter response converter. +- [x] Preserve Cloudflare's intentional abort-on-drop for framing-bodyless responses; handle a null-body 205 without calling `stream()`, disarm only clean absent/zero-length suppression, and keep positive declared length on the abort path. +- [x] Pin Spin's `BodyWriter` default-on-drop host-error contract in source and retain nonblocking deadline exits instead of awaiting after budget expiry. +- [x] Add `max_chunk_bytes` to the demo, generated template, generator sentinel, and both copyable proxy-guide examples; document that rechunking bounds emitted item shape, not provider allocation. +- [x] Add Cloudflare/Spin three-slot preflight-order coverage, executable adapter write-deadline probes, Cloudflare abort lifecycle coverage, and host-duration floor/saturation tests. +- [x] Document intentional zero-cap deny-all behavior and keep loop/self-reference policy explicitly application-owned. +- [x] Run every workspace, strict lint, documentation, generated-project, demo, and target-specific WASM gate. diff --git a/docs/superpowers/specs/2026-05-21-outbound-http-design.md b/docs/superpowers/specs/2026-05-21-outbound-http-design.md new file mode 100644 index 00000000..a28c6d3a --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-outbound-http-design.md @@ -0,0 +1,6973 @@ +# EdgeZero Outbound HTTP — Design Spec + +> **Status:** Normative design complete; Phases 1a-7 and review hardening are implemented on PR 275. Transport-observed response-egress certification remains explicitly unsupported as described in §1.3 and the response-egress design. · **Date:** 2026-09-11 +> **Branch:** `docs/outbound-http-spec` · **Audience:** EdgeZero maintainers +> **Driving pattern:** fan-out HTTP workloads — N concurrent outbound requests under a shared wall-clock deadline, results harvested in input order. The spec is written against this pattern as a portable substrate; it deliberately does not name a specific consumer. +> **Target codebase baseline:** [`stackpop/edgezero` PR #269](https://github.com/stackpop/edgezero/pull/269) (`feature/extensible-cli`, rev `b4c80e9`) — **now merged into `main`** (squash-merged as `e483723`). Relevant baseline changes are the `edgezero_cli::adapter::execute(..)` shell-or-registry dispatcher, expanded runtime `AdapterAction` variants, Spin SDK 6 / wasip2, the contributor-only `demo` command replacing `dev`, and the app-demo integration crate. Non-outbound store/config lifecycle changes remain outside this design. +> **Current checkout (post-#269 and staged-deploy work):** the CLI surface includes `Command::{Build, Serve, Deploy, Auth, Provision, Config, Demo, New}`; `Action` / `AdapterAction` additionally include `DeployStaged`, `EmitVersion`, `Healthcheck`, and `Rollback`; and adapter dispatch has both `execute(..)` and `execute_capture(..)` entry points. `dev` is gone. This outbound spec gates construction/deployment of the current runtime: `build` / `serve` / `deploy` / `deploy --staging` through both dispatch entry points, plus `demo` before Axum starts. Operational auth/version/health/rollback actions and provisioning/config/store lifecycle policy are exempt or belong to their owning specifications (§3.5.3). +> **Where rebase claims live (authoritative surfaces):** §3.5.3 build-enforcement, §3.5.2 `Adapter` trait shape, §5.4 capability tests, and the §7 `edgezero-cli` migration bullet. The §3.5.3 + §7 active text is authoritative. + +## 1. Overview + +### 1.1 Goal + +Make EdgeZero a production-safe substrate for **outbound HTTP fan-out**: an app must be +able to issue many independent target requests concurrently, enforce per-request and +whole-fan-out batch deadlines, keep memory predictable, and run the *same handler source* +unchanged on Axum, Cloudflare Workers, Fastly Compute, and Spin. + +"Predictable memory" here means: a documented, bounded cost per buffered outbound request +and response, plus an explicit batch-level memory model the app controls (§3.4.4). +It does **not** mean EdgeZero imposes a global allocation ceiling. + +### 1.2 Context + +Applications today proxy a single outbound request through the current +`ProxyClient` / `ProxyHandle`. What is missing: + +- A first-class, **independently constructed** outbound request type. +- **True concurrent fan-out.** Today's Fastly client calls `pending_request.wait()` + inside a single `send()`, so any `join_all` of `send()` calls runs strictly serially. +- A **portable deadline** primitive. +- **Bounded buffering** helpers with clean error mapping. +- A way for an app to **declare required capabilities** and fail the build early. + +### 1.3 Non-goals + +- No consumer-specific target logic in EdgeZero. +- EdgeZero does not own application target policy or allowlists. It exposes + `OutboundRequest::uri()` so apps enforce their own policy; it never blocks a request itself. +- EdgeZero does not add an application loop counter, `Via` policy, or self-reference rejection. + Those depend on deployment identity and trust boundaries and remain application policy; the + reference proxy example must not imply that `PROXY_HEADER` is a loop-prevention mechanism. +- No new direct dependency on `tokio`, `reqwest`, `fastly`, `worker`, or `spin-sdk` in + application/library crates or in `edgezero-core`. Those stay inside adapter crates. +- No general-purpose "timeout any future" combinator in this spec — see §3.3.5. +- Inbound admission, request-start stamping, request-body read deadlines, and raw request + framing rejection are owned by the + [inbound-body design](2026-08-22-inbound-body-design.md), not by outbound HTTP. Outbound + implementation may start independently; it must not claim those ingress guarantees. +- The lifetime after a core `Response` reaches a platform response converter is a separate + downstream-response concern owned by the + [response-egress design](2026-09-08-response-egress-design.md) and its + [implementation plan](../plans/2026-09-08-response-egress-implementation.md). No adapter + may claim bounded response writes until that contract's absolute write deadline, + backpressure/finish, platform abort, disconnect, and exactly-once terminal reporting work + lands. §3.3.3 defines the precise boundary of the outbound request deadline. + +### 1.4 Decisions locked before / during review + +- **No backward compatibility.** `ProxyClient` is renamed and reshaped in place; + `app-demo`, scaffolding templates, and docs are migrated. No deprecated + aliases. +- **One portable buffered fan-out primitive.** `send_all` is the only fan-out API + for buffered request bodies + buffered responses. Its **input/output contract** + is identical on every adapter (preflight, index alignment, per-slot Ok/Err + shape — see §3.1.1 / §3.2). **Cross-slot timing is not uniform** — on + Axum/CF/Spin `join_all` fans out complete exchanges concurrently. On Fastly, + dispatch calls are issued sequentially, but each successful `send_async` returns when + transmission begins and the host continues a buffered upload in the background. A + buffered upload still has no finite completion bound and can leave its slot unresolved; + buffered response bodies also drain serially in harvest order (§3.3.4). The + `send-all-slot-isolation` capability (§3.5.1 footnote 4) lets apps require + the stricter guarantee and fail closed on Fastly. **Streamed-response fan-out + is explicitly non-portable** — Fastly's dispatch-all-then-harvest model and + lack of a concurrent body-drain primitive (§3.3.4 / §3.2 / §8 risk 8) make + it unsafe to expose as a portable primitive. Apps that need streamed-response + concurrency use single `send` per request and orchestrate themselves; that is + reactor-bearing only (Axum/CF/Spin), as is any concurrent body consumption. + `futures::future::join_all` is an internal adapter detail for `send_all`'s + implementation on the three reactor-bearing adapters, never app-facing. +- **Unified body.** Outbound request and response bodies use the existing core `Body` + type and may be **buffered (default)** or **streamed (opt-in)**. Streaming + proxy-forwarding is preserved — it is not dropped (review finding / residual risk). +- **Deliverables:** this design plus phased implementation plans. Code changes are + follow-ups executed from those plans. + +## 2. Current state (summary) + +| Concern | Today | File | +| --- | --- | --- | +| Outbound trait | `ProxyClient::send(ProxyRequest) -> Result` | `crates/edgezero-core/src/proxy.rs:16` | +| Handle | `ProxyHandle` (`Arc`), `RequestContext::proxy_handle()` | `proxy.rs:21`, `context.rs:97` | +| Request type | `ProxyRequest::new(method, uri)`; `ProxyRequest::from_request` (streaming) | `proxy.rs:138`, `proxy.rs:100` | +| Body | `Body { Once(Bytes), Stream(..) }`; `Body::into_bytes_bounded(max)` exists | `body.rs:14`, `body.rs:76` | +| Errors | Phase 1a has typed 502/504 errors; response-limit 502 remains Phase 2. `EdgeError` is `#[non_exhaustive]` | `crates/edgezero-core/src/error.rs` | +| Deadlines | Phase 1a has `Deadline` and constants; `DispatchBudget` / `dispatch_budget` remain Phase 1b | `crates/edgezero-core/src/time.rs` | +| Fastly send | `send_async_streaming()` then `pending_request.wait()` — serializes | `crates/edgezero-adapter-fastly/src/proxy.rs:30` | +| Fastly backend name | host with only `.`/`:` sanitized | `crates/edgezero-adapter-fastly/src/proxy.rs:110` | +| Manifest | no capability declaration or outbound host plumbing | `crates/edgezero-core/src/manifest.rs` | +| Adapter trait | `execute` / `name` plus existing non-outbound lifecycle hooks; no capability metadata | `crates/edgezero-adapter/src/registry.rs` | +| Contract tests | exist for Cloudflare/Fastly/Spin; **Axum has none** | `crates/edgezero-adapter-*/tests/contract.rs` | +| Scaffold templates | emit proxy code | `crates/edgezero-cli/.../handlers.rs.hbs`, `spin.toml.hbs:13` | +| Public docs | document `ProxyService`/`ProxyRequest` | `docs/guide/proxying.md`, `docs/guide/handlers.md`, `docs/guide/architecture.md`, `docs/guide/what-is-edgezero.md`, `docs/guide/adapters/*` | + +## 3. Design + +> **⚠️ Code blocks in this spec are ILLUSTRATIVE, and many predate the strict lint +> gate.** The workspace denies `clippy::restriction` and warns `clippy::pedantic` under +> `-D warnings` (root `Cargo.toml`). Any snippet copied into an implementation **must** +> be brought to that gate before it will compile in CI. The recurring offenders, with +> their required forms (the Phase 1a plan carries the full list and a verified-green +> example): +> - **`missing_inline_in_public_items`** → every public fn needs `#[inline]`. +> - **`min_ident_chars`** → no single-char idents (`d`→`duration`, `e`→`err`, `m`→`manifest`). +> - **`arithmetic_side_effects`** → no bare `+`/`-`/`*`/`/`; use `checked_*` / `saturating_*` / `try_from`. +> - **`as_conversions`** → no `as` casts; use `From`/`TryFrom`/`u64::from`. +> - **`expect_used` / `unwrap_used`** → forbidden in production (allowed in `#[cfg(test)]`); use `?`/`ok_or`. +> - **`arbitrary_source_item_ordering`** → module items, struct fields, enum variants, and impl fns alphabetical (or a documented `#[expect(..)]`). +> - **`duration_suboptimal_units`** → `Duration::from_hours(168)`, `from_mins(1)` — not `from_secs(7*24*60*60)` / `from_secs(60)`. +> +> Where a snippet below still shows a bare-`d` closure param, `x as u64`, or +> `a + b`, read it as shorthand for the lint-clean form — do not copy it verbatim. + +### 3.1 Outbound HTTP client abstraction + +`crates/edgezero-core/src/proxy.rs` is renamed to `crates/edgezero-core/src/outbound.rs`. +Bodies use the **existing core `Body`** type (`Once(Bytes)` | `Stream(..)`), so a request +or response may be buffered or streamed. Buffered is the default; +streaming is an explicit opt-in that preserves proxy-forwarding. + +#### 3.1.1 Adapter-facing trait — two required methods + +```rust +// crates/edgezero-core/src/outbound.rs + +/// One index-aligned terminal result from `send_all`. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundSlotResult { + pub elapsed: Duration, + pub outcome: Result, +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + /// Send a single request. Accepts streamed request bodies — this is the API + /// for streaming proxy-forwarding (one inbound → one outbound). + /// + /// **`Buffered` mode:** `Ok(resp)` means the full exchange completed — + /// headers AND the response body buffered within the deadline and the + /// final collection cap, plus any configured encoded/decoded/resource caps. + /// `Err(_)` is returned for transport failure + /// (DNS/TLS/connect), deadline expiry, or over-cap. + /// + /// **`Streamed` mode:** `Ok(resp)` means headers completed. Body-phase + /// failures surface later, when the caller consumes `resp.into_body()`: + /// - **Read errors / decompression failures / deadline expiry** during + /// chunk reads come from the deadline-aware stream wrapper + /// as `Err(EdgeError::..)` chunks. + /// - A configured **decoded-output cap** wraps effective identity and EdgeZero-decoded + /// gzip/Brotli output in both response modes, but never raw passthrough. It can yield + /// `ResponseLimitReason::DecodedBody` from the returned stream. A later bounded helper + /// (`OutboundResponse::into_bytes_bounded(max)`, `into_bytes_bounded_until`, or + /// `json_bounded[_until]`) owns a distinct final collection cap and reports + /// `BufferedBody`. Independently configured response-resource controls still apply: + /// header limits can fail `send` before it returns; encoded-byte, Brotli-window, and + /// decoder-heap limits can yield a typed error from the returned stream; and + /// `max_chunk_bytes` shapes emitted items (§3.4.5). + /// - Raw passthrough bypasses `max_decoded_response_bytes` on every adapter because + /// EdgeZero did not produce decoded output. Cloudflare is the sole adapter that streams + /// that `Body::Stream` lazily to the downstream wire without an additional converter + /// collection cap. **Axum, Fastly, AND Spin all BUFFER `Body::Stream`** in their response + /// converters within a separate adapter-level 16 MiB cap + /// (`AXUM_/FASTLY_/SPIN_RESPONSE_STREAM_BUFFER_BYTES` → 502 on overflow), so on those + /// three a raw passthrough is still capped. Cloudflare is the exception, not Axum. + /// If the caller has *already started writing the downstream response + /// headers* (e.g. a proxy-forward via `into_response` that the platform + /// converter has begun sending), HTTP no longer allows a status change. + /// The adapter response converter then requests the strongest platform-supported + /// downstream-body abort and logs the originating `EdgeError`; clients observe an + /// incomplete response rather than a synthetic 502/504. Exact wire behavior (for example, + /// a connection close or stream reset) is platform/protocol behavior and must be + /// characterized, not universally asserted. The separate response-egress contract in + /// §1.3 owns write deadlines and exactly-once completion. + async fn send(&self, req: OutboundRequest) -> Result; + + /// Issue every request concurrently, then collect every result. + /// + /// The returned vec is index-aligned with `reqs`: `out[i].outcome` is the result of + /// `reqs[i]`, and `out[i].elapsed` is that slot's terminal elapsed time. `send_all` + /// captures one `batch_started_at` monotonic instant as its first operation. Every slot, + /// including a slot rejected during preflight, measures from that same instant through + /// the instant its own outcome becomes terminal. Elapsed time therefore includes + /// EdgeZero validation, adapter preparation, provider queueing, upload, response headers, + /// Buffered body drain, and any platform-imposed delayed observation. It is not a pure + /// wire RTT. Each terminal slot samples the clock independently; the batch's eventual + /// return time is never copied into every slot. Monotonic subtraction is checked. A + /// backwards injected clock replaces that slot's outcome with an internal invariant error + /// and uses `elapsed = Duration::ZERO` as a fallback. Zero is also a valid elapsed value + /// when start and terminal samples are equal at the clock's resolution; callers distinguish + /// the clock-fault case by its internal outcome, not by elapsed alone. + /// + /// **Input handling is isolated per slot**: a `bad_request` for + /// one preflight failure never changes another slot's input shape, and one + /// slot's `Ok`/`Err` type never mutates another's. Cross-slot *timing* is + /// **not uniformly isolated** — see the `send-all-slot-isolation` capability + /// (footnote 4): on Axum/CF/Spin it is `Native` (concurrent complete + /// exchanges), but on Fastly it is `BestEffort` because dispatch is + /// sequential, cold backend registration can block later issuance, + /// background request uploads can leave their own slots unresolved, and + /// buffered response-body drains run in harvest order. An earlier slot + /// can therefore delay later observation, and a later slot + /// whose own budget would have covered it can still return + /// `gateway_timeout`. Apps that require the stricter cross-slot timing + /// guarantee declare the capability required and get a hard build failure + /// on Fastly. `send_all(vec![])` returns `vec![]`. + /// + /// **Memory model — CORE-OWNED retained payload only.** This formula bounds the + /// buffers EdgeZero core holds; it deliberately EXCLUDES (a) adapter-side upload + /// staging copies (e.g. a `chunk.to_vec()` handed to a platform write path) and + /// (b) opaque host/runtime buffering (the Fastly/CF/Spin host may retain its own + /// copy of in-flight bytes). Worst-case core-owned retained buffer for + /// one `send_all` is `Σᵢ request_bodyᵢ.len + Σᵢ max_response_bytesᵢ` + /// (per-slot caps). Transient core overhead during a buffered drain adds up to + /// one in-flight chunk per actively-draining slot (the + /// `current_chunk.len()` term from §3.4.4); the full core-owned bound is therefore + /// `Σᵢ request_bodyᵢ.len + Σᵢ max_response_bytesᵢ + Σⱼ + /// current_chunkⱼ.len()` where j ranges over slots currently in a drain + /// step. Actual process RSS can exceed this by the excluded adapter/host terms. EdgeZero does NOT impose a global cap on N — apps are + /// responsible for bounding the number of requests passed in. Fastly attempts + /// to dispatch every slot before harvest, and every slot whose sequential + /// dispatch returns is then in flight at the host. Cold dynamic-backend creation + /// can delay a later dispatch; an unbounded background upload can leave its slot + /// unresolved and delay ordered harvest, but does not block later `send_async` + /// calls after it has returned. A `max_concurrency` knob would not repair those + /// platform gaps, so bound N at the application layer + /// (typically the fan-out batch's target count). + /// + /// **Request bodies MUST be buffered (`Body::Once`).** A `Body::Stream` + /// request body yields `out[i].outcome = Err(EdgeError::bad_request("send_all + /// requires buffered request bodies; use send for a streamed upload"))`, + /// identically on every adapter. This rule removes the unbounded + /// **source-pull** problem from portable fan-out. It does NOT bound Fastly's + /// guest-to-origin write of a non-empty `Body::Once`; that separate + /// cross-slot limitation is owned by `send-all-slot-isolation` footnote 4. + /// + /// **Response mode MUST be Buffered.** A request whose `response_mode` + /// is `Streamed` (via `stream_response`) yields `out[i].outcome = + /// Err(EdgeError::bad_request("send_all requires buffered responses; + /// use send for a streamed response"))`, identically on every adapter. + /// Reason: `send_all` returns its `Vec` only after every slot has reached + /// headers, so a fast slot's deadline-aware streamed body wrapper has + /// already been running while later siblings were still in headers phase + /// — by the time the consumer gets the Vec, the fast slot's body may + /// already be at-or-past its deadline. There is no concurrent + /// body-consumption primitive in `send_all` to fix this (Fastly has no + /// guest reactor; even on Axum/CF/Spin a consumer iterating + /// `out[i].body` serially can't outrun the wrapper deadlines that have + /// been ticking since headers). Apps that want streamed responses use + /// single `send` and orchestrate concurrency themselves on the three + /// reactor-bearing adapters: join N complete per-request async tasks, each + /// awaiting `send` and immediately consuming its response through the + /// **app-facing consuming accessor `into_body -> Body`** before that task + /// completes. Do not join header-only sends and then start body consumption: + /// that recreates the same fast-response deadline hazard. Each task drives + /// its body while siblings may still be waiting for headers. + /// `into_parts(..)` exists too but is labelled adapter-facing because it + /// returns the (request method, status, headers, body) tuple that response converters + /// need; pure orchestration paths just want the body. This rule keeps + /// `send-all-slot-isolation`'s `Native` claim on Axum/CF/Spin honest — + /// the cross-slot body-lifetime problem is removed by construction rather + /// than papered over. + /// + /// **"Identical" scope.** The trait contract guarantees identical + /// **input handling**: same preflight, same index alignment, same + /// per-slot Ok/Err shape. The *cross-slot timing behaviour* is **not** + /// uniform — see the `send-all-slot-isolation` capability. + /// On Axum/CF/Spin `join_all` fans out complete exchanges concurrently and a + /// slot's result reflects what it would have produced in isolation. + /// On Fastly, cold backend registration can delay later dispatch. An + /// unresolved background request upload or a harvest-order response-body + /// drain can delay later result observation, but does not block later + /// `send_async` calls once the earlier call has returned. A slot can therefore return `gateway_timeout` even + /// when its own `budget.deadline` would have covered it in isolation. Apps that require cross-slot + /// isolation declare the capability required and get a hard build + /// failure on Fastly. + /// + /// Per-slot `Ok`/`Err` semantics: since preflight rejects streamed bodies AND + /// streamed responses, every surviving slot is Buffered on both sides, so the + /// per-slot outcome shape matches `send`'s **Buffered-mode** semantics — `Ok(resp)` + /// means the full exchange completed within the deadline and the body fits + /// within `max_response_bytes`; `Err(_)` is transport / deadline / over-cap. + /// Streamed-mode `Ok`-means-headers-only does not apply here because there are + /// no streamed slots. + async fn send_all( + &self, + reqs: Vec, + ) -> Vec; +} +``` + +Both `send` and `send_all` are required on the trait. Each adapter implements both; in +practice they share an internal helper for buffered-body single sends, so the +single-request and batch paths cannot drift. + +#### 3.1.2 App-facing handle + +```rust +/// Cloneable handle stored in request extensions and handed to handlers. +/// This is the only outbound *client/handle* type application code touches; +/// handlers also build `OutboundRequest` and read `OutboundResponse`. +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +impl HttpClient { + pub fn new(client: Arc) -> Self; + pub async fn send(&self, req: OutboundRequest) -> Result; + pub async fn send_all( + &self, + reqs: Vec, + ) -> Vec; + pub fn with_client(client: C) -> Self; +} +``` + +Obtained from the context: + +```rust +// crates/edgezero-core/src/context.rs — replaces proxy_handle +// The accessor is valid both before and after the inbound-body restructuring: as an +// inherent method it reads `self.request.extensions()` directly and does not require a +// new public `RequestContext::extensions()` API. +impl RequestContext { + pub fn http_client(&self) -> Option { + self.request.extensions().get::().cloned() + } +} +``` + +#### 3.1.3 Request and response types + +```rust +pub struct OutboundRequest { + body: Body, // buffered or streamed + deadline: Option, // shared absolute cap; copy one value into every target request, do not recompute per request (see §3.3.2) + headers: HeaderMap, + max_brotli_decoder_bytes: u64, // source-audited decoder-state charge cap; default 32 MiB + max_brotli_window_bits: u8, // advertised-window cap; default 24, valid 10..=30 + max_chunk_bytes: Option, // opt-in Streamed output rechunker + max_decoded_response_bytes: Option, // identity/EdgeZero-decoded output only + max_encoded_response_bytes: Option, // pre-decode guest-visible body cap + max_request_body_bytes: u64, // cap for buffered or streamed body bytes (default 8 MiB) + max_response_header_bytes: Option, // guest-visible names + values + max_response_header_count: Option, // guest-visible name/value entries + method: Method, + response_mode: ResponseMode, // Buffered { max_bytes } (default) | Streamed + timeout: Option, // per-request budget + uri: Uri, // validated + canonicalized; see below +} + +// **All OUTBOUND public byte caps and byte-accounting counters are `u64`, NOT `usize`.** +// This invariant is intentionally scoped to the outbound APIs introduced or changed here. +// The +// crate compiles to `wasm32` on three of the four adapters, where `usize` is **32-bit**: +// a cap or a wire `Content-Length` above 4 GiB is not merely wrap-prone, it is +// **unrepresentable** as `usize`, which would silently break the "portable across all +// four adapters" claim on exactly the targets that matter. `u64` gives one ceiling +// (16 EiB) on every target. The buffered `Bytes` a drain produces is still `usize`-length +// (bounded by available guest memory), but the *cap* and the *running total* it is +// compared against are `u64`, so the comparison and the arithmetic cannot wrap or +// truncate. `Content-Length` is parsed as `u64`; for an **effective identity** response +// (no `content-encoding`, or exactly one bare `identity` value) it equals the decompressed +// size, so it can be compared against the +// `u64` decoded cap for an early over-cap reject BEFORE buffering. For a **compressed** +// response the wire `Content-Length` is the compressed size and does NOT bound the decoded +// size, so it cannot early-reject against either the decoded-output or final-buffer cap. When the optional +// `max_encoded_response_bytes` cap is set, that same length can reject against the encoded +// cap before the body read (§3.4.5). Conversions use `u64::from` / `TryFrom`, never `as` +// (denied lint). + +/// How the adapter delivers the response body. Default is `Buffered`. +pub enum ResponseMode { + /// Adapter reads the full delivered body within the deadline, enforcing a final + /// collection/allocation cap regardless of whether the bytes are identity, decoded, or + /// raw passthrough. `OutboundResponse.body` is `Body::Once`. This is independent of + /// `max_decoded_response_bytes`: choose a larger `max_bytes` to permit a large raw + /// passthrough while retaining a smaller decoded-output cap. + Buffered { max_bytes: u64 }, // default max_bytes = DEFAULT_MAX_RESPONSE_BYTES + /// Adapter returns headers; `OutboundResponse.body` is `Body::Stream`. The + /// caller buffers later (e.g. `into_bytes_bounded`) or passes the body through. + Streamed, +} + +impl OutboundRequest { + /// Constructors validate **and canonicalize** the URI once, before app policy or + /// adapter conversion. The canonical serialized URI is the target identity that + /// `uri()` exposes and every adapter sends: + /// + /// - Scheme must be `http` or `https` (plain `http` is permitted — + /// required for loopback contract tests). Other schemes → + /// `Err(EdgeError::bad_request("outbound URI scheme must be http or + /// https"))`. + /// - An authority must be present. Missing authority → + /// `Err(EdgeError::bad_request("outbound URI must be absolute with + /// authority"))`. + /// - **Userinfo is rejected.** `https://user:pass@example.com` → + /// `Err(EdgeError::bad_request("outbound URI must not contain + /// userinfo; pass credentials via the `authorization` header"))`. + /// This keeps the Fastly backend Host override unambiguous and + /// stops accidental credential leakage. Before WHATWG parsing, string constructors reject + /// every raw `\\` byte. Backslash is a special-URL separator that WHATWG parsing can + /// normalize into `/`; rejecting it avoids a second, security-sensitive authority parser + /// and prevents forms such as `https:\\@example.com/`, `https:/\\@example.com/`, and + /// `https:\\/\\@example.com/` from hiding empty userinfo during normalization. The + /// constructor then locates the raw authority only after the literal `://` delimiter and + /// before the next `/`, `?`, or `#`, and rejects any literal `@`, including + /// `https://@example.com/`. A percent-encoded `%40` in path/query data is not an authority + /// delimiter and remains allowed. Tests pin all four slash/backslash separator shapes and + /// verify rejection occurs before `url::Url` is invoked. + /// - **Fragments are rejected at the string-input boundary.** + /// `OutboundRequest::get("https://x/p#anchor")` and `::post(..)` parse + /// the input as a string *first* (they take `impl AsRef` — see + /// below) and reject a `#` before `http::Uri` ever sees it, with + /// `Err(EdgeError::bad_request("outbound URI must not contain a + /// fragment"))`. A Uri-typed input has already passed through + /// `http::Uri`; `new` and `from_parts` canonicalize its serialized value but cannot + /// recover syntax the caller's parser discarded. Raw strings should therefore use + /// `get`/`post`. + /// - Canonicalization uses the pinned WHATWG `url::Url` parser, then converts + /// `Url::as_str()` to `http::Uri`. This adds `url` as a direct workspace/core + /// dependency in Phase 1b; adapters do not reparse or rebuild the URI. It normalizes + /// scheme/host case, IDNA and numeric host spellings, default ports, and dot segments, + /// and applies the pinned parser's path/query percent-encoding rules before an app + /// allowlist sees the target. + /// - **Default ports are normalized away.** A URL parsed from + /// `https://example.com:443` is rewritten so `uri.port` returns + /// `None`; `http://example.com:80` likewise. This means + /// `https://example.com` and `https://example.com:443` produce + /// identical `OutboundRequest`s — same `resolved_port` in the + /// Fastly identity, same Host override, one dynamic backend. Explicit + /// non-default ports (`:8443`, `:3000`) are preserved verbatim. + /// - **Scheme and host are lowercased.** `https://EXAMPLE.com`, + /// `HTTPS://example.com`, and `https://example.com` are the same + /// origin. The canonicalization rewrites the stored URI to lowercase + /// so `OutboundRequest::uri` always reports the lowercase form, + /// and downstream consumers (including Fastly backend identity in §4.3, + /// app-level allowlist checks, Spin `allowed_outbound_hosts` + /// matching) compare against one canonical spelling. Userinfo and fragments are + /// rejected above. Path and query are not promised verbatim: WHATWG serialization may + /// remove dot segments or normalize percent-encoding. The canonical serialized result, + /// not the caller's original spelling, is the security and wire identity. + /// + /// Every adapter passes this stored serialization to its SDK without independently + /// joining path/query components. Tier 2 and live-host tests include dot segments, + /// percent-encoded delimiters, numeric IPv4 aliases, IDNA, empty paths, and query + /// characters; an adapter/runtime that changes the effective target after this boundary + /// fails the portable URI contract rather than silently weakening app policy. + pub fn new(method: Method, uri: Uri) -> Result; + /// `get` and `post` take `impl AsRef` (not `TryInto`) so the raw + /// string is available for fragment detection *before* `http::Uri` + /// truncates at `#`. The impl checks for `#` in the input bytes, then + /// parses with `url::Url`, converts the canonical serialization to `Uri`, then runs + /// the remaining + /// canonicalization. `&str`, `String`, and any `AsRef` work; an + /// already-built `Uri` goes through `OutboundRequest::new` (which cannot + /// recover fragments because the `Uri` has already lost them — see + /// "Fragments are rejected at the string-input boundary" above). + pub fn get(uri: impl AsRef) -> Result; + pub fn post(uri: impl AsRef) -> Result; + + /// Forward an inbound request to a new target. Preserves method and body + /// (which may stream). Headers are normalized for proxy forwarding — + /// the rules live in core so adapters cannot diverge: + /// + /// - hop-by-hop headers are stripped: `connection`, `keep-alive`, + /// `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, + /// `transfer-encoding`, `upgrade` (RFC 7230), plus every header + /// named in the inbound `connection` header value; + /// - `host` is **dropped** from the headers. Axum, Fastly, and Spin set the final + /// `Host` value (or platform SDK equivalent) from + /// `req.host_authority` at SDK-construction time. Cloudflare does not attempt a + /// restricted `Host` override: Fetch derives it from the already-canonical request + /// URL authority. The accessor + /// already encodes the rules: explicit port preserved when the URI + /// carries a non-default port (`https://example.com:8443` → + /// `Host: example.com:8443`); port stripped when default + /// (`https://example.com` → `Host: example.com`); IPv6 hosts + /// bracketed. **Adapters MUST NOT read `req.uri` for the Host + /// value** on Axum/Fastly/Spin — `host_authority` is their single source of truth, so the + /// Fastly identity hash, the Axum reqwest Host setter, and the Spin + /// outgoing-request Host field all observe the same string. Cloudflare consumes the + /// canonical URI serialization directly and must not add a separate Host header. No part of the pipeline reads + /// `host` from `req.headers`. `normalize_for_dispatch` re-strips + /// `host` defensively as a safety net for callers that reached past + /// `header(..)` via `headers_mut`; + /// - `content-length` is dropped — the adapter sets it from the new body + /// for `Body::Once`, or omits it (relying on chunked transfer) for + /// `Body::Stream`. + /// + /// All other headers are preserved verbatim. Validates `uri` per `new`. + pub fn from_request(request: Request, uri: Uri) -> Result; + + /// Fallible: header name/value construction from arbitrary inputs can + /// fail. The signature takes `impl AsRef<[u8]>` for both name and value + /// — **not** `TryInto` / `TryInto`. The standard + /// `TryFrom<&str> for HeaderValue` path is built on + /// `HeaderValue::from_str`, which rejects every byte outside visible + /// ASCII and would refuse a valid non-ASCII UTF-8 header + /// (`x-app-display-name: café`) before EdgeZero's own UTF-8 rule could + /// run. By taking bytes directly: + /// + /// 1. `HeaderName::from_bytes(name.as_ref)` — strict name check (HTTP + /// grammar). + /// 2. `std::str::from_utf8(value.as_ref).is_err` → reject with + /// `EdgeError::bad_request("header value is not valid UTF-8: ")` + /// (the EdgeZero rule). + /// 3. `HeaderValue::from_bytes(value.as_ref)` — applies the **HTTP + /// header-value byte rule** (visible ASCII + obs-text; rejects + /// control bytes like `\n`, `\0` that would enable header injection). + /// Combined with step 2, the values that survive are exactly the ones + /// that are **both** valid UTF-8 **and** valid HTTP header bytes — a + /// valid-UTF-8 string containing a forbidden control byte is still + /// rejected, which is intended security behaviour. Two distinct error + /// messages distinguish the cause (forbidden-bytes vs invalid-UTF-8). + /// + /// Works for `&str`, `String`, `&[u8]`, `Vec`, and `HeaderName` / + /// `HeaderValue` (both `AsRef<[u8]>`). + pub fn header(self, name: N, value: V) -> Result + where + N: AsRef<[u8]>, + V: AsRef<[u8]>; + /// Escape hatch for callers holding already-validated + /// `HeaderName`/`HeaderValue` (or building from `from_request`). The + /// returned `HeaderMap` is not validated here — non-UTF-8 values and + /// stray hop-by-hop / framing headers (`host`, `content-length`, + /// `transfer-encoding`) are caught by the adapter's + /// `normalize_for_dispatch` sweep before the request is issued. + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + /// Set the body from `Body` or a buffered conversion. `Bytes`, `Vec`, + /// `&[u8]`, `&str`, and `String` convert to `Body::Once`. A raw `Stream` + /// does not implement `Into`; callers wrap typed streams with + /// `Body::from_stream` and arbitrary external streams with + /// `Body::from_external_stream`. + pub fn body(self, body: impl Into) -> Self; + /// Serialize `value` as JSON and set the request body to the resulting + /// bytes. Sets `content-type: application/json` only if the request has + /// no `content-type` yet — a caller-set value is preserved unchanged. + /// `content-length` is left to the adapter (it is recomputed from the + /// serialized body for `Body::Once` and omitted for `Body::Stream`). + /// Serialization failure yields `Err(EdgeError::internal(..))`. + pub fn json(self, value: &T) -> Result; + + pub fn timeout(self, duration: Duration) -> Self; + pub fn deadline(self, deadline: Deadline) -> Self; + pub fn max_brotli_decoder_bytes(self, max: u64) -> Self; + pub fn max_brotli_window_bits(self, bits: u8) -> Self; // 10..=30 at dispatch + pub fn max_chunk_bytes(self, max: NonZeroU64) -> Self; // Streamed emitted-item size + pub fn max_decoded_response_bytes(self, max: u64) -> Self; + pub fn max_encoded_response_bytes(self, max: u64) -> Self; + pub fn max_response_header_bytes(self, max: u64) -> Self; + pub fn max_response_header_count(self, max: u64) -> Self; + pub fn max_response_bytes(self, max: u64) -> Self; // selects Buffered { max }; last mode setter wins + pub fn stream_response(self) -> Self; // selects Streamed; last mode setter wins + + // These two methods select mutually exclusive `ResponseMode` variants. They do not compose: + // `max_response_bytes(..).stream_response()` is an uncapped streamed response, while + // `stream_response().max_response_bytes(..)` is a buffered response with the supplied cap. + + // Zero is an intentional deny-all value for every `u64` cap. It is not a malformed + // configuration: an empty body can satisfy a zero body cap, while the first visible byte or + // header entry fails with the cap's typed reason. `max_chunk_bytes` alone requires + // `NonZeroU64` because a zero-sized output item cannot make stream progress. + + /// Cap on the **request** body for both `Body::Once` and `Body::Stream`. + /// EdgeZero's core `Body::Stream` is `LocalBoxStream` + /// (WASM-friendly, not `Send + 'static`), so adapters cannot hand it + /// directly to a SDK that requires `Send` streams (notably reqwest + /// without its `stream` feature). The contract is therefore: every request + /// body is **bounded** by this cap on every adapter; adapters + /// MAY pass the stream through to the platform natively (Fastly's + /// `send_async_streaming`, Spin's WASI outgoing body) or buffer to + /// `Bytes` within the cap before dispatch (Axum, Cloudflare). Over-cap + /// during drain → `bad_request` (400) — a client-side misuse. + /// Default `DEFAULT_OUTBOUND_REQUEST_BODY_BYTES = 8 MiB`. + pub fn max_request_body_bytes(self, max: u64) -> Self; + + pub fn method(&self) -> &Method; + pub fn uri(&self) -> &Uri; // canonical target apps inspect for their allowlist + pub fn headers(&self) -> &HeaderMap; + + // ---- Canonicalized URI accessors (adapter-facing, non-consuming) ---- + // + // These five accessors are the **single canonical source** of the + // host/port/SNI/cert-host split that every adapter needs. They are + // derived from `self.uri` after the canonicalization rules + // have rejected **userinfo and fragments**, validated the port, and applied the pinned + // WHATWG canonicalization. Path and query do not + // appear in these accessors because none of them are host/port/SNI/cert + // values, but they remain accessible via `self.uri` for the wire-level + // request line. **Adapters MUST consume these accessors rather than + // re-deriving from `uri`** for the host/port/SNI/cert split — both to + // share the canonicalization logic and so the Fastly identity hash + // sees a single canonical form. They are also the values + // tested by the Tier 1 half of the five-value row. + // + // **Manifest `[capabilities.outbound].hosts` entries are a separate + // grammar** — those entries are host-authority-only + // declarations, so the manifest-host validator **rejects** path / query + // / fragment / userinfo on the manifest side. That validator and the + // request-URI canonicalization rules above share the userinfo / fragment + // reject and the lowercase-scheme/host pass, but diverge on path/query: + // request URIs canonicalize them; manifest host entries reject them. The + // two rule sets must not be conflated. + + /// Connection target — always `":"`, with the port resolved + /// (default ports filled in: `http` → 80, `https` → 443). IPv6 hosts + /// are bracketed (`[::1]:443`). This is what Fastly's + /// `Backend::builder(name..)` expects and what Spin uses for its + /// `allowed_outbound_hosts` rendering when the source had no explicit + /// port. Stable across canonicalization (same value whether the input + /// was `https://example.com` or `https://example.com:443`). + pub fn backend_target(&self) -> String; + + /// Authority for the outgoing `Host` header. Carries the explicit port + /// **only when it is non-default** for the scheme: + /// `https://example.com:8443` → `"example.com:8443"`; + /// `https://example.com` → `"example.com"`. IPv6 hosts are bracketed. + /// This is what Fastly's `.override_host(..)` and the Axum/Spin + /// outgoing Host fields consume. Cloudflare deliberately does not set it: Fetch derives + /// Host from the canonical URL authority, and host-observed tests prove the same value. + pub fn host_authority(&self) -> String; + + /// Canonical host only, with no port and no IPv6 brackets. Unlike + /// `sni_hostname`, this returns IP literals too. Fastly uses this value in + /// `BackendIdentity`; adapters MUST NOT recover it by reparsing `uri` or + /// splitting `backend_target`. + pub fn host_name(&self) -> &str; + + /// SNI hostname — what an HTTPS adapter passes to its TLS stack's + /// SNI setter (Fastly's `.sni_hostname(..)`, Spin/CF's underlying + /// TLS config, etc.). Port-stripped, bracket-stripped for IPv6. + /// **Returns `None` for IP-literal hosts** (IPv4 and IPv6) + /// RFC 6066, which forbids SNI for IP literals. Adapters call + /// the TLS-stack SNI setter only when this returns `Some`; for `None` + /// the SNI extension is omitted from the ClientHello. **Adapters + /// MUST NOT fall back to `uri.host` for SNI** — `None` here + /// means "send no SNI," not "derive it yourself." The cert verification + /// host is `cert_host` below, not this accessor. + pub fn sni_hostname(&self) -> Option<&str>; + + /// Certificate-verification host — what an HTTPS adapter passes to + /// its TLS stack's certificate-verification setter (Fastly's + /// `.check_certificate(..)`, Spin/CF's underlying TLS verifier). + /// **Always present for HTTPS, always port-stripped, always + /// bracket-stripped for IPv6.** Unlike SNI, certificate verification + /// is meaningful for IP literals too — verification will check the + /// presented certificate's SAN against the IP literal (e.g. `127.0.0.1`, + /// `::1`). Returns `None` only for non-HTTPS schemes (i.e. `http`), + /// where the accessor is not used by the adapter. **This is the + /// single canonical source for `.check_certificate(..)` arguments + /// across every adapter**; adapters MUST NOT call `uri.host` and + /// post-process — they call `cert_host` and pass it through. + /// + /// Concrete examples: + /// - `https://example.com` / `https://example.com:443` → `Some("example.com")` + /// - `https://example.com:8443` → `Some("example.com")` (port stripped — cert is not port-qualified) + /// - `https://127.0.0.1` → `Some("127.0.0.1")` + /// - `https://[::1]` / `https://[::1]:443` → `Some("::1")` (brackets stripped) + /// - `http://example.com` → `None` + pub fn cert_host(&self) -> Option<&str>; + + // ---- Adapter-facing inspection (non-consuming) ---- + /// Cheap non-consuming check used by `send_all` preflight: if `true`, + /// the slot is rejected with `bad_request` + /// *before* `send_one` is invoked, so the streamed-upload path is never + /// reached from `send_all`. `send` (single-request) handles `Body::Stream` + /// directly per its trait contract. + pub fn is_stream_body(&self) -> bool; + + /// Cheap non-consuming check used by `send_all` preflight: if `true` + /// (i.e. `response_mode == Streamed`), the slot is rejected with + /// `bad_request` before `send_one` is invoked. `send` (single-request) + /// handles streamed responses directly. + pub fn is_stream_response(&self) -> bool; + + // ---- Adapter-facing disassembly / reassembly ---- + /// Consume the request into its constituent parts. Adapters call this + /// inside `send` / `send_all` after `normalize_for_dispatch` has run, + /// to hand the components to the platform SDK. + pub fn into_parts(self) -> OutboundRequestParts; + /// Round-trip constructor for adapters that need to destructure, mutate + /// a single field, and reassemble (rare — most adapter paths consume). + /// All fields are pub on `OutboundRequestParts`, so this is just a + /// disciplined re-wrap and applies the same invariants as + /// `new`/`get`/`post` (URI validation re-runs). + pub fn from_parts(parts: OutboundRequestParts) -> Result; +} + +/// The one shared stream shape used by `Body` and every transport-independent outbound +/// wrapper. Returning this concrete erased type lets raw, gzip, Brotli, limit, and +/// rechunk branches compose without incompatible opaque `impl Stream` return types. +pub type BodyStream = LocalBoxStream<'static, Result>; + +/// The public streamed-body error surface is exact: adapters, decoders, and +/// other EdgeZero-owned producers can carry typed 502/504/over-cap failures +/// without converting them through `anyhow::Error`. +pub enum Body { + Once(Bytes), + Stream(BodyStream), +} + +// Public ownership paths are exact and stable: +// - `edgezero_core::body::BodyStream` and the root re-export +// `edgezero_core::BodyStream` own the shared body stream type. +// - `edgezero_core::compression::{BROTLI_DECODER_FIXED_CHARGE_BYTES, ContentEncoding, +// brotli_decoder_memory_charge, classify_content_encoding, decode_brotli_stream, +// decode_gzip_stream}` own codec policy. +// - `edgezero_core::outbound::{ResponseHeaderLimiter, collect_response_stream, +// enforce_payload_content_length, limit_decoded_stream, limit_encoded_stream, +// normalize_response_headers, rechunk_stream}` own outbound response policy. +// Every listed item is also re-exported from `edgezero_core`; adapter crates import the +// root path and do not depend on private module layout. + +impl Body { + /// Explicit compatibility boundary for arbitrary external stream errors. + /// Every source error is converted to `anyhow::Error` and then wrapped as + /// `EdgeError::internal`; this constructor never attempts to recover a typed + /// `EdgeError` hidden inside the external error. + pub fn from_external_stream(stream: S) -> Self + where + S: Stream> + 'static, + anyhow::Error: From; + + /// Construct a fallible stream whose errors are already classified. This is + /// the required constructor for every in-tree adapter, decoder, and deadline + /// wrapper that can emit an `EdgeError`. + pub fn from_stream(stream: S) -> Self + where + S: Stream> + 'static; + + /// Drain a buffered or streamed body with pre-append checked accounting. A typed + /// `EdgeError` yielded by `Body::Stream` is returned unchanged; it is never wrapped as + /// `internal`. Foreign/ingress errors have already been sanitized by + /// `from_external_stream` before entering this method, so preserving the item here does + /// not expose an unclassified external error. + pub async fn into_bytes_bounded(self, max_size: usize) -> Result; + + /// Returns the exact typed stream for `Body::Stream`, or `None` for + /// `Body::Once`. + pub fn into_stream(self) -> Option; + + /// Construct an infallible byte stream. + pub fn stream(stream: S) -> Self + where + S: Stream + 'static; +} + +impl From for Body { + fn from(value: Bytes) -> Self { Body::Once(value) } +} +// The existing `From>`, `From<&[u8]>`, `From<&str>`, and +// `From` implementations remain buffered conversions. + +// The separate constructors are intentional. Stable Rust cannot provide one +// generic `from_stream` that preserves `E = EdgeError` but maps every +// other `E` to `internal` without overlapping implementations/specialization. +// In-tree code MUST NOT pass an `EdgeError` stream through +// `from_external_stream`, because doing so would erase its status and kind. +// Conversely, platform inbound bodies and every other foreign producer MUST classify or +// sanitize its error before constructing `Body`: use `from_external_stream` when the +// existing behavior is an opaque internal failure, or map deliberately to a typed +// `EdgeError` before `from_stream` when that boundary owns a public classification. Tests +// cover exact propagation through `into_bytes_bounded` and sanitization of a foreign error. + +// Shared transport-independent response pipeline. These helpers are public because +// adapters are separate crates. Every branch accepts and returns the same `BodyStream`, +// preserving exact typed source errors and making the pipeline skeleton buildable. +// `edgezero_core::compression` items, in module order: +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContentEncoding { + /// One bare, case-insensitive `br` field. + Brotli, + /// One bare, case-insensitive `gzip` field. + Gzip, + /// No visible `content-encoding`, or one bare `identity` field. + Identity, + /// Repeated, stacked, parameterized, malformed, non-UTF-8, or unknown coding. + Passthrough, +} +// A direct call with bits outside 10..=30 is invalid caller policy and returns BadRequest; +// checked accounting overflow retains ResponseLimitReason::DecoderMemory. +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result; +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding; +/// The returned state machine reads and validates the fixed-size stream prefix before it +/// constructs the decoder, so polling it inside the adapter wrapper keeps prefix reads, +/// decoder allocation, and all output under one absolute deadline/cancellation owner. +pub fn decode_brotli_stream( + stream: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream; +pub fn decode_gzip_stream(stream: BodyStream) -> BodyStream; + +// `edgezero_core::outbound` items, in module order: +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result; +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn rechunk_stream(stream: BodyStream, max: Option) -> BodyStream; + +/// Cumulative guest-visible field-section accounting retained by the adapter from the +/// first exposed informational block through final headers and exposed trailers. +pub struct ResponseHeaderLimiter { /* private checked-u64 counters and limits */ } +impl ResponseHeaderLimiter { + pub fn new(max_bytes: Option, max_count: Option) -> Self; + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError>; +} + +/// Disassembled form of an `OutboundRequest`. Adapter-facing only. +#[non_exhaustive] +pub struct OutboundRequestParts { + pub body: Body, + pub deadline: Option, + pub headers: HeaderMap, + pub max_brotli_decoder_bytes: u64, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, // applies to Body::Once and Body::Stream (u64 — see cap note) + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +// `into_parts` and `from_parts` move every field above without defaulting or dropping +// one. In particular, adapter request conversion must retain the response-resource +// settings until response metadata/body processing. Adapters that consume the request at +// dispatch copy the settings they need after the native send into their pending-response +// state; they do not reconstruct them from `ResponseMode`. + +/// Result of the authoritative raw-response metadata pass. Adapters must act on this +/// before inspecting content encoding/length or constructing `OutboundResponse`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponseBodyDisposition { + FramingBodyless, // HEAD, 1xx, 204, or 304: settle/drop the platform handle; never decode + Payload, // normal payload path: inspect encoding, decode, and enforce the cap + ResetContent { + // Captured before the helper rewrites the downstream `content-length` to zero. + declared_body: bool, + }, // 205: apply the bounded settle/abort protocol in §3.4.1 +} + +/// Parse the already-normalized payload-bearing `content-length` and apply every +/// sound pre-poll size decision. Adapters call this only for `Payload`: bodyless and +/// 205 settlement is owned by `ResponseBodyDisposition`. `max_buffered_bytes` is `Some` +/// only for Buffered mode; the independent decoded cap may also be present in Streamed. +/// Malformed, comma-list, or conflicting lengths are protocol 502. The encoded cap applies +/// to every coding. Effective identity compares decoded and Buffered caps too; raw +/// passthrough compares the Buffered cap but never the decoded cap; gzip/Brotli-to-decode +/// cannot soundly compare wire length with either output cap. +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; + +pub fn normalize_response_headers( + request_method: &Method, + status: StatusCode, + headers: &mut HeaderMap, +) -> Result; + +pub struct OutboundResponse { + body: Body, // Once in Buffered mode, Stream in Streamed mode + headers: HeaderMap, + monotonic_clock: MonotonicClock, // paired app/client clock for bounded-until helpers + request_method: Method, // retained for HEAD/bodyless response semantics + status: StatusCode, +} + +impl OutboundResponse { + /// Adapter-facing post-conversion constructor. Before calling this, the adapter MUST run + /// `normalize_response_headers` on the raw response metadata, settle any bodyless response, + /// and only then inspect `content-encoding` / `content-length`, decode, and cap. The headers + /// supplied here are therefore already safe for app-facing `headers()` / `into_body()`: + /// hop-by-hop fields and every `connection` nomination are gone; decompression has removed + /// `content-encoding` / `content-length`; and lossy UTF-8 handling has run. `Body::Once` is + /// used in Buffered mode after the adapter has drained and capped; `Body::Stream` is wrapped + /// with the decoded-output deadline guard in Streamed mode. + /// Low-level constructor using `MonotonicClock::default()`. Standard adapters instead pair the + /// response with the outbound client's retained application clock. + pub fn new( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + ) -> Self; + + #[doc(hidden)] + pub fn new_with_monotonic_clock( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + monotonic_clock: MonotonicClock, + ) -> Self; + + /// Adapter-facing destructure. Mirrors `OutboundRequest::into_parts`; the retained + /// method is required by response converters for HEAD/bodyless normalization. This method + /// does not run the final defensive response-header normalization performed by + /// `into_response`; application boundaries that need that pass call `into_response`. + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body); + + /// Adapter-facing mutation point — used during construction (e.g. to + /// strip `content-encoding` after decompression). App code uses the + /// immutable `headers` accessor instead. + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + // ---- App-facing accessors ---- + pub fn status(&self) -> StatusCode; + pub fn is_success(&self) -> bool; // 2xx + pub fn headers(&self) -> &HeaderMap; + pub fn body(&self) -> &Body; + + /// **App-facing consuming accessor** for the response body — the orchestration + /// path for streamed responses recommended by `send_all`'s rustdoc. + /// Returns the underlying `Body` so app code can iterate `Body::Stream` chunks + /// directly (the wrapper installed at response construction time still + /// enforces `dispatch_budget(req).deadline`) or extract the + /// `Body::Once` `Bytes` if the adapter buffered. This is distinct from the + /// adapter-facing `into_parts(self) -> (Method, StatusCode, HeaderMap, Body)` + /// destructure used inside response converters; apps that need just the + /// body for streaming orchestration call `into_body` and drop the rest. + /// On `Streamed` mode with single `send`, this is the canonical orchestration + /// path: on Axum/CF/Spin, join per-request async tasks that each await + /// `send` and immediately iterate that response's `into_body` stream. + /// Body consumption is inside each task, not deferred until all sends + /// have returned headers. `send_all` remains buffered-only. + pub fn into_body(self) -> Body; + + /// Buffer the delivered body with a final collection cap. Works for both `Once` + /// and `Stream`. Over-cap yields + /// `Err(EdgeError::response_too_large_with_reason(.., + /// ResponseLimitReason::BufferedBody))` (distinct kind, 502 — NOT + /// `bad_gateway`; §3.4.1). + /// + /// This is NOT a thin wrapper over `Body::into_bytes_bounded` — that + /// helper maps over-limit to `bad_request` (400), correct for inbound + /// bodies but wrong for an over-large upstream response. This method + /// performs its own bounded drain (pre-append checked accounting) + /// and maps over-cap to `response_too_large` with reason `BufferedBody` + /// (distinct kind, 502 — §3.4.1; consistent with the top of this doc, + /// NOT `bad_gateway`). + /// For effective identity or EdgeZero-decoded gzip/Brotli, the request's independent + /// `max_decoded_response_bytes` policy has already wrapped the response stream. For raw + /// passthrough, this helper counts the delivered encoded bytes and does not invent a + /// decoded interpretation. + /// + /// **Effective-budget deadline is already honoured on a streamed body.** + /// Adapters wrap `Streamed` response bodies with a deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` — which is non-`None` even for + /// timeout-only and no-deadline requests (the synthetic 30 s ceiling) — + /// so a detected expiry yields a `gateway_timeout` error chunk and this + /// drain returns 504. Axum/Cloudflare provide timer-backed cancellation; + /// Spin and Fastly retain the BestEffort host gaps documented in §3.5.2. + /// There is no need to thread the deadline through manually — call + /// `into_bytes_bounded_until(max, deadline)` only when you want to + /// **cooperatively narrow** the failure timing on top of the request + /// budget (see the precise bound and caveat below). + pub async fn into_bytes_bounded(self, max: u64) -> Result; + + /// As `into_bytes_bounded`, but additionally bounded by a `Deadline` + /// that the caller passes per drain. **The helper is a *cooperative* + /// post-read / EOF validator, not a timer-backed race.** The bound it + /// provides is *exactly* "the first paired-clock expiry check that observes + /// expiry returns `gateway_timeout`," where the check sites are + /// enumerated below. A read that is already blocked when the deadline + /// passes does **not** get preempted by this helper — it returns when + /// the underlying source returns (chunk, EOF, or wrapper-emitted error + /// chunk past the request budget), and the helper's *next* check (or + /// post-return check for `Body::Once`) is what fires. Real-time + /// preemption is the *wrapper's* job (the adapter installs a + /// deadline-aware stream bounded by `dispatch_budget(req).deadline` at + /// response construction time); the helper only catches the + /// **tighter `until`** case at yield boundaries. + /// + /// Concretely, if the wrapper still has 500 ms and the caller passes + /// `until_deadline = now + 100 ms`, and a body read happens to block + /// for the full 500 ms, the helper does **not** return at 100 ms — it + /// observes the expired `until` at the 500 ms post-read check and + /// returns `gateway_timeout`. The bound the helper provides is "first + /// expiry check at or after `until_deadline`," not wall-clock = `until`. + /// Apps that need wall-clock preemption tighter than the request budget + /// must either lower `dispatch_budget(req).deadline` (set + /// `.deadline(min(req_deadline, app_inner_deadline))` on the builder) + /// or split the work into a smaller request. + /// + /// Works on both `Body::Once` and `Body::Stream`: + /// + /// - **`Body::Once` (already buffered)**: the helper checks + /// `until_deadline.is_expired_at(monotonic_clock.now())` **at entry**, before doing anything + /// else, and returns `gateway_timeout` if expired. Otherwise it + /// checks the buffered length against `max` — under cap → `Ok(bytes)`; + /// over cap → `response_too_large` (distinct kind, 502; NOT `bad_gateway` + /// — see "Oversize is a distinct outcome", §3.4.1). **Precedence: expired deadline beats + /// over-cap** (an over-cap error after the deadline has expired is + /// masked by the deadline check, since the caller's `until` rolled + /// the result regardless of cap behaviour). This entry-time check + /// makes single `send` + `Body::Once` callers see consistent + /// `gateway_timeout` semantics whether their response arrived + /// already-buffered or streamed. + /// - **`Body::Stream`**: the helper checks + /// `until_deadline.is_expired_at(monotonic_clock.now())` **both before issuing each blocking body read and again after it + /// returns** — including the EOF read. Returns + /// `Err(EdgeError::gateway_timeout(..))` (504) on the first expired + /// check. + /// + /// **Enforcement composes layer-wise without sharing state.** The + /// adapter wrapper installed at response construction time enforces + /// the request's `dispatch_budget(req).deadline` by yielding + /// `Err(EdgeError::gateway_timeout_caused(.., budget.cause))` chunks past *that* deadline + ///; this helper enforces `until_deadline` cooperatively at + /// the four check sites enumerated above (entry for `Body::Once`; + /// before and after each underlying read including EOF for + /// `Body::Stream`). **"Whichever fires first" is at yield boundaries + /// only**: the wrapper's error chunk is timer-selected on Axum / CF / Spin + /// (with Spin's host teardown still BestEffort) and cooperatively detected + /// on Fastly; the + /// helper's `until_deadline` fires at the next check site. If the + /// caller's `until_deadline` is tighter and the next underlying read + /// returns promptly, the helper fires first; if the next underlying + /// read blocks past `until` but within the wrapper's budget, the helper + /// still fires (post-read check) and the helper's bound is "read + /// latency + at most one extra check," not zero. There is no shared + /// "effective deadline" stored on `OutboundResponse`; it retains only the paired clock, + /// request method, status, headers, and body. There is no `min(..)` computation in the helper. + /// Apps that need a single combined check with **timer-backed + /// preemption** of the tighter deadline pass + /// `min(req_deadline, app_inner_deadline)` to `.deadline(..)` on the + /// `OutboundRequest` builder instead of layering here — that pushes + /// the tighter deadline into the wrapper. Adapter support remains exactly + /// as classified by `outbound-deadlines` in §3.5.2. + /// + /// **Enforcement is layered.** The helper itself is cooperative on every + /// adapter — its before-and-after-read `is_expired` check cannot + /// preempt a read in progress. Real-time enforcement of the request + /// budget comes from the adapter wrapping streamed response bodies at + /// construction time: + /// + /// - **Axum, Cloudflare, Spin** — the adapter wraps the response body + /// with a deadline-aware stream using its platform timer (tokio / + /// `worker::Delay` / wasi monotonic-clock), bounded by + /// `dispatch_budget(req).deadline`. That deadline is non-`None` for + /// every request (synthetic 30 s ceiling when `req.deadline` was + /// absent), so the wrapping is unconditional — *not* "only when + /// `req.deadline.is_some`." Each chunk read is bounded by the + /// request's effective deadline, so a peer that stalls mid-stream + /// produces an error chunk at that deadline rather than blocking. + /// `into_bytes_bounded_until`'s helper-side `is_expired` check on + /// the caller-supplied `until_deadline` is what catches the + /// *tighter* `until` case (e.g. the wrapper has 500 ms left but the + /// caller passed a 100 ms `until`) **at the next yield boundary**, + /// not in real time. If a read happens to block for the full 500 ms, + /// the helper returns at 500 ms with `gateway_timeout` (post-read + /// check observed expiry), not at 100 ms. Use + /// `min(req_deadline, app_inner_deadline)` on the builder for + /// timer-backed preemption. + /// - **Fastly** — no guest async timer, but the adapter still + /// wraps the streamed response body with a **cooperative + /// deadline-aware stream** that checks `budget.deadline.is_expired()` + /// **both before issuing the underlying body read and again after it + /// returns** (including the read that discovers EOF) and + /// emits a `gateway_timeout` error chunk past the deadline instead + /// of `Ok(chunk)` or stream-end. This makes `into_bytes_bounded`, + /// `into_response` passthrough, and any other consumer of the + /// wrapped body honour the deadline uniformly — the deadline does + /// not depend on whether the caller chose this helper specifically. + /// Bounded-cooperative semantics apply: a stream that yields one + /// chunk and then stalls returns control on the host's + /// between-bytes-timeout, so worst-case overshoot per chunk + /// gap is one between-bytes-timeout interval — never unbounded. + /// + /// The real-vs-bounded distinction matches the `outbound-deadlines` + /// capability matrix. Decompression-cap and 502-mapping behavior matches + /// `into_bytes_bounded`. Because this helper receives only a `Deadline`, expiry + /// introduced by its `deadline` argument uses `BudgetSource::Unspecified`. + /// An adapter-installed deadline wrapper instead retains and reports the + /// `DispatchBudget::cause` that selected the request budget. + pub async fn into_bytes_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result; + /// JSON-decode the already-buffered body. Requires `Body::Once`; on a + /// `Body::Stream` returns `Err(EdgeError::bad_gateway_with_reason("response body + /// not buffered; use json_bounded(max) or json_bounded_until(max, + /// deadline)", BadGatewayReason::Protocol))`. Malformed JSON uses reason + /// `Decode(Json)` — an upstream returning unparseable JSON is a 502 outcome, not a 400. + pub fn json(&self) -> Result; + + /// Buffer (with a final collection cap) then JSON-decode in one step. + /// Consuming convenience for the `Streamed` mode: equivalent to + /// `into_bytes_bounded(max).await` + `serde_json::from_slice`, with + /// malformed JSON mapping to `bad_gateway` reason `Decode(Json)` (502). + pub async fn json_bounded(self, max: u64) + -> Result; + + /// As `json_bounded`, additionally bounded by a caller-supplied + /// `Deadline`. **The caller-supplied deadline is enforced + /// cooperatively by `into_bytes_bounded_until`** — that is, at the + /// yield boundaries enumerated in that helper's rustdoc (entry for + /// `Body::Once`; before and after each underlying read including EOF + /// for `Body::Stream`). A read already blocked when `deadline` passes + /// does **not** get preempted by this helper; it returns when the + /// underlying source returns, and the next check fires. **Real-time + /// enforcement is the wrapper's job** — Axum / CF / Spin install a + /// timer-selected deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` at response construction time + /// so expiry becomes a typed 504. Spin's host teardown is still cooperative + /// and BestEffort (footnote 8). Fastly detects expiry cooperatively on the + /// body phase; its capability remains BestEffort because cold dispatch and + /// upload-write gaps are unbounded (footnotes 1–2). + /// The `deadline` argument here only adds the cooperative + /// post-read tighten; it does not get its own wrapper. Apps that need + /// timer-backed preemption of a deadline tighter than the request + /// budget set `.deadline(min(req_deadline, app_inner_deadline))` on + /// the `OutboundRequest` builder so the tighter deadline lands in the + /// wrapper. Malformed JSON maps to `bad_gateway` reason `Decode(Json)` (502). + pub async fn json_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result; + /// Pass the response through as a core `Response` (keeps a streamed body lazy). + /// Infallible in safe use: like the other terminal methods it takes `self` by + /// move, so double-consumption of the body is prevented at compile time. The + /// `Result` carries exactly two error classes on adapters with + /// `outbound-header-fidelity = Native`: `Err(EdgeError::internal(..))` for an + /// adapter-invariant violation, and `Err(EdgeError::bad_gateway_with_reason(.., + /// BadGatewayReason::Protocol))` for a malformed `connection` value introduced after + /// construction through adapter-facing mutation. + /// Malformed visible nomination syntax is rejected on every adapter; a raw non-UTF-8 + /// value is additionally detectable on Native-fidelity adapters. Cloudflare receives + /// only post-workerd strings, so it cannot detect the raw-byte case; its documented + /// `BestEffort` behavior is scoped by `outbound-header-fidelity` (§3.5.2). No other + /// network/status condition produces an error here. + /// + /// **RESPONSE-SIDE hop-by-hop normalization is re-applied here as an idempotent defense + /// (symmetric with the request side, §3.1.4).** The authoritative pass already ran on + /// raw response metadata before bodyless/decode/cap decisions. This final pass protects + /// against a later adapter-side `headers_mut()` mutation. A proxied UPSTREAM response + /// can carry hop-by-hop headers + /// that MUST NOT be forwarded downstream: `into_response` strips `connection`, + /// `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, + /// `transfer-encoding`, `upgrade`, **AND every header NOMINATED by the response's own + /// `connection` value** — so an upstream `Connection: x-private` + `X-Private: secret` + /// cannot leak `X-Private` to the downstream client. Both passes use one core helper, + /// `outbound::normalize_response_headers(..)` (the response twin of + /// `normalize_for_dispatch`), so every adapter and passthrough goes through the same + /// stripping. On Axum/Fastly/Spin, the `connection` header is resolved + /// **fail-closed** exactly as on the request side: a non-UTF-8 value is rejected as + /// `bad_gateway` reason `Protocol`, never silently dropped. On every adapter, a visible + /// nomination that is not an RFC field-name token uses that same reason; implementations + /// do not partially honor a malformed list. Cloudflare applies the same stripping to + /// the strings visible after workerd processing but cannot detect malformed raw bytes or + /// recover original non-`set-cookie` field boundaries. §5.4 pins both the portable visible + /// header behavior and the stronger `outbound-header-fidelity` contract. + pub fn into_response(self) -> Result; +} +``` + +The complete builder surface — `new`/`get`/`post`/`from_request`/`header`/`headers_mut`/ +`body`/`json`/`timeout`/`deadline`/`max_brotli_decoder_bytes`/ +`max_brotli_window_bits`/`max_chunk_bytes`/`max_decoded_response_bytes`/ +`max_encoded_response_bytes`/`max_request_body_bytes`/`max_response_header_bytes`/ +`max_response_header_count`/`max_response_bytes`/`stream_response`. Every fallible step +returns `EdgeError`, so handler code uses `?` uniformly. + +#### 3.1.4 Adapter behaviour contract — redirects and header encoding + +These rules define two explicit levels. The portable baseline on all four adapters strips +visible hop-by-hop headers and visible `connection` nominations and preserves repeated +`set-cookie`. The stronger **`outbound-header-fidelity`** capability covers both directions: +outbound request field lines reach the platform transport without EdgeZero coalescing, and +raw response-header octets plus original field-line boundaries reach normalization. This +includes fail-closed malformed `connection`, malformed/repeated `content-encoding` +disposition, and repeated non-`set-cookie` field-line preservation. Axum/Fastly/Spin are +`Native`; Cloudflare is `BestEffort`, so a `required` declaration hard-fails there. +Cloudflare's Fetch `Headers` may semantically combine repeated request values, and response +processing sees post-workerd strings and comma-joined non-`set-cookie` values. It cannot +claim original request field-line preservation, malformed raw `connection` → 502, or +malformed raw `content-encoding` → forced passthrough. + +**Redirects: not followed automatically.** A 3xx upstream response is delivered to the +app as `Ok(OutboundResponse)` with the 3xx status and the `Location` header preserved. +EdgeZero never silently follows a redirect on the app's behalf. This is a security +property: an app that allowlists `https://trusted.example` and checks `req.uri()` before +sending can never be diverted to `https://attacker.example` by an upstream 302, because +following the redirect requires the app to issue a fresh `OutboundRequest` — at which +point its allowlist runs again. Per-adapter mechanics: + +| Adapter | How to disable auto-redirect | +| --- | --- | +| Axum | `reqwest::ClientBuilder::redirect(reqwest::redirect::Policy::none())` | +| Cloudflare | `worker::RequestInit { redirect: worker::RequestRedirect::Manual, .. }` (the enum, **not** the string `"manual"`) | +| Spin (WASI) | the hand-built `wasi:http` request (§4.4) does not auto-follow — no opt-out needed | +| Fastly | `fastly` does not auto-follow — no opt-out needed | + +Apps that want to follow a redirect read `resp.headers().get("location")`, run their +allowlist against the new URI, and issue a new request. + +**Header value encoding: UTF-8.** EdgeZero requires every outbound and inbound-of-outbound +header value to be valid UTF-8. The rationale is **portability, not a WASI limitation**: +WASI `http` `fields` values are `list`, so WASI *can* carry arbitrary bytes — but +Cloudflare Workers models headers as JS strings, and other adapters' header types +(`reqwest`'s `HeaderValue`, etc.) do not uniformly round-trip arbitrary bytes. A single +valid-UTF-8 rule is the portable intersection — uniform behaviour beats per-adapter +lossiness for headers that matter. (The check is additionally an HTTP-validity check: a + UTF-8 string still bearing a forbidden control byte like `\n`/`\0` is rejected — + §3.1.3 `header(..)`.) + +- *Outbound request headers.* `OutboundRequest::header(..)` constructs the + `HeaderValue` via `HeaderValue::from_bytes(value.as_ref())`, **not** + `HeaderValue::from_str` — the latter rejects every byte outside visible ASCII and + would refuse a perfectly valid non-ASCII UTF-8 header like + `x-app-display-name: café` before EdgeZero's UTF-8 rule runs. The builder's + `V: AsRef<[u8]>` bound means `value.as_ref() -> &[u8]` works uniformly for `&str`, + `String`, `&[u8]`, `Vec`, `HeaderName`, and `HeaderValue`. + `HeaderValue::from_bytes` accepts the **HTTP header-value byte set** (visible + ASCII + obs-text, with control bytes like `\n`/`\0` rejected to prevent header + injection); EdgeZero then layers its own UTF-8 check via + `std::str::from_utf8(value.as_ref()).is_ok()`. The accepted set is therefore + **valid UTF-8 *and* valid HTTP header-value bytes**, not "all valid UTF-8" — an + HTTP-invalid byte (`\n`, `\0`) inside a UTF-8-valid string still rejects, and + that's intended security behaviour. Two distinct failure messages: + `Err(EdgeError::bad_request("header value contains forbidden bytes: "))` + for the HTTP-validity reject, `Err(EdgeError::bad_request("header value is not + valid UTF-8: "))` for the UTF-8 reject. Loud and at construction time. + Repeated values in `HeaderMap` are appended separately at every adapter boundary. + Axum/Fastly/Spin preserve those entries as distinct transport fields and satisfy the + Native capability. Cloudflare also calls `Headers::append` for every entry, preserving + list semantics and `set-cookie` behavior where Fetch permits it, but workerd owns final + request serialization and may combine non-`set-cookie` lines. That is the request-side + half of its documented BestEffort result; no code or test may promote semantic equality + to original field-line fidelity. +- *Outbound response headers on `outbound-header-fidelity = Native` adapters.* If an upstream response carries non-UTF-8 header values, + **each individual value** is checked (`std::str::from_utf8` on the raw byte slice from + the platform SDK) — invalid values are dropped, valid sibling values for the same + header name are preserved. Multi-value headers like `set-cookie` therefore keep + every valid entry even if one duplicate is invalid. The adapter emits a `log::warn!` + naming each dropped header. + **TWO headers are exempt from this silent drop, because dropping one field line would + change how the REMAINING lines are interpreted** (the same smuggling shape in both + cases): (a) **`connection`** — resolved fail-closed by the authoritative raw-response + normalizer before body disposition or decoding (a non-UTF-8 value → `bad_gateway`), else + a nominated header could smuggle past hop-by-hop removal; + and (b) **`content-encoding`** — an invalid value **forces the stacked/passthrough + branch** (§3.4.1): the value is not decoded and `content-encoding`/`content-length` are + **preserved**, never stripped. Otherwise `content-encoding: gzip` + a second, invalid + `content-encoding` line would drop to an apparent *single* `gzip`, and the converter + would decode a body that is still one layer compressed while stripping the very headers + that revealed it. §5.4 pins a row **crossing** the two rules (repeated field lines where + one is non-UTF-8). The rest of the response is delivered normally so a + malformed exotic header cannot poison an otherwise valid fan-out batch response. + Cloudflare cannot execute this raw-byte branch. It applies normalization only to the + post-workerd strings it receives; this is the documented `BestEffort` deviation of + `outbound-header-fidelity`, not part of the all-adapter baseline. +- ***Cloudflare degradation — precisely scoped (verified against `worker` 0.8.3 + + workerd source).*** Earlier drafts said CF simply "cannot do multi-value headers." + That is **too pessimistic and wrong for `set-cookie`**. The actual split: + + - **`set-cookie` IS preserved on Cloudflare.** workerd's `getDisplayedHeaders` + special-cases it: with the `httpHeadersGetSetCookie` compatibility flag on, each + `set-cookie` is yielded as its **own** `entries()` tuple. **This repo already + qualifies** — `wrangler.toml.hbs` pins `compatibility_date = "2023-05-01"`, and the + flag enables at `2023-03-01`. So the collapse today is **not** a platform limit but + an **EdgeZero bug**: `cloudflare/src/proxy.rs` calls `HeaderMap::insert`, which + *removes all previous values*. **Fix: `insert` → `append`.** (`worker`'s own + `http`-feature conversion does exactly this.) A compat-flag-independent hardening + is to skip `set-cookie` in the `entries()` loop and re-add it via + `Headers::get_all("set-cookie")`. + - **Repeated *non*-`set-cookie` response headers are irrecoverably comma-joined.** workerd + joins same-name values (`kj::strArray(values, ", ")`) in both `entries()` and + `get()`, and its `getAll()` **throws `TypeError` for any name except + `set-cookie`**. There is **no API** to recover the original separate field lines — + two `x-foo` headers arrive as `x-foo: a, b`. Per RFC 9110 §5.3 that is semantically + equivalent for list-valued fields (exactly why the standard special-cases + `Set-Cookie`), but it is **not byte-faithful**. This is the real, narrow CF + limitation. + - **Raw bytes are unreachable; invalid UTF-8 is lost upstream of EdgeZero.** Header + values cross `v8::String::NewFromUtf8` before the guest sees them, arriving as Rust + `String`. So on CF the adapter **cannot detect** whether an upstream value was + invalid UTF-8, and the "drop the invalid value, keep valid siblings" rule degrades + to "whatever the runtime already decided". Axum, Fastly, and Spin expose raw bytes + and honour the rule as written. + + > **⚠️ `get_all` landmine.** `worker-sys`'s `getAll` binding has **no `catch`**, and + > workerd throws `TypeError` for any name other than `set-cookie`. A + > `get_all("x-foo")` therefore **unwinds across the wasm boundary** rather than + > returning `Err`. **Only ever call `get_all("set-cookie")`.** + + Worker 0.8.3 binds this Workers-only `Headers.getAll()` method without a catch boundary; + ordinary browser WASM runners do not implement it. Therefore real SDK response conversion, + the `get_all("set-cookie")` call, and repeated-Set-Cookie preservation execute only in the + pinned workerd/deployed fixture. Browser WASM tests are limited to portable Web APIs, raw + fetch-option construction, and bridge compilation; a browser pass cannot establish the + Workers response-header contract. + + Narrowing the portable contract to ASCII-only was **rejected** — it would degrade the + three adapters that *can* do this correctly. The §5.4 request/response repeated-field and + non-ASCII rows are asserted on Axum / Fastly / Spin and are **best-effort on + Cloudflare**. + +**Method and request-body portability.** `OutboundRequest` accepts an arbitrary +`Method`, but the platforms do not. Cloudflare's `fetch` restricts the method set and +**forbids a body on `GET`/`HEAD`**; today the CF adapter silently coerces unsupported +methods to `GET`, which is a correctness bug — a `DELETE` would be issued as a `GET`. +The portable contract: + +- **Supported methods** (all four adapters): `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, + `DELETE`, `OPTIONS`. These are guaranteed to reach the upstream with the method + intact. +- **Custom / extension methods** are **not portable**. An `OutboundRequest` carrying a + method outside the list above is rejected at **preflight** with + `Err(EdgeError::bad_request("method is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"))` + — on **every** adapter, so the failure is uniform rather than "works on Fastly, + silently becomes GET on Cloudflare". +- **`GET`/`HEAD` with a non-empty body** is rejected at preflight with + `Err(EdgeError::bad_request("GET/HEAD request must not carry a body"))` on every + adapter (CF's `fetch` forbids it; the others would happily send it, so EdgeZero + normalises to the strictest platform). +- **No silent coercion, ever.** An adapter MUST NOT rewrite the method to make a + request sendable. Preflight rejects; it never downgrades. + +**Enforcement point: one core validator, run at DISPATCH — not at construction.** +Validating at construction is **not enforceable**: `OutboundRequest::body(..)` is an +infallible chainable setter (`pub fn body(self, body: impl Into) -> Self`), so a +`GET` that passed a construction-time check can acquire a body immediately afterwards +(`OutboundRequest::get(url)?.body(payload)`) and reach the wire unchecked. The rules +above are therefore enforced by a **single core function**: + +```rust +// edgezero-core/src/outbound.rs — the ONLY place these rules live. +// PUBLIC, not pub(crate): the four adapters are SEPARATE crates and must call it. +// (A pub(crate) fn is unreachable from edgezero-adapter-{axum,cloudflare,fastly,spin}; +// verified — a pub(crate) validator fails to compile at the adapter call site.) +pub fn validate_for_dispatch(req: &OutboundRequest) -> Result<(), EdgeError>; +``` + +It is called **exactly once per request before body polling or platform request +construction**, from **both** paths: every adapter's `send`, immediately after its required +method-entry monotonic snapshot, and `send_all`'s per-slot preflight after the one shared +batch snapshot. The snapshot is the only operation that precedes validation, so request +preparation time consumes the original budget (§3.3.3). Neither path may skip the validator +and no adapter re-implements it — that is what makes the failure identical on all four +adapters, keeps a single `send` and a one-slot `send_all` equivalent (§5.4), and preserves +slot-index alignment (§3.1.1). + +**`GET`/`HEAD` + `Body::Stream` is always rejected.** "Non-empty body" is not decidable +for a stream: `Body::Stream` has **no observable emptiness** without polling it, and +polling consumes it. The validator therefore does **not** attempt to peek-and-rechain. +**Preflight precedence (a `GET`/`HEAD` + `Body::Stream` in `send_all` matches TWO rules — +the method/body rule here AND `send_all`'s generic "no `Body::Stream` in buffered fan-out" +rejection).** The **method/body check runs FIRST**, so the error is the specific +`"GET/HEAD request must not carry a streamed body…"` (below), NOT the generic +streamed-in-`send_all` message — the more informative, method-specific diagnostic wins. +(§5.4 pins this precedence with a `send_all([GET + Body::Stream])` row asserting the +method-specific message.) The rule is: + +| Method | Body | Outcome | +| --- | --- | --- | +| `GET` / `HEAD` | `Body::Once(empty)` | `Ok` | +| `GET` / `HEAD` | `Body::Once(non-empty)` | `bad_request("GET/HEAD request must not carry a body")` | +| `GET` / `HEAD` | **`Body::Stream` (any)** | `bad_request("GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream")` — rejected **unconditionally**, even if the stream would have yielded nothing | +| other methods | any | `Ok` (subject to the portable-method check above) | + +Rejecting an empty-but-streamed `GET` is a deliberate, documented false-positive: the +alternative (peek the first chunk and re-chain it) adds a buffering seam to every +request for a case with no legitimate use. + +*Implementation guardrail.* The UTF-8 check uses `std::str::from_utf8(value.as_bytes())`, +**not** `HeaderValue::to_str()`. `to_str()` is stricter than UTF-8 — it rejects any +byte outside visible ASCII — and would incorrectly drop valid non-ASCII UTF-8 headers +(e.g. an `x-app-display-name: café` style header). Adapters and the core +`normalize_for_dispatch` helper both use `str::from_utf8(value.as_bytes()).is_ok()`. +§5.4 asserts exact non-ASCII UTF-8 request/response preservation on the three +Native-fidelity adapters. Cloudflare separately proves that its guest-visible string path +does not panic or discard a valid value, without claiming raw-octet or field-line fidelity; +that distinction is its documented BestEffort cell. The same tests assert that a raw header +containing a `\x80` byte is dropped on a Native response or rejected on a request. + +Headers that matter for security, tracing, caching, and content negotiation +(`authorization`, `traceparent`, `cookie`, `cache-control`, `accept`, `content-type`, +…) are ASCII-only by spec and are unaffected by this rule. The trade-off only restricts +exotic non-UTF-8 custom headers; apps requiring fidelity for those must not use +EdgeZero outbound for that case. + +**Response normalization happens before semantic interpretation.** Every adapter calls the +public core helper `outbound::normalize_response_headers(..)` immediately after extracting +the upstream method/status/header fields and **before** bodyless handling, `Content-Encoding` +selection, early `Content-Length` rejection, decompression, or construction of +`OutboundResponse`. The helper resolves every visible `connection` line fail-closed, strips +the standard hop-by-hop fields plus all valid nominations, applies the response UTF-8 policy, +and returns the method/status body disposition used by §3.4.1. This order is security- +relevant: `Connection: content-encoding` and `Connection: content-length` must remove those +fields before either can influence decoding or cap logic. As a result, app-facing +`OutboundResponse::headers()` and `into_body()` never expose raw hop-by-hop metadata. +`into_response()` runs the same helper again only as an idempotent defense against later +adapter-facing mutation. + +For 205, `ResponseBodyDisposition::ResetContent { declared_body }` captures whether a +still-visible, valid `Content-Length` declared a positive length **after** nomination +stripping but **before** the helper rewrites the downstream value to `0`. This lets an +adapter abort immediately without consulting a header the normalizer has already changed. + +For response `connection`, each comma-delimited nomination across every field line must be +a non-empty RFC field-name token after optional whitespace. A non-UTF-8 value (where raw +bytes are available), empty member, forbidden byte, or invalid token rejects the whole +upstream response as `bad_gateway` reason `Protocol` (502); valid prefixes are not +partially honored. +Cloudflare enforces the same visible-token rule on post-workerd strings but cannot detect a +raw malformed value that workerd did not expose, which remains its header-fidelity caveat. + +**Final normalization at dispatch (`outbound::normalize_for_dispatch`).** Two surfaces +bypass the construction-time `header(..)` check — `headers_mut()` exposes raw +`HeaderMap`, and `from_request(..)` carries inbound headers in. Adapters MUST call a +core helper `outbound::normalize_for_dispatch(&mut OutboundRequest)` immediately before +handing the request to the platform SDK. The helper is idempotent and runs the same +rules end-to-end: + +1. **First**, handle every `connection` field value (see step 3's nomination list) **before** + any UTF-8 drop, because it governs the removal of *other* headers. A `connection` + value that is **not valid UTF-8 is rejected** (`EdgeError::bad_request`), NOT silently + dropped: dropping it would discard the removal intent and let a sender **smuggle a + nominated header past hop-by-hop stripping** by appending an invalid byte + (`Connection: x-private,` would otherwise forward `X-Private`). This is the + one header where the lossy drop below is a security hole, so it fails closed instead. + Parse each comma-delimited nomination after optional whitespace as an RFC field-name + token. An empty item, forbidden byte, or otherwise invalid token rejects the request as + `bad_request`; do not strip only a valid prefix and continue. Header-name comparison and + removal are case-insensitive, and nominations are accumulated across repeated + `connection` field lines before any nominated field is removed. +2. Drop any *other* header value that is not valid UTF-8 (drop + `log::warn!` naming the + header) — same lossy semantics as the response side. This applies **only** to + values that arrived via `headers_mut()` or `from_request(..)` (which carries + inbound headers verbatim). `OutboundRequest::header(..)` already rejects invalid + UTF-8 at construction with `bad_request` (§3.1.3), so a non-UTF-8 value can only + reach this stage by bypassing the checked builder. The policy split is + deliberate: construction is loud (caller error → 400); proxy-forward and + pre-validated-map paths are lossy (don't fail an otherwise-good forward over an + exotic header). The `warn!` makes the drop observable in either case. **The + `connection` header is exempt — it was already resolved fail-closed in step 1.** +3. Strip hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, + `proxy-authorization`, `te`, `trailer`, `transfer-encoding`, `upgrade`, plus every + header named in any `connection` header value — parsed and validated from the + now-guaranteed-UTF-8 values per step 1). Idempotent for `from_request` + output; mandatory for manually built requests. +4. Remove `host` — `normalize_for_dispatch` is the single source of truth for stripping + it from the request. Axum, Fastly, and Spin then set the final `Host` header (or platform + SDK equivalent) from `req.host_authority()` at SDK-construction time — the canonical + accessor (§3.1.4) — and do **not** re-read whatever was in `req.headers()` nor + reconstruct it from `req.uri()` directly. Cloudflare passes the exact canonical URL to + Fetch and relies on its authority-derived Host; it must not attempt a separate Host + override. `from_request` (§3.1.3) also drops `host` + so the two sites agree end-to-end: the request structure carries no `host` from the + moment it leaves the core builders; the value on the wire comes from + `host_authority()` on Axum/Fastly/Spin or the same canonical URI authority on + Cloudflare. Host-observed Cloudflare tests assert the final URL and wire Host agree. +5. Remove `content-length` — the adapter sets it from the body (length for + `Body::Once`; omitted for `Body::Stream`). +6. Remove `transfer-encoding` — the adapter sets it per body type and HTTP version. + +Apps can therefore use `headers_mut()` and `from_request` freely; portability and +framing safety are guaranteed by this final sweep, not by individual callers +remembering to sanitize. + +**Multi-value headers preserved.** `HeaderMap` permits repeated names — `set-cookie`, +`warning`, custom tracing headers, etc. EdgeZero adapters MUST preserve every entry for +a repeated header **on requests, and for response `set-cookie`**; repeated +*non-`set-cookie`* **response** field-lines are **outside the portable baseline** +(Cloudflare comma-joins them — the documented §3.1.4 exception), so apps needing that +fidelity declare the capability and target a `Native` adapter. Within that scope: use +`HeaderMap::append` (never `insert`) when building, and read with `get_all` (never `get`) +when serializing to the platform SDK or deserializing platform responses. Per-adapter mechanics (the spots +current code uses single-value APIs that collapse): + +| Adapter | Request side (build platform request) | Response side (read platform response) | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::header` (calls `HeaderMap::append`) | iterate `reqwest::Response::headers()` which is already a `HeaderMap` — preserve as-is | +| Cloudflare | `worker::Headers::append(name, value)` — **not** `set` (collapses) | iterate `worker::Headers` entries; `set-cookie` is enumerated separately by the worker runtime, handled explicitly | +| Fastly | `fastly::Request::append_header(name, value)` — **not** `set_header` | `fastly::Response::get_header_all(name)` per name, **not** `get_header` (returns first only) | +| Spin | append via the WASI HTTP `fields` resource (`wasip3::http::types::Fields::append`, re-exported through `spin_sdk`) — natively multi-value. There is **no** `spin_sdk::http::Headers` type; earlier drafts named one that does not exist | iterate WASI `fields` per name | + +Contract tests in §5.4 exercise repeated `set-cookie` response headers and repeated +outbound request headers, so any regression to collapsing duplicates is caught at CI +time. Cloudflare's response-side case runs in workerd, never the browser harness. If a future SDK update breaks multi-value round-tripping on one adapter, the +spec downgrades the contract for that adapter and documents the limitation rather than +silently dropping headers. + +### 3.2 Concurrent fan-out + +`HttpClient::send_all` is the single concurrency API **for buffered fan-out** — the +pattern it serves: N requests, each with a *buffered* response (`send_all` is +buffered-only by design; it rejects `Body::Stream` requests and `Streamed` response mode +in preflight). It is concurrent on Axum/Cloudflare/Spin and uses Fastly's +dispatch-all-then-harvest mechanism. Fastly's sequential dispatch can still be held by cold +dynamic-backend registration before a later slot is in flight. A non-empty request upload +continues in the background after `send_async` returns and cannot block later dispatch calls, +but it can leave its own pending slot unresolved and therefore delay input-order harvest. +Those limitations are reported by `send-all-slot-isolation = BestEffort`. The **input/output contract** is +identical (preflight, index alignment, per-slot Ok/Err shape). Cross-slot +timing **is not uniform** — see the `send-all-slot-isolation` capability and §3.3.4 for +Fastly's sequential-dispatch, upload-write, and response-harvest caveats. **For buffered +fan-out, app code never calls `futures::future::join_all`** — `send_all` is it. +(Concurrent *streamed*-response requests +are outside `send_all`'s scope: an app that wants several lazy streamed bodies at once +issues individual `send(..)` calls and orchestrates them itself — that is not "app code +duplicating `send_all`", it is a different, non-buffered use case `send_all` does not cover. +Each concurrent task owns both its send and subsequent body consumption, so a fast +response starts draining without waiting for every sibling's headers. The "single +concurrency API" claim is scoped to buffered fan-out.) + +| Adapter | `send_all` mechanism | Concurrency source | +| --- | --- | --- | +| Axum | `futures::future::join_all` of per-request `reqwest` sends | tokio reactor | +| Cloudflare | `futures::future::join_all` of `worker::Fetch` sends | Workers JS event loop | +| Spin | `futures::future::join_all` of per-request hand-built `wasi:http` sends (§4.4) | wasi async reactor | +| Fastly | dispatch every request with `send_async`, **then** harvest | Fastly host (parallel) | + +**Why a batch API and not `join_all` in app code.** Axum/Cloudflare/Spin have an async +reactor, so `join_all` of independent futures fans out. Fastly Compute has no guest +reactor: a future wrapping Fastly's poll-based `PendingRequest` would return `Pending` +with no waker, and `block_on` would deadlock. Fastly fan-out therefore *must* be +structured as "dispatch all, then harvest" — a shape that cannot be decomposed into N +independent futures. Making `send_all` the one primitive hides this entirely. + +**Where "identical" stops being identical: Fastly dispatch, upload, and response harvest.** +Fastly dispatches slots sequentially. A cold backend registration can block before a later +slot is issued. A non-empty buffered request upload has no finite host-write bound, but after +`send_async` returns it proceeds in the background and does not block issuance of later slots; +it can instead leave its own slot unresolved and block input-order observation. Once responses +arrive, Fastly's buffered response-body drain also runs in harvest order rather +than concurrently with sibling drains (§3.3.4 "Buffered body drain runs in harvest +order"). Small **response** bodies make only the final term negligible; they do not repair +the cold-registration or request-upload terms. For large responses on Fastly, EdgeZero has +no API that delivers concurrent large-body +fan-out — `Streamed` mode defers drain but does not let the app consume chunks +concurrently across slots either (no guest reactor; §3.2). This is a known +limitation, not a recommendation. + +**Partial failure.** `send_all` returns `Vec` index-aligned with the +input. A single target timing out or returning a 502 yields +`out[i].outcome = Err(..)` or `out[i].outcome = Ok(non-2xx)` without changing the *type* +of any other slot's result. `out[i].elapsed` is captured when that individual outcome +becomes terminal, including for preflight and dispatch failures; it is not measured when +the whole vector is returned. Cross-slot **timing** is governed by `send-all-slot-isolation` +(§3.5.1 footnote 4): `Native` on Axum/CF/Spin, `BestEffort` on Fastly because cold +registration can delay issuance, while unresolved background request writes and serial +response harvest can delay observation past the result a slot would have produced in +isolation (§3.3.4). Apps that need the stricter +timing guarantee declare the capability required and get a hard build failure on +Fastly. Cold registration is the only one of these terms that delays dispatch; request upload +and serial response harvest delay observation. The deviation is not response-body-only. + +### 3.3 Portable deadline + +#### 3.3.1 `Deadline` — portable value type, in core + +```rust +// crates/edgezero-core/src/time.rs (new module) + +/// Portable monotonic clock instant used by every public EdgeZero timing API. Downstream +/// crates do not need a direct `web-time` dependency to name or construct this value. +pub type MonotonicInstant = web_time::Instant; + +/// Cloneable clock source shared by one application, its admitted requests, and the +/// standard outbound clients installed by adapters. +#[derive(Clone)] +pub struct MonotonicClock { /* Arc MonotonicInstant + Send + Sync> */ } + +impl MonotonicClock { + pub fn new(now: Now) -> Self + where Now: Fn() -> MonotonicInstant + Send + Sync + 'static; + pub fn now(&self) -> MonotonicInstant; +} + +impl Default for MonotonicClock { /* web_time-backed production source */ } + +/// An absolute monotonic instant after which work should stop. A pure value type +/// — arithmetic over `MonotonicInstant`, identical on every target, with no +/// runtime dependency. `time.rs` contains `Deadline`, `DispatchBudget`, +/// `dispatch_budget`, and the public timing constants; the deliberate +/// constraint is that core carries **no runtime / timer / platform +/// dependency** — none of those types reaches outside the value-level +/// arithmetic and the trait surface adapters implement. +#[derive(Clone, Copy, Debug)] +pub struct Deadline(MonotonicInstant); + +impl Deadline { + /// `now + min(d, DEADLINE_FAR_FUTURE)`, where `DEADLINE_FAR_FUTURE` is a + /// **defined constant** clamp (7 days, see below). Bounded far-future clamping, + /// not "saturate to whatever MonotonicInstant::MAX happens to be" — `std::time::Instant` + /// has no `MAX` and platform overflow behaviour differs. The clamp is + /// finite and well above any realistic fan-out batch/proxy budget, so this never + /// truncates a legitimate caller and never panics. Adapter boundaries must + /// not crash the host. The internal `now + min(d, DEADLINE_FAR_FUTURE)` addition + /// itself uses the same saturating `now.checked_add(clamped).unwrap_or(now)` form + /// as `dispatch_budget`, so even the defensive case where the clamped add + /// would overflow the underlying `Instant` yields an already-expired deadline + /// (fails closed) rather than panicking. + pub fn after(duration: Duration) -> Self; + pub fn at_instant(instant: MonotonicInstant) -> Self; // construct from absolute instant + pub fn instant(&self) -> MonotonicInstant; // accessor for the absolute instant + pub fn is_expired(&self) -> bool; + pub fn is_expired_at(&self, now: MonotonicInstant) -> bool; + pub fn remaining(&self) -> Option; // None once passed + pub fn remaining_at(&self, now: MonotonicInstant) -> Option; +} + +/// Hard upper bound on any caller-supplied duration. The clamp exists so +/// `Deadline::after` and `dispatch_budget` cannot panic on a pathological +/// `Duration::MAX` input. Set to **7 days** rather than something larger so the +/// ceiling fits inside every supported platform's per-request timeout range — in +/// particular Fastly's backend timeouts are `u32` milliseconds (≈ 49.7 days max +/// per Fastly 0.12.1), so the EdgeZero clamp must stay well below that. 7 days +/// is still orders of magnitude above any realistic outbound budget; nobody hits +/// it legitimately. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); // 7 days; from_hours, not from_secs(7*24*60*60), which trips clippy::duration_suboptimal_units +``` + +Every standard adapter entry clones the exact `App::monotonic_clock()` used to capture +ingress timing into the outbound client inserted in request extensions. Axum exposes +`try_with_clock`; Cloudflare, Fastly, and Spin expose `with_clock`. Default low-level +constructors remain convenient and explicitly use `MonotonicClock::default()`. +They are not app-clock propagation paths. Cloudflare and Spin's former unit-struct literal +construction is intentionally removed by this hard cut; low-level callers migrate to +`CloudflareOutboundClient::new()` / `SpinOutboundClient::new()` or `Default` so every client +has an explicit clock source. + +Once `send` or `send_all` begins, every EdgeZero-owned outbound observation uses that stored +clock: method-entry and terminal slot samples, dispatch budget selection, preparation and +provider error precedence, pre/post-ready checks, and streamed upload/response checks. A +deferred body stream owns a clock clone; it never falls back to process-global time after +`send` returns. Each returned `OutboundResponse` also owns that clock clone, so +`into_bytes_bounded_until` and `json_bounded_until` remain in the same time domain after adapter +return. Provider timers still supply target-specific preemption, but the duration +armed into them comes from the same paired clock snapshot. Later remaining-budget reads use +`min(budget.deadline.remaining_at(clock.now()), budget.duration)`, so a backwards injected +clock cannot enlarge the budget selected at method entry. Checked elapsed subtraction maps a +backwards terminal sample to the existing zero-elapsed internal invariant outcome. + +#### 3.3.2 Mapping an external batch deadline to EdgeZero deadlines + +| External concept | EdgeZero mechanism | +| --- | --- | +| External batch deadline (whole fan-out) | Compute one absolute `batch_deadline` at handler entry, then pass it into every target request via `.deadline(batch_deadline)`. A default-clock app may call `Deadline::after(..)` once. Code using an injected application clock constructs the absolute deadline with `Deadline::at_instant(..)` from one `RequestContext::monotonic_clock().now()` sample, checked arithmetic, and the `DEADLINE_FAR_FUTURE` clamp. `Deadline` is `Copy` and absolute, so all targets share the same cap. Do **not** construct it per target or mix the process-global helper with an injected clock. | +| Per-target request timeout | `OutboundRequest::timeout(per_target)` | +| Effective per-request budget | computed by `dispatch_budget` — see below | + +**Effective budget rule (`dispatch_budget(req)`).** Returns a `DispatchBudget` struct +carrying **both** the duration to feed to platform SDK timeouts AND the absolute +`Deadline` to use for cooperative body-phase `is_expired()` checks. The implementation +computes a single set of candidate **absolute** deadlines from one monotonic `now` +snapshot and takes the minimum — so the effective deadline can never extend an +original `req.deadline`, and "no deadline" never gets conflated with "expired +deadline" via an `Option` round-trip. + +```rust +pub struct DispatchBudget { + pub cause: BudgetSource, // WHICH input set the effective deadline (for attribution) + pub deadline: Deadline, // effective absolute deadline + pub duration: Duration, // SDK timeout setting +} + +/// Records which configured input selected the effective deadline. The +/// per-call `OutboundRequest::timeout` and the shared batch `deadline` are separate +/// inputs (§3.3.2 table); the effective deadline is the tighter of the two, and `cause` +/// remembers which one won. This is provenance, not the physical timer phase and not +/// proof that the named deadline itself expired. +// ONE definition, shared by `DispatchBudget` (here) and `EdgeError::GatewayTimeout` +// (§3.4.3). **Defined in `error.rs` (Phase 1a Task 1)** — NOT the `time` module — because +// `error.rs` (Task 1, committed/built first) NAMES it in `GatewayTimeout`, so it must +// exist in Task 1's deliverable or the Task-1 commit fails to build. `time.rs` (Task 2) +// and `dispatch_budget` (Phase 1b) `use crate::error::BudgetSource;`. +// DERIVES + ORDER are COMPILE-VERIFIED (a throwaway crate under `arbitrary_source_item_ordering`): +// - `Debug` — `EdgeError` derives `Debug` and contains `cause`. +// - `Clone`, `Copy` — budget/error carriers pass provenance by value. +// - `PartialEq, Eq` — the Phase 1a contract tests assert `cause == Unspecified` etc. +// - Variants are **alphabetical** — the denied `clippy::arbitrary_source_item_ordering` +// rejects any other order (verified: the earlier `PerCallTimeout`-first order errored). +/// Which budget INPUT produced the effective deadline (the tightest bound) — the budget +/// SOURCE, NOT the physical phase-timer that fired. On Fastly the per-phase timers +/// (connect/first-byte/between-bytes) are sub-divisions of the budget; when one fires the +/// timeout is still attributed to this source (documented `BestEffort` — §3.5.2 footnote 5), +/// so `BatchDeadline` may be reported for a connect-phase-slice expiry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// `#[non_exhaustive]`: a future phase/reason variant must stay non-breaking (public enum). +#[non_exhaustive] +pub enum BudgetSource { + /// The shared batch `deadline` was the tighter bound. + BatchDeadline, + /// Neither timeout nor deadline was set — `DEFAULT_NO_DEADLINE_BUDGET` (30 s) applies. + Default, + /// The per-call `OutboundRequest::timeout` was the tighter bound. + PerCallTimeout, + /// A timeout with no proven dispatch-budget source: either raised outside a dispatch + /// budget (for example `json_bounded_until`) or reported early by a provider timer + /// that the adapter cannot attribute. The default a bare `gateway_timeout(msg)` carries. + Unspecified, +} + +/// `now` is passed in (not snapshotted internally) so a single `send_all` can use +/// **one** `now` snapshot across every slot. Without that, sequential per-slot +/// stored-clock calls produce slightly different `duration` values for the same +/// shared `Deadline`, which on Fastly would produce different `budget_ms` values +/// and therefore different dynamic-backend identities for the same host under one +/// batch deadline. `send` (single request) passes its outbound client's first +/// `clock.now()` sample. +// PRIVACY + NAME-COLLISION CONTRACT (both verified by compiling a skeleton). +// `time.rs` is a sibling module of `outbound.rs` and cannot read `OutboundRequest`'s +// private fields directly — earlier pseudocode did, which does not compile. +// **Crucially, the accessors CANNOT be named `timeout` / `deadline` / +// `max_response_bytes`**: those names are already taken by the PUBLIC BUILDER SETTERS +// ( — `pub fn timeout(self, duration: Duration) -> Self`, etc.). Rust does not overload +// inherent methods, so a same-named getter on the same type is a hard `E0592` +// duplicate-definition error. The inputs are therefore exposed through **one** +// crate-visible accessor returning a struct — no name clash with any setter: +// +// pub(crate) struct BudgetInputs { +// pub timeout: Option, +// pub deadline: Option, +// } +// impl OutboundRequest { +// pub(crate) fn budget_inputs(&self) -> BudgetInputs; +// } +// +// (`pub(crate)` — an internal contract between `outbound.rs` and `time.rs`, not app +// surface. Colocating `dispatch_budget` inside `outbound.rs` is rejected: `time.rs` +// must stay independently unit-testable and free of platform-shaped code.) The +// pseudocode below reads `let inputs = req.budget_inputs;` then `inputs.timeout` / +// `inputs.deadline` — **never** `req.timeout` (field) or `req.timeout` (setter). +// Lint contract (workspace denies `clippy::restriction`): public items need +// `#[inline]` (`missing_inline_in_public_items`); no single-char idents +// (`min_ident_chars`) — hence `duration`/`candidate`, never `d`; no bare arithmetic +// (`arithmetic_side_effects`) — hence `checked_add`; no `expect`/`unwrap` +// (`expect_used`/`unwrap_used`); items alphabetical (`arbitrary_source_item_ordering`). +#[inline] +pub fn dispatch_budget( + req: &OutboundRequest, + now: MonotonicInstant, +) -> Result { + let inputs = req.budget_inputs(); // single crate-visible accessor (see contract above) + + // (1) Candidate absolute deadlines. Build candidates directly from the single `now` + // snapshot; do NOT round-trip through `Deadline::remaining()`. An expired caller + // deadline remains a real (past) candidate, while absence remains no candidate, so + // "no deadline" and "expired deadline" cannot collapse into the same `None`. + // Use checked_add throughout — a caller- + // supplied Duration::MAX must not panic the adapter. The same clamp as + // Deadline::after: cap the duration at DEADLINE_FAR_FUTURE + // *before* the add, so the addition itself never overflows in practice + // (now + 7 days is well within Instant range). checked_add on the + // clamped value is belt-and-suspenders. + let saturating = |duration: Duration| -> Deadline { + let clamped = duration.min(DEADLINE_FAR_FUTURE); + let inst = now.checked_add(clamped).unwrap_or(now); // last-resort: now (immediate) + Deadline::at_instant(inst) + }; + let from_timeout = inputs.timeout.map(&saturating); + // `Deadline::at_instant` is public, so a caller could construct a + // Deadline well past DEADLINE_FAR_FUTURE and bypass Deadline::after's clamp. + // Re-clamp `from_caller` here: the caller's deadline is never honoured beyond + // `now + DEADLINE_FAR_FUTURE`. This only tightens; a caller's deadline closer + // than that is unaffected. + let from_caller = inputs.deadline.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_default_only = + (inputs.timeout.is_none() && inputs.deadline.is_none()) + .then(|| saturating(DEFAULT_NO_DEADLINE_BUDGET)); + + // (2) Effective deadline = min of the candidates (always at least one). + // NOTE: no `.expect(..)` — `clippy::expect_used` is DENIED in production code + // (the workspace denies the whole `restriction` group; the test exemption in + // clippy.toml does not apply here). The "unreachable by construction" case + // becomes an explicit invariant error instead of a panic, which is also the + // rule that adapter/core boundaries never crash the host. + // Tag each candidate with the BudgetSource it represents, pick the tightest, and + // CARRY the cause. On an exact-instant tie the iteration order wins — `from_timeout` + // is first, so a per-call timeout that coincides with the batch deadline attributes + // to `PerCallTimeout` (the more specific bound). This is the attribution §3.3 needs. + let (cause, deadline) = [ + from_timeout.map(|deadline| (BudgetSource::PerCallTimeout, deadline)), + from_caller.map(|deadline| (BudgetSource::BatchDeadline, deadline)), + from_default_only.map(|deadline| (BudgetSource::Default, deadline)), + ] + .into_iter() + .flatten() + .min_by_key(|(_, deadline)| deadline.instant()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "dispatch_budget: no deadline candidate — invariant violated (adapter bug)" + )) + })?; + + // (3) Duration is derived from the chosen deadline and the same now snapshot + // — never `Deadline::after(duration)`, which would re-anchor to a *later* + // now and could extend the absolute deadline past the caller's intent. + let duration = deadline.instant().saturating_duration_since(now); + if duration.is_zero() { + // `cause` was just computed above — attribute the zero-budget timeout to it. + return Err(EdgeError::gateway_timeout_caused("effective budget is zero", cause)); + } + + Ok(DispatchBudget { duration, deadline, cause }) +} +``` + +**Timeout provenance — the outcome carries the selected budget input, not the timer phase.** +Because `DispatchBudget` records `cause`, every timeout an adapter (or the pre-dispatch +check) raises carries the effective `budget.cause`. `BudgetSource::BatchDeadline` means +only that the shared deadline was the tighter configured bound. It does **not** prove that +the shared deadline has elapsed: Fastly may fire a rigid connect/first-byte phase timer +while that absolute deadline is still live (§4.3). It is not a retry classification, +physical timeout phase, or batch-abandonment signal. A timeout says only that EdgeZero +stopped waiting, while the origin may already have committed the effect. The provenance is +carried as a typed field on the error: +`EdgeError::GatewayTimeout { message, cause: BudgetSource }` (§3.4.3) — NOT a message string +to be parsed. A slot that times out is raised via `gateway_timeout_caused(msg, +budget.cause)`; a timeout outside a budget context uses `gateway_timeout(msg)`, whose cause +is `Unspecified`. `dispatch_budget` selects provenance before its zero-duration return, so +an already-expired caller deadline is `BatchDeadline` unless a per-call zero timeout has +the same absolute instant; the documented equality rule then selects `PerCallTimeout`. +The zero-effective-budget timeout carries that selected cause. + +**This is NORMATIVE for EVERY adapter, not just Fastly.** Every EdgeZero-owned budget timer +that expires — the `reqwest` timeout on **Axum**, the `worker::Delay` expiry on +**Cloudflare**, the raced wasi-timer on **Spin**, Fastly's explicitly configured +connect/response phase timers, and the **streamed-response wrapper's** `gateway_timeout` +chunk on all four — MUST use `gateway_timeout_caused(msg, budget.cause)`, NOT bare +`gateway_timeout`. A provider timeout not configured from the selected budget is different: +the pure classifier receives the absolute deadline and observation instant. At/after that +deadline, deadline precedence attributes the result to `budget.cause`; before it, an +independent provider timeout uses `gateway_timeout(msg)` and `BudgetSource::Unspecified`. +Spin applies that early rule to all five WASI timeout variants because the binding cannot +prove which accepted option caused a result. Fastly applies it to `DnsTimeout`, while its +configured `ConnectionTimeout` and `HttpResponseTimeout` phase timers remain attributed. +The deadline-aware stream wrapper is constructed with `budget.cause` so its past-deadline +chunk carries it. **§5.4 asserts the attributed cause +through ACTUAL adapter results** (not just the core helper): for each of Axum/CF/Spin, a +`.timeout(short).deadline(long)` expiry yields a `PerCallTimeout`-attributed harvested error +and the mirror yields `BatchDeadline`; a no-deadline default yields `Default`. §5.4 pins a Tier 1 test: +`.timeout(short).deadline(long)` that expires yields a `PerCallTimeout`-attributed error, +and `.timeout(long).deadline(short)` yields a `BatchDeadline`-attributed one — the two are +distinguishable from the harvested result alone. + +Behaviour table (the implementation gives these directly; listed here for clarity): + +All `now + t` entries in this table are shorthand for `now + min(t, +DEADLINE_FAR_FUTURE)` (§3.3.1) — the clamp is universal, not a special case for +`Duration::MAX`. + +Below, `clamped(d)` denotes `Deadline::at_instant(d.instant().min(now + +DEADLINE_FAR_FUTURE))` — the re-clamp of a caller's `req.deadline` performed by +`dispatch_budget` so a `Deadline::at_instant` constructed past the 7-day clamp +cannot escape the bound (§3.3.2 step 1). For brevity the table writes +`clamped(d)` rather than the full expression. + +| `req.timeout` | `req.deadline` | `duration` | `deadline` (absolute) | +| --- | --- | --- | --- | +| `None` | `None` | `30 s` | `now + 30 s` | +| `Some(t)` | `None` | `min(t, DEADLINE_FAR_FUTURE)` | `now + min(t, DEADLINE_FAR_FUTURE)` | +| `None` | `Some(d)` | `clamped(d).instant() - now` | `clamped(d)` | +| `Some(t)` | `Some(d)` with `now + min(t, …) ≤ clamped(d).instant()` | `min(t, …)` | `now + min(t, …)` (tighter) — **cause `PerCallTimeout`; EQUALITY goes HERE** | +| `Some(t)` | `Some(d)` with `now + min(t, …) > clamped(d).instant()` | `clamped(d).instant() - now` | `clamped(d)` (strictly tighter) — cause `BatchDeadline` | +| any nonzero/absent timeout | expired (`d.instant() <= now`) | — | `Err(gateway_timeout)` with cause `BatchDeadline` | +| `Some(Duration::ZERO)` | `Some(d)` with `d.instant() == now` | — | `Err(gateway_timeout)` with cause `PerCallTimeout` (the equality tie rule) | +| any | selected duration ends up zero | — | `Err(gateway_timeout)` with the selected cause | +| `Some(Duration::MAX)` | `None` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | +| `None` | `Some(d)` 100 years out via `at_instant` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | + +`.timeout(50ms)` with no batch deadline therefore yields `duration = 50ms` and +`deadline = now + 50ms`, **not** 30 s. The single absolute `deadline` is what Fastly's +between-chunk checks (§3.3.4) and the streamed-body wrappers in §4.1/§4.2/§4.4 use, so +per-request `timeout` is honoured across the entire exchange — including the streamed +body phase — whether or not an batch deadline was provided. + +"No deadline configured" therefore differs from "deadline configured and expired" — +the former is bounded by the synthetic 30 s ceiling; the latter is a hard fail at +dispatch with `gateway_timeout`. + +The same rule governs the dispatch+headers phase in `Streamed` mode. The body phase is +**also** governed by `dispatch_budget(req).deadline` (see §3.3.3) — the spec +deliberately does +not split the deadline into "before headers" and "after headers" pieces. + +#### 3.3.3 What the deadline covers + +The deadline on `OutboundRequest` covers the **entire upstream exchange** in both modes: +request preparation/upload, dispatch, response headers, and the upstream response-body +read/completion protocol. It ends when the core `OutboundResponse` has been fully buffered +or its Streamed body reaches validated native EOF. It does **not** include the later write of +a resulting core `Response` to the downstream client. That response-egress lifetime needs +its own absolute write deadline, abort semantics, and exactly-once completion contract +(§1.3); an outbound deadline must never be silently reused or restarted for it. The +adapter captures one monotonic `entry_now` as the first operation inside `send`, or one +`batch_now` as the first operation inside `send_all`, **before** request normalization, +batch preflight, platform builder construction, or any other preparation. Validation still +runs before `dispatch_budget` so the portable bad-request precedence is unchanged, but every +valid request computes its budget from that entry snapshot. No adapter re-anchors after +preflight. Clock-seam tests advance time during preparation and prove it consumes the same +absolute budget; `send_all` additionally proves every valid slot shares exactly one snapshot. +The upstream mechanism differs: + +- **`Buffered` (default):** the adapter buffers the body *inside* the deadline-bounded + region, so a slow body counts against the budget. `Ok(resp)` from `send`/`send_all` + means the full exchange completed within the deadline. +- **`Streamed`:** `Ok(resp)` is returned once headers arrive — earliest possible + delivery — but the **body stream returned in `resp` is adapter-wrapped to honour + `dispatch_budget(req).deadline`.** That deadline is the *effective* one computed by + the budget rule (§3.3.2), which is non-`None` even for timeout-only and no-deadline + requests — adapters wrap the body stream in every case, not only when + `req.deadline.is_some()`. Axum and Cloudflare provide timer-backed cancellation; + Spin races a monotonic timer but has only cooperative Component Model teardown + (BestEffort, footnote 8); Fastly checks cooperatively between host reads. Every + adapter surfaces deadline expiry as a typed `gateway_timeout`; the capability + matrix states where host work can outlive that result. + +What this means in practice: + +- `OutboundResponse::into_bytes_bounded(max)` on a streamed body already honours the + effective-budget deadline through the wrapped stream — body chunks past the + deadline yield `gateway_timeout`. +- `OutboundResponse::into_bytes_bounded_until(max, deadline)` is for tightening the + bound below the effective-budget deadline (e.g. an inner budget for body-only) — + not for re-applying the same deadline, which is automatic. +- If the caller dropped the `Deadline` value but still wants the same effective + ceiling, passing `Deadline::after(remaining_budget_from_some_source)` works; or + just call `into_bytes_bounded` and trust the wrapped stream. + +This is one upstream contract for everyone: handlers never have to remember "Streamed cuts +the deadline at headers." Adapter notes (§4.1–§4.4) implement it through validated upstream +completion. + +#### 3.3.4 Per-adapter enforcement (`Buffered` mode) + +| Adapter | Mechanism | Strength | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::timeout(effective)` — reqwest applies it through response-body read | Real, whole-operation | +| Cloudflare | race the adapter-private `fetch_raw_with_signal` (+ body drain) against `worker::Delay::from(effective)`; final fetch options carry manual response encoding and the abort signal (§4.2); the guard aborts on expiry | Real, whole-operation with cancellation | +| Spin | race the entire `send_one` future (send **and** body collect) against a WASI monotonic-clock timer; drop the guest future on expiry | Guest-visible 504 at the timer; host teardown is cooperative and therefore `BestEffort` (footnote 8) | +| Fastly | host phase timers split per §4.3 (`connect = budget/4`, `first_byte = 3*budget/4`, `between_bytes = budget`); during body drain, `budget.deadline.is_expired()` is checked **after every blocking body read returns, including the EOF read** (the synthetic 30 s deadline applies when no caller deadline was set); the host between-bytes timeout bounds each gap | Warm-path connect/headers have a documented phase split and returned body reads have a cooperative bound; the end-to-end capability remains `BestEffort` because cold registration and request-write gaps are unbounded | + +**Drop-cancellation guarantee, per adapter (what happens to a LOSING arm).** A fan-out +consumer under deadline pressure needs to know whether a timed-out/deadline-lost send is +actually *aborted* or merely *stopped-being-waited-on* — "harvest returns" ≠ "the pending +request is cancelled." The guarantee: + +| Adapter | On deadline/timeout, the in-flight send is… | Origin observes cancel? | +| --- | --- | --- | +| **Axum** | the request/body future and EdgeZero source are dropped; Reqwest/Hyper performs protocol-specific transport cleanup | HTTP/1 may close the connection; HTTP/2 resets the stream while retaining the pooled connection. No generic connection-drop or bounded origin-observation claim | +| **Cloudflare** | **cancelled** — `controller.abort()` (NOT a bare future-drop, which would leave the subrequest running) | Yes (§5.3 blocking test) | +| **Spin** | guest future is dropped and Component Model cancellation is requested; completion writers default to `Err` | Not guaranteed within a finite bound; host-observed tests are upgrade evidence (footnote 8) | +| **Fastly** | **No bounded origin cancellation guarantee.** Buffered `send_all` harvests every successfully dispatched `PendingRequest` via blocking `wait()`/`poll()`. Single-`send` streamed uploads instead follow §4.3's upload protocol: source/cap failure, deadline expiry, write/flush/finish failure, and future drop can exit without harvesting. Dropping unfinished upload handles aborts that upload protocol but does not establish a finite host-teardown bound. Host phase timers can fail a request; a **sibling slot's** deadline never cancels another slot | **No finite bound** — documented BestEffort limitation | + +Axum gives a bounded guest-visible timeout and deterministic future/source drop, while its +wire cleanup remains protocol-dependent. Cloudflare gives explicit bounded abort through +`AbortController`. Spin and Fastly expose different BestEffort gaps: Spin requests +cooperative teardown without a documented host bound, while Fastly exposes no guest +cancellation primitive for a dispatched request. Axum loopback tests distinguish HTTP/1 +connection closure from HTTP/2 stream reset; optional deployed HTTP/2/HTTP/3 +characterization records what intervening proxies and origins observe without promoting +that observation into a portable guarantee. + +**Fastly precision, stated honestly.** Fastly has no guest wall-clock primitive to +preempt a chunk read in progress. At dispatch the adapter computes `let budget = +dispatch_budget(req, now)?` (§3.3.2, `now` snapshotted inline for single `send`, +passed in as `batch_now` for `send_all` — round 23. `DEFAULT_NO_DEADLINE_BUDGET = 30 s` +and the synthetic absolute deadline both apply when no deadline is set, identical to +every other adapter) and derives the host timeouts via the named helper: + +```rust +// Lint contract: the workspace DENIES `clippy::restriction`, so `as_conversions` +// and `arithmetic_side_effects` are hard errors — no `as` casts, no bare `+`/`-`/`/`. +// Use `Duration::as_millis` (already integer-ms), saturating/checked arithmetic, and +// `u64::try_from` instead of `as u64`. +// Returns the EXACT host-timeout ms (true ceil-to-ms) — the single source used for BOTH +// the phase split (connect/first-byte/between-bytes) AND the backend identity key, so a +// cached backend's timers always match the identity it was registered under. No +// bucketing: the cache is per-session and bounded by the fan-out size, so the raw value +// is fine and keeps the deadline bound exact. +fn fastly_timeout_ms(budget: &DispatchBudget) -> u64 { + // True ceil-to-ms — never floor a sub-ms remainder away. + // `as_millis` floors, so add 1 when there is a sub-ms remainder. + let nanos = budget.duration.subsec_nanos(); + let has_remainder = !nanos.is_multiple_of(1_000_000); + let ceil_ms = budget + .duration + .as_millis() + .saturating_add(u128::from(has_remainder)) + .max(1); + + // The DEADLINE_FAR_FUTURE clamp keeps this below Fastly's 2^32 ms ceiling. Clamp + // defensively, then convert fallibly — a bug elsewhere must not crash the host, + // and a bare `as` cast is forbidden by the lint. + let ceiling = u128::from(u32::MAX).saturating_sub(1); + let clamped = ceil_ms.min(ceiling); + u64::try_from(clamped).unwrap_or(u64::from(u32::MAX).saturating_sub(1)) +} + +// `dispatch_budget` always takes an explicit `now`. Public `send` snapshots at method +// entry before preflight; `send_all` snapshots once into `batch_now` at method entry and reuses it +// across slots so the dynamic-backend identity stays consistent for a shared +// caller Deadline. +let now = self.clock.now(); // single `send`; `send_all` passes batch_now +let budget = dispatch_budget(req, now)?; + +// Fastly 0.12.1 exposes the timeout setters on BackendBuilder, NOT on Request — see +// https://docs.rs/fastly/0.12.1/fastly/backend/struct.BackendBuilder.html. +// IMPORTANT: connect_timeout and first_byte_timeout are *separate* phase timers +// per Fastly's docs — connect bounds DNS+TCP+TLS setup; first_byte bounds the gap +// from "request sent" until headers are received. Setting both to the same `t` +// would make the dispatch+headers worst case ~2*t, breaking the absolute-deadline +// bound. We therefore SPLIT the budget across the two phases (and the third, +// between-bytes, which only applies once chunks are flowing during body drain), +// keeping the sum exactly equal to total_ms: +// total_ms = ceil-to-ms(budget.duration) +// connect_ms = total_ms / 4 [floor; most connects take <100ms] +// first_byte_ms = total_ms - connect_ms [remainder; sum invariant] +// between_ms = total_ms [body-phase ceiling unchanged] +// Sub-4 ms degenerate case: both = total_ms (sum = 2*total_ms, documented). +// SSL configuration also lives on BackendBuilder: `use_ssl` defaults to false, so +// HTTPS targets MUST opt in explicitly with .enable_ssl() and configure SNI + +// certificate verification (per the existing pattern at +// crates/edgezero-adapter-fastly/src/proxy.rs:120). HTTP targets opt out via +// .disable_ssl(). +// +// Five canonicalized values come from the OutboundRequest accessors — +// adapters MUST consume these, never re-derive from `req.uri`): +// - `req.backend_target` — connection target `"host:port"` with the +// resolved port; passed as the +// BackendBuilder's `target` arg. +// (current adapter precedent: +// `host_with_port` at +// crates/edgezero-adapter-fastly/src/proxy.rs:108) +// - `req.host_authority` — authority for `.override_host(..)` +// (carries the explicit port only when +// non-default; preserves Host +// semantics). +// - `req.host_name` — canonical host only, with no port or IPv6 brackets; +// used in `BackendIdentity`. +// - `req.sni_hostname` — `Option<&str>`. `Some(host)` for DNS-name HTTPS +// targets; `None` for IP-literal HTTPS (RFC 6066 +// forbids SNI for IP literals). When `None`, the +// adapter omits `.sni_hostname(..)` entirely; it +// does NOT fall back to `req.uri.host`. +// - `req.cert_host` — `Option<&str>`. `Some(host)` for any HTTPS target +// (DNS name OR IP literal — port-stripped, +// bracket-stripped); `None` for non-HTTPS schemes. +// Passed to `.check_certificate(..)` verbatim; the +// adapter does NOT bracket-trim, parse, or +// post-process. +// Phase split. The documented semantics: connect gets a *floor quarter* of the +// already-ceiled total; first_byte gets the remainder; between_bytes gets the full +// budget. Invariant we want: connect_ms + first_byte_ms == total_ms exactly, so +// the worst-case dispatch+headers wall-clock is bounded by `budget.duration` +// (modulo ms rounding). Using `total_ms / 4` (floor) keeps the sum exact; the +// earlier "ceil-to-ms of budget * 1/4" framing was a misnomer — that would have +// made the sum exceed total_ms by up to 1 ms for some inputs. For tiny budgets +// where the 1/4 share would round to 0, we degenerate to "both = total_ms" — +// the absolute-deadline bound becomes 2*total_ms but at sub-4 ms scale this is +// negligible (and the ceil-to-ms rounding already dominates). +let total_ms = fastly_timeout_ms(&budget); // exact ceil-to-ms of budget.duration +let (connect_ms, first_byte_ms) = if total_ms < 4 { + (total_ms, total_ms) // sum = 2*total_ms; documented +} else { + let connect = total_ms / 4; // floor — keeps sum exact + let first_byte = total_ms - connect; // sum = total_ms exactly + (connect, first_byte) +}; +let between_ms = total_ms; +let mut builder = Backend::builder(&backend_name, &req.backend_target()) + .connect_timeout(Duration::from_millis(connect_ms)) + .first_byte_timeout(Duration::from_millis(first_byte_ms)) + .between_bytes_timeout(Duration::from_millis(between_ms)) + .override_host(req.host_authority()); +// TLS handling — the accessors carry the canonicalized split. We do NOT +// inspect `req.uri` directly: `cert_host` returns `Some` iff the scheme is +// HTTPS (the adapter-local "is TLS?" question), and `sni_hostname` carries +// the DNS-vs-IP-literal distinction (`None` for IP literals per RFC 6066). +builder = match req.cert_host() { + Some(cert) => { + // HTTPS: always set .check_certificate(..). Pass req.cert_host + // through unmodified — bracket-stripping for IPv6 is already done in + // the accessor; we never call .trim_start_matches('['). + let mut b = builder.enable_ssl().check_certificate(cert); + // SNI: only when the accessor returns Some (DNS-name host). + // For IP literals (`None`).sni_hostname is omitted entirely. + if let Some(sni) = req.sni_hostname() { + b = b.sni_hostname(sni); + } + b + } + None => builder.disable_ssl(), // HTTP +}; +let backend = builder.finish()?; +// Fastly's Request public API has no `with_backend`. The backend is passed as +// the argument to `send` / `send_async` / `send_async_streaming` at send time +// (each accepts `impl ToBackend`). `Backend` implements `ToBackend`. +// Buffered request body (send_all only — preflight rejected streams): +let pending = fastly_req.send_async(&backend)?; +// Streamed request body (single `send` only): +// let (streaming_body, pending) = fastly_req.send_async_streaming(&backend)?; +``` + +The dynamic-backend identity tuple (§4.3) is `scheme + ":" + host + ":" + +resolved_port + ":" + tls_mode + ":" + budget_ms`, where `tls_mode` is derived from +`req.uri().scheme_str()` and `budget_ms = ceil-to-ms(budget.duration)` — the same +`total_ms` that drives the `connect_ms / first_byte_ms / between_ms` deterministic +phase split above. The cached `Backend` and a freshly-requested one therefore always +carry identical timeouts AND identical SSL configuration because both are +deterministic functions of the same tuple. Existing in-tree precedent for +the SSL setters lives at `crates/edgezero-adapter-fastly/src/proxy.rs:120`; the +migration generalises that pattern to every dynamic backend. The budget is set once +before `send_async` and not mutated afterwards — the Fastly SDK does not expose +dynamic per-chunk timeout updates. During body drain the adapter checks +`budget.deadline.is_expired()` **after every blocking body read returns, including +the EOF read** (per the §3.3.4 rule — the earlier "between chunks" wording was +incomplete because a final EOF read can itself cross the deadline). Because +`dispatch_budget` always returns a concrete `Deadline` (synthetic if the request +had none), this cooperative check works uniformly whether or not the caller +supplied a deadline. +`connect-timeout` and `first-byte-timeout` together bound the dispatch+headers phase +at `budget.duration` (their sum, by the §4.3 split) **when `total_ms ≥ 4`**; for +`total_ms < 4` the code degenerates to `connect = first_byte = total_ms` and the +sum is `2 * total_ms`. The absolute-deadline guarantee in the sub-4 ms branch is +therefore "≤ `total_ms + BATCH_DISPATCH_SLACK_MAX + ms_rounding` past deadline" +(strict upper bound: `BATCH_DISPATCH_SLACK_MAX + total_ms + ms_rounding` +which is `25 + (≤ 3) + (≤ 1) < 29` ms), not the common-case "≤ 26 ms" — see +the two explicit +branches in §4.3 "Net guarantee." Sub-4 ms outbound budgets are degenerate inputs +where ms-rounding already dominates, not a normal operating point. The documented trade-off (§4.3) is that a request +spending more than `budget/4` on the configured connect phase (TCP+TLS) fails at the +connect timer even if the remaining budget would have sufficed for headers; that +is captured by the separate `outbound-flexible-phase-budget` capability (§3.5.1). +DNS is excluded from this causal claim because the pinned SDK exposes no DNS-timeout +setter; live observations may characterize provider behavior but do not turn it into a +portable configured-timer guarantee. +During body drain (post-`wait()`), the adapter checks `budget.deadline.is_expired()` +**after every blocking body read returns, including the EOF read** (not "between +chunks" — the EOF read can itself block past the deadline and would otherwise +slip through with `Ok(resp)`). On the first expired check the slot is aborted +with `gateway_timeout`; each individual chunk-gap (including the gap before EOF) +is bounded by the host `between-bytes-timeout`. So the Buffered `Ok(resp)` +contract — "headers AND body completed within the deadline" — holds **for the response +phase**: either every read (including EOF) observed `!is_expired()`, or the slot returned +`gateway_timeout`. **The REQUEST-transmission phase is not covered by this argument** — no +Fastly timer bounds the guest-to-origin write (footnote 2), which is why Fastly's +`outbound-deadlines` is `BestEffort` rather than `Native`. + +**Slot-level vs. wall-clock-observed completion.** The response-side bound above begins +only after a slot's sequential `send_async` dispatch has returned. A cold backend +registration can block before that call and prevent later dispatches. A buffered request +upload also has no finite host-write completion bound, but pinned Fastly 0.12.1 +`send_async` returns as soon as sending begins and continues transmitting headers/body in +the background; that upload therefore cannot synchronously prevent the guest from issuing +later `send_async` calls. It can leave its own `PendingRequest` unresolved and later block +ordered harvest. For slots that have reached `PendingRequest`, the Fastly host runs them in +parallel and applies each slot's configured response timeouts independently. What the guest +**observes** is then gated again by harvest order — a dispatched slot with a 50 ms effective +budget sitting behind a +3 s `wait()` on slot 0 may have completed at the host at t ≈ 50 ms, but the guest does not +see the result until slot 0's `wait()` returns. So: + +- **Per-slot result correctness after dispatch (headers phase):** each dispatched slot's + connect / first-byte / between-bytes timeouts are configured from its own + `budget.duration`, and the host enforces them independently. A 50 ms slot that fails to + receive headers in time errors at 50 ms host-side, not 3 s. This statement does not cover a + later slot that an earlier cold registration prevented from dispatching. An earlier + background upload may instead keep its own pending slot unresolved and delay ordered harvest. + For dispatched slots the statement holds only for the headers phase. Buffered + response-body drain is bounded by the same host timeouts on a per-chunk-gap basis but is + **scheduled sequentially in harvest order** — see the next bullet for the wall-clock + consequence. +- **Per-slot wall-clock-observed delivery after Phase 1:** once every surviving slot has + reached `PendingRequest`, Phase 2 is bounded by the response-harvest terms below. There is + deliberately no finite whole-call bound on Fastly: Phase 1 may include cold registration, + and Phase 2 may wait behind an unresolved background upload or response. The opportunistic + `poll()` of later slots after each `wait()` reduces response-harvest delay in practice but + does not eliminate it. +- **Buffered body drain runs in harvest order, not concurrently.** `harvest()` does + `pending.wait()` *and then* drains the response body (Buffered mode) *and then* + moves to the next slot. On Axum/CF/Spin `join_all` polls all `send_one` futures + concurrently, so two slow body drains complete in parallel; on Fastly they are + sequential. Wall-clock for **Phase 2 after every slot has dispatched** is therefore + `max(header_arrivals) + Σ buffered_body_drain_times` on Fastly versus + `max(header_arrivals + buffered_body_drain_times)` elsewhere. **A slot can therefore + return `gateway_timeout` even though its host-side headers + body would have + completed within `budget.deadline` in isolation** — its body-drain phase started + late because an earlier slot's drain monopolised harvest, and the inter-chunk + `is_expired()` check fires once `budget.deadline` is crossed. The + "per-slot result correctness" bullet above applies only to the *headers* phase; + for the body phase, results genuinely depend on harvest order. The `send_all` + contract on Fastly therefore *admits* harvest-order-induced 504s in Buffered mode, + and the §5.4 test row asserts this explicitly. Concrete contract: + - Small response bodies reduce only the serial drain term. The contract assigns no + universal size threshold or latency bound because host-call timing is deployment-specific. + - For large body responses, Fastly `send_all` is **simply suboptimal** compared + to the other three adapters and there is no current EdgeZero API that recovers + parallel large-body fan-out on Fastly. `Streamed` mode defers each slot's drain + to the consumer, but the consumer has no concurrent body-drain primitive + either — Fastly's body reads are synchronous host calls with no guest reactor + (§3.2 / §3.3.5), so iterating `Stream::next` on `out[0].body()` and + `out[1].body()` still serializes at the guest. Apps that fan out to large-body + upstreams on Fastly should either (a) target a different adapter for that + workload, (b) issue requests in a topology that doesn't require parallel + large-body drains, or (c) wait for the interleaved-drain follow-up in §8 risk 8. + Typical small **response** bodies make this response-harvest term negligible. They do + not make batches with cold registration or non-empty request bodies isolated. + +The worst-case post-deadline overshoot per slot **once that slot is actively draining** +is therefore **one between-bytes-timeout interval, which is ≤ `effective_at_dispatch`**. +That bound is on the host timeout set at dispatch and does *not* shrink while a slot waits +behind earlier harvest work. **Phase-2 wall-clock observed by the caller** is not bounded +by one between-bytes-timeout — it also includes the sum of preceding slots' response-drain +times. Concretely, after all slots have dispatched, slot `k`'s observed response harvest can +be as late as `Σᵢ<ₖ drain_timeᵢ + (effective_at_dispatch for slot k)`. Whole-call wall-clock +also includes Phase 1 and has no finite Fastly bound when cold registration or a non-empty +upload stalls. Once slot `k`'s response drain begins, the inter-chunk +`is_expired()` check fires within one between-bytes-timeout of `budget.deadline` for that slot. + +Apps reasoning about precise wall-clock should treat `effective_at_dispatch` as the +maximum per-slot *active response-drain* overshoot only. It is not a bound on request upload, +sequential dispatch, or observed completion across the whole `send_all`. The +`send-all-slot-isolation` capability +(§3.5.1 footnote 4) is what scopes the cross-slot half: declaring it required gives +the hard build failure on Fastly, signalling that an app needs isolation guarantees +the Fastly dispatch/upload/harvest sequence does not provide. The warm single-slot body-read mechanism has a +documented cooperative bound, but the static `outbound-deadlines` cell remains +`BestEffort` because cold registration and request-write paths have unbounded gaps. +The three cross-slot weaknesses are the separate `BestEffort` +`send-all-slot-isolation` story. A peer dribbling **response** bytes cannot blow past its +active-drain bound indefinitely, but a peer that stops reading a non-empty request can block +without a finite write bound and therefore delay the whole Fastly batch. + +#### 3.3.5 No general-purpose timeout combinator (deliberate) + +An earlier draft put a `timeout(deadline, future)` combinator for *arbitrary* futures in +`edgezero-core`. That is **removed**: a real timer future needs a platform runtime +(`tokio` / `worker` / `spin-sdk`), which core may not depend on (§1.3). Core therefore +ships only the `Deadline` value type; outbound-deadline enforcement lives entirely inside +adapters (§3.3.4). A general arbitrary-future timeout would require an adapter-injected +`Timer` trait and a dedicated capability; it is **out of scope** here because the fan-out pattern's +timing needs are fully met by the outbound path. Noted as possible future work. + +### 3.4 Bounded buffering & error mapping + +#### 3.4.1 Outbound responses + +**Guest-visible header limits happen first; normalization and no-content handling follow, +before body decode or body-cap logic.** §3.4.5 measures response headers at the earliest +guest-visible adapter boundary, before normalization can remove fields. After those limits +pass, `normalize_response_headers` strips hop-by-hop fields and `connection` +nominations (§3.1.4), a response that is bodyless by HTTP framing — the response to a +**`HEAD`** request, or any **`1xx`**, **`204`**, or **`304`** status — carries no payload even +though HEAD/304 MAY legitimately carry `Content-Encoding` and a *representation* +`Content-Length` (e.g. a `HEAD` echoing what a `GET` would return; a `304` echoing the cached +representation's metadata). For these: +- **Do NOT attempt to decode.** There are no body bytes; feeding EOF to the gzip/br decoder + would error and produce a **false `bad_gateway` (502)**. Skip the decoder entirely. +- **Framing headers are status-dependent (RFC 9110 §8.6 / RFC 9112 §6.2).** For a + **`HEAD` response and `304`**, `content-encoding` and a *representation* `content-length` + are legitimate metadata the client needs (a `304` or `HEAD` with them stripped breaks + cache validation). Validate every visible `content-length` as one consistent `u64` first: + malformed values, comma lists, conflicting duplicates, and values above `u64::MAX` are protocol + 502 before any body poll; valid values, including values above 4 GiB, are preserved unchanged. + For **`1xx` and `204`**, + `content-length` is prohibited and is removed. This framing normalization is centralized + in the same response helper as hop-by-hop stripping so every adapter applies it + identically. +- The adapter settles or drops the platform body handle according to that runtime's + protocol and constructs an empty core body. It never feeds a framing-bodyless response + to the content decoder. + +**`205 Reset Content` is a separate semantic-suppression case.** RFC 9110 forbids a server +from generating content in a 205 response, but HTTP/1.1 message framing does not grant 205 +the same automatic no-body precedence as HEAD/1xx/204/304. An illegal framed body therefore +cannot simply be replaced with `Body::Once(empty)` while leaving the native body unread: +that would risk connection reuse on unread bytes and, on Spin, falsely complete the +`consume_body` caller-result protocol. For 205, the adapter skips content decoding and awaits +at most one native body read under the effective deadline. An explicit EOF writes/observes +clean completion; any observed non-empty body bytes cause the adapter to abort/drop the +remaining native body and mark the platform completion protocol as failed. The +downstream/core result is still a 205 with an empty body, and `content-length` is normalized +to `0`. A native read failure before that disposition is `bad_gateway` reason `Transport`; +invalid completion/framing is reason `Protocol`; deadline expiry is the attributed +`gateway_timeout`. `ResetContent { declared_body: true }` permits +immediate abort without a read. + +This contract applies to **guest-visible read results**, not HTTP frame boundaries. +Fastly's `std::io::Read` returns `Ok(0)` for EOF when given a non-empty buffer and cannot +expose an empty wire data frame. Stream APIs can instead yield an empty item before EOF: +that item is not proof of completion, so the adapter conservatively aborts/drops the +remaining body and marks completion failed, still returning an empty 205. It need not +classify the empty item as illegal HTTP content. The one-read limit avoids an unbounded +discard of empty items; tests exercise each SDK's observable EOF/empty-item distinction +without asserting that every adapter can inspect wire frames. On Cloudflare every early +abort disposition calls the subrequest's `AbortController::abort()` (§4.2). + +Cloudflare has one narrower observable boundary: workerd can expose `Response.body == null` +for 205 after suppressing content itself. After guest-visible header checks, a positive +`Content-Length` still selects `declared_body` and aborts the subrequest. With absent/zero +length and a null body, the adapter treats the host-suppressed body as clean empty 205 and +does not call `stream()` or invent a read. It cannot prove whether illegal bytes existed +before workerd suppressed them, so Tier 3 characterizes this behavior and no test claims raw +framing visibility. A non-null body uses the ordinary one-read protocol above. + +The bodyless determination is **method- and status-aware**. Every adapter passes the +originating request method into `OutboundResponse::new`; `OutboundResponse` retains it until +`into_parts`/`into_response`, so a downstream conversion cannot lose HEAD semantics. The +adapter performs the authoritative disposition before constructing the response; final +conversion only rechecks the already-normalized metadata defensively. §5.4 pins tests for +`HEAD 200` (with `Content-Encoding: +gzip` + representation `Content-Length`, no body → passes through, headers preserved, no +502), `1xx`, `204`, clean 205, illegal-body 205, and `304`. Only AFTER this handling do the +decode/cap rules below apply to payload-bearing responses. + +In `Buffered` mode, `max_response_bytes` (default `DEFAULT_MAX_RESPONSE_BYTES = 1 MiB`) +caps the final collected `Body::Once` for every coding disposition. The independent +`max_decoded_response_bytes` cap is measured only in effective-identity or +EdgeZero-decoded gzip/Brotli output. Every adapter **must enforce that decoded cap +incrementally** and abort as soon as output exceeds it; this closes the decompression-bomb +gap while allowing a caller to set a larger final buffer for an intentionally retained raw +passthrough body. Decoded over-cap → +`Err(EdgeError::response_too_large_with_reason("response body exceeded N bytes", +ResponseLimitReason::DecodedBody))` (the distinct kind, 502 — §3.4.3; NOT +`bad_gateway`, so a consumer classifies it apart from transport). Final collection +over-cap uses `ResponseLimitReason::BufferedBody` instead. + +**Early `Content-Length` rejection is sound ONLY for effective identity (no +`content-encoding`, or exactly one bare `identity` value) responses.** In that case the wire +`Content-Length` *is* the decompressed +size, so a `u64` `Content-Length` above the `u64` cap can be rejected **before buffering** +(cheap, correct). When the response **is** compressed, the wire `Content-Length` is the +*compressed* size, which bounds the decompressed size in NEITHER direction — gzip +typically expands, but incompressible input can make the compressed representation +*larger* than its decoded output — so an early reject on the wire `Content-Length` could +wrongly reject a body whose decompressed size is under the cap. For compressed responses +the decoded cap is therefore enforced **only** incrementally during decompression (above); +the decoded early-reject shortcut is skipped. It is also skipped for unknown/stacked +`Passthrough`, whose bytes may remain encoded. A configured encoded-byte cap is independent +and may reject the same wire `Content-Length` before reading (§3.4.5). (This corrects an +earlier note that implied every wire `Content-Length` over the decoded cap is rejected up +front.) + +**Pre-append check is mandatory.** Outbound bounded drains +(`OutboundResponse::into_bytes_bounded` / `_until` and adapter buffered-response drains) +MUST check the running total against `max` **before** extending the buffer. The comparison +is done in the outbound cap's `u64` type (§3.1.3): convert `usize` lengths with +`u64::try_from`, use checked addition, compare, then extend. A single oversized chunk on a +small cap would otherwise allocate past the limit before erroring. The persistent collected +buffer therefore never exceeds `max`; inbound bounded-drain semantics are owned by the +[inbound body design](2026-08-22-inbound-body-design.md). + +Worst-case **transient** resident memory during a drain is `max + current_chunk.len()`: +the in-flight chunk briefly co-exists with the collected buffer during the check, then +is dropped (over-cap) or appended (under-cap). **`current_chunk.len()` is +source-controlled, not bounded by this spec.** The `8–64 KiB` figure typical sources +yield (`tokio::io` 8 KiB, `hyper` 16 KiB, WASI body reads 64 KiB) is descriptive of the +adapters' incoming stream chunking, not a contract. Three concrete consequences readers +must internalise: + +- **An upstream that yields one large `Bytes` exceeds the typical figure.** A peer + returning a 4 MiB response in a single chunk produces a single 4 MiB in-flight + `Bytes` while the over-cap check runs; if the cap is 1 MiB, the persistent buffer + never grows past 1 MiB but resident memory transiently includes the full 4 MiB + chunk. The check still aborts before any append, but the host did receive 4 MiB. +- **Rechunking is opt-in.** `OutboundRequest::max_chunk_bytes(NonZeroU64)` installs the + §3.4.5 core wrapper on Streamed app-visible output. Unset requests preserve source chunk + boundaries. Rechunking limits emitted item length; it does not prevent the adapter/host + from allocating or yielding one large source chunk, and shared `Bytes` slices may retain + that large backing allocation until the last slice is dropped. +- **The batch model in §3.4.4 inherits the same property.** `Σⱼ current_chunkⱼ.len()` + for actively-draining slots is bounded by what each source yields, not by EdgeZero. + EdgeZero cannot currently provide a hard per-batch ceiling against adversarial source + allocation. Apps that require one must bound fan-out (N) against a documented + provider/upstream frame or chunk ceiling; `max_chunk_bytes` alone is not that guarantee + (§8 risk 11). + +This is a per-call drain bound, **not** a whole-process memory ceiling; the batch-level +bound is `Σ persistent buffers + Σ in-flight chunks` per §3.4.4, with the same +source-controlled caveat on the in-flight term. + +Decoded-output-cap responsibility per adapter: + +- **Cloudflare** — first disables host response decoding in the final fetch options + using `encodeResponseBody: "manual"` (§4.2). Only then can the shared decoder apply + this policy and its independent decoded-output cap. Existing explicit decoding alone does + not prevent workerd from decoding the subrequest body first. +- **Fastly, Spin** — already decompress gzip/br explicitly today; the cap obligation + applies in-line in their decode paths. +- **Axum** — the workspace `reqwest` dependency is currently + `default-features = false` and does not enable gzip/brotli decoding. This migration + does **NOT** enable reqwest's `gzip`/`brotli` auto-decoding. Axum inserts + `accept-encoding: identity` only when the normalized request does not already contain + `accept-encoding`; a caller-supplied value is preserved and its response is processed by + the shared decoder. reqwest's built-in decoder matches + **exact lowercase** `content-encoding` values from a single map entry and would not + honour the portable contract (case-insensitive `GZIP`, `identity`, unknown/**stacked**/ + repeated → passthrough untouched). Instead Axum routes the raw response body through + the **same shared `content-encoding` inspection + decoder** the other adapters use + (§3.4.1 policy table), enforcing the independent decoded-output cap incrementally. This is the + only way all four adapters share one decompression contract. + +**Portable `content-encoding` policy for visible values.** The action table is identical on +all four adapters for the values and field structure visible to the guest. Exact treatment +of malformed raw bytes or field lines that workerd has already joined is guaranteed only by +`outbound-header-fidelity`; Cloudflare applies the table to its post-workerd representation. +Every adapter calls the public core `classify_content_encoding(&HeaderMap)` helper and uses +its four-state `ContentEncoding::{Brotli, Gzip, Identity, Passthrough}` result. `Identity` +is distinct from `Passthrough` internally even though neither decodes: only `Identity` lets +`enforce_payload_content_length` compare the visible length with the decoded cap before the +first body poll. Unknown/stacked passthrough bytes may still be encoded, so their encoded +cap and, in Buffered mode, raw final-buffer cap are sound; the decoded cap is not. + +| `content-encoding` value | Action | +| --- | --- | +| absent, or `identity` | **Identity delivery** — no codec runs and bytes are delivered as-is, but the independent decoded-output counter applies because these are already representation bytes. `identity` is treated exactly as absent. | +| a single `gzip` | **Decode** one gzip layer; strip `content-encoding` + `content-length`. | +| a single `br` | **Decode** one brotli layer; strip `content-encoding` + `content-length`. | +| anything else — an **unknown** token (`zstd`, `deflate`, `compress`, …) **or a stacked list** (`gzip, br`, `br, gzip`, …) | **Passthrough, untouched** — do **not** attempt to decode; deliver the raw bytes **and leave `content-encoding` / `content-length` intact** so the app can decode itself. Never a hard failure. | + +- **Matching is case-insensitive** on the token (`GZIP` == `gzip`) and tolerant of + optional ASCII space/tab surrounding whitespace. The classifier examines every value + returned by `HeaderMap::get_all`; exactly one field is required for `identity`, `gzip`, + or `br`. Two or more fields are `Passthrough`, even if each field contains the same token. + A comma in the sole field is a stacked list and therefore `Passthrough`; an empty, + non-UTF-8, or otherwise malformed value is also `Passthrough`, never a decode attempt. + **`Content-Encoding` is a bare content-coding token + (RFC 9110 §8.4.1) and carries NO `q=` weight** — quality values belong to + `Accept-Encoding`, not `Content-Encoding`. A value bearing any parameter (`gzip;q=0.5`, + `gzip;x=1`) is therefore **not** the bare `gzip`/`br` form: it falls through to + **passthrough**, exactly like an unknown token — never decoded. Only the two known + bare single-layer forms decode; everything else passes through. +- **A repeated `content-encoding` field** (two header lines) is treated as the stacked + case → passthrough untouched. +- Passthrough here means `max_encoded_response_bytes` still counts the raw transport body + and Buffered `max_response_bytes` caps the raw final collection. The independent + `max_decoded_response_bytes` counter does not run because EdgeZero produced no decoded + representation. +- Rationale: decoding stacked/unknown encodings is unbounded surface for little value on + edge fan-out; failing them hard (`502`) would break apps that can decode a `zstd` body + themselves. Passthrough is deterministic and never worse than "the app got the bytes." + +Whenever an adapter **does** decompress (the two known single-layer cases above), the +`OutboundResponse.headers` it returns MUST have +both `content-encoding` and `content-length` removed — the original values describe +compressed wire bytes and no longer match the app-visible body. This applies in both +`Buffered` and `Streamed` modes: callers must never see decoded bytes alongside stale +compressed metadata. Existing Cloudflare and Fastly proxy code already does this and +the contract codifies it. + +**Streaming-decompressor design (Streamed mode).** Lazy +`lazy-streamed-response-passthrough` on **Cloudflare** (the only `Native` adapter) +coexists with the cap obligation because the adapter wraps the raw compressed byte +stream with a **streaming decoder** that emits decompressed chunks as they arrive, +never buffering the full body. (**Axum, Fastly, and Spin are all `BestEffort`** for +lazy passthrough, for three different reasons — non-Send `LocalBoxStream` +[footnote 3], `stream_to_client()` vs `#[fastly::main]` [footnote 6], and Spin's +buffered `FullBody` public response surface [footnote 7]. On all three the +streaming-decompressor wrapper still runs, but the response converter buffers +downstream of it within its adapter-level constant — +`AXUM_RESPONSE_STREAM_BUFFER_BYTES` / `FASTLY_RESPONSE_STREAM_BUFFER_BYTES` / +`SPIN_RESPONSE_STREAM_BUFFER_BYTES`, all 16 MiB.) The decoder's *only* +responsibilities are decoding bytes, stripping the two compressed-only headers, and +surfacing decoder errors. The independent `max_decoded_response_bytes` counter wraps +effective identity and known decoded output in both response modes; it is not part of the +codec implementation and never wraps passthrough. `ResponseMode::Streamed` carries no final +collection cap. The encoded-byte counter and Brotli prefix/window/decoder-state charge are +separate upstream layers (§3.4.5): + +1. Pull a raw compressed chunk from the platform stream. +2. Feed it into the decoder; emit whatever decompressed output is currently available + (zero, one, or many output chunks per input chunk). +3. Yield each decompressed chunk through the decoded-output counter. The decoder itself does + no policy accounting. A later optional rechunker may split that item. +4. End successfully only after codec completion **and** verified native-body EOF; + a codec end marker alone is not EOF. Apply the completion protocol below. +5. `content-encoding` and `content-length` are stripped from + `OutboundResponse.headers` at construction time — the wrapper's output bytes are + the new ground truth. + +Cap ownership is then unambiguous: + +- **Buffered mode:** the adapter drains the decompressed stream inside the + buffered-drain helper with `max_response_bytes` (per-append-checked, §3.4.1). + Cap fires inside the adapter. +- **Streamed mode + `into_bytes_bounded(max)` / `into_bytes_bounded_until(max, + deadline)`:** the helper's own pre-append check enforces `max` against the + decompressed chunks it pulls from the wrapped stream. Cap fires in the helper. +- **Streamed mode + `into_response()` passthrough (proxy-forward):** uncapped for + **decoded output** on Cloudflare ONLY — the sole adapter that streams `Body::Stream` + lazily to the downstream wire. Configured encoded/header/Brotli limits still apply; no + implicit decoded cap truncates a valid transparent proxy stream. **Axum, + Fastly, and Spin do NOT stream lazily** — their response converters buffer `Body::Stream` + into `Bytes` within an adapter-level 16 MiB limit (`AXUM_/FASTLY_/SPIN_RESPONSE_STREAM_BUFFER_BYTES`), + so on those three a raw `into_response()` passthrough IS capped (over that limit → + `response_too_large`, §3.4.1) — matching the trait rustdoc and the capability matrix + (Cloudflare is the only `lazy-streamed-response-passthrough = Native`). Apps that want a + smaller cap on any adapter do `into_bytes_bounded` first, then re-emit. + +**Oversize is a DISTINCT outcome, not a transport error.** When a decoded/encoded/header +cap fires, a Brotli window is refused, or an adapter's response-converter fallback buffer +overflows, the result is `EdgeError::response_too_large_with_reason(.., reason)`, a distinct +variant/kind, **NOT** `bad_gateway`. The bare `response_too_large(..)` constructor uses +`ResponseLimitReason::Unspecified` for call sites with no narrower origin. It maps to HTTP +**502** on the wire, but its `kind_str()` is `response_too_large`, so a fan-out consumer can +classify policy/resource failure apart from a genuine transport `bad_gateway`; Rust callers +can additionally distinguish the exact resource without parsing the message. Byte-cap +enforcement stays **incremental**. For an identity response, a wire `Content-Length` above +the decoded cap rejects before buffering; a compressed wire length is not comparable to the +decoded cap, while the independent encoded cap may reject it (§3.4.5). Adding +`ResponseTooLarge` and `ResponseLimitReason` follows the same exhaustive-match discipline as +the Phase 1a variants (every `match` arm and test matrix updated; neither reason nor the +variant adds `Retry-After`/`field_path` to the wire). §5.4 pins resource-reason tests and +keeps transport failure distinguishable from every limit outcome. +- Request-body over-cap keeps its distinct existing outcome — `bad_request` (400), + a client-side misuse — unchanged. + +**Pipeline order — resource and decoder work stay inside the absolute-deadline output +wrapper.** After guest-visible header limits and bodyless disposition, payload layers compose +in exactly one order: +`platform raw/completion stream → encoded-byte counter → optional Brotli prefix gate → +EdgeError/io::Error carrier bridge → gzip/br decoder + native-EOF validation → exact carrier +restoration → decoded-byte cap → optional rechunker → absolute-deadline/cancellation wrapper → +consumer`. Unknown/stacked encodings bypass only the prefix/decoder/carrier stages and decoded +cap; they still pass through raw counting, rechunking, and the deadline. The outer deadline sees +every emitted item and terminal result, while adapter-specific ready-input quotas prevent an +inner decoder poll from monopolizing the runtime. Codec completion and native-EOF validation +remain inside this pipeline, never in an unbounded drain after the deadline wrapper ends. +- Each adapter keeps whatever raw-read timer its transport provides. On Axum, Cloudflare, + and Spin, the outer decoded-output wrapper races every `decoded.next().await` against the + absolute deadline and re-checks the deadline after readiness, including terminal EOF and + error. Fastly performs the same pre/post absolute-deadline checks around each blocking + decoded read but cannot preempt that read guest-side. This covers work performed by the + decoder after the final raw read; a converter-only check cannot cover lazy `Streamed` + consumption. Cloudflare additionally applies §4.2's host-event yield quota to raw + decoder input, decoded output, and terminal checks; clock reads alone cannot advance + its production clock during ready-only processing. +- Its timeout chunk is a typed + `EdgeError::gateway_timeout_caused(.., budget.cause)` (504). On timeout it drops the + decoder/raw stream and invokes the adapter's available transport cancellation. Fastly has + no guest timer, so it retains the host raw-read timeout plus cooperative pre/post checks at + this same outer boundary. +- **Precedence at the decoded-stream boundary:** at or after the deadline, timeout wins over + simultaneous success, EOF, raw-read failure, or decoder failure. A malformed-compression + 502 applies only when the decoder produces that error before the deadline. A caller-owned + final collection cap outside this wrapper may legitimately win after the wrapper yielded a + within-deadline chunk: the generic streamed helper does not retain the request deadline, so it + cannot reclassify a cap decision merely because the clock crossed after that yield. Buffered + adapter drains remain + inside the adapter's whole-exchange race, and `into_bytes_bounded_until` rechecks its own + caller-supplied deadline before returning over-cap; those two surfaces preserve + timeout-over-cap precedence for the deadline they actually own. +- §5.4 tests all four: a compressed **stall before first decoded byte**, **mid-stream stall**, + **stall at EOF** → each `gateway_timeout` (504); **malformed compression with no stall** → 502; + and **malformed-compression-vs-timeout precedence** (deadline fires first → 504, not 502). + Separate cap-precedence tests assert the narrower ownership rule above rather than claiming + a universal race after a within-deadline chunk has already been yielded. + +**Implementation hooks (extend the existing shared helpers).** The async stream +decoders for gzip and brotli **already live in `edgezero-core` at +`compression.rs:15` and `compression.rs:41`** — they are core helpers, not +adapter-local code. (Spin's `decompress.rs` is a separate **buffered slice** +decoder — not the async helper.) The existing helpers' chunk error type is +**`io::Error`**, and that is **not a free choice**: `TryStreamExt::into_async_read` +(which both helpers use to feed the decoder) is hard-bound to +`Self: TryStreamExt` (futures-util `try_stream/mod.rs`). A +decoder input stream therefore **cannot** simply be re-typed to `EdgeError` — that does +not compile — and naively mapping `EdgeError -> io::Error` on the way in would collapse a +`gateway_timeout` (504) into the decoder's generic 502 outcome, the exact bug §3.4.1 +forbids. Their existing stop-at-first-decoder-EOF loops also need the following +completion correction; reusing those loops unchanged is not conformant. + +**Codec completion is not native-body completion.** A gzip representation can contain +concatenated members within one content-coding layer +([RFC 1952 §2.2](https://www.rfc-editor.org/rfc/rfc1952.html#section-2.2)). The shared helpers +must preserve all decoded payload and observe late source errors before reporting success: + +- **Gzip:** enable `GzipDecoder::multiple_members(true)` in exact manifest-pinned + `async-compression` 0.4.43. Decode every member in order under the same absolute + deadline, including empty members. Any configured decoded-byte cap counts cumulatively + across members in the separate output wrapper; no cap enters the decoder. + Member boundaries reset neither budget. A truncated/corrupt later member or trailing bytes + that are not a valid member are `bad_gateway` reason `Decode(Gzip)`; stacked content-coding + passthrough is unchanged. +- **Brotli:** EdgeZero accepts one complete Brotli stream for a single `br` coding. + After decoder completion, recover its buffered reader and inspect both unread buffered + bytes and subsequent source input. Any trailing non-empty bytes, including a second + Brotli stream, are `bad_gateway` reason `Decode(Brotli)`; never silently discard read-ahead bytes + when recovering the reader. Empty source items are not EOF. Continue completion checks until native EOF + or failure, inside the same deadline wrapper and Cloudflare yield discipline. +- **Both:** a late source `EdgeError` still passes through the exact carrier restoration + below. A source that stalls after the codec end marker remains pending until the + adapter's deadline enforcement yields attributed 504, with Fastly's documented host + gaps unchanged. The outer wrapper checks terminal readiness before exposing EOF/error; + timeout precedence and the existing cap-ownership rule remain unchanged. Do not drain + the remaining input after a cap/error/timeout just to reach EOF; abort/drop it instead. +- Successful decoded-stream EOF requires native EOF and the adapter's required + trailer/completion outcomes. Keep the underlying reader and cancellation/completion + ownership until success or early termination; no terminal result may swallow unread + input or a pending native error. Cloudflare disarms its abort guard only after this + success, and Spin reports caller-result success only after the response protocol and + decoding have succeeded. Already-yielded Streamed bytes remain delivered, but a later + failure must still surface as an error chunk rather than false clean EOF. + +**The bridge (compile-verified).** Carry the typed error *through* the `io::Error` +boundary instead of converting it. On input, wrap each inner `EdgeError` in a private +carrier: `stream.map_err(|error| io::Error::other(Carried(error)))`. On output, first +capture the `io::Error` diagnostic, then inspect `into_inner()` and restore the original +`EdgeError` only when the boxed source downcasts exactly to `Carried`. A missing or failed +downcast is the decoder's own failure and maps to +`EdgeError::bad_gateway_with_reason(.., BadGatewayReason::Decode(coding))`, where the +helper supplies `Gzip` or `Brotli`, while preserving the captured diagnostic. The restored +stream is then wrapped by the +decoded-output deadline guard above, so a lazy stream checks every yield and terminal EOF. + +CF/Fastly/Spin response converters call +into these existing core helpers; **Axum calls into the same shared streaming +decoder — the wrapper runs incrementally there too (§3.4.1), never a non-streaming +whole-body decode.** Axum is `BestEffort` for lazy passthrough only because its +response converter re-collects the already-decompressed chunks into `Bytes` at the +`axum::body::Body::from_stream` (`Send + 'static`) boundary within +`AXUM_RESPONSE_STREAM_BUFFER_BYTES`; the decoder itself never buffers the whole body, +and `Streamed` mode never collects except at that final conversion step (§4.1). + +In `Streamed` mode a configured `max_decoded_response_bytes` is enforced incrementally on +effective identity and EdgeZero-decoded gzip/Brotli output before each item reaches the app. +If that optional policy is unset, there is no default decoded-output cap. A caller may still +apply a distinct final collection cap later with +`OutboundResponse::into_bytes_bounded(max)`. The independent encoded, header, and +Brotli-window/decoder-state limits in §3.4.5 also apply before or during streamed delivery. +The bounded collection method does **not** delegate to +`Body::into_bytes_bounded` directly — `Body::into_bytes_bounded` maps over-limit to +`bad_request` (400), correct for the inbound body case but wrong for an over-large +upstream response. `OutboundResponse::into_bytes_bounded` performs its own bounded +drain and maps over-cap to **`response_too_large`** (distinct kind, 502 — §3.4.1; NOT +`bad_gateway`, so a consumer classifies it apart from transport). On adapters that +decompress, the cap is enforced against decompressed output here too. + +#### 3.4.2 Inbound dependency boundary + +Inbound `RequestContext::into_request` semantics are owned by the dedicated +[inbound body design](2026-08-22-inbound-body-design.md). This outbound spec requires only +that `OutboundRequest::from_request` preserve the source method, normalized headers, and +whatever buffered or streamed `Body` the core request supplies; it does not deliver or test +the inbound body state machine, extractor limits, or adapter ingress buffering. +#### 3.4.3 New `EdgeError` variants & mapping + +`EdgeError` is `#[non_exhaustive]`, so this is additive. + +```rust +// crates/edgezero-core/src/error.rs +// Phase 1a lands the first TWO variants (needed for deadline/transport mapping). +// BadGateway carries a typed reason so consumers never classify retries or diagnostics by +// parsing a provider-specific message. `Unspecified` preserves the ergonomic bare +// constructor and is used when a platform exposes no narrower cause. +EdgeError::BadGateway { message: String, reason: BadGatewayReason } // -> 502 (Phase 1a) +// GatewayTimeout carries a TYPED `cause`, NOT just a message: consumers can observe +// which configured budget input selected the effective deadline without parsing strings +// (§3.3.2). This does not say which physical timer fired or prove that input elapsed. +// **Phase 1a MUST land this shape** (the `BudgetSource` enum + +// the field), even though its producer `dispatch_budget` is Phase 1b — freezing +// `GatewayTimeout { message }` now would bake in a variant the master contract can't use, +// forcing a breaking change later. `inner()` is still `None` (a `cause` is not a source error). +EdgeError::GatewayTimeout { message: String, cause: BudgetSource } // -> 504 (Phase 1a) +// The OUTBOUND response-handling phase adds a THIRD, so response-cap over-run is a +// distinct machine-classifiable outcome, NOT collapsed into `bad_gateway` (§3.4.1): +EdgeError::ResponseTooLarge { message: String, reason: ResponseLimitReason } +// -> 502, kind "response_too_large" + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { Brotli, Gzip, Json } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +// Defined ONCE in `error.rs` (this file's crate, Phase 1a Task 1); §3.3.2's `DispatchBudget` +// uses it from here. `dispatch_budget` sets `PerCallTimeout`/`BatchDeadline`/`Default`; +// `Unspecified` is what `gateway_timeout(msg)` carries outside a budget context or when an +// independently controlled provider timeout fired before the selected absolute budget +// expired. A phase timer explicitly configured from the selected budget retains its cause. +// Derives + ALPHABETICAL order are COMPILE-VERIFIED (§3.3.2): `Clone`/`Copy` support +// passing provenance by value; alphabetical order satisfies the denied +// `arbitrary_source_item_ordering`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BudgetSource { BatchDeadline, Default, PerCallTimeout, Unspecified } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseLimitReason { + BrotliWindow, + BufferedBody, + DecodedBody, + DecoderMemory, + EncodedBody, + HeaderBytes, + HeaderCount, + Unspecified, +} + +pub fn bad_gateway(message: impl Into) -> Self; +pub fn bad_gateway_with_reason( + message: impl Into, + reason: BadGatewayReason, +) -> Self; +pub fn gateway_timeout(message: impl Into) -> Self; // cause = Unspecified +pub fn gateway_timeout_caused(message: impl Into, cause: BudgetSource) -> Self; +pub fn response_too_large(message: impl Into) -> Self; // reason = Unspecified +pub fn response_too_large_with_reason( + message: impl Into, + reason: ResponseLimitReason, +) -> Self; +``` + +`Unreachable` is reserved for a failure before any upstream response head is available: +DNS resolution, connection establishment/refusal, or TLS establishment. `Transport` covers +an established exchange failing during request upload, response body/trailer reads, native +completion, or a later connection termination. If a provider does not expose enough phase +information, use `Unspecified`; never guess from message text. EdgeZero-owned decoders always +use `Decode(BadGatewayDecodeReason::{Brotli,Gzip,Json})` as applicable. Provider-native decoding +remains `BadGatewayReason::Unspecified` unless the adapter can establish the coding without +parsing a diagnostic string. + +`EdgeError::status()` gains `BadGateway => 502`, `GatewayTimeout => 504`, and (outbound +phase) `ResponseTooLarge => 502` with `kind_str() == "response_too_large"`. Like the other +two it carries no `Retry-After` and no `field_path`. `BadGatewayReason` and +`ResponseLimitReason` are Rust-side fields and are not serialized; the JSON envelope stays +`status`/`kind`/`message`. The message at that HTTP boundary is category-only: +`BadGateway` emits `"bad gateway"`, `GatewayTimeout` emits `"gateway timeout"`, +`ResponseTooLarge` emits `"upstream response exceeded configured limits"`, and `Internal` +emits `"internal server error"`. `EdgeError::message()` and `Display` remain internal +diagnostic surfaces, but adapters never interpolate a provider error, URL, query, token, or +response content into an outbound diagnostic. Tests use token-bearing fixtures to prove +`IntoResponse` cannot serialize those values. Each addition follows the same +exhaustive-match discipline (every `match` arm + test matrix updated, alphabetical +insertion); Phase 1a does the first two plus `BadGatewayReason`, and the outbound work does +the third plus `ResponseLimitReason` in the same mechanical style. + +| Condition | `EdgeError` | HTTP status | +| --- | --- | --- | +| Invalid outbound URI (relative / no authority / bad scheme) | `bad_request` | 400 | +| Upstream unreachable before a response head (DNS / connect / TLS establishment / connection refusal) | `bad_gateway`, reason `Unreachable` when the adapter can establish it, else `Unspecified` | 502 | +| Outbound transport failure after connection progress (request upload, response read, trailer/completion, connection termination) | `bad_gateway`, reason `Transport` when the adapter can establish it, else `Unspecified` | 502 | +| Outbound protocol/framing/completion failure | `bad_gateway`, reason `Protocol` | 502 | +| Outbound response over a body/header/window resource limit | `response_too_large`, typed `ResponseLimitReason` (§3.4.5) | 502 | +| Outbound response body not valid JSON, gzip, or Brotli / `json::` called on a streamed body | `bad_gateway`, reason `Decode(Json/Gzip/Brotli)` for malformed content and `Protocol` for invalid API state | 502 | +| Outbound per-request timeout or batch deadline exceeded | `gateway_timeout` (carries `budget.cause`: per-call vs batch — §3.3.2) | 504 | +| Outbound completed with a non-2xx status | **not an error** — `Ok(OutboundResponse)` | app decides | + +The non-2xx rule is load-bearing: a target returning 204/400/500 is a normal fan-out batch +outcome, not a transport error. + +#### 3.4.4 Batch memory model (explicit) + +`send_all` does not impose a global allocation ceiling. Its logical payload accounting has +two parts — +a **persistent collected buffer** term that holds the request payloads and the +buffered response payloads, plus a **transient in-flight chunk** term that +briefly co-exists with the collected buffer per actively-draining slot (per +§3.4.1's pre-append checked accounting, the in-flight chunk is held during the +overflow check before being appended or dropped): + +``` +persistent collected buffer = Σᵢ request_bodyᵢ.len() + + Σᵢ max_response_bytesᵢ (send_all is buffered-only) + +transient in-flight chunks = Σⱼ current_chunkⱼ.len() + // j ranges over slots + // currently inside a drain + // step; typically 8-64 KiB + // per active slot + +worst-case LOGICAL PAYLOAD bytes = persistent + transient +// This is NOT a complete core-allocation or process-RSS bound. It EXCLUDES: +// `Vec`/`BytesMut` spare capacity (amortised growth over-allocates), shared `Bytes` backing +// allocations not yet freed, gzip decoder state, Brotli state other than the separately +// bounded source-audited decoder-state charge, and allocator overhead/fragmentation. During active Brotli +// decodes, add up to each slot's `max_brotli_decoder_bytes` to this logical payload number; +// even that sum remains narrower than RSS. Actual RSS also includes adapter/host buffering +// and every opaque term listed below (§ send_all rustdoc). + +// Equivalently, when all slots share the same response cap, the persistent term is: +// Σᵢ request_bodyᵢ.len() + N × max_response_bytes +// — but the precise sum is over the per-slot caps, not a single N × max. +// Heterogeneous caps (mix of `.max_response_bytes(small)` and unset slots) bound +// the persistent term by Σᵢ instead of N × max(capᵢ). +``` + +`send_all` rejects streamed request bodies and streamed responses in preflight +(§3.1.1), so a Streamed-mode batch memory model does not exist. Single `send` +with `Streamed` is the path for lazy bodies, where memory is bounded by the +streaming chunk buffer plus whatever the consumer chooses to buffer via +`into_bytes_bounded`. + +This arithmetic is not an admission-safe process/isolate RSS bound. Informational response +blocks that a host consumes before exposing the final response, parser tables, provider-side +field sections, trailers not exposed to the guest, native receive-chunk allocation, shared +backing storage, `BytesMut` spare capacity, allocator metadata/fragmentation, task stacks, +and host copies can all exist outside it. When an adapter exposes informational fields or +trailers to guest code, it applies the configured header count/byte totals cumulatively +across every exposed field section and final completion; it never claims that this prevents +an earlier host allocation. When those fields are not exposed, their allocation is opaque. +The `outbound-complete-resource-accounting` capability is therefore `Unsupported` for every +current adapter. This is a deliberate fail-closed statement, not a reason to weaken the +narrower limits below. + +EdgeZero's contract — **persistent** (post-append, retained) vs **transient** +(in-flight, dropped after the cap check): + +- **Per-response (Buffered).** *Persistent* memory — the collected buffer — is bounded + by `max_response_bytes`. *Transient* worst-case core-owned payload during a drain is + `max_response_bytes + current_chunk.len()`, where `current_chunk.len()` is + source-controlled (§3.4.1). The post-check buffer never exceeds `max_response_bytes`. +- **Batch (N)** memory is the app's responsibility: the app must bound the number of + requests passed to `send_all`. Both terms add up — *persistent* is + `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ` (`request_bodyᵢ` and + `max_response_bytesᵢ` denote slot `i`'s buffered request body length and its + per-request response cap respectively); *transient* adds + `Σⱼ current_chunkⱼ.len()` over actively-draining slots, source-controlled, plus up to + `Σₖ max_brotli_decoder_bytesₖ` over actively decoding Brotli slots. This remains a set of + enforceable guest terms rather than a complete memory bound. + For typical fan-out workloads this is intrinsic — `N` is the fixed, configured target count and + target responses are small JSON. The spec deliberately does **not** add a + `max_concurrency` knob: on Fastly all requests must be in-flight at once for + fan-out to work, so throttling concurrency would defeat the feature. This + requirement is documented in the `send_all` rustdoc and in `docs/`. The optional + `max_chunk_bytes` wrapper shapes app-visible items but does not remove the source-chunk + term from this model (§3.4.5). + +#### 3.4.5 Response resource limits + +`ResponseMode::Buffered { max_bytes }` (set by `max_response_bytes`) is the final collection +cap for every Buffered response, including raw passthrough. It is not the decoded-output +policy. Eight independent controls cover transport bytes, identity/decoded output, decoder +heap requests, field metadata, and Streamed item shape. This separation permits, for +example, a 4 MiB decoded limit with a 16 MiB final buffer for an intentionally retained raw +passthrough representation: + +| Builder | Default | Measures | Typed overflow/rejection reason | +| --- | --- | --- | --- | +| `max_brotli_decoder_bytes(u64)` | `DEFAULT_MAX_BROTLI_DECODER_BYTES = 32 MiB` | conservative decoder-state charge `BROTLI_DECODER_FIXED_CHARGE_BYTES + 2^WBITS`, checked before decoder construction | `ResponseLimitReason::DecoderMemory` | +| `max_encoded_response_bytes(u64)` | unset | cumulative guest-visible body bytes before content decoding | `ResponseLimitReason::EncodedBody` | +| `max_decoded_response_bytes(u64)` | unset | cumulative identity or EdgeZero-decoded gzip/Brotli output; never raw passthrough | `ResponseLimitReason::DecodedBody` | +| `max_response_header_bytes(u64)` | unset | sum of each guest-visible field name length plus value length | `ResponseLimitReason::HeaderBytes` | +| `max_response_header_count(u64)` | unset | guest-visible name/value entries, including repeated values | `ResponseLimitReason::HeaderCount` | +| `max_brotli_window_bits(u8)` | `DEFAULT_MAX_BROTLI_WINDOW_BITS = 24` | advertised Brotli window bits, valid configured range `10..=30` | `ResponseLimitReason::BrotliWindow` | +| `max_chunk_bytes(NonZeroU64)` | unset | each app-visible `Body::Stream` item after decode or passthrough | no overflow; items are split lazily | +| `max_response_bytes(u64)` | `DEFAULT_MAX_RESPONSE_BYTES = 1 MiB` | final bytes collected into `Body::Once`, regardless of coding disposition | `ResponseLimitReason::BufferedBody` | + +All byte/count fields and running totals use checked `u64` arithmetic. A Brotli limit outside +`10..=30` is caller configuration and fails dispatch as `bad_request`; zero chunk size is +unrepresentable through `NonZeroU64`. Unset optional controls preserve existing behavior. +These are per-response controls, not aggregate batch limits. + +**Enforcement order and precedence:** + +1. At the earliest adapter boundary where a response field section is guest-visible, count + entries and `name.as_bytes().len() + value.as_bytes().len()` for every visible pair + before normalization, duplicate coalescing by EdgeZero, or additional collected header + state. Maintain one cumulative count/byte total across every guest-visible informational + block, the final response headers, and guest-visible trailers; a later field section can + therefore fail the returned body/completion stream with the same typed reason. + Increment and check the entry count before adding/checking bytes for that entry, so if + the same field crosses both configured limits, `HeaderCount` wins. Reject on the first + exceeded configured limit; never truncate. Cloudflare can measure + only workerd's already-materialized strings and merged non-`set-cookie` entries. No + adapter claims these limits prevent provider/SDK allocation, parsing of an informational + block or trailer the SDK does not expose, or a host-side rejection that occurs before + metadata reaches the guest. +2. Apply bodyless/205 disposition. Framing-bodyless responses do not consume a body or + invoke body/window limits. A visible normalized `Content-Length` greater than a configured + encoded cap rejects a payload-bearing response before reading, including compressed + responses. Identity responses independently reject against the decoded cap and, in + Buffered mode, the final-buffer cap. Passthrough responses compare their raw length to + the encoded cap and, in Buffered mode, the final-buffer cap, but never to the decoded cap. + A compressed response that EdgeZero will decode cannot compare wire `Content-Length` to + either output cap. For a + payload-bearing response or 205, malformed, comma-list, or conflicting repeated visible + `Content-Length` is `bad_gateway` reason `Protocol` before body reads. HEAD/304 preserve + representation metadata without interpreting it as a body promise. +3. Count every guest-visible raw body item before handing it to the content decoder or + passthrough stream. The encoded total is cumulative across gzip members and native + completion reads. Check before forwarding/appending. On raw overflow, abort/drop the + owned native response and return/yield `response_too_large_with_reason(.., + ResponseLimitReason::EncodedBody)`. +4. For a single `br` coding, parse only the fixed-size stream prefix needed to determine + WBITS **before constructing the decoder or allocating its state**, then replay those + bytes unchanged. The parser handles both the standard one-byte encoding and the pinned + decoder's two-byte large-window extension (10 through 30 bits); the extension cannot + bypass the configured cap. A valid over-limit value is `BrotliWindow`; malformed or + truncated prefix syntax is `bad_gateway` with reason `Decode(Brotli)`. +5. Before decoder construction, compute + `brotli_decoder_memory_charge(WBITS) = BROTLI_DECODER_FIXED_CHARGE_BYTES + 2^WBITS` + with checked `u64` arithmetic. `BROTLI_DECODER_FIXED_CHARGE_BYTES` is 16 MiB and covers + the pinned Rust decoder's non-ring state; `2^WBITS` charges its maximum ring window. The + implementation plan pins and source-audits the decoder graph, records every allocation + family included in the 16 MiB constant, and runs an allocation-tracking adversarial + corpus for every supported WBITS. A dependency upgrade must repeat that audit and may + raise the constant; it may not retain the old charge on test evidence alone. Reject with + `DecoderMemory` before constructing the decoder when the charge exceeds + `max_brotli_decoder_bytes`. The default 32 MiB cap therefore admits the default WBITS 24 + and rejects a raised window unless the caller also raises the memory policy. This is a + conservative reservation charge, not a claim that the process reserves or uses exactly + that many bytes. It excludes allocator metadata, fragmentation, output/body buffers, + task stacks, adapter/SDK buffers, and host RSS; `outbound-complete-resource-accounting` + remains Unsupported because those terms are not portable (§3.5.2). +6. Apply `max_decoded_response_bytes` only to effective identity and EdgeZero-decoded + gzip/Brotli output. Unknown, parameterized, stacked, or otherwise passthrough encodings + bypass this decoded counter. The encoded counter still applies to every path. +7. For Streamed output apply the optional rechunker, then wrap the stream in the adapter's + absolute deadline/cancellation owner. For Buffered output, collect under + `ResponseMode::Buffered { max_bytes }`; that final cap applies to identity, decoded, and + passthrough bytes and reports `BufferedBody`. + The rechunker preserves byte order, error order, cancellation ownership, and absolute + deadline/fairness checks. It pulls lazily and emits items no larger than the configured + value. It does not alter a final Buffered body. Splitting a `Bytes` value may retain its + original backing allocation; copying can temporarily coexist with it. Therefore + `max_chunk_bytes` is an item-shape guarantee, not a source-allocation or RSS guarantee. + +For adapter-owned decisions inside the exchange wrapper, if the request deadline is already +expired when a resource decision becomes ready, the attributed `gateway_timeout` wins. +`into_bytes_bounded_until` applies the same rule to its caller-supplied deadline. The generic +`into_bytes_bounded` helper intentionally retains no deadline; after it receives a chunk that +the stream yielded within budget, its decoded cap can therefore win even if wall clock crosses +the original request deadline before the helper finishes accounting (§3.4.1). Otherwise +pipeline order breaks simultaneous resource ties: header limits precede body limits; encoded +bytes precede decoder/window/decoded bytes. All early termination follows each adapter's +existing native cleanup and completion protocol. + +### 3.5 Capability declaration + +#### 3.5.1 Manifest section + +```toml +# edgezero.toml +[capabilities] +# This strict declaration intentionally rejects Fastly: both of these cells are +# BestEffort there. A portable four-adapter app declares `outbound-http` optional and +# accepts Fastly's documented dynamic-backend service prerequisite. +required = ["outbound-http", "outbound-deadlines"] + +[capabilities.outbound] +# Optional plumbing. OMITTING this field is NOT the same as `["*"]`: +# - field absent → https-only default `["https://*:*"]` (no cleartext) +# - hosts = ["*"] → explicit opt-in to BOTH http and https +# So an existing manifest that never declared hosts keeps its https-only posture. +hosts = ["*"] +``` + +```rust +// crates/edgezero-core/src/manifest.rs — defined INLINE here, NOT in a separate +// capability.rs. `manifest.rs` is textually `include!`d by edgezero-macros +// (manifest_definitions.rs), and edgezero-core depends on edgezero-macros, so the +// macro crate can neither see `edgezero_core::` paths nor add core as a dep (cycle). +// A separate `capability.rs` that `manifest.rs` imports would fail to compile in the +// macro crate. Core re-exports these: `pub use manifest::{Capability, CapabilitySupport};`. +// +// MUST derive Serialize as well as Deserialize: `Manifest` derives `Serialize` and +// `app!` calls `serde_json::to_string(&manifest)` — a Deserialize-only capability type +// breaks the `Manifest` derive. (Both facts verified against the current tree.) + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] // a future capability must not break out-of-tree adapters +// Outbound-only excerpt. The complete shared enum also contains config, ingress, and +// response-egress cells specified by their owning designs. Declaration order follows the +// repository's alphabetical-item lint. +pub enum Capability { + LazyStreamedResponsePassthrough, // downstream response chunks flow without + // collecting the whole body. Cloudflare is + // Native; Axum/Fastly/Spin are BestEffort. + OutboundCompleteResourceAccounting, // every response allocation from host parsing + // through guest delivery has a portable bound; + // Unsupported on all current targets. + OutboundDeadlines, // one exchange budget: connect, headers, + // buffered body, and streamed body yields. + // Cross-slot harvest delay is owned by + // SendAllSlotIsolation. + OutboundFlexiblePhaseBudget, // the total budget is one elastic pool rather + // than a rigid provider-specific phase split. + OutboundHeaderFidelity, // request field lines survive transport conversion; + // raw response-header octets and original field-line + // boundaries reach security-sensitive normalization. + OutboundHttp, // adapter supplies an outbound HTTP dispatch path; + // support level and documented prerequisites determine + // whether a selected deployment may rely on it + SendAllSlotIsolation, // sibling timing cannot change the result a slot + // would have produced in isolation. + StreamedUploadDeadlines, // can preempt a stalled request-body source/write; + // Fastly and Spin are BestEffort. +} + +impl Capability { + pub fn as_str(&self) -> &'static str; // kebab-case, for messages +} + +// Also inline in manifest.rs (see Capability note above). `Serialize` is needed only if +// it ever appears in a serialized manifest field; it does not today (support is computed +// per-adapter via `Adapter::capability`, not stored), so `Deserialize`/`Serialize` are +// omitted here. If a future manifest field carries a `CapabilitySupport`, add both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilitySupport { + /// Implemented, but with a documented limitation or deployment prerequisite that the + /// matrix footnotes describe. A prerequisite that static adapter metadata cannot prove + /// may make the path unavailable in a particular deployment; applications declaring the + /// capability optional explicitly accept that runtime failure mode. The limitation can + /// otherwise be timing-related (unbounded cooperative + /// enforcement, e.g. Fastly source-stream-stall in + /// `streamed-upload-deadlines`) **or functional** (deterministic behaviour + /// differs from `Native`, e.g. Axum `lazy-streamed-response-passthrough` + /// buffers rather than streaming). `BestEffort` therefore means + /// "implemented, with a real-world deviation or prerequisite you need to read the + /// footnote to understand" — not specifically "unbounded cooperative timing." + BestEffort, + /// Real enforcement with a precisely documented, deterministic bound on any + /// deviation. No current outbound matrix cell uses this level; it remains in + /// the support ladder for future capabilities with a true end-to-end bound. + BoundedCooperative, + /// Fully supported with no documented caveats. + Native, + /// Not available. + Unsupported, +} +``` + +The capability is named **`outbound-deadlines`**, not `timers`, and describes the +wall-clock budget contract for one outbound HTTP exchange. The matrix support level says +whether that contract is Native or has a documented BestEffort gap. It makes no claim +about timing arbitrary guest computation (which EdgeZero does not offer — §3.3.5). + +```rust +// crates/edgezero-core/src/manifest.rs — new field on Manifest. +// +// MUST derive Serialize as well as Deserialize: `Manifest` derives `Serialize` +// (manifest.rs) and `app!` calls `serde_json::to_string(&manifest)`. A +// Deserialize-only member breaks `Manifest`'s derive and therefore edgezero-core +// AND the macro crate (which textually `include!`s this file). Same for every +// nested type and for `Capability` itself. +// `deny_unknown_fields` is REQUIRED — without it a typo like `require = [..]` or +// `host = [..]` is silently ignored, disabling enforcement or invoking the broad default +// (fail-open). The `#[validate(custom = ..)]` attaches the disjoint/duplicate check. +// `#[non_exhaustive]` matches the existing manifest-struct precedent and keeps future field additions +// non-breaking for out-of-tree code and composes fine with `Default` + `Deserialize` +// (these are built by deserialization / `..Default::default()`, never external literals). +#[derive(Debug, Default, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +#[validate(schema(function = "validate_capabilities_disjoint"))] +pub struct ManifestCapabilities { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + #[validate(nested)] + pub outbound: ManifestOutboundCapability, +} +// `validate_capabilities_disjoint(&ManifestCapabilities) -> Result<(), ValidationError>` +// rejects a capability DUPLICATED within `required` or `optional`, or listed in BOTH +// (required ∩ optional must be empty — asking for a capability as both is a +// contradiction). Every manifest parse path rejects these failures; baked input becomes +// `Malformed`. §5.4 tests: an unknown field (typo `require`), a duplicate, and a +// required∩optional overlap each fail. + +#[derive(Debug, Default, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct ManifestOutboundCapability { + /// Outbound host plumbing. + /// + /// **`Option>`, NOT `Vec` — this is security-relevant.** + /// The renderer defines an explicit `"*"` as **http + https**, while an + /// **absent** field must preserve today's **https-only** default + /// (`["https://*:*"]`). A bare `Vec` with `#[serde(default)] = ["*"]` collapses + /// those two cases, so **every existing manifest that never declared hosts would + /// silently gain cleartext (http) outbound permission** on its next build. The + /// `Option` keeps them distinct: + /// - field absent → `None` → render `["https://*:*"]` (unchanged) + /// - explicit `hosts = ["*"]`→ `Some(["*"])` → render http + https (opt-in) + /// - explicit `hosts = []` → `Some([])` → rejected by `length(min = 1)` + /// + /// Validation applies only when present: `length(min = 1)` enforces at least one + /// entry, and `validate_outbound_hosts` (below) checks each entry against + /// the accepted forms (wildcard, scheme-prefixed, host:port, bare host, + /// wildcard subdomain). `#[validate(nested)]`-style option handling: the custom + /// validator is a no-op for `None`. + #[serde(default)] + #[validate(length(min = 1), custom(function = "validate_outbound_hosts"))] + pub hosts: Option>, +} + +/// Per-entry validation for `[capabilities.outbound].hosts`. This is +/// **host-authority-only plumbing**, not a URI field — the same rationale as +/// `OutboundRequest`'s userinfo rejection: credentials must not leak +/// through the manifest into `allowed_outbound_hosts`. +/// +/// Each entry MUST be one of: +/// - `"*"` (the wildcard). +/// - `scheme://host[:port]` where: +/// - `scheme ∈ {http, https}`, case-**insensitive** at the validator +/// (RFC 3986) — `HTTPS`, `https`, `Https` all accepted. The +/// Spin renderer then canonicalizes to lowercase before emitting +/// `spin.toml`, so the rendered manifest carries one canonical +/// spelling. Other schemes → rejected at the validator. +/// - `host` is a DNS label, IPv4 literal, IPv6 literal in brackets, or +/// `*` / `*.domain.tld` wildcard form. +/// - `port`, if present, is a decimal integer in `1..=65535` **or the literal +/// `*` (wildcard port = any port)**. The wildcard port is what lets a manifest +/// express "**HTTPS to any host on any port**" as `https://*:*` while still +/// scoping HTTP narrowly (`http://api.internal:8080`) — a distinction the bare +/// `"*"` (which grants http+https on any host/port) cannot make. `:*` is +/// therefore VALID; only a bare numeric out of `1..=65535` rejects. +/// - **NO userinfo, NO path, NO query, NO fragment.** `https://user:pass@x`, +/// `https://x/p`, `https://x?q`, `https://x#f` all reject. +/// - `host[:port]` (no scheme) — same host/port rules as above. +/// +/// Empty entries, schemes other than `http`/`https`, ports outside +/// `1..=65535` or non-numeric, any userinfo / path / query / fragment, and +/// malformed authorities the hand-rolled splitter rejects (NOT `http::Uri` — +/// the macro-crate constraint below forbids that dep) all yield a `ValidationError`. `"*"` +/// mixed with specific hosts is allowed; the wildcard renders both schemes +/// and specific hosts render alongside. +/// +/// **Grammar (this is a security-relevant splitter — spell it out, don't hand-wave):** +/// `entry := "*" | [scheme "://"] authority`; `scheme := "http" | "https"` (ASCII, +/// case-insensitive); `authority := hostpat [":" port]`; `hostpat := "*" | +/// "*." label ("." label)* | label ("." label)* | "[" ipv6 "]"`. A **`label`** is a +/// non-empty LDH DNS label: 1..=63 chars of ASCII letter/digit/hyphen, **no leading or +/// trailing hyphen, no underscore, no empty label** (so `-x.com`, `x-.com`, `x..com` +/// (empty middle label), `x_y.com` all reject; full name ≤ 253 chars). **`ipv6`** is a +/// standard RFC 4291 address parsed by `std::net::Ipv6Addr::from_str` on the +/// bracket-stripped inner text — anything it rejects (`[::g]`, `[:::1]`, `[12345::]`, +/// `[1:2:3:4:5:6:7:8:9]`) rejects here. `port := "*" | 1..=65535`. **ASCII/IDNA policy:** hostnames are **ASCII-only** — a non-ASCII byte +/// or a raw Unicode label is REJECTED (callers must pre-encode to punycode `xn--`); +/// this splitter does not perform IDNA itself. +/// +/// has a Tier 1 test row exercising every accept AND reject case. Rejects: +/// empty string, bad scheme (`ftp://x`), missing authority (`https://`), +/// userinfo (`https://u:p@x`), path (`https://x/p`), query (`https://x?q`), +/// fragment (`https://x#f`), out-of-range port (`https://x:0`, `https://x:70000`), +/// non-numeric port (`https://x:abc`), **empty port (`https://x:`)**, +/// **malformed brackets (`https://[::1` unclosed, `https://::1]` no open bracket)**, +/// **unbracketed IPv6 (`https://::1` — colons ambiguous with the port sep)**, +/// **invalid wildcard placement (`ex*ample.com`, `*.*.com`, `a.*.com`, `**.com`)**, +/// **internal/leading/trailing whitespace (`https:// x`, `x .com`, ` x.com`, `x.com `)**, +/// **trailing dot (`x.com.`)**, **non-ASCII / raw-Unicode host (`ex€ample.com`, `café.com`)**. +/// Accepts: wildcard (`*`), wildcard subdomain (`*.example.com`), bare host with port +/// (`x:8443`), bracketed IPv6 (`https://[::1]`), IPv4 (`https://127.0.0.1`), +/// punycode (`xn--caf-dma.com`), and mixed `"*"` + host. +// Takes the INNER Vec — `validator` applies a custom function on `Option` to the +// contained value only, so `None` (field absent → https-only default) is a +// no-op and never fails validation. Signature matches the `Option>` field. +fn validate_outbound_hosts(hosts: &[String]) -> Result<(), ValidationError>; + +// SHARED CANONICALIZER — one parser, three consumers, ATOMIC entries. `validate_outbound_hosts` +// returns `()` (validator contract), but platform-manifest rendering and the +// build/serve/deploy DRIFT check both need the *canonical* form to compare — and they +// MUST NOT re-implement parsing (divergence = a manifest that validates but drifts, or +// drifts spuriously). So both delegate to one function that expands each manifest entry +// into a SET of ATOMIC `(scheme, host, port)` triples — because ONE manifest entry can +// render as MULTIPLE `spin.toml` entries (`"*"` = http AND https → two lines), a +// canonicalizer that returned a single multi-scheme value could never set-equal the two +// rendered lines. Atomic-and-flatten fixes that: +// PUBLIC + cross-crate: the consumers (platform-manifest generation and Spin drift validation +// in the adapter crate) are DIFFERENT crates, so the fn and ALL its types MUST be `pub` and +// exported from `edgezero-core` (a private `fn`/type would not compile at those call sites). +// Concretely: +// - The error is a DEDICATED `pub enum HostParseError` — NOT the validator's +// `ValidationError` (that would leak validator internals into the adapter crate, which +// doesn't depend on `validator`). The MANIFEST validator is a thin wrapper that calls +// `canonicalize_outbound_host` and maps `HostParseError -> ValidationError` at the +// validator boundary only; rendering/drift get the `HostParseError` directly. +// - `AtomicHost` and ALL its component types are public: `pub struct AtomicHost` with +// `pub scheme: Scheme`, `pub host: HostPat`, `pub port: Port`, and `pub enum Scheme`, +// `pub enum HostPat` (`Any` | `Exact(String)` | `WildcardSubdomain(String)`), +// `pub enum Port` (`Any` | `Exact(u16)`). `HostPat` is part of the surface (an earlier +// draft omitted it). Derive `Hash, Eq, PartialEq` for the drift `HashSet`. +// - The parser error surface is exact and safe to expose across crates: +// +// #[non_exhaustive] +// #[derive(Clone, Debug, Eq, PartialEq)] +// pub enum HostParseError { +// Empty, +// FragmentNotAllowed, +// InvalidHost, +// InvalidPort, +// InvalidScheme, +// InvalidWildcard, +// MissingAuthority, +// NonAsciiHost, +// PathNotAllowed, +// QueryNotAllowed, +// UserinfoNotAllowed, +// Whitespace, +// } +// +// It implements `std::error::Error`. Its `Display` messages are respectively +// `host entry is empty`, `fragment is not allowed`, `host is invalid`, +// `port must be * or 1..=65535`, `scheme must be http or https`, +// `wildcard is invalid`, `authority is required`, `host must be ASCII`, +// `path is not allowed`, `query is not allowed`, `userinfo is not allowed`, and +// `whitespace is not allowed`. +// Messages never echo the caller input. Tier 1 maps every reject-table row to one +// variant and asserts nonempty stable Display output; downstream callers include a +// wildcard arm because the enum is non-exhaustive. +// For inputs violating multiple rules, classification precedence is fixed as follows; +// host syntax (including malformed brackets or unbracketed IPv6) is resolved before a +// trailing port is parsed: Empty → Whitespace → InvalidScheme → +// MissingAuthority → UserinfoNotAllowed → PathNotAllowed → QueryNotAllowed → +// FragmentNotAllowed → NonAsciiHost → InvalidWildcard → InvalidHost → InvalidPort. +// - Platform-manifest generation must not hand-inspect internals to build `spin.toml`, so `AtomicHost` +// exposes a canonical rendering method: `pub fn render_spin_host(&self) -> String`. +// **It OMITS a scheme-default exact port** (443 for `Https`, 80 for `Http`) so the +// output is deterministic: `https://x` and `https://x:443` both canonicalize to +// `{Https, Exact("x"), Exact(443)}` and BOTH render as **`"https://x"`** (NOT +// `"https://x:443"` — explicitness is already lost at canonicalization, so the renderer +// must not re-introduce a port that would then mismatch the manifest's `https://x` +// form). A non-default port renders explicitly: `{Https, Exact("x"), Exact(8443)}` -> +// `"https://x:8443"`. `Port::Any` renders `":*"`; `HostPat::Any` renders `"*"` as +// the HOST COMPONENT while retaining the atomic's concrete scheme and port. Therefore +// `{Https, Any, Any}` renders exactly `"https://*:*"`, NEVER bare `"*"` (which Spin +// interprets as both http and https and would widen the https-only default). +// `WildcardSubdomain("example.com")` renders the host component `"*.example.com"`. +// Drift uses `Eq`/`Hash` on the atomics; rendering uses `render_spin_host`. Neither +// re-parses. The input shorthand `"*"` expands to two atomics and consequently renders +// as two explicit lines: `"http://*:*"` and `"https://*:*"`. +// Keep it dependency-free (no `http::Uri`, per the macro-crate constraint below). +// pub fn canonicalize_outbound_host(entry: &str) -> Result, HostParseError>; +// pub struct AtomicHost { pub scheme: Scheme /* Http | Https, CONCRETE — never "both" */, +// pub host: HostPat, pub port: Port /* Any | Exact(u16) */ } +// `"*"` -> [ {Http, *, *}, {Https, *, *} ] (two); `https://x` -> [ {Https, x, Exact(443)} ] +// (one); `https://x:*` -> [ {Https, x, Port::Any} ]. Both the manifest hosts AND the parsed +// `spin.toml` `allowed_outbound_hosts` are flattened through this into a `HashSet`, +// and DRIFT compares the two SETS. So `https://x:443` == `https://x`, `HTTPS`==`https`, +// `:*` == any-port, list order is irrelevant, AND `"*"`/`:*` round-trip +// (manifest one-entry -> two atomics == spin.toml two-lines -> two atomics). §5.4 pins a +// render-then-validate round-trip for BOTH `"*"` and `:*` (the generated values must +// report NO drift). +// +// DEPENDENCY CONSTRAINT: `canonicalize_outbound_host` lives in `manifest.rs`, which is +// **textually `include!`d into `edgezero-macros`** — a crate with **no `http` dependency**. +// So the canonicalizer/`validate_outbound_hosts` MUST be **dependency-free**: parse the +// authority with a small hand-rolled splitter (scheme `://` split, rsplit host:port, +// bracket-strip IPv6), **NOT** `http::Uri`. Using `http::Uri` here would force adding +// `http` to `edgezero-macros`'s `Cargo.toml` (and the include! then drags it into every +// build of the macro crate). Either keep it dependency-free (preferred) or the §7 +// inventory must add `http` to `crates/edgezero-macros/Cargo.toml`. Preferred: no dep. + +// Manifest gains: #[serde(default)] #[validate(nested)] +// pub capabilities: ManifestCapabilities, +// +// AND the TOP-LEVEL `Manifest` struct itself gains `#[serde(deny_unknown_fields)]`. +// Without it the strictness above is defeated one level up: `deny_unknown_fields` on +// `ManifestCapabilities` only catches a bad key INSIDE a correctly-spelled +// `[capabilities]` table. A misspelled SECTION — `[capabilites]`, `[capability]`, +// `[Capabilities]` — is an unknown top-level field, silently dropped, leaving +// `capabilities` at its `Default` (empty required+optional). That is fail-OPEN: the +// app declares a contract, the contract vanishes, and `ensure_capabilities` waves it +// through because `caps.required` is empty. +// #[serde(deny_unknown_fields)] +// pub struct Manifest { .. } +``` + +Every capability field is `#[serde(default)]`, so **schema-conforming** manifests parse +unchanged. This is deliberately narrower than "all existing manifests": the top-level +`#[serde(deny_unknown_fields)]` (above) is an intentional behaviour change — a manifest +carrying an **unknown top-level section** (a custom/misspelled `[...]` table that older +builds silently ignored) now **fails to parse**. That break is the fail-closed direction +and is inventoried here; any repo relying on unknown top-level sections for custom +metadata must move them under a modelled section. + +**Top-level strictness is safe here** — `Manifest` already models every documented +section (`adapters`, `app`, `environment`, `logging`, `stores`, `triggers`), so +`deny_unknown_fields` rejects only genuinely-unknown sections, not valid ones. It is a +deliberate behaviour change: a stray/misspelled top-level section becomes a **hard parse +error** instead of a silent drop, which is the fail-closed direction for a +security-relevant contract. (`#[serde(skip)]` internals are unaffected — they are never +read from input.) §5.4 pins this with regression rows: a manifest whose only capability +declaration is under `[capabilites]` (transposed) must **fail**, not parse to an empty +contract; likewise `[capability]` and a stray unknown section. + +**Top-level `deny_unknown_fields` is necessary but NOT sufficient — a `[capabilities]` table +misplaced at ANY depth would be silently dropped.** Per-struct `deny_unknown_fields` only +catches ONE level (`[app.capabilities]`), and chasing every level is whack-a-mole: +`[triggers.http.capabilities]`, `[environment.variables.capabilities]`, +`[adapters.axum.build.capabilities]` are two levels down, and `ManifestAdapter` can't even +take `deny_unknown_fields` (its `#[serde(flatten)]` legacy map is mutually exclusive with +it). **So the PRIMARY defense is a depth-independent reserved-key scan, not per-struct +strictness.** Before deserializing into `Manifest`, parse the input into a `toml::Value` +or `serde_json::Value` and recursively inspect table/object keys. The only accepted reserved +key is the exact lowercase top-level `capabilities`; any nested key for which +`key.eq_ignore_ascii_case("capabilities")`, and any non-lowercase top-level spelling, is a +hard error. The TOML and baked-JSON walkers share this key policy and array recursion. This +catches +`[app.capabilities]`, `[triggers.http.capabilities]`, `[environment.variables.capabilities]`, +`[adapters.axum.build.capabilities]`, and anything future, in one place, without touching +every struct and without the `flatten` conflict. (Top-level `deny_unknown_fields` still +guards misspelled top-level *sections* like `[capabilites]`; the two are complementary — the +scan owns the depth problem.) §5.4 adds rows: a `capabilities` table nested under `app`, +`triggers.http`, `environment.variables`, and `adapters.axum.build` must EACH **fail to +parse**, not silently drop the block and run with an empty contract. + +#### 3.5.2 Adapter capability metadata + +The `Capability` / `CapabilitySupport` enums are `#[non_exhaustive]` (a future capability +must not force every out-of-tree adapter to recompile-or-break), and `Adapter::capability` +carries a **default returning `CapabilitySupport::Unsupported`** for any capability an +adapter doesn't recognize — so an out-of-tree adapter compiled against an older core still +builds, and an unknown capability fails closed (Unsupported → a `required` mismatch +hard-fails) rather than failing to compile. The registry `Adapter` trait gains one method +(`capability`). This outbound spec does not change or depend on the trait's store/config +lifecycle methods: + +```rust +// crates/edgezero-adapter/src/registry.rs — current (post-#269) shape +pub trait Adapter: Sync + Send { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + fn name(&self) -> &'static str; + // Added by this spec. MUST carry a default body: without one, every existing + // (and every out-of-tree) adapter compiled against an older core would fail to + // build, and the intended "unknown capability ⇒ unsupported" contract would not + // hold. The default returns `Unsupported`; in-tree adapters override it. + fn capability(&self, _capability: Capability) -> CapabilitySupport { + CapabilitySupport::Unsupported + } + // NOTE for in-tree overrides: an adapter that overrides `capability` REPLACES this + // default — the default does not run for capabilities the override's `match` doesn't + // name. `Capability` is `#[non_exhaustive]`, so every in-tree `match capability { .. }` + // MUST end with `_ => CapabilitySupport::Unsupported`, or a capability added later + // reads as some accidental value instead of the intended fail-closed `Unsupported`. + + // Existing non-outbound methods are elided. `ensure_capabilities` consults only + // `capability(..)`. +} +``` + +This reference is intentionally partial. Existing non-outbound trait methods retain +their current ownership and behavior. + +**Publication order:** adding the trait/default does not immediately advertise the matrix. +Each in-tree adapter inherits `Unsupported` until its outbound implementation, deterministic +contracts, and any required host evidence land. That adapter's exact override is committed +in the same phase. The completed repository has the matrix below; no intermediate branch +state may claim support for behavior that has not landed. + +Capability matrix (all four adapters): + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-http` | Native | Native | BestEffort⁹ | Native | +| `outbound-complete-resource-accounting` | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | +| `outbound-header-fidelity` | Native | BestEffort⁸ | Native | Native | +| `outbound-deadlines` | Native | Native | BestEffort¹ | BestEffort⁸ | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort⁵ | BestEffort⁵ | +| `send-all-slot-isolation` | Native | Native | BestEffort⁴ | Native | +| `streamed-upload-deadlines` | Native | Native | BestEffort² | BestEffort⁸ | +| `lazy-streamed-response-passthrough` | BestEffort³ | Native | BestEffort⁶ | BestEffort⁷ | + +[^resource-accounting]: This capability means a complete pre-admission bound for process or + isolate memory attributable to one outbound response, including provider parser and + field-section materialization, informational responses, final headers, trailers, + native receive chunks, guest buffers and spare capacity, decoder state, allocator + metadata, and runtime copies. Current platform APIs do not expose or bound every term, + so all four adapters report `Unsupported`. EdgeZero still enforces and documents its + narrower guest-visible limits: encoded bytes, decoded bytes, final Buffered bytes, + visible header fields, Brotli decoder-state charge, and emitted item shape. A caller + requiring a complete RSS/isolate bound must declare this capability required and fail + closed on every current target rather than treating the narrower arithmetic in §3.4.4 + as a complete memory guarantee. + +¹ **Fastly `outbound-deadlines` is `BestEffort`, because it cannot be guaranteed on +every request.** Even with an already-registered/cached backend, any non-empty request body +has an unbounded guest-to-origin host-write interval: Fastly exposes no upload-write timer. +For a zero-length request body on the warm path, the dispatch/headers and response-read +portions have the documented deterministic overshoot bounds below (common-case +`total_ms ≥ 4` phase split; the sub-4 ms branch adds `total_ms`, see §4.3 "Net guarantee"). +Separately, the **FIRST request to a new host** calls `Backend::builder(..).finish()`, a synchronous host +call that can block waiting for a service-wide dynamic-backend slot. Nothing guest-side +can preempt it, so it may overshoot the deadline *before* the `BATCH_DISPATCH_SLACK_MAX` +check (which runs immediately before `send_async`, i.e. after `finish()` has returned) +ever executes; on that path the guard checks the **absolute deadline FIRST** and returns an +**attributed `gateway_timeout` (504)** — a `finish()` that returns past the deadline is a +genuine expiry, not an EdgeZero bug (`internal` is reserved for slack exceeded *while time +remains*, §4.3). Either way the wall-clock overshoot happened, so the capability is still +`BestEffort`. Because `capability()` is a **static** value +that cannot distinguish cached from cold, the honest declaration is **`BestEffort`** — a +`required outbound-deadlines` therefore hard-fails on Fastly rather than passing a gate it +cannot actually honour for cold registration or body-bearing writes. Apps that accept +those gaps declare it **`optional`** (logged, never gated) and get the documented partial +bounds below. + +**These dispatch/headers and response-read bounds hold once the backend is registered +(cached); they do not bound request-body transmission.** On the +**first** request to a new host, `Backend::builder(..).finish()` — a +synchronous host call that can block waiting for a service-wide dynamic-backend slot — +may overshoot *before* the `BATCH_DISPATCH_SLACK_MAX` check (which runs immediately before +`send_async`, i.e. after `finish()` has returned) executes. On that path the guard checks +the deadline first and returns an **attributed `gateway_timeout` (504)** for the actual +expiry (not `internal`); the wall-clock was still overshot, which is exactly why the +capability is `BestEffort` and not `BoundedCooperative` — see §4.3 +*Honesty note — what the guard can and +cannot do*. +- **Single `send`** — `now` is snapshotted inline so there is no batch drift, + but the **same `BATCH_DISPATCH_SLACK_MAX` guard** applies to the gap between + `dispatch_budget(req, now)` and `send_async` (backend lookup, possible + `Backend::builder().finish()`, SDK request construction; see §4.3). Worst-case + dispatch+headers overshoot is `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (the + same bound as `send_all`); the window is typically narrower because there's + no per-slot harvest loop. Response-body phase overshoot ≤ one between-bytes-timeout + interval (§3.3.4). For a non-empty buffered or streamed request body, the upload write + remains unbounded and sits outside these finite terms. **Streamed-upload-specific + post-upload overshoot**: when the request + body is `Body::Stream` and the upload drain leaves a tiny positive + `budget.deadline.remaining()`, the post-upload headers wait can additionally + cost up to one dispatch-time `first_byte_ms` interval before the cooperative + check at the `wait()` boundary or the response-wrapper preemption fires + (§4.3 "Response phase"). That overshoot is **one-shot**, not per-chunk — + the response wrapper preempts at the first post-deadline read. +- **`send_all`** — `batch_now` is shared across slots so the measured setup and + host-timer portions of dispatch+headers carry + `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (≈ 26 ms when `total_ms ≥ 4`, §4.3 + "Dispatch-overhead slack"). A non-empty buffered body's unbounded host write proceeds + after `send_async` has returned, so it does not prevent later dispatch calls, but the slot + cannot complete until that background transmission and its response progress. Response-body + phase **once a slot is actively draining** is still ≤ one between-bytes-timeout — but the slot's **observed + completion** can additionally be delayed by the harvest-order serialization + (preceding slots' drain times). The harvest delay is what the separate + `send-all-slot-isolation` capability owns (footnote 4); the + `outbound-deadlines` bound here is on the active-drain phase only, not on + total observed wall-clock across the batch. + +The finite terms above are hard adapter constants, not "scales with preflight"; cold +registration and request-write completion remain explicitly unbounded. `Native` is reserved +for adapters with no such caveat — this rubric lets future adapters be judged consistently +without quiet downgrading. A new adapter unable to honour a capability declares +`Unsupported` and is caught at build time. The `send_all` *buffered-body* cross-slot +caveat (harvest-order false 504s) is **not** within this capability — that one is +`send-all-slot-isolation` (footnote 4), so each label means exactly one thing. + +² Fastly has no guest primitive to preempt a stalled `stream.next().await` while feeding +a streamed REQUEST body via `send_async_streaming` (§4.3). **Both phases are unbounded +on Fastly:** (a) the *source pull* — a source stream that never yields the next chunk +cannot be preempted; and (b) the *host write* — `between_bytes_timeout` is documented as +**receive-side only** (it bounds the gap between bytes *received from origin*) and does +**not** bound guest-to-origin writes, so it gives no inter-chunk guarantee on the upload +path. (An earlier draft of this footnote claimed `between-bytes-timeout` still bounds +upload inter-chunk gaps — that is wrong; §4.3 and §8 risk 7 are correct.) The only +adapter-side bound is the cooperative `budget.deadline.is_expired()` check **between** +chunks. This is `BestEffort` — no documented preemption bound — and is exposed as the +separate +`streamed-upload-deadlines` capability so apps that need real-time enforcement on this +specific path declare it required and get a hard build failure on every BestEffort target, +including Fastly and Spin, per §3.5.3. +Apps that buffer their request bodies before calling `send` are unaffected **on the +source-pull axis only** — buffered uploads use `Body::Once` with no `stream.next().await`. +They are **NOT** exempt on the **host-write** axis: Fastly's `connect_timeout` ends at TLS +setup and `first_byte_timeout` starts at *"request sent"*, so the interval in which the +host pushes the buffered body to the origin is bounded by **no** Fastly timer, and +`between_bytes_timeout` is receive-side only. An origin that completes the handshake then +stops reading stalls that write unbounded, and the guest cannot abort a dispatched +`PendingRequest` (§3.3.3). Buffered uploads therefore fall under `outbound-deadlines`, +which is **`BestEffort` on Fastly** (footnote 1) — a label that already covers this gap; +buffering narrows the exposure to the write phase, it does not remove it. + +⁵ `outbound-flexible-phase-budget` captures whether the adapter treats the request +budget as one elastic pool. Axum and Cloudflare have one authoritative total timer +(reqwest's `.timeout(..)` and `worker::Delay`); a slow connect followed by fast +headers/body inside the total budget succeeds. On Fastly the budget is +**rigidly split** (§4.3 — `connect = budget/4`, `first_byte = 3*budget/4`, +`between_bytes = budget`); a request that takes more than `budget/4` on connect-phase +work fails at the connect timer even though the rest of the budget would have +sufficed. This is a documented `BestEffort` deviation — the platform-level cause is +that Fastly's `BackendBuilder` exposes per-phase timers and no total-budget timer. +Apps that need elastic budget allocation (slow-connect workloads, mixed-latency +upstreams) declare this capability required and get the hard build failure on +Fastly per §3.5.3. Spin also runs an outer monotonic total race, but WASI permits any +`RequestOptions` phase-timer setter to return `NotSupported`. In that case an opaque host +default can fail earlier than the configured total budget, so the static capability is +`BestEffort` even though the guest-visible outer race remains. Promotion requires a target +whose three setters are guaranteed or a host API that disables independent defaults. + +⁴ `send-all-slot-isolation` is `BestEffort` on Fastly for **three** reasons. +**(a) Harvest-order response-body drain** (§3.3.4): a slot whose own +`budget.deadline` would have covered its body in isolation can still return +`gateway_timeout` because an earlier slot's body drain monopolised harvest. **(b) Cold +sequential registration** (§4.3): a first-time `Backend::builder(..).finish()` can block +without a guest-side bound, preventing later slots from being dispatched. **(c) Non-empty +buffered request upload:** `send_all` removes streamed source pulls, but Fastly has no timer +that bounds the guest-to-origin write of the surviving `Body::Once`. A slow-reading origin +can therefore leave an earlier slot unresolved after `send_async` returns. This does **not** +prevent later `send_async` dispatch calls, but input-order harvesting can remain blocked on +that slot before later completed results are observed, without a finite guest-visible bound. + +Only (a) becomes negligible for small response bodies. Small responses do not repair (b), +and a small request body still has no documented write-time bound for (c). Consequently the +spec makes no general "typical small-body fan-outs are unaffected" claim. Apps that need +cross-slot result isolation declare this capability required and get a hard build failure on +Fastly per the "required + BestEffort = hard fail" rule (§3.5.3). On Axum/CF/Spin, +`join_all` drives complete per-slot exchanges concurrently, so isolation is `Native`. + +³ `lazy-streamed-response-passthrough` captures whether +`OutboundResponse::into_response()` delivers a streamed upstream body to the platform +response **without buffering**. **Cloudflare is the only `Native` adapter** (Axum, +Fastly, and Spin are all `BestEffort` — footnotes 3 / 6 / 7 — and each falls back to +bounded buffered passthrough). On Cloudflare the platform SDK accepts a +non-`Send` stream natively (WASM single-threaded guest), and the response converter +chains the wrapped `Body::Stream` through — first chunks flow before the upstream stream +ends. On Axum, `axum::body::Body::from_stream` requires `Send + 'static` and core's +`LocalBoxStream` is intentionally non-Send (WASM compat). Rather than spec an +unspecified shim, the Axum response converter buffers `Body::Stream` to `Bytes` within +the adapter-level constant `AXUM_RESPONSE_STREAM_BUFFER_BYTES` (default 16 MiB; the +per-outbound-request `max_response_bytes` is gone by the time the converter runs) +before constructing the axum response — correct, bounded, but first bytes only flow +after full collection. Apps that need true lazy streaming on Axum declare this +capability required and either (a) target a different adapter or (b) wait for a future +mpsc-bridged implementation. Buffered fan-outs are unaffected. See §4.1 and +§7 for the implementation, §8 for the open mpsc-bridge follow-up. + +⁶ `lazy-streamed-response-passthrough` is `BestEffort` on Fastly for an +**entry-point-structural** reason, not a WASM-`Send` one. The Fastly Rust SDK does +not expose a `Response::with_streaming_body` method (that exists on `Request`, for +outbound bodies). Early/lazy response streaming to the downstream client goes +through `Response::stream_to_client(self) -> StreamingBody`, which the SDK +explicitly documents as **incompatible with `#[fastly::main]`** — the attribute +implicitly calls `Response::send_to_client()` on the returned response, and +`stream_to_client()` "cannot be used to send final responses with `#[fastly::main]`." +Apps that want true lazy passthrough on Fastly must: +1. drop the `#[fastly::main]` attribute on the entry function, +2. use an undecorated `main()` plus `Request::from_client()` to receive the + incoming request, +3. construct the `Response`, then call `stream_to_client()` to obtain a + `StreamingBody` they `finish()` manually. + +That is a structural constraint on the Fastly scaffold — `edgezero new` (which takes only +`` and `--dir`; there is **no** `--adapter` flag, it scaffolds all adapters) today +emits a `#[fastly::main]` entry for the Fastly component, and +`OutboundResponse::into_response()` +on Fastly therefore falls back to **buffered passthrough**: drain the wrapped +`Body::Stream` to `Bytes` within the adapter-level constant +**`FASTLY_RESPONSE_STREAM_BUFFER_BYTES`** (default 16 MiB, mirroring Axum's +`AXUM_RESPONSE_STREAM_BUFFER_BYTES`). The per-outbound-request +`max_response_bytes` is unavailable by the time the response converter runs +(`OutboundResponse` carries request method / status / headers / body, but no cap — §3.1.4), so the +adapter-level constant is what the converter uses. Over-cap during the buffered +drain → `response_too_large` (distinct kind, 502 — §3.4.1) — same shape as Axum. After +draining, the buffered `Bytes` is returned through the normal `#[fastly::main]` flow. Apps that need +lazy passthrough on Fastly declare this capability required and get a hard +build failure; the migration path is either (a) target **Cloudflare** (the only +`Native` adapter for this capability) or (b) wait for the §8 risk 12 +follow-up that adds a non-`#[fastly::main]` entry-point template + the +`stream_to_client()` plumbing. Buffered passthrough still works on Fastly +unconditionally — only the *lazy* variant is gated. + +⁷ `lazy-streamed-response-passthrough` is `BestEffort` on **Spin** for an +**EdgeZero-side public-API** reason — **not** a platform limitation, and not a +WASM-`Send` one. **Spin SDK 6 fully supports lazy response streaming**: its +`Response` body is `IncomingBody`, which implements +`http_body::Body` and reads in 16 KiB frames via `poll_frame`, and +`IncomingBodyExt::stream()` yields a lazy `BodyDataStream`. The SDK is not the +blocker — **EdgeZero's own alias is**. The adapter currently *chooses* the buffered +path (`crates/edgezero-adapter-spin/src/proxy.rs` calls `.bytes()`), and +`crates/edgezero-adapter-spin/src/lib.rs` pins `SpinFullResponse = +Response>` across `AppExt::dispatch`, `request::dispatch*`, +`from_core_response`, and `run_app`. Delivering lazy passthrough therefore requires +**migrating those public aliases and signatures** to a streamable response shape — a +breaking public-API change that ripples into `examples/app-demo`, the Spin scaffold +templates, and every downstream consumer of `SpinFullResponse`. It carries its own +design and test surface, so it is **deliberately out of scope for this change**: +Spin's response converter performs **buffered passthrough** (drain the wrapped +`Body::Stream` to `Bytes` within a `SPIN_RESPONSE_STREAM_BUFFER_BYTES` constant, +default 16 MiB, mirroring Axum and Fastly; over-cap → `response_too_large` (502, §3.4.1)), exactly +the Axum/Fastly fallback shape. Apps that need lazy passthrough today declare the +capability required and target **Cloudflare**. Because the platform *does* support it, +lifting Spin to `Native` is a pure EdgeZero refactor and is tracked as a follow-up +(§8 risk 13) — unlike Fastly's footnote 6, which is a genuine platform constraint. +This affects only the **response-out** direction; Spin's outbound request path still +uses the hand-built `wasi:http` request in §4.4 for **both buffered and streamed bodies**. +The SDK's high-level `send` is not used for either body kind because its detached body +pump is not owned by the deadline race; a finite `Body::Once` can still block on host +backpressure. The hand-built path's cancellation guarantee remains `BestEffort` per +footnote 8. + +⁸ **Spin deadline and Cloudflare header-fidelity caveats.** Spin has a monotonic timer and can race the +guest-visible exchange, but Component Model cancellation is cooperative. Dropping a +`FutureWriter` that has not written launches a background default write, and dropping a +canonical-ABI subtask does not establish a documented one-tick host teardown bound. The +adapter therefore implements explicit request/response completion protocols (§4.4) and +returns a timeout when its timer wins, while `outbound-deadlines` and +`streamed-upload-deadlines` remain `BestEffort` until host-observed runtime tests prove +bounded teardown for stalled upload and response paths. Separately, Spin exposes raw +header bytes and original field lines, so `outbound-header-fidelity` is `Native`. +Cloudflare's `BestEffort` header-fidelity cell reflects workerd's loss of raw octets and +original non-`set-cookie` field boundaries; its response-out runtime can also recompute or +ignore `Content-Length` for a streamed passthrough. EdgeZero uses `EncodeBody::Manual` to +preserve already encoded bytes and visible `Content-Encoding`, but it does not claim an +exact downstream-wire length where Workers owns framing. + +⁹ **Fastly `outbound-http` requires service enablement.** The adapter implements arbitrary +dynamic destinations, but Fastly disables dynamic backends by default and the local CLI has +no authoritative service-entitlement query. A static capability result therefore cannot +promise that the selected deployment can issue any request. The current cell is +`BestEffort`: optional use is supported with an explicit warning, while a required +declaration fails closed. Trusted Phase 6 live probes characterize enabled and disabled +services; promotion to `Native` requires a separately reviewed deployment preflight that +proves enablement for the selected service. + +#### 3.5.3 Build / startup enforcement + +`ensure_capabilities` is an **outbound runtime gate**, not the owner of every CLI lifecycle. +It runs before shell or registry dispatch for `build` / `serve` / `deploy` / +`deploy --staging`, and before the Axum `demo` runtime starts. The outbound-scoped adapter +dispatch entry points, `execute_runtime(..)` and `execute_capture_runtime(..)`, accept only an owned +`ResolvedRuntime` plus adapter arguments. They derive the canonical adapter identity and +action from that runtime and call the same action-aware gate **before** dispatching to its +already resolved shell or registry target. This is required because +`execute_capture_runtime(..)` owns a separate `run_shell_tee(..)` branch and can otherwise bypass +a gate placed only in `execute_runtime(..)`. `auth *`, `EmitVersion`, `Healthcheck`, and `Rollback` +are operational actions rather than construction/deployment of the current manifest's +runtime and remain exempt; the latter three may be invoked without any manifest contract. +`provision` and every `config` subcommand are outside this outbound specification. Their +owning store/capability specifications decide whether they participate in any broader +cross-capability gate. + +```rust +// crates/edgezero-cli/src/adapter.rs +// Keep the action classification exhaustive over today's Action variants. A newly added +// action must be deliberately classified here; it must not inherit an accidental default. +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaged | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } +} + +fn ensure_action_capabilities(runtime: &ResolvedRuntime) -> Result<(), String> { + if !produces_current_runtime(runtime.action()) { + return Err("operational action entered outbound runtime dispatcher".to_string()); + } + ensure_capabilities( + runtime.adapter_name(), + ManifestContract::from_opt(runtime.manifest()), + ) +} + +// Outbound-scoped dispatch path 1. Gate BEFORE branching to the resolved target. +pub fn execute_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + ensure_action_capabilities(&runtime)?; + // Existing shell-command / registry dispatch follows. The registered-adapter branch + // lives in a private helper so execute_capture can reuse it without gating twice. + execute_after_gate(runtime, adapter_args) +} + +// Outbound-scoped dispatch path 2. This owns a distinct shell/tee branch, so it gates too. +pub fn execute_capture_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + ensure_action_capabilities(&runtime)?; + if let ResolvedAdapterTarget::Shell(shell) = runtime.target() { + // Command/root/environment/bind setup is already stored in `shell`; this branch + // does not re-run `manifest_command`, target discovery, or manifest parsing. + return run_shell_tee(shell, adapter_args).map(Some); + } + // Call the private already-gated registry path, NOT public execute_runtime(..): calling the + // public entry point would duplicate optional-capability warnings. Pass the complete + // resolved pair so the registry branch executes `runtime.target` and preserves + // manifest-present/absent diagnostics without rediscovery. + execute_registered_after_gate(runtime, adapter_args)?; + Ok(None) +} + +// crates/edgezero-cli/src/demo_server.rs — no manifest FILE exists; read the +// manifest baked in by `app!` (Hooks::manifest). +#[cfg(feature = "demo-example")] +pub fn run_demo() -> Result<(), String> { + // baked ('static): BakedManifest -> ManifestContract via as_contract + ensure_capabilities("axum", ::manifest().as_contract())?; + /* …Axum runner… */ +} + +``` + +There is deliberately no separate `adapter_name` or `action` argument on either outbound +dispatcher. The resolver records the canonical adapter identity and requested action in +`ResolvedRuntime`; the gate, registry lookup, shell diagnostics, and +`AdapterAction` conversion all consume those same fields. Resolver-produced values are the +only construction path exposed outside `manifest_source`, so a caller cannot gate adapter A +or action X and then dispatch adapter B or action Y. The private post-gate helpers likewise +accept the complete runtime rather than duplicate identity arguments. The pre-existing +`execute(..)` / `execute_capture(..)` operational path remains available to auth, version, +healthcheck, and rollback and never accepts `ResolvedRuntime`. + +`run_demo` is feature-gated (`demo-example`) and always selects Axum implicitly, so its +gate hardcodes the adapter name and reads the **baked** manifest rather than a file. The +two adapter dispatch entry points plus `run_demo` are exhaustive for the outbound runtime +gate; unrelated CLI lifecycles are deliberately absent. The exact private-helper split may +follow the current module, but the invariant is normative: each public dispatch path gates +exactly once before either shell or registry work. + +`ensure_capabilities` itself reads from the **registry** (not from `Adapter::execute`) +because capability metadata is the trait fact `capability(Capability) -> +CapabilitySupport`, and the registry is where adapter implementations are looked up +by name. That means **shell-overridden adapters still get checked**: even if the +manifest configures `[adapters..commands.build]` so dispatch never reaches +`Adapter::execute`, the gate still consults the registered adapter's `capability(..)` +tuple — the shell override only routes the *action*, it does not opt out of the +*manifest contract*. + +**Missing-from-registry policy.** If `registry::get_adapter(adapter_name)` returns +`None`, the policy depends on whether the manifest declares any required or optional +capabilities: + +| Manifest `[capabilities]` shape | Adapter in registry? | Outcome | +| --- | --- | --- | +| absent or empty (`required = []`, `optional = []`) | no | `log::warn!` "capability check skipped (no capabilities declared)" — proceed | +| **any `required`** entry | no | **hard failure**: `Err("adapter '' is not in the registry; cannot verify REQUIRED capabilities. …")` | +| **only `optional`** entries (no `required`) | no | `log::warn!` "cannot verify its OPTIONAL capabilities — proceeding, since optional capabilities never hard-fail" — proceed | +| absent / empty | yes | proceed (loop bodies trivially pass) | +| has entries | yes | check each per the rubric below | + +This preserves the "required capabilities fail early" contract while honouring +"optional never hard-fails" (§3.5.3) — an unverifiable *optional* capability warns and +proceeds, exactly as a known-degraded optional one does; only an unverifiable **required** +capability is fatal. It also keeps the brand-new-shell-only-adapter ergonomics for the +*no-capabilities* case (e.g. a contributor wiring a new edge platform via shell-out, +before they've written the adapter stub). An app that declares a **required** capability +needs a registered adapter that can answer the `capability(Capability) -> +CapabilitySupport` question; there is no silent bypass of a required contract. + +Commands covered by the outbound gate sites above: + +| Command/action | Entry point | Gate site | +| --- | --- | --- | +| `edgezero build` | `run_build` resolves `Action::Build` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero serve` | `run_serve` resolves `Action::Serve` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero deploy` | `run_deploy` resolves `Action::Deploy` → `execute_runtime(runtime, ..)` or, for captured Fastly output, `execute_capture_runtime(runtime, ..)` | whichever outbound-scoped dispatcher is selected — **gated before its shell branch** | +| `edgezero deploy --staging` | `run_deploy` resolves `Action::DeployStaged` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero auth login` / `logout` / `status` | existing `run_auth` → existing `execute(adapter, action, manifest, ..)` | **EXEMPT** (credential + read-only class); no paired resolver migration | +| version emission / healthcheck / rollback | existing operational call sites → existing dispatcher | **EXEMPT** — no paired resolver or manifest requirement | +| `edgezero demo` (feature `demo-example`) | `run_demo` → Axum runner. `run_demo()` takes **no path or loader** and reads no manifest file, so a file-based gate is impossible. **Locked resolution — gate on baked manifest metadata via a new `Hooks` accessor** (see below) | `run_demo()` calls `ensure_capabilities("axum", ::manifest().as_contract())` before the Axum runner starts | + +**The `demo` gate needs a baked-manifest accessor — `app!` must emit one.** +`run_demo()` (`crates/edgezero-cli/src/demo_server.rs`) hardcodes `run_app::()` for the +concrete `app_demo_core::App` (a **struct**, so it cannot be a trait bound), so a test cannot +inject a crafted `Hooks`. **Add a PURE gate helper**, not a generic `run_demo`: + +```rust +// edgezero-cli — no server start, so a test can call it directly and cheaply. +// NOT feature-gated: this helper and its test are compiled UNCONDITIONALLY (they do not +// live behind `#[cfg(feature = "demo-example")]`), so the row runs under the plain +// `cargo test --workspace --all-targets` CI gate. Only `run_demo()` itself — which pulls +// in the `app-demo` example — stays behind `demo-example`; the pure gate does not depend +// on the example, only on the `Hooks` trait, so keeping it always-compiled is free. +pub(crate) fn demo_capability_gate() -> Result<(), String> { + ensure_capabilities("axum", ::manifest().as_contract()) +} +``` + +`run_demo()` (behind `demo-example`) calls `demo_capability_gate::()?` +**before** `run_app`. The failure test calls `demo_capability_gate::()` directly +with an in-crate `TestApp` — no `demo-example` feature, no blocking server on the success +path — so it is exercised by the default test command. **`TestApp` must override `manifest()`, not just `manifest_json()`:** the default +`manifest()` returns `Absent` (a static in the trait default would be shared across impls), so +overriding only `manifest_json()` would leave the gate seeing `Absent` and proceeding. `TestApp` +overrides `manifest()` to return `Manifest::from_baked_json()`. +`run_demo` has no path, no `ManifestLoader`, and no way to find `edgezero.toml` at runtime. But +the `app!` macro **already parses, validates, and serializes the manifest at compile +time** (`crates/edgezero-macros/src/app.rs`: `manifest.finalize()` → +`serde_json::to_string(&manifest)` → `manifest_json_lit`) — it just embeds that JSON for +the router and never exposes it. Today `Hooks` +(`crates/edgezero-core/src/app.rs`) has `routes()` / `stores()` / `name()` and **no +manifest accessor**, so the gate has nothing to consult. Add one: + +```rust +// edgezero-core/src/app.rs — extend the existing Hooks trait. +pub trait Hooks { + // …existing: build_app / configure / name / routes / stores… + + /// Raw manifest JSON baked in at compile time by `app!`. + /// Default `None` for hand-written `Hooks` impls that never ran the macro. + fn manifest_json() -> Option<&'static str> { None } + + /// Parsed + finalized, cached view of the above. + /// + /// **The default MUST be `Absent` — it must NOT hold a cache.** A `static` declared + /// inside a trait default method body is **one item shared by every implementor**, + /// not one per `Self` (items in generic fns are not monomorphized — Rust Reference). + /// A caching default would therefore let the FIRST app to call `manifest` + /// populate the value every OTHER app reads — capability checks against the wrong + /// manifest. Proven: two impls relying on such a default both returned the first + /// impl's value. The cache lives in each **macro-generated impl** instead (below), + /// where the `static` is a distinct item per impl. + fn manifest() -> BakedManifest { BakedManifest::Absent } +} + +// edgezero-core/src/manifest.rs +// +// THREE states, not `Option`. `Option<&Manifest>` conflates "this app has no baked +// manifest" (legitimate: a hand-written `Hooks`, no macro → no capability contract → +// proceed) with "the baked manifest is CORRUPT" (an adapter/macro contract bug). If +// both collapse to `None` and `ensure_capabilities` treats `None` as permission to +// proceed, a malformed contract **silently disables required-capability enforcement** +// — it fails OPEN. This enum makes that unrepresentable. +// `#[non_exhaustive]`: a future state (e.g. a lazily-parsed variant) must not silently +// pass a match written today. Matches in OTHER crates (the CLI gate) therefore need a +// `_` arm, and that arm MUST fail closed (treat an unknown state as "cannot verify → +// refuse"), never proceed — see `ensure_capabilities`. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum BakedManifest { + /// No `app!`-baked manifest (hand-written `Hooks`). No capability contract. + Absent, + /// `manifest_json` returned Some, but it did not parse/finalize. An + /// adapter/macro contract bug: the JSON came from `serde_json::to_string` on an + /// already-validated `Manifest`, so this is unreachable unless the macro is broken. + Malformed(&'static str), // static reason, for the diagnostic + /// Successfully parsed + finalized. + Present(&'static Manifest), +} + +impl Manifest { + /// Parse baked JSON and rebuild derived state (`finalize`). Returns + /// `BakedManifest::Malformed` — never `Absent` — on failure, so a corrupt + /// contract can never be mistaken for "no contract". + pub fn from_baked_json(json: &'static str) -> BakedManifest { /* … */ } +} + +// What `app!` GENERATES per app — each impl gets its OWN `manifest` fn, hence its +// OWN `static`, hence a genuinely per-app cache. +// +// TWO requirements on the generated code: +// 1. FULLY-QUALIFIED PATHS. This expands in the DOWNSTREAM crate, which may not have +// `Manifest` or `OnceLock` in scope (or may shadow them). Macro output must name +// `::edgezero_core::manifest::Manifest`, `::std::sync::OnceLock`, etc. — never a +// bare `Manifest`/`OnceLock`. (The snippet below is written qualified for exactly +// this reason; do not "tidy" it into short paths.) +// 2. FAIL CLOSED on a malformed baked contract — see `BakedManifest` below. +impl ::edgezero_core::app::Hooks for MyApp { + fn manifest_json() -> Option<&'static str> { Some(r#"{…baked…}"#) } + + fn manifest() -> ::edgezero_core::manifest::BakedManifest { + // per-IMPL: a distinct static item, because this fn is generated per impl. + static CACHE: ::std::sync::OnceLock<::edgezero_core::manifest::BakedManifest> = + ::std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + match ::manifest_json() { + None => ::edgezero_core::manifest::BakedManifest::Absent, + Some(json) => ::edgezero_core::manifest::Manifest::from_baked_json(json), + } + }) + } + // …routes/stores/etc… +} + +// edgezero-core/src/manifest.rs — the accessor CANNOT parse+finalize itself, because +// `Manifest::finalize` is `pub(crate)` and the `app!`-generated `manifest` lives in +// the DOWNSTREAM crate, which can't reach it. Core owns the parse+finalize: +impl Manifest { + /// Parse baked JSON and rebuild derived state (`finalize`). + /// Returns `Malformed` — NEVER `Absent` — on failure, so a corrupt contract can + /// never be mistaken for "no contract" (fail closed, see `ensure_capabilities`). + pub fn from_baked_json(json: &'static str) -> BakedManifest { + // SAME pipeline as `try_load_from_str`: parse -> VALIDATE -> finalize. + // Skipping validate() would fail OPEN: `{}` is valid JSON and parses to a + // default Manifest with EMPTY capabilities, so an invalid-but-parseable + // contract would become `Present` and the gate would proceed against no + // required capabilities. finalize() only rebuilds derived logging state — it + // is NOT validation. A parse OR validation failure is a contract bug -> Malformed. + let value: serde_json::Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(_) => return BakedManifest::Malformed("baked manifest did not parse"), + }; + if reject_reserved_capability_keys_json(&value).is_err() { + return BakedManifest::Malformed("baked manifest has misplaced capabilities"); + } + let mut manifest: Manifest = match serde_json::from_value(value) { + Ok(manifest) => manifest, + Err(_) => return BakedManifest::Malformed("baked manifest did not parse"), + }; + if manifest.validate().is_err() { + return BakedManifest::Malformed("baked manifest failed validation"); + } + manifest.finalize(); // pub(crate) — reachable here, inside core + // Leaked into a 'static: parsed once per process behind the generated + // per-impl OnceLock, and the result must outlive the call. + BakedManifest::Present(Box::leak(Box::new(manifest))) + } +} +``` + +> **`from_baked_json` is `#[doc(hidden)]` macro-support API — call it AT MOST ONCE per +> process.** Each successful call `Box::leak`s a `Manifest` into a `'static`; the generated +> `app!` code invokes it exactly once behind a per-impl `OnceLock`, so the leak is bounded +> to one allocation per app. It is `pub` only because the macro expands in the app's crate, +> **not** an invitation for arbitrary callers — a hand-written caller that invokes it per +> request would leak unboundedly. The doc-comment states the once-per-process contract and +> the type is `#[doc(hidden)]`; direct use is out of contract. + +- **`app!` MUST emit BOTH methods explicitly.** The macro's generated `impl Hooks` + cannot rely on the trait defaults: `crates/edgezero-macros/src/app.rs` sets + **`clippy::missing_trait_methods = deny`** (verified — a generated impl that omits a + defaulted method is a hard clippy error: *"missing trait method provided by default"*). + So the macro emits `manifest_json()` (returning `Some(#manifest_json_lit)`) **and** + `manifest()` (the `OnceLock` + `from_baked_json` body above), exactly as it already + emits `configure`/`build_app` for the same reason. **Any hand-written `Hooks` impl in + the codebase must also emit both** (or locally `#[allow]` the lint) — "additive and + defaulted" is **not** free here. +- **Reparsing JSON alone is WRONG — it skips `finalize()`.** `Manifest::finalize()` + rebuilds derived state (e.g. resolved routes) that is **not** in the serialized JSON, + and several fields are `#[serde(skip)]`. A bare `serde_json::from_str` yields a + half-built `Manifest`. Hence `from_baked_json` calls `finalize()` — and hence it must + live in **core** (only core can call the `pub(crate)` `finalize`), not in the generated + downstream impl. +- **Ownership / lifetime — the cache MUST live in the generated impl, never in the trait + default.** The JSON is a `&'static str` in the binary. The `OnceLock` is a + function-local `static` inside **each generated `manifest()`**; because every impl + emits its own `manifest()` fn, each gets a **distinct** `static` — a genuine per-app + cache, parse paid once per process. **A `static` in the trait DEFAULT body is shared + by all implementors** (items inside generic fns are not monomorphized), so a caching + default would serve app A's manifest to app B. This was **empirically proven** with two + impls relying on such a default: both returned the first one's value. Hence the default + is `BakedManifest::Absent`, and a single core-global `OnceLock` is likewise rejected. *(This is a second, + independent reason the macro must emit `manifest()` explicitly — beyond the denied + `clippy::missing_trait_methods`.)* +- **Failure mode — FAIL CLOSED, and the two failures are distinct.** `manifest()` + returns a three-state `BakedManifest`, never an `Option`, because `Option` conflates + two opposite situations: `Absent` (hand-written `Hooks`, no macro → genuinely *no + capability contract* → `ensure_capabilities` short-circuits `Ok(())`, the same "no + manifest" policy as §3.5.3) versus `Malformed` (the JSON was baked but doesn't + parse → an `app!`/core contract bug). If both were `None` and `None` meant "proceed", + **a corrupt baked contract would silently disable required-capability enforcement** — + the gate would report success precisely when it can no longer verify anything. + `Malformed` therefore **hard-fails** with an actionable message. A gate that fails + open on unreadable input is worse than no gate. +- **Test seam:** capability-gate failure tests drive `demo` with a **test-only `Hooks` + impl** overriding `manifest_json()` to return crafted JSON (which then flows through + the real `from_baked_json` + `finalize`) — no file, no macro re-expansion. This is why + `manifest_json()` is a trait method with a default, not a free function or bare `const`. + (The test impl must also emit `manifest()` — or `#[allow(clippy::missing_trait_methods)]` + — per the lint above.) + +Commands **not** covered (and why): +- `edgezero new` — generates source files; no adapter is selected, so capabilities + cannot be checked. The scaffold itself is identical across adapters. +- `edgezero auth *` — **exempt by command class** (credential; §3.5.3 table), so the + gate never runs for it regardless of manifest presence. (Independently, a genuinely + absent manifest is `BakedManifest::Absent` ⇒ no capability contract ⇒ `Ok(())`; a + *malformed* one hard-fails. Documented in the rustdoc.) + +**Support-level enforcement ladder (what `required` means).** `capability()` returns one of `Native` > `BoundedCooperative` > `BestEffort` > `Unsupported`. A capability in `required` is satisfied by **`Native` or `BoundedCooperative`** (both are *real* enforcement — `BoundedCooperative` has a precisely documented, deterministic bound); it **hard-fails** on `BestEffort` (real-world deviation the app must opt into) or `Unsupported`. `optional` never hard-fails — a `BestEffort`/`Unsupported` optional capability is logged, not gated. **Apps that require the documented outbound-deadline guarantee declare `outbound-deadlines` `required`.** No adapter reports `BoundedCooperative` for that capability, so the declaration is accepted only on Axum and Cloudflare; it hard-fails on Fastly and Spin. A separate `outbound-deadlines-exact` capability is unnecessary. + +**Historical (pre-#269) shape — now superseded (PR #269 has merged to main):** +Before #269 landed, `Command::{Build, Serve, Deploy, Dev}` all dispatched through +the registry's `Adapter::execute(AdapterAction::{Build, Serve, Deploy}, ..)` plus +`Command::Dev`'s implicit-Axum runner, and the gate went at the top of each of +those four handlers (or the equivalent helper they called). #269 collapsed the +runtime-producing actions into the single `execute(..)` dispatcher; `demo` remains +the second outbound runtime gate. + +```rust +// NOTE: takes an already-parsed manifest, NOT a `ManifestLoader`. The `demo` gate has +// no manifest *file* to load — it reads the manifest baked in by `app!` via +// `::manifest` (see the demo row above). File-backed callers pass +// `Present`/`Absent` from their loader; `demo` passes the baked `BakedManifest`. +// +// FAIL CLOSED. `Absent` (no contract) proceeds; `Malformed` MUST NOT — an earlier +// draft used `Option` and treated `None` as permission to proceed, so a corrupt baked +// contract silently disabled required-capability enforcement. A capability gate that +// fails open on malformed input is worse than no gate: it reports success. +// INPUT TYPE: a LIFETIME-BEARING contract, NOT `BakedManifest`. File-backed runtime +// sites hold a **local** `&Manifest` borrowed from a loader — those are not +// `'static`, so they cannot be wrapped in `BakedManifest::Present(&'static Manifest)`. +// Only `run_demo` has a `'static` (baked) manifest. So the gate accepts a borrow of any +// lifetime, and `BakedManifest` (which is `'static`) converts INTO it: +// +// #[non_exhaustive] // future states must fail closed in the cross-crate CLI match +// pub enum ManifestContract<'a> { +// Malformed(&'static str), // corrupt baked contract → fail closed +// None, // no contract → proceed (legit: no manifest) +// Present(&'a Manifest), // any lifetime — file-backed OR baked +// } +// impl BakedManifest { +// pub fn as_contract(&self) -> ManifestContract<'_> { /* Absent→None, Malformed→Malformed, Present→Present */ } +// } +// impl<'a> ManifestContract<'a> { +// pub fn from_opt(manifest: Option<&'a Manifest>) -> Self { +// manifest.map_or(Self::None, Self::Present) +// } +// } +// `ManifestContract` + `as_contract` are public because the generated demo app lives +// outside edgezero-core. File-backed callers build `Present(local_ref)` / `None`; +// `run_demo` calls `::manifest.as_contract`. +// `ensure_capabilities` is `pub(crate)` because `run_demo` imports it from another +// edgezero-cli module. +pub(crate) fn ensure_capabilities( + adapter_name: &str, + manifest: ManifestContract<'_>, +) -> Result<(), String> { + let manifest = match manifest { + // No manifest ⇒ no capability contract to enforce. Legitimate. + ManifestContract::None => return Ok(()), + // Corrupt baked contract ⇒ we cannot know what was required. Refuse. + ManifestContract::Malformed(reason) => { + return Err(format!( + "capability check aborted: {reason}. This is an EdgeZero/app! contract \ + bug — the baked manifest is unreadable, so required capabilities \ + cannot be verified. Refusing to proceed rather than silently skipping \ + enforcement." + )); + } + ManifestContract::Present(manifest) => manifest, + // `ManifestContract` is `#[non_exhaustive]`; a future state we don't recognize is + // treated like `Malformed` — we cannot verify the contract, so we FAIL CLOSED. + _ => { + return Err( + "capability check aborted: unrecognized manifest-contract state. \ + Refusing to proceed rather than skipping enforcement." + .to_string(), + ); + } + }; + let caps = &manifest.capabilities; + let Some(adapter) = registry::get_adapter(adapter_name) else { + // Missing-from-registry policy (see table). If the manifest + // declares no capabilities, we can't verify anything anyway — log + // and proceed so brand-new shell-only adapters work before a stub + // is wired. Optional declarations still warn and proceed because optional never + // hard-fails; only a REQUIRED declaration is unverifiable and must fail closed. + if caps.required.is_empty() { + // No REQUIRED capabilities to verify. Optional ones can't be verified either, + // but "optional never hard-fails" (§3.5.3) — so warn and proceed rather than + // erroring. A hard fail here would break an optional-only manifest. + if caps.optional.is_empty() { + log::warn!( + "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)", + ); + } else { + log::warn!( + "adapter '{adapter_name}' not in registry; cannot verify its OPTIONAL \ + capabilities — proceeding, since optional capabilities never hard-fail", + ); + } + return Ok(()); + } + // At least one REQUIRED capability and no metadata to check it against → fail closed. + return Err(format!( + "adapter '{adapter_name}' is not in the registry; cannot verify REQUIRED \ + capabilities. Register an adapter stub that returns capability metadata, or \ + move those entries to `optional`.", + )); + }; + + // POSITIVELY accept only `Native | BoundedCooperative` for a REQUIRED capability. + // `CapabilitySupport` is `#[non_exhaustive]`; matching the two acceptable values and + // routing EVERYTHING else — `BestEffort`, `Unsupported`, and any FUTURE variant via + // `_` — to a rejection fails CLOSED. A `.filter(== Unsupported)` / `.filter(== + // BestEffort)` pair would let an unknown future support level pass a required gate. + let mut unsupported: Vec<&str> = Vec::new(); + let mut best_effort: Vec<&str> = Vec::new(); + for cap in caps.required.iter().copied() { + match adapter.capability(cap) { + CapabilitySupport::Native => {} + CapabilitySupport::BoundedCooperative => log::info!( + "adapter '{adapter_name}': required capability '{}' is bounded-cooperative; \ + see capability docs for the bound", + cap.as_str(), + ), + CapabilitySupport::BestEffort => best_effort.push(cap.as_str()), + // `Unsupported` AND any unknown/future variant → treated as unsupported. + _ => unsupported.push(cap.as_str()), + } + } + if !unsupported.is_empty() { + return Err(format!( + "adapter '{adapter_name}' does not support required capabilities: {}", + unsupported.join(", "), + )); + } + if !best_effort.is_empty() { + return Err(format!( + "adapter '{adapter_name}': required capabilities are only best-effort: {}. \ + best-effort means a documented limitation applies — timing (e.g. \ + unbounded cooperative enforcement) or functional (e.g. lazy streaming \ + becomes buffered). See the capability reference at \ + https://edgezero.dev/guide/capabilities. Declare them \ + `optional` if the documented limitation is acceptable.", + best_effort.join(", "), + )); + } + // Optional: warn on BestEffort, Unsupported, AND any unknown/future variant (the + // ladder logs all degradations, per §3.5.3); only `Native | BoundedCooperative` is + // "available" and silent. An unknown future support level is a degradation we can't + // characterize, so it warns rather than passing silently. + for cap in caps.optional.iter().copied() { + match adapter.capability(cap) { + CapabilitySupport::Native | CapabilitySupport::BoundedCooperative => {} + CapabilitySupport::Unsupported => log::warn!( + "adapter '{adapter_name}': optional capability '{}' unavailable", + cap.as_str(), + ), + CapabilitySupport::BestEffort => log::warn!( + "adapter '{adapter_name}': optional capability '{}' is best-effort — a \ + documented deviation applies; see the capability reference at \ + https://edgezero.dev/guide/capabilities", + cap.as_str(), + ), + _ => log::warn!( + "adapter '{adapter_name}': optional capability '{}' reports an unrecognized \ + support level; treating as degraded", + cap.as_str(), + ), + } + } + Ok(()) +} +``` + +**Resolve the execution target and its capability contract as one operation.** +`ManifestContract::None → proceed` is safe only when it means no contract belongs to the +target that will actually execute. Resolving `./edgezero.toml` while Spin independently +searches ancestors and workspace descendants for `spin.toml` lets the gate inspect one app +or no app and then build another. The shared resolver therefore returns both values: + +``` +// crates/edgezero-adapter/src/registry.rs — the exact cross-crate handoff. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AdapterExecutionTarget { + app_root: PathBuf, + component: Option, + platform_manifest: Option, +} + +impl AdapterExecutionTarget { + pub fn app_root(&self) -> &Path; + pub fn component(&self) -> Option<&str>; + pub fn new( + app_root: PathBuf, + component: Option, + platform_manifest: Option, + ) -> Self; + pub fn platform_manifest(&self) -> Option<&Path>; +} + +// The existing `execute` remains the direct-discovery API. The CLI's resolved-runtime +// path calls this method, which may not rediscover from cwd or encode the target in args. +pub trait Adapter { + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String>; +} + +// crates/edgezero-cli/src/manifest_source.rs +pub enum ResolvedAdapterTarget { + Registered(AdapterExecutionTarget), + Shell(ResolvedShellTarget), +} + +pub struct ResolvedManifest { + loader: ManifestLoader, + path: PathBuf, +} + +pub struct ResolvedRuntime { + action: Action, + adapter: String, + contract: Option, + target: ResolvedAdapterTarget, +} + +pub struct ResolvedShellTarget { + bind_host: Option, + bind_port: Option, + command: String, + environment: ResolvedEnvironment, + root: PathBuf, +} + +``` + +These declarations and their fields are alphabetized for the repository's denied +`arbitrary_source_item_ordering` lint. `AdapterExecutionTarget` lives in +`edgezero_adapter::registry`; its fields are private and its public constructor/accessors are +the only cross-crate handoff. The remaining fields are private to `manifest_source`. +`ResolvedRuntime` exposes only the alphabetically ordered crate-private read-only getters +`action()`, `adapter_name()`, `manifest()`, and `target()`; the contained +target/manifest types expose read-only getters for their listed fields. No `ResolvedRuntime` +constructor or mutator is exposed outside the resolver. + +`ResolvedRuntime` exists only for the outbound-gated runtime-producing actions `Build`, +`Serve`, `Deploy`, and `DeployStaged`. Its `ResolvedAdapterTarget` is action-specific and +complete: the shell variant owns every input the current shell dispatcher derives from a +manifest; the registered variant pins its app root and optional platform +manifest/component. `AdapterExecutionTarget::app_root` and `ResolvedShellTarget::root` are +absolute canonical directories. `ResolvedManifest::path` and any +`AdapterExecutionTarget::platform_manifest` are absolute canonical regular files contained +by the corresponding root; a platform manifest must also belong to the same selected app +target as the capability contract. Pre-dispatch code asks the target value whether the selected action is +shell- or registry-backed; it does not call `has_manifest_command` or independently resolve +an adapter manifest path after the pair is formed. + +Capture the invocation directory once. An explicit `EDGEZERO_MANIFEST` is authoritative; +resolve a relative value against that captured directory, load exactly that file, and +resolve the adapter target declared relative to its manifest root. Missing, malformed, or +conflicting explicit input fails without fallback. For default discovery, apply the +adapter's existing ancestor/workspace-descendant selection domain to **paired app +candidates**: each candidate `edgezero.toml` is loaded once and its declared target is +resolved with the existing canonical containment/regular-file checks. Select the same +candidate for contract and execution; reject ambiguity or a target/contract mismatch. +`contract: None` is reserved for a selected hand-written target for which discovery proved +there is no associated `edgezero.toml`, not merely no ancestor manifest from the invocation +directory. + +`run_build`, `run_serve`, and `run_deploy` pass one owned `ResolvedRuntime` into new +`execute_runtime(..)` or `execute_capture_runtime(..)` entry points. The scoped dispatcher +derives the adapter and action from that runtime, gates its `contract` exactly once, then executes its pinned `target`; +neither shell nor registry branches rediscover a path or reparse a manifest. A registry +adapter receives `AdapterExecutionTarget` through `Adapter::execute_target`; every in-tree +adapter implements that method by calling target-aware internal build/serve/deploy helpers. +The default trait implementation fails with an explicit "pinned target unsupported" error, +so an out-of-tree adapter cannot silently fall back to `execute` and rediscover cwd. The +existing `Adapter::execute` remains the direct adapter API and preserves its current +discovery behavior for callers outside the paired CLI path. The resolved-runtime dispatcher +must not inject paths/components into stringly passthrough arguments. +Shell overrides retain their declared arguments and run with `Command::current_dir` rooted +at the selected manifest; the CLI never mutates process-wide cwd or environment. A shell +can retarget itself, so the checked target is the declared contract, not a claim that an +arbitrary script's behavior was statically verified. Spin target/component validation must +remain available even when no Spin registry adapter is linked. + +The existing `execute(..)` / `execute_capture(..)` signatures and resolution behavior remain +unchanged for `AuthLogin`, `AuthLogout`, `AuthStatus`, `EmitVersion`, `Healthcheck`, and +`Rollback`. They do not construct `ResolvedRuntime`, enter the outbound capability gate, or +acquire a new manifest requirement. After a captured Fastly Deploy produces no version, +`run_deploy` invokes the existing registered `EmitVersion` path exactly as it does today; +that second operational action is explicitly outside the paired outbound resolver. Provision +and config commands are likewise unchanged. Any attempt to migrate all actions to one owned +runtime belongs in a separate CLI lifecycle specification. + +- **Required + `Unsupported` → hard failure** with an explicit message. +- **Required + `BestEffort` → hard failure.** `BestEffort` means a **documented + deviation from `Native`** — that can be timing (e.g. Fastly's unbounded source-stall + in `streamed-upload-deadlines`) or functional (e.g. Axum's buffering of streamed + responses in `lazy-streamed-response-passthrough`). Either way the deviation is + real, the matrix footnotes describe it, and "required" should mean the deviation + is unacceptable. If degradation is acceptable, declare the capability `optional` + instead — the principle is "required means the matrix footnote's deviation is not + acceptable for this deployment." +- Required + `BoundedCooperative` → informational log (works, with a documented bound). +- Optional + **`BestEffort` OR `Unsupported`** → `log::warn!` naming the capability and + the degradation (the ladder logs *both*, not just Unsupported); never a hard fail. + +#### 3.5.4 Outbound host plumbing — not policy + +`[capabilities.outbound].hosts` is **plumbing**, not an application security allowlist +(non-goal §1.3). Applications still enforce target policy in handler code. + +- **Spin** requires `allowed_outbound_hosts` in `spin.toml`. The platform manifest + consumes the canonical list below; absent hosts preserve today's + `["https://*:*"]` default. The outbound runtime path for `build` / `serve` / + `deploy` resolves the component with existing CLI semantics: an explicit selector must + exist, one component is implicit, and zero or multiple components without a selector + fail. It compares only that selected component's canonicalized + `allowed_outbound_hosts` set before shell or registry dispatch; a union across components + could let one component's permission mask drift in the component being built. On drift, + fail with the `edgezero.toml` path, selected component, and expected canonical list. + Synchronization is manual: update that component field and rerun. Generation initializes + new projects; this spec does not promise a regeneration command for an existing + user-owned `spin.toml`. + + Every entry is canonicalized by the host-authority subset of `OutboundRequest`'s + URI rules (§3.1.3): lowercase scheme and host; strip `:443` for `https` and + `:80` for `http`; reject userinfo and fragments. Manifest host entries are + declarations, not request targets, so paths and queries are also rejected. Drift + comparison is set-based and order-insensitive. + + The absent default deliberately remains `["https://*:*"]`; it is not widened to + cleartext HTTP. Apps that need cleartext outbound access declare it explicitly. + + | Input form (after canonicalization) | Example | Spin output | + | --- | --- | --- | + | wildcard | `"*"` | `["http://*:*", "https://*:*"]` | + | scheme-prefixed | `"http://localhost:3000"`, `"https://api.example.com:8443"` | rendered as-is | + | `host:port` (no scheme) | `"api.example.com:8443"` | `"https://api.example.com:8443"` | + | bare host | `"api.example.com"` | `"https://api.example.com"` | + | wildcard subdomain | `"*.example.com"` | `"https://*.example.com"` | + + The §3.5.1 validator is authoritative; there is no fallback for other forms. + Mixing `"*"` with specific hosts is allowed. Bare hosts mean HTTPS on the default + port only. Canonicalization occurs once before both rendering and drift comparison. +- **Fastly** uses runtime dynamic backends, so it does not need the list at build time; + `hosts` is informational there. +- **Axum / Cloudflare** ignore the list because they require no host pre-declaration. + +## 4. Adapter-by-adapter implementation notes + +Each adapter renames `src/proxy.rs` → `src/outbound.rs`, replaces its `ProxyClient` +impl with an `OutboundHttpClient` impl, adds `capability()`, and gains a +`tests/contract.rs`. + +### 4.1 Axum — `crates/edgezero-adapter-axum` + +- `AxumProxyClient` → `AxumOutboundClient`; keeps the pooled `reqwest::Client`. +- Build that client with `no_gzip()`, `no_brotli()`, `no_deflate()`, and `no_zstd()` in + addition to the manifest's disabled default features. This makes the raw-byte boundary + explicit even if Reqwest feature selection changes later; only the shared EdgeZero + classifier/decoder may transform a response body. +- `send_all` first snapshots `let batch_now = self.clock.now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request whose `body` is + `Body::Stream` OR whose `response_mode` is `Streamed` is converted in place to + `Err(EdgeError::bad_request(..))` (§3.1.1) so the trait contract holds identically + on every adapter. The Buffered-mode buffered-body survivors are fanned out via + `futures::future::join_all` over a private `send_one_validated(req, batch_now)`; index + alignment is preserved by tracking the original positions while building the + future set. It passes that entry snapshot to every per-slot + `dispatch_budget(req, batch_now)` — see §3.3.2 / §4.3 for why a per-slot + second clock sample would drift the shared-deadline `duration` and (on Fastly) the + backend identity. +- A public single `send` takes its monotonic snapshot first, calls + `validate_for_dispatch(&req)` exactly once immediately afterward, then invokes the same + private already-validated flow used for batch survivors. +- `send_one_validated(req, now)` flow, in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now)?` (§3.3.2 — + never an adapter-local formula, so `DEFAULT_NO_DEADLINE_BUDGET = 30 s` is + applied uniformly when no deadline is set). On expiry-before-dispatch this + returns `Err(gateway_timeout)` for the slot immediately. For a single `send`, + `now = self.clock.now()` is taken inline. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Core + `Body::Stream` is `LocalBoxStream` (not the `Send + 'static` stream + `reqwest::Body::wrap_stream` requires), so Axum drains a streamed request body + into `Bytes` up to `req.max_request_body_bytes` (default 8 MiB) **before** + constructing the reqwest request. Pre-append checked accounting per §3.4.1; + over-cap → `bad_request`. Every pull uses this absolute-deadline protocol: + (a) call `budget.deadline.remaining()` immediately before the pull and return + attributed `gateway_timeout` if it is `None`; (b) race `source.next()` against + `tokio::time::sleep(remaining)`; (c) when the source arm becomes ready, call + `budget.deadline.is_expired()` **before** inspecting or accepting its chunk, + EOF, or error, and return the same timeout if expired. The post-ready check + makes the deadline win simultaneous source/timer readiness and prevents an + always-ready stream, including one yielding empty chunks, from starving the + timer past the absolute boundary. Only a source result proven ready before the + deadline proceeds to error propagation and pre-append cap accounting. Adding + reqwest's `stream` feature is **not** required. + 3. **Construct the reqwest request.** Build the `reqwest::Request` / + `RequestBuilder` from the buffered (or now-buffered) body, URI, method, + and normalized headers. Do not arm the timeout yet — it gets re-read + at the very last moment in step 4. + 4. **Arm the reqwest timeout and send.** Immediately before + `.send().await`, re-read `budget.deadline.remaining()`. If `None` (drain + + construction consumed the budget) → `gateway_timeout` without + sending. Otherwise `.timeout(remaining)` is set from this + just-re-read value, **not** from the cached value at end-of-drain and + **not** from the original `budget.duration`. Re-reading at arming time + (matching Spin's "at the moment the race starts" — round 21) closes + the construction-time gap that would otherwise let a 100 ms build + phase silently extend the SDK timeout past the absolute deadline. + reqwest's timeout covers the response-body read, so a `Buffered` + drain inherits the deadline. `Buffered` mode drains the response + body with a running decompressed-byte counter against `max_bytes` + (pre-append check per §3.4.1). `Streamed` mode wraps `reqwest`'s + byte stream with a `tokio::time::timeout`-per-chunk wrapper bounded + by `budget.deadline`; the wrapper yields a `gateway_timeout` (attributed via `budget.cause`, §3.3.2) error + chunk past the deadline so the streamed body honours the deadline + end-to-end per §3.3.3. +- **The buffered response-out fallback must not block the Tokio reactor with + `futures::executor::block_on`.** Make `into_axum_response` async. In `service.rs`, keep + router dispatch and response conversion inside one + `task::block_in_place(|| Handle::current().block_on(async { .. }))` region, awaiting the + non-`Send` core stream there. This preserves the bounded 16 MiB fallback without nesting + an unrelated executor on the reactor thread. Contract tests run a one-worker Tokio + runtime with a stream that awaits a Tokio timer; conversion must complete and an + independent timer task must make progress, proving the fallback neither deadlocks nor + monopolizes the sole worker. +- Errors: `reqwest` timeout → **`gateway_timeout_caused(msg, budget.cause)`** (carries the + attribution — §3.3.2, NOT bare `gateway_timeout`); connect/DNS/TLS before a response + head → `bad_gateway_with_reason(.., Unreachable)`; transport failure after connection + progress → `Transport`; invalid upstream framing/completion → `Protocol`; shared JSON, + gzip, and Brotli failures → `Decode(Json)`, `Decode(Gzip)`, and `Decode(Brotli)`; + response over-cap → the exact + typed `response_too_large` reason (§3.4.5). Any completed exchange (incl. non-2xx) → + `Ok`. +- `capability()` per §3.5.2: `outbound-http` = `Native`, + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-header-fidelity` = `Native`, `outbound-deadlines` = `Native`, + `outbound-flexible-phase-budget` = `Native` (Axum's reqwest exposes a single total + timeout, not a phase split), `send-all-slot-isolation` = `Native`, + `streamed-upload-deadlines` = `Native`, `lazy-streamed-response-passthrough` = + `BestEffort` (footnote 3 — Axum buffers, see `response.rs` task in §7). +- Reference adapter for the contract (§5): real loopback HTTP. + +### 4.2 Cloudflare — `crates/edgezero-adapter-cloudflare` + +- `CloudflareProxyClient` → `CloudflareOutboundClient` (stays stateless). +- `send_all` first snapshots `let batch_now = MonotonicInstant::now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 *before* `send_one_validated` is invoked. It passes that entry snapshot to every + `send_one_validated(req, batch_now)`. Buffered-mode buffered-body survivors are fanned out + via `join_all`; the Workers JS event loop provides the concurrency. Index + alignment is preserved. +- A public single `send` takes its monotonic snapshot first, calls + `validate_for_dispatch(&req)` exactly once immediately afterward, then invokes the same + private already-validated flow used for batch survivors. +- The private already-validated flow runs in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now)?` (§3.3.2). + Expiry before dispatch returns `Err(gateway_timeout)` for the slot. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Up to + `req.max_request_body_bytes` (default 8 MiB), pre-append checked accounting; + over-cap → `bad_request`. Every pull uses the same absolute-deadline protocol + as Axum: read `remaining()` immediately before the pull and fail with an + attributed `gateway_timeout` when absent; race `source.next()` against + `worker::Delay::from(remaining)`; then, if the source arm becomes ready, + recheck `is_expired()` **before** accepting its chunk, EOF, or error. Timeout + wins simultaneous readiness. Also apply the host-event yield quota below: + post-ready clock checks alone do not prevent starvation on Cloudflare. + Source errors and cap accounting run only after the deadline checks. + 3. **Construct the `worker::Request`.** Build the request from the + buffered (or now-buffered) body, URI, method, and normalized headers. + Set `worker::RequestInit::redirect` to `RequestRedirect::Manual` here and assert + `request.inner().redirect()` is manual. Do not add a `Host` header: pass the exact + canonical URI serialization as the string supplied to `worker::Request` and let Fetch + derive Host from that authority. The Web API may apply its own WHATWG + parse/serialization before network dispatch, so no test claims byte-for-byte equality + between that input string and the final wire URL. Native bridge tests assert the + supplied string; workerd/deployed tests assert the effective origin-observed + scheme/authority/path/query and Host against the corresponding WHATWG canonical + semantics, including dot segments, numeric IPv4 aliases, IDNA, percent encodings, + empty paths, and queries. + Do not start the `worker::Delay` race yet. + 4. **Prepare raw, signal-aware fetch options, then arm the race and send.** + Create a `worker::AbortController` owned by the guard below and take its `signal()`. + The adapter-private `fetch_raw_with_signal` helper follows worker 0.8.3's + `global.rs::fetch_with_request` bridge, but supplies **both** the abort signal and + top-level `encodeResponseBody: "manual"` in the **final fetch options**: + + - Create `worker::web_sys::RequestInit` and call `set_signal(Some(&**signal))` + where `signal: &worker::AbortSignal`. + - Set the `encodeResponseBody` property to the JS string `"manual"` with + `worker::js_sys::Reflect::set`. Handle both an exception and `Ok(false)` as + `internal` setup failures with no dispatch; never ignore a failed property set. + - Use the SDK's `WorkerGlobalScope` bridge to call + `fetch_with_request_and_init(request.inner(), &init)`. Await the Promise through + `worker::wasm_bindgen_futures::JsFuture`, cast the result to + `worker::web_sys::Response` with `JsCast`, then convert it to `worker::Response`. + Fetch rejection before a response head is `bad_gateway` reason `Unreachable` only when + platform evidence establishes DNS/connect/TLS establishment failure. The current Workers + Fetch bridge exposes a generic rejected promise without a stable phase discriminator, so + current generic rejections are `Unspecified`; expiry still wins. An unexpected JS response + type is an `internal` binding invariant failure. + + Redirect mode remains on the already-constructed Request; the final raw + `web_sys::RequestInit` owns only the abort signal and response-encoding override and + must not reset redirect behavior. + + The property setup completes **before** issuing fetch. Immediately before the + fetch call and timer arming, re-read `budget.deadline.remaining()`; `None` → + `gateway_timeout_caused(.., budget.cause)` without sending. Otherwise race the + helper's fetch/response conversion **and**, + in `Buffered` mode, the body drain against `worker::Delay::from(remaining)` + (worker 0.8.3 — `Delay` has private fields, so `worker::Delay(remaining)` tuple + construction does NOT compile; use the `From` impl). **On expiry, + abort through the guard.** Dropping the underlying fetch future alone does not + cancel the in-flight subrequest; dropping the guarded send future cancels it through + the guard's `Drop`. Return `gateway_timeout_caused(.., budget.cause)` on expiry. + The existing gzip/br + decompression path is kept; the independent decoded-output cap is enforced + incrementally for identity and EdgeZero-decoded output (§3.4.1). The final Buffered + collection cap is enforced separately for every coding disposition. +- **Raw-response option placement is mandatory.** Workerd's + [request initializer](https://github.com/cloudflare/workerd/blob/f2300a54038995b2ceaf0851447aa5b5b36cda13/src/workerd/api/http.h) + defaults to automatic response decoding; `encodeResponseBody: "manual"` returns raw + encoded bytes. Worker 0.8.3's high-level `RequestInit` does not expose this option, + and `Fetch::send_with_signal` constructs final options containing only the signal. + Setting the property on an earlier Request is insufficient: the pinned workerd + [Request-copy path](https://github.com/cloudflare/workerd/blob/f2300a54038995b2ceaf0851447aa5b5b36cda13/src/workerd/api/http.c%2B%2B) + can reset its response-encoding mode. The helper must set it at the final fetch call. + It is a top-level request option, not a `cf` property, and is independent of the + downstream `EncodeBody::Manual` setting. Keep ordinary `worker::Request` construction; + no raw Request re-wrapping or post-construction mutation is needed. Worker re-exports + `web_sys`, `js_sys`, `wasm_bindgen`, and `wasm_bindgen_futures` with the necessary + bindings, so this bridge adds no direct dependency. Phase 4 pins a workerd harness + that implements this option and verifies actual raw bytes; successful `Reflect::set` + alone does not prove the host honors it. +- **Host-event fairness is required, not just repeated clock reads.** In deployed + Cloudflare Workers, `performance.now()` (and therefore `MonotonicInstant`) advances + only after I/O, unlike the normal local workerd clock + ([Cloudflare timing semantics](https://developers.cloudflare.com/workers/runtime-apis/performance/)). + `worker::Delay` depends on a `setTimeout` callback. An always-ready empty source can + otherwise keep the clock frozen, starve that callback, and never hit the byte cap. + Apply a fixed, nonzero adapter-private ready-item quota of at most 64 items between + explicit host-event yields, counting empty items. The Phase 4 authoring gate must select + a primitive that a deployed timing probe proves both yields to another host event and + permits the production clock to advance. The adapter uses a cancellable + `globalThis.setTimeout(0)` future because worker 0.8.3's `Delay` binding does not execute + under the pinned Node WASM runner; the future owns its callback and timer handle and clears + the timer when dropped. After the yield, recheck the original absolute + deadline before processing another item. Count state persists across Rust polls, stream + yields, and `next()` calls; reset it only after the proven host-event yield completes. A + self-wake, `Poll::Pending` round trip, microtask-only yield, or fake clock that advances + independently of the host event is insufficient. + + This discipline applies to request-source draining, raw response input **before** the + stream-to-reader bridge, and decoded-output processing, including the completion + protocol in §3.4.1. Raw-input enforcement must interrupt ready-empty loops even when + the decoder emits no output. Before returning/yielding terminal success, EOF, or error, + and before an adapter-owned cap error, perform a host-event yield and recheck expiry; + a terminal result must not use a clock snapshot frozen throughout its CPU work. + The generic streamed consumer's cap decision after a within-budget yield retains + §3.4.1's narrower deadline ownership; this does not add request-budget state to core + helpers. Existing per-read remaining-budget races may remain: neither progress nor + host yielding reanchors the absolute deadline. All yield futures and native handles + remain owned, so cancellation during a yield follows the same cleanup rules below. + This is cooperative scheduling between bounded processing steps, not preemption of + arbitrary synchronous work inside one source poll. +- **One abort guard owns every in-flight subrequest.** Arm an adapter-private guard + before polling fetch; it owns the `AbortController` and calls `abort()` on early + termination. In Buffered mode it remains owned through metadata normalization, + bodyless/205 settlement, decoding, and bounded draining. Cap overflow (including + identity `Content-Length` rejection), malformed metadata, decoder/read failure, + timeout, and an early 205 abort disposition all abort the subrequest before return. + Dropping the send future while it owns a live subrequest also aborts via the guard's + `Drop`. Preserve each restored `EdgeError` unchanged. Native transport and genuine + codec/trailing-data failures are `bad_gateway`, cap overflow is `response_too_large`, + and the already-expired adapter budget wins with + `gateway_timeout_caused(.., budget.cause)`. An early 205 disposition still returns an + empty 205 after aborting, as specified in §3.4.1. Disarm only after normal native-body + settlement and successful decoding; headers or decoded EOF alone do not prove that + the native body has completed. Abort is idempotent, and the guard records settlement + so explicit abort followed by `Drop` does not repeat it. Native transport failures use + `BadGatewayReason::Transport`; protocol/completion failures use `Protocol`; shared + codec/trailing-data failures use `Decode(Gzip)` or `Decode(Brotli)`. In worker 0.8.3, + `AbortController::abort(self)` consumes the controller: store it as an `Option` and + `take()` it when aborting or disarming; `Drop` aborts only a remaining `Some`. + A successful Streamed return moves the still-armed guard into the decoded-output + wrapper without an ownership gap. +- **205 host suppression.** If workerd exposes a null body for 205, apply the §3.4.1 + branch: positive visible `Content-Length` aborts immediately; absent/zero length plus + null body is accepted as host-suppressed clean EOF without calling `stream()`. A non-null + body receives the ordinary one-read disposition. The adapter does not claim visibility + into bytes workerd may have suppressed before the guest boundary. +- **Streamed responses honour the deadline AND cancel the subrequest.** Wrap the + response body as `Body::Stream`, with a per-chunk race against a `worker::Delay` + bounded by `budget.deadline`. **The abort guard moves into the decoded-output wrapper**, + because the subrequest is still live while the body streams. The guard aborts in + **all three** cases, but what it yields differs by cause, so the status is not + conflated: (a) the deadline `worker::Delay` fires → abort **+ a + `gateway_timeout_caused(.., budget.cause)` (504) error chunk**; (b) a read, decoding, + or completion failure → abort **+ the restored `EdgeError` unchanged**; native + transport and genuine codec/trailing-data failures map to typed-reason `bad_gateway` + (502), while expiry of the adapter deadline wins over either class; (c) the **consumer drops the stream early** (`Drop` on the wrapper) → + **abort only, no error chunk** (the consumer that dropped is not waiting for one). + Merely yielding an error chunk without `abort()` (an earlier draft) leaves the CF + subrequest running after EdgeZero has stopped reading — the same bug the buffered path + fixes. So the deadline is honoured end-to-end and a mid-stream transport failure maps to + 502 (not 504). The guard invokes `abort()` on every early-exit case. Origin-observed + cancellation is required only for probes that deliberately keep the origin active after + that trigger; a transport/completion failure after the origin already closed cannot + produce a second observable disconnect and is not used as cancellation evidence. +- Errors: `worker::Delay` expiry → + **`gateway_timeout_caused(msg, budget.cause)`** (attributed — §3.3.2); + `worker::fetch` DNS/TLS/connection-establishment failures before a response head → + `bad_gateway_with_reason(.., Unreachable)` when provider evidence establishes that + category, otherwise `Unspecified`; later transport failures → `Transport`; invalid + visible upstream framing/completion → `Protocol`; shared JSON/gzip/Brotli failures → + `Decode(Json)` / `Decode(Gzip)` / `Decode(Brotli)`; **request**-body over-cap → + `bad_request` (400); response-resource failures preserve their exact + `ResponseLimitReason`. A provider error with no defensible narrower category uses + `Unspecified`. Any completed exchange (incl. non-2xx) → `Ok`. +- **Method / body preflight — no silent coercion.** The current adapter maps + unsupported methods to `GET`; that is **removed**. Per §3.1.4, a non-portable method + or a `GET`/`HEAD` carrying a body is rejected in **core preflight** with + `bad_request`, identically on every adapter. The CF adapter never rewrites the + method to satisfy `fetch`'s restrictions. +- **Multi-value response headers — two real bugs to fix, not a platform limit.** + `worker::Headers` has both `append(&self, ..)` and `get_all(&self, ..)`, and this + repo's pinned `compatibility_date = "2023-05-01"` already enables workerd's + per-`set-cookie` `entries()` behaviour. `set-cookie` is therefore **fully + preservable** on CF; today it is dropped by EdgeZero's own code: + 1. **`src/proxy.rs` (upstream → core):** the `entries()` loop calls + `HeaderMap::insert`, which **removes all previous values** — two upstream + `set-cookie`s collapse to the last one. **Fix: `insert` → `append`.** + 2. **`src/response.rs` (core → client):** the `&parts.headers` loop calls + `Headers::set`, which **replaces** — a handler emitting two `Set-Cookie`s ships + only the last to the browser. **Fix: `set` → `append`.** (`&HeaderMap` iteration + already yields the name once per value, so `append` is correct and complete.) +- **Panic hazard in the outbound request path — must be fixed.** `Headers::from(&HeaderMap)` + does `value.to_str().unwrap()`, and `HeaderValue::to_str` **errors on any byte outside + visible ASCII**. A proxied non-ASCII (but perfectly valid UTF-8) header such as + `x-app-display-name: café` therefore **panics the worker**. Replace `Headers::from(..)` + with an explicit loop (`Headers::append` takes `&self`, so no `mut` needed) that + decodes each `HeaderValue::as_bytes()` with `std::str::from_utf8` and appends that string + unchanged. Valid UTF-8 such as `café` survives; invalid UTF-8 returns the typed request + validation error from §3.1.4 before fetch, never `unwrap`, lossy conversion, or panic. + Duplicate preservation in this direction is already correct (`Headers::from` appends per + value) — the defect is the panic, not the multi-value handling. +- **What stays irrecoverable (§3.1.4):** repeated **non**-`set-cookie` headers are + comma-joined by workerd (`x-foo: a, b`) with no API to recover the separate field + lines, and raw upstream bytes / invalid UTF-8 are lost before the guest sees them. + **Never call `get_all` with any name but `"set-cookie"`** — the binding lacks `catch` + and workerd throws, unwinding across the wasm boundary. +- **Encoded passthrough must stay byte-preserving.** When the portable content-encoding + policy chooses passthrough (unknown, parameterized, or stacked/repeated encoding), the + core response retains the raw bytes and visible `content-encoding`. The Cloudflare + response-out converter MUST then select `worker::EncodeBody::Manual` (via + `Response::with_encode_body`) whenever it forwards an already encoded body; leaving the + default automatic mode lets Workers transform bytes that EdgeZero promised to pass + through unchanged. Known gzip/br responses decoded by EdgeZero have their encoding + header removed and use the normal automatic/identity path. Workers can recompute or + ignore a user `content-length` for a streamed response, so exact downstream-wire + `Content-Length` retention is part of Cloudflare's documented + `outbound-header-fidelity = BestEffort` deviation; the app-visible header decision and + raw encoded payload remain deterministic. +- `capability()` per §3.5.2: `Native` for six of the eight outbound capabilities + (`outbound-http`, `outbound-deadlines`, `outbound-flexible-phase-budget` (single + `worker::Delay` for the total race, no per-phase split), `send-all-slot-isolation`, + `streamed-upload-deadlines`, `lazy-streamed-response-passthrough`), `BestEffort` + for `outbound-header-fidelity` because workerd removes raw-octet/field-line information + before the guest, and `Unsupported` for `outbound-complete-resource-accounting`. + Cloudflare's WASM single-threaded guest carries no + `Send` constraint, so `worker::Body::from_stream` consumes the core `Body::Stream` + directly **in the response-out direction** + (`lazy-streamed-response-passthrough` — see §7 `src/response.rs`). The + **outbound-request upload direction** still drains `Body::Stream` to `Bytes` + first (bounded by `max_request_body_bytes`, raced against `budget.deadline`), + because `send_async`-style streamed uploads aren't part of this migration and + the worker SDK's request-body shape differs from `Body::from_stream`. Don't + conflate the two — `send_one`'s flow above is the request side; this bullet is + the response side. + +### 4.3 Fastly — `crates/edgezero-adapter-fastly` + +The critical adapter. The current code (`proxy.rs:30-35`) does +`send_async_streaming()` then `pending_request.wait()` inside one `send()`, so a +`join_all` of `send()` is fully serial. The fix is **dispatch-all-then-harvest**. + +Confirmed `fastly` 0.12.1 API: + +```rust +// fastly::http::request +pub fn select>(pending_reqs: I) + -> (Result, Vec); // no index returned +pub enum PollResult { Pending(PendingRequest), Done(Result) } +// PendingRequest::poll(self) -> PollResult (non-blocking) +// PendingRequest::wait(self) -> Result (blocks on one) +// Request::send_async(self, backend) -> Result +``` + +`select` does not report which request completed, so it cannot preserve request↔slot +identity — and the application must know which target answered. The adapter harvests by **indexed +slot** with `wait()` / `poll()`: + +```rust +// Each pending slot carries every response setting `harvest` needs. `dispatch` +// consumes `OutboundRequest`, so dropping these values there would silently disable +// limits after `wait`/`poll` returns. `send_all` rejects streamed request bodies and +// streamed responses in preflight, so `max_chunk_bytes` has no effect on this path; +// all settings that apply to Buffered responses remain in the policy. +struct PendingResponsePolicy { + max_brotli_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_buffered_response_bytes: u64, // from ResponseMode::Buffered { max_bytes } + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + request_method: Method, +} + +struct PendingSlot { + pending: PendingRequest, + budget: DispatchBudget, // duration + absolute deadline + cause (§3.3.2) + response: PendingResponsePolicy, +} + +enum Slot { + Done(OutboundSlotResult), + Pending(PendingSlot), + Taken, +} + +fn finish_slot( + batch_started_at: MonotonicInstant, + outcome: Result, +) -> OutboundSlotResult { + let observed_at = MonotonicInstant::now(); + match observed_at.checked_duration_since(batch_started_at) { + Some(elapsed) => OutboundSlotResult { elapsed, outcome }, + None => OutboundSlotResult { + elapsed: Duration::ZERO, + outcome: Err(EdgeError::internal(anyhow::anyhow!( + "outbound slot clock moved before batch start (adapter bug)" + ))), + }, + } +} + +async fn send_all( + &self, + reqs: Vec, +) -> Vec { + // Single batch-level `now` snapshot — same value passed to every per-slot + // dispatch_budget so a shared caller Deadline produces the same `duration` + // and ceiled `budget_ms`, and therefore one dynamic-backend identity per host + // in a homogeneous-budget batch. This is the first operation, even for an empty batch. + let batch_started_at = MonotonicInstant::now(); + let request_count = reqs.len(); + + // Phase 0 — preflight. After the method-entry clock snapshot above, **the shared + // `validate_for_dispatch` runs FIRST, exactly once per slot** (§3.1.4 — the mandatory + // shared validator, giving method/body errors + // precedence: a `GET`/`HEAD` + streamed body yields the method-specific message, not the + // generic "send_all requires buffered bodies"). ONLY THEN the batch-only checks + // (send_all rejects streamed REQUEST bodies and streamed RESPONSES). Fastly must not skip + // the validator and check the batch-only rejection first — that would (a) drop the shared + // method/body/resource-policy validation and (b) invert the documented precedence. + let reqs: Vec> = reqs.into_iter() + .map(|req| { + validate_for_dispatch(&req)?; // FIRST — shared validator, method/body precedence + if req.is_stream_body() { // THEN batch-only: send_all is buffered-only + return Err(EdgeError::bad_request( + "send_all requires buffered request bodies")); + } + if req.is_stream_response() { + return Err(EdgeError::bad_request( + "send_all requires buffered responses")); + } + Ok(req) + }) + .collect(); + + // Phase 1 — dispatch. Every request is in-flight at the host concurrently. + // dispatch returns Err for an expired/zero deadline so those slots + // never enter Phase 2. The host connect/first-byte/between-bytes timeouts are + // set from budget.duration; budget.deadline governs the body-phase cooperative + // check below. + let mut slots: Vec = reqs.into_iter() + .map(|maybe_req| match maybe_req { + Err(err) => Slot::Done(finish_slot(batch_started_at, Err(err))), + Ok(req) => { + match dispatch(req, batch_started_at) { + // `dispatch` moves the applicable fields from `OutboundRequestParts` into + // `PendingResponsePolicy` before handing the request to Fastly. In particular, it + // captures `request_method` for HEAD disposition and all Buffered response limits. + // Result<(PendingRequest, DispatchBudget, PendingResponsePolicy), EdgeError> + Ok((pending, budget, response)) => Slot::Pending(PendingSlot { + pending, budget, response, + }), + Err(err) => Slot::Done(finish_slot(batch_started_at, Err(err))), + } + }, + }) + .collect(); + + // Phase 2 — harvest. wait blocks on one slot; siblings keep progressing at + // the host. For the headers phase, wall-clock is ~max(header_arrivals), not + // the sum. Buffered body drain runs *serially* in harvest order, so total + // wall-clock is ~max(header_arrivals) + Σ body_drain_times — see + // "Buffered body drain runs in harvest order". poll opportunistically + // collects siblings that already finished headers. Only Buffered responses + // reach this point — Streamed responses were rejected in Phase 0 preflight. + let mut outputs: Vec> = + (0..request_count).map(|_| None).collect(); + for index in 0..request_count { + match std::mem::replace(&mut slots[index], Slot::Taken) { + Slot::Done(result) => outputs[index] = Some(result), + Slot::Taken => { /* already harvested by an earlier poll() */ } + Slot::Pending(pending_slot) => { + outputs[index] = Some(finish_slot( + batch_started_at, + harvest( + pending_slot.pending.wait(), + &pending_slot.budget, + &pending_slot.response, + ), + )); + for sibling_index in index.saturating_add(1)..request_count { + // Carefully preserve every variant; the bug we are + // avoiding here is "take a Slot::Done(Err(..)) from + // preflight or dispatch and replace it with Slot::Taken, + // which then drops the Err on the floor and the outer + // loop reports a generic 'slot unresolved' internal + // error." + match std::mem::replace(&mut slots[sibling_index], Slot::Taken) { + Slot::Done(result) => outputs[sibling_index] = Some(result), // preserve preflight / dispatch error + Slot::Taken => { /* already harvested */ } + Slot::Pending(sibling) => match sibling.pending.poll() { + PollResult::Done(result) => { + outputs[sibling_index] = Some(finish_slot( + batch_started_at, + harvest(result, &sibling.budget, &sibling.response), + )); + } + PollResult::Pending(pending) => slots[sibling_index] = Slot::Pending(PendingSlot { + pending, + budget: sibling.budget, + response: sibling.response, + }), + }, + } + } + } + } + } + // Invariant: every slot resolved above. Map any unfilled slot to an + // internal error rather than panic — adapter boundaries must never + // crash the host on a contract bug. + outputs.into_iter() + .enumerate() + .map(|(index, result)| result.unwrap_or_else(|| finish_slot( + batch_started_at, + Err(EdgeError::internal(anyhow::anyhow!( + "fastly outbound: slot {index} unresolved by harvest loop (adapter bug)" + ))), + ))) + .collect() +} +``` + +- **`.wait()` is not the problem** — calling it before all requests are dispatched was. + After Phase 1 every request runs at the host; Phase 2 only collects results. +- **Deadline:** each request's host timeouts are set to the effective budget at dispatch, + so connect+headers cannot block past it. The body phase checks `budget.deadline` + **after every blocking body read returns, including the EOF read** (per §3.3.4 — + the read that discovers EOF can itself cross the deadline and would otherwise + slip through with `Ok(resp)`). Streamed bodies are wrapped to check before and + after each underlying read. Bounded overshoot per §3.3.4. +- **Cancellation / drop semantics.** Fastly exposes no async-cancellation primitive + for an in-flight `PendingRequest`, and Phase 2 harvests with **blocking** `wait()` / + `poll()` (no `.await` between dispatch and completion), so `send_all` has no interior + suspension point at which the future could be dropped mid-harvest — once Phase 1 + returns, the loop runs synchronously to completion. Two consequences the contract + guarantees: (a) **every successfully dispatched `PendingRequest` in this buffered + `send_all` path is harvested**. This invariant does not cover single-`send` streamed + uploads: source/cap/deadline/write/flush/finish-error and future-drop exits follow + "Streamed request bodies in single `send`" below, including drop without wait. + (b) **A sibling + slot's deadline firing does not abort other slots** — each slot's budget is enforced + independently by its own dispatch-time host timeouts plus the per-slot cooperative + `budget.deadline` check, never by cancelling a neighbour. The cross-slot effect is + strictly a *harvest-order delay* (§3.3.4 / §8 risk 8), not cross-slot cancellation. +- **Dynamic backends.** Arbitrary HTTPS hosts use Fastly dynamic backends + (`Backend::builder`). Per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — a dynamic backend name must + not match the name of any static service backend nor any other dynamic backend + built during this session. `NameInUse` carries no property-comparison + semantics: the SDK signals only "this name is taken in this session," and its + documented recovery (`Backend::from_str(name)`) returns a handle without + exposing the registered properties. EdgeZero therefore owns the entire + uniqueness story **at the guest layer**: a **session-scoped** adapter-local cache + (a `Mutex>` field on the + request-context `FastlyOutboundClient` — `Mutex` not `RefCell`, because the trait is + `Send + Sync`; see *Cache ownership* below) holds the identity → + backend mapping, and a hit + reuses the cached `Backend` while a miss calls `Backend::builder(..).finish()` + exactly once. Because EdgeZero hashes every relevant property into the + backend name (`ez_{sha256_128(identity)}`), distinct identities map to + distinct names — so a 50 ms slot and a 3 s slot to the same host get distinct + backends by construction, not by SDK-side property comparison. A + `NameInUse` on a name **not** in the adapter's collision map can therefore + only mean an externally-registered backend (a static service backend, or another + component in **this** session — NOT a prior session, whose names are gone) is squatting the name — fail-closed `EdgeError::internal` because + the SDK does not let us prove identity match. The precise collision-detection + protocol is in the §4.3 algorithm later in this section. + + Identity tuple: + `scheme + ":" + host + ":" + resolved_port + ":" + tls_mode + ":" + budget_ms`, + where: + - `resolved_port` is the URI port or scheme default (`80`/`443`). + - `tls_mode` is `"tls"` for `https` or `"plain"` for `http`. + - `budget_ms` is the **exact true-ceil-to-ms** of `dispatch_budget(req).duration` — + `((duration.as_nanos() + 999_999) / 1_000_000).max(1)`, lint-clean form in the + `fastly_timeout_ms` helper. **No bucketing.** The cache is **per-session** (below), + so it cannot grow unbounded across requests — earlier drafts bucketed the budget to + bound a *cross-request* thread-local cache, but that cache model was wrong. Dynamic + backend registration names are session-scoped. Connection pooling is a separate SDK + feature. Fastly 0.12.1 reuses a connection only when both the backend name and every + backend setting are identical; the name below already incorporates the exact rounded + budget, host, port, scheme, and TLS mode, while timer/TLS settings are deterministic from + that identity. EdgeZero therefore leaves the SDK default pooling enabled: reuse cannot + cross a budget or TLS identity, and disabling it would only forgo connection reuse. Within + one session a `send_all` + shares a single `batch_now`, so same-budget slots to the same host compute the + **same** `budget_ms` → one backend; distinct budgets → distinct backends. The cache + is therefore bounded by the number of distinct `(host, budget)` pairs in the + session's fan-out (≤ batch size), with **no** millisecond drift to bucket away. + - The **host timers use `budget_ms` exactly** (`connect-timeout` / + `first-byte-timeout` / `between-bytes-timeout` derive from the exact value), so the + headers phase is bounded by `budget.duration` (+ ms-rounding + `BATCH_DISPATCH_SLACK_MAX`), + **not** a looser bucket. The body phase is additionally enforced to the millisecond by + the cooperative `budget.deadline.is_expired()` check against the original `Deadline`. + Removing the bucket removes the ~10 % headers-phase overshoot an earlier draft + introduced; the net guarantee is again exactly what §4.3 "Net guarantee" states. + - `budget_ms` is the **true ceil-to-ms** — `((duration.as_nanos() + 999_999) / + 1_000_000).max(1)` (lint-clean form in `fastly_timeout_ms`), since `as_millis()` + floors and would make the host timeout too tight. (Apps wanting sub-ms wall-clock + should not target Fastly — host timeouts are millisecond-granular.) §3.3.4's "host + timeouts = `budget.duration`" is an abbreviation for "= ceil-to-ms of `budget.duration`". + + A 50 ms slot and a 3 s slot to the same host get **distinct** backends (distinct + `budget_ms` → distinct identity → distinct name) — they must, since their host timeouts + genuinely differ. Within one `send_all`, same-budget slots share `batch_now` → identical + `budget_ms` → one backend. + + Name = `format!("ez_{:032x}", sha256_128(identity))` — the first 128 bits of a + SHA-256 digest, collision-resistant in any realistic deployment (the previous + 64-bit FNV-1a draft was not). The name fits inside Fastly's backend-name length + limit (`ez_` + 32 hex chars = 35 chars) and is valid for any host. In a + homogeneous-budget batch all slots targeting the same host + share one backend — **but only because `send_all` takes a single `now` snapshot + and passes it to every per-slot `dispatch_budget` call** (§3.3.2). Without that, + sequential `MonotonicInstant::now()` per slot would derive slightly different `duration`s + for the same shared caller `Deadline`, which would produce slightly different + ceiled `budget_ms` values and therefore different identities for the same host + under one batch deadline. The shared-`now` snapshot is a normative requirement + of the `send_all` flow, not an implementation hint. In heterogeneous-budget + fan-out each distinct budget gets its own backend, by design. Per-handler + backend count is bounded by `unique(host, port, tls, budget_ms)` tuples; apps + that mix wildly varying budgets should be aware of the dynamic-backend limit on + their Fastly service. + + **Dispatch-overhead slack — hard-bounded for a CACHED backend, fail-closed-*detected* + for first-time registration** (see the honesty note after the bullets). Because `batch_now` is captured + *before* preflight, dynamic-backend creation, and `send_async`, the `budget_ms` + baked into the backend identity is a *snapshot* timeout (computed from `batch_now`) — + not the exact remaining wall-clock at the moment the SDK timer is armed. The Fastly host enforces + `budget_ms` from the moment it sees the request, so a request can in principle + complete up to `(now_at_send_async − batch_now) ms` after the absolute fan-out batch + deadline before the host fires its timeout. To keep this slack + **deterministically bounded** for warm/cached adapter setup and host-timer arming (not + request-body transmission, whose write gap remains unbounded). The capability itself is + `BestEffort` because both the cold path below and non-empty request writes have no such + bound: + + - The adapter caps `(now_at_send_async − batch_now)` at + `pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25);` + (defined alongside `DEADLINE_FAR_FUTURE` in `src/time.rs`, §7). + - Before each slot's `send_async`, the adapter checks two things **in this order**: + **(1) the absolute deadline FIRST** — `if budget.deadline.is_expired() { return + Err(EdgeError::gateway_timeout_caused("deadline expired during Fastly dispatch", + budget.cause)); }`. A cold `Backend::builder(..).finish()` can block past the deadline + (§4.3 honesty note), and when it returns *after* the deadline, that is a genuine + **504 timeout**, NOT an EdgeZero bug — so it must be an attributed `gateway_timeout`, + never `internal`. **(2) THEN, only if time still remains** (`!is_expired()`) but the + adapter overhead exceeded the slack, `MonotonicInstant::now() - batch_now > + BATCH_DISPATCH_SLACK_MAX` → the remaining slots fail closed with + `Err(EdgeError::internal("Fastly send_all adapter overhead between batch_now \ + and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) \ + exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale \ + duration"))`. So `internal` is reserved for **excess adapter overhead while the + deadline still had time** — a real "our setup is too slow" signal — and never for an + actual expiry. This is an internal diagnostic about **adapter-side** work, + not a handler-side complaint — handler code runs before `send_all` is even + invoked, so it runs before `batch_now` is captured and cannot exhaust this + budget. The interval measured here is adapter overhead: per-slot preflight + validation, dynamic-backend lookup/creation host calls, and SDK setup + before `send_async`. If this fires in production, the operator looks at + backend-creation hostcall latency or a noisy neighbour, not at handler + code. + - The cooperative `budget.deadline.is_expired()` check during body drain still + catches body-phase overshoot per §3.3.4 (one between-bytes-timeout bound). + + **Honesty note — what the guard can and cannot do.** The check above runs + *immediately before* `send_async`, i.e. **after** any `Backend::builder(..).finish()` + in this slot has already returned. It therefore **detects** an overshoot; it cannot + **preempt** one. That distinction splits the claim in two: + + - **Cache hit (no `finish()` call).** The measured interval is pure adapter compute — + preflight, a map lookup, SDK setup. It is genuinely bounded, and the setup/arming + portion holds the stated `BATCH_DISPATCH_SLACK_MAX + ms_rounding` bound. This does not + turn a body-bearing exchange into a finite end-to-end bound because the subsequent + host write remains untimed. + - **First-time dynamic-backend registration.** `finish()` is a **synchronous host + call that can block** — Fastly may make it wait for a service-wide dynamic-backend + slot. Nothing guest-side can interrupt it. If it blocks past the deadline, the + wall-clock overshoot has *already happened* by the time the guard runs — so the guard + **checks the absolute deadline FIRST and returns an attributed `gateway_timeout` (504)** + (the honest outcome for a real expiry). It does **not** call this `internal`: `internal` + is reserved for the *other* case — adapter overhead exceeding the slack **while the + deadline still has time** (a genuine "our setup is too slow" bug), never for a + time-that-actually-ran-out. **On this path the dispatch+headers phase is `BestEffort` for + wall-clock, not bounded** — the guard detects the overshoot and surfaces it as a 504, it + does not prevent it. + + So the honest one-line statement, which the matrix footnote and §5.4 rows must match: + *Fastly declares `outbound-deadlines` = `BestEffort`. The warm/cached path enforces a + documented bound, but a first-time `finish()` registration can overshoot the deadline; the + guard checks the absolute deadline first and returns an **attributed `gateway_timeout` + (504)** for that expiry (NOT `internal` — `internal` is reserved for adapter overhead + exceeding the slack while time still remains), and `capability()` cannot tell warm from + cold — so the static value is `BestEffort`, and a `required outbound-deadlines` hard-fails + on Fastly.* Apps + needing an exact absolute deadline on the dispatch+headers phase — including the first + request to a new host — target Axum or Cloudflare. Spin also avoids Fastly's blocking + registration step, but its cooperative host teardown keeps `outbound-deadlines` + BestEffort (footnote 8). + + Net guarantee, with the explicit **sub-4 ms branch** broken out separately. **Both + branches below hold on the WARM path only — an already-registered (cached) backend, no + `finish()` in the measured interval.** On the first request to a new host, + `Backend::builder(..).finish()` can block unboundedly (per the honesty note above), so + these equations do NOT apply and the phase is `BestEffort`; that is exactly why the + capability is `BestEffort` (footnote 1). The bounds: + + - **`total_ms ≥ 4` (the common case), cached backend**: a Fastly slot can complete at most + **`BATCH_DISPATCH_SLACK_MAX + ms_rounding`** past the absolute fan-out batch + deadline on the dispatch+headers phase. Because connect and first-byte are + *separate* host timers (Fastly docs), the budget is split — `connect_ms = + total_ms / 4`, `first_byte_ms = total_ms - connect_ms` — so their sum equals + `total_ms` exactly and the dispatch+headers host enforcement is bounded by + `budget.duration`. If dispatch happens at `batch_now + Δ` with + `Δ ≤ BATCH_DISPATCH_SLACK_MAX`, the host fires at + `(batch_now + Δ) + (connect_ms + first_byte_ms) = (batch_now + Δ) + total_ms`, + which is `Δ + ms_rounding` past the absolute deadline. Setting *both* timers + to the full budget would have made the worst case ~2× — explicitly *not* what + this design does (see §3.3.4 / §4.3 code block). + - **`total_ms < 4` (the sub-4 ms degenerate case)**: §4.3 sets both + `connect_ms = first_byte_ms = total_ms`, so the dispatch+headers host + enforcement is bounded by `2 × total_ms` (≤ 6 ms total at the edge). The + post-deadline slack is therefore up to `BATCH_DISPATCH_SLACK_MAX + total_ms + + ms_rounding` (strict upper bound `25 + (≤ 3) + (≤ 1) < 29 ms` wall-clock). + At this scale ms-rounding already + dominates a meaningful deadline; sub-4 ms outbound budgets are degenerate + inputs, not a normal operating point. The test row asserts the 2× bound + explicitly rather than the `=` invariant. + + The body-phase cooperative check still adds up to one between-bytes-timeout + overshoot during drain (§3.3.4) in either case, but that's the only other + source. All terms are hard adapter constants, not "scales with preflight." + + Single `send` snapshots `now` at the public method entry before preflight and passes it + into `send_one` — there is no + `batch_now` shared across slots — but time still passes between + `dispatch_budget(req, now)` and `send_async` (backend lookup, possible + `Backend::builder().finish()` host call, SDK request construction). The + **same TWO-CHECK guard in the SAME ORDER as `send_all`** applies (§4.3 — this was + inconsistent before; corrected): immediately before `send_async`, the adapter checks + **(1) the absolute deadline FIRST** — `if budget.deadline.is_expired() { return + Err(gateway_timeout_caused("deadline expired during Fastly dispatch", budget.cause)); }` + (a cold `finish()` returning past the deadline is a genuine **504**, not an EdgeZero bug); + **(2) THEN, only while time remains**, `MonotonicInstant::now() - now > BATCH_DISPATCH_SLACK_MAX` + → `EdgeError::internal(..)` with the same "adapter overhead between dispatch_budget and + SDK arming" diagnostic as `send_all`. So single `send` returns the attributed **504** for a + real expiry and `internal` only for excess overhead with time to spare — identical to + `send_all`. The slack window is typically narrower for single `send` (no per-slot harvest + loop), but the bound is the same hard constant; the previous "structurally 0" wording was + incorrect. The phase-budget split and sub-4 ms branch apply identically. + + §5.4 has a row that locks this. The test cannot use a handler-side sleep before + `send_all` — that runs *before* the adapter captures `batch_now`, so it never + exercises the slack guard. The test instead uses an **adapter-internal injection + hook** (a **`#[cfg(feature = "test-utils")]`** callback on the SDK-independent + dispatch driver used by `FastlyOutboundClient`, invoked between `batch_now` capture + and per-slot dispatch) to introduce a + synthetic delay exceeding `BATCH_DISPATCH_SLACK_MAX`. **It must be feature-gated, + not `#[cfg(test)]`** — `tests/contract.rs` is an external integration test and + compiles the adapter *without* `cfg(test)`, so a `#[cfg(test)]` hook would be + invisible to it (§5.5 *Executable test seams*). With the hook set, late slots return + `internal("Fastly send_all adapter overhead between batch_now and SDK arming \ + (preflight + dynamic-backend lookup/creation + SDK setup) exceeded \ + BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration")`; + without it, no slot ever returns that error. Apps that need exact + absolute-deadline enforcement on the dispatch+headers phase target Axum or Cloudflare. + Spin also arms from `budget.deadline.remaining()` (§4.4 step 3), but does not claim a + finite host-teardown bound. **Collision detection** is + belt-and-suspenders. + + **Cache ownership — a request-context/session map (a `Mutex` field on the client).** + This is the single authoritative statement; it governs the protocol below and the + §4.3 *Dynamic backends* discussion. **Fastly dynamic-backend names are + session-scoped, NOT global across requests** (verified against the `BackendBuilder` + docs: dynamic-backend *registration* is per-session — each session registers into its + own namespace, so a `NameInUse` applies to the active session). The SDK separately + enables same-name/exact-same-settings connection pooling across sessions by default; + EdgeZero retains that default because the deterministic backend identity and settings + prevent cross-budget or cross-TLS reuse. `FastlyOutboundClient` is constructed for + each EdgeZero request context (`crates/edgezero-adapter-fastly/src/request.rs`), receives + a fresh cache, and must not be retained beyond that context. The cache is therefore a + field on the client whose intended lifetime is the active request/session: + + ```rust + struct FastlyOutboundClient { + // Per-request/per-session dedup map. MUST be Mutex, not RefCell: + // OutboundHttpClient: Send + Sync (stored as Arc in http::Extensions), + // and RefCell is !Sync. The Mutex is uncontended on the single-threaded WASM + // guest — it satisfies the Sync bound, it does not serialize anything. + backends: Mutex>, + // … + } + ``` + + **This reverses two earlier drafts wrong for the same root reason** — the + belief that registration names persist across requests. One made the cache a cross-request + `thread_local!` (which would carry **stale** entries into a reused instance's next + session, where those names are unregistered); another bucketed the budget to bound + that non-existent cross-request growth. Neither is needed: the map exists only to + **dedup within a single session's fan-out** (multiple `send_all` slots / multiple + `send`s in one handler to the same host+budget reuse one registration), so its size + is bounded by the fan-out, and it is discarded when the request context ends. Disabling + SDK connection pooling is distinct from discarding this registration cache. The **test seam** + (§5.5) exposes this client field. + + The protocol below takes the map's **uncontended `Mutex`**. To state the one model + plainly, because earlier drafts said three different things: the `Mutex` exists solely + to satisfy `OutboundHttpClient: Send + Sync` (the handle is stored as + `Arc` in `http::Extensions`) — **not** to serialize anything. + The Fastly guest is single-threaded, so no two `send_one` bodies interleave during a + synchronous host call, and **the lock is never held across a host call** (step 3). + (Read any surviving "no lock", "thread-local", or "lock held through `finish()`" + phrasing here as superseded by this paragraph.) + + 1. Lock the map (`self.backends.lock()` — uncontended; handle any poison by treating a poisoned lock as an internal error, never by unwrapping in production). + 2. If the name maps to a stored entry `(stored_identity, cached)`: + - **`stored_identity == identity`**: clone the cached `Backend`, drop the + lock, dispatch. + - **`stored_identity != identity`** (an in-adapter SHA-256-128 collision + between two distinct identities mapping to the same name): fail closed with + `EdgeError::internal("Fastly dynamic backend name collision in this + adapter's map — two distinct identities hashed to the same backend name; + refusing to silently swap settings")`. The previous-round wording reused + the cached backend by name alone, which would have silently bound a new + request to whichever identity got cached first — that bug is fixed by the + explicit identity comparison here. Release the borrow. §5.4 has a row that + exercises this path via an injectable hash collision under the `test-utils` feature + (**not** `#[cfg(test)]` — see §5.5 *Executable test seams*). + 3. Otherwise (name is absent), **release the lock**, then call + `Backend::builder(..).finish()`. The lock is **not** held across this host call: + `finish()` registers a dynamic backend and can block waiting for a service-wide + backend slot (see *Dispatch-phase deadline honesty* below), and holding a lock + across a potentially-blocking host call is a hazard for any future multi-threaded + host. Releasing it does **not** reintroduce the same-identity race an earlier draft + worried about: the guest is single-threaded and `finish()` is a synchronous + (non-`await`) call, so no other `send_one` can run in the gap — a `NameInUse` from a + genuine miss is therefore still unambiguously external (step 5). + 4. On `Ok(backend)`: **re-acquire the lock**, insert `(identity, backend.clone())` + into the map, drop the lock, and return the `Backend`. + 5. On `Err(NameInUse)`: per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — "a dynamic backend name + must not match the name of any static service backend nor match any other + dynamic backend built during this session." `NameInUse` does **not** carry + property-comparison semantics ("same identity → returns Ok" was a false + premise in earlier drafts); the SDK signals only "this name is taken in + this session," period. The SDK's documented recovery pattern is to call + `Backend::from_str(name)` (alias `Backend::from_name`) to obtain a handle + to the already-registered backend — but `from_str` returns a handle only + and **does not expose the registered backend's properties** to the guest + for comparison. + + The lock is released across `finish()` in step 3 and re-acquired in step 4 (not + held continuously — see *Cache ownership*), but the reasoning is unaffected because + the Fastly guest is **single-threaded** and `finish()` is a synchronous + (non-`await`) call: nothing else can run in the gap, so any + name *we* registered in this session necessarily showed up in step 2's borrow. A + `NameInUse` here therefore means the name was registered by an **external + party in this same session**: a static service backend, or another component + of this instance (**not** a prior session — dynamic-backend names are + session-scoped and a fresh session starts with a clean namespace). Since the + SDK does not let us inspect that external + backend's properties, we cannot prove its identity matches ours. Fail + closed with `EdgeError::internal("Fastly Backend::builder returned + NameInUse for a name not in this adapter's collision map; the SDK does + not expose the externally-registered backend's properties, so we cannot + prove identity match — refusing to dispatch to a backend with possibly + mismatched TLS / timeout / SNI configuration")`. Release the borrow. + + The alternative — falling back to `Backend::from_str(name)` and trusting + the external registration — is exactly the "you should be careful to only + use this capability in situations in which you are 100% sure that this + name will always lead to the same place" caveat that Fastly's docs + attach to `from_str`. Since EdgeZero owns the `ez_{sha256_128(identity)}` + naming scheme, a `NameInUse` for a name **absent from this session's map** + can only mean one of: (a) a **static service backend** is configured with + that name (the SDK's uniqueness rule spans static + dynamic within the + session), or (b) another component **in this same session** registered it, + or (c) a SHA-256-128 collision (vanishingly unlikely given the 128-bit + identity space). None is safe to silently inherit. + + > Note: it canNOT mean "a prior session registered it." Dynamic-backend + > names are **scoped to the session** and may overlap freely across + > sessions/instances — a fresh session starts with a clean namespace. An + > earlier draft attributed the collision to a prior session / another + > EdgeZero deployment sharing an edge dictionary; that is not how the + > lifetime works. This matters for the session-scoped cache above: the map + > and the host's name registry share **exactly** the same lifetime (the + > session), which is precisely why the map is a reliable mirror of it — and + > why a *cross-request* (process-persisted) map was not: a longer-lived map would + > carry stale names into a new session whose host registry has been cleared, so it + > would stop mirroring the registry. (The map here is per-session/per-request — the + > correct, shorter lifetime; the rejected alternative is the *cross-request* one.) + 6. On any other `Backend::builder(..).finish()` error — i.e. a + **`BackendCreationError`** — **map per the exhaustive stage-1 table below, NOT + with a blanket `bad_gateway`.** This is the one authoritative mapping; earlier + prose here said "map every other creation error to `bad_gateway`", which is wrong: + `ConnectTimeoutTooLarge` / `FirstByteTimeoutTooLarge` / `BetweenBytesTimeoutTooLarge` + / `NameTooLong` / `EncodingError` mean **EdgeZero** violated its own clamp/naming + invariant → **`internal` (500)**, not a 502. Only genuine host rejections + (`Disallowed`, `HostError`) are `bad_gateway`. `Disallowed` gets the dedicated + "enable dynamic backends" diagnostic. (`Backend::builder` is `#[non_exhaustive] = + false`, so the match is exhaustive with **no** `_` arm — a future SDK variant is a + compile error to be classified deliberately.) + + > **DNS / TLS / connect failures do NOT occur at this stage.** The SDK separates + > **`BackendCreationError`** (registering a backend — this step) from + > **`SendErrorCause`** (actually performing the exchange). Registration does not + > resolve DNS or complete a TLS handshake; those happen on **send**, and are + > mapped by the send-stage `SendErrorCause` table below. An earlier draft listed + > "DNS resolution failure / TLS misconfiguration" here — that is wrong, and it + > made the corresponding §5.4 test unwritable (a fake *builder* cannot produce a + > DNS branch). Test the two stages against their own error types. + `EdgeError::internal` is reserved for **adapter contract bugs** — invariant + violations the adapter itself should have prevented (the unfilled-slot case + in the harvest loop, the `BATCH_DISPATCH_SLACK_MAX` overshoot, this + section's `NameInUse` external-registration case). Release the borrow. + + **Backend *creation* errors are not the transport errors.** The list above covers + `Backend::builder(..).finish()` — i.e. *registering* a dynamic backend. **DNS, TLS, + and connection failures do not surface there**; they arrive later, from the **send** + itself, as a `SendError` whose `SendErrorCause` names the failure. Mapping the two + stages together (as earlier drafts did) would mislabel a connect failure as a + "backend setup" error. The normative **send-stage** mapping — applied at + `pending.wait()` / `poll()` in the harvest loop and on the single-`send` path, + replacing today's blanket `EdgeError::internal(..)` on `wait()` failure: + + **Per-variant policy.** The variant names below are the real `fastly` 0.12.1 enums + (`backend::builder::BackendCreationError`, `http::request::SendErrorCause`). **A + blanket "anything else → 502" is wrong**: several variants mean *EdgeZero* violated + its own invariant, and reporting those as an upstream gateway failure hides an adapter + bug behind a plausible 502. + + > **⚠️ The two enums have different exhaustiveness properties — verified against the SDK source, and + > this drives both the code and the tests:** + > + > | | `BackendCreationError` | `SendErrorCause` | + > | --- | --- | --- | + > | `#[non_exhaustive]`? | **No** | **Yes** | + > | Exhaustively matchable by us? | **Yes** — omit a `_` arm so a future SDK variant is a **compile error** | **No** — a `_` arm is *mandatory*; new variants silently fall through | + > | Constructible in a test? | **Yes** (`PartialEq` too) | **Yes**, for public known variants; hypothetical future variants cannot be constructed | + > + > So: match `BackendCreationError` exhaustively (no `_`). For `SendErrorCause` an + > exhaustive match is **impossible** — an earlier draft's instruction to "match them + > exhaustively so a future variant is a compile error" is unachievable there, and the + > mandatory `_` arm is exactly why its default must be the *narrow* `Custom`-style + > 502 rather than a blanket. By contrast, the `SendError` wrapper is unconstructible (private fields, + > no public ctor), though `SendError::root_cause() -> &SendErrorCause` lets the + > adapter read the cause. See *Send-stage test seam* below for how this is tested. + + **Stage 1 — `BackendCreationError` (registration):** + + | Variant | EdgeError | Status | Why | + | --- | --- | --- | --- | + | `Disallowed` | `bad_gateway`, reason `Unspecified` | 502 | Dynamic backends off on the service — operator action; carries the dedicated diagnostic but is not a transport/protocol/decode failure. | + | `NameInUse` | *(not an error here)* | — | Handled by the cache protocol above (identity compare → reuse or fail closed). | + | `ConnectTimeoutTooLarge`, `FirstByteTimeoutTooLarge`, `BetweenBytesTimeoutTooLarge` | **`internal`** | **500** | **EdgeZero bug.** The `DEADLINE_FAR_FUTURE` clamp (7 d) plus `fastly_timeout_ms`'s u32-ms clamp exist precisely to make these unreachable. Reaching one means our clamp is broken — not an upstream failure. | + | `NameTooLong` | **`internal`** | **500** | **EdgeZero bug.** We own the naming scheme (`ez_` + 32 hex = fixed length); too-long is impossible unless the scheme changed. | + | `EncodingError` | **`internal`** | **500** | **EdgeZero bug.** We construct the backend strings; invalid UTF-8 is ours. | + | `HostError(FastlyStatus)` | `bad_gateway`, reason `Unspecified` | 502 | Genuine host-side registration rejection with no narrower portable category. | + + **Stage 2 — `SendErrorCause` (the exchange):** + + | Variant(s) | EdgeError | Status | + | --- | --- | --- | + | `DnsTimeout` | `gateway_timeout`; `Unspecified` before absolute expiry, selected cause at/after expiry | **504** | + | `ConnectionTimeout`, `HttpResponseTimeout` | `gateway_timeout` with selected cause because EdgeZero configures those phase timers from the selected budget | **504** | + | `DnsError`, `DestinationNotFound`, `DestinationUnavailable`, `DestinationIpUnroutable`, `ConnectionRefused`, `ConnectionLimitReached` | `bad_gateway`, reason `Unreachable` | 502 — the exchange did not establish a usable upstream response path. | + | `TlsProtocolError`, `TlsCertificateError`, `TlsAlertReceived`, `TlsConfigurationError` | `bad_gateway`, reason `Unreachable` | 502 — TLS establishment failed before a response head. | + | `ConnectionTerminated` | `bad_gateway`, reason `Transport` | 502 — the connection made progress and then terminated. | + | `HttpIncompleteResponse`, `HttpResponseHeaderSectionTooLarge`, `HttpResponseBodyTooLarge`, `HttpResponseStatusInvalid`, `HttpUpgradeFailed`, `Http2StreamError`, `HttpProtocolError` | `bad_gateway`, reason `Protocol` | 502 — malformed/oversized **upstream** response. | + | `IoError` | `bad_gateway`, reason `Transport` | 502 | + | `ImageOptimizerUnsupported` | `bad_gateway`, reason `Unspecified` | 502 — no narrower portable category. | + | `HttpRequestUriInvalid`, `HttpRequestCacheKeyInvalid`, `HttpCacheLimitExceeded`, `HttpCacheApiUnsupported` | **`internal`** | **500** — **EdgeZero bug.** We build the request/URI and don't use the cache API; a *locally-invalid request* is ours, not the upstream's. (§3.1.3/§3.1.4 validate the URI long before dispatch.) | + | `InternalError(..)` | **`internal`** | **500** — the SDK's own unexpected host-internal fault: not the *origin's* failure (so not 502) and not a malformed request WE built (so not kind-(i)), but a platform-internal error. `internal` (500) covers this kind-(ii) fault per the broadened taxonomy (§5.4). | + | `Custom(..)` | `bad_gateway`, reason `Unspecified` | 502 — unknown/extension cause; the only defensible default, and it is *narrow* rather than a blanket. | + + **Send-stage test seam — test known SDK causes and SDK-independent policy.** + Public known `SendErrorCause` variants are constructible outside the SDK; enum-level + `#[non_exhaustive]` prevents exhaustive external matching, not construction of those + variants. Only the `SendError` wrapper requires an SDK-produced value. Retain the + two-stage classification so native policy tests remain SDK-independent, and directly + test the SDK boundary for every known cause: + + ```rust + // 1. A locally-defined, CONSTRUCTIBLE classification of what went wrong. + // Unit tests build these directly — no SDK types involved. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum SendFailure { + BudgetedTimeout, // ConnectionTimeout | HttpResponseTimeout + LocalInvariant, // HttpRequestUriInvalid | HttpRequestCacheKeyInvalid | HttpCacheLimitExceeded | HttpCacheApiUnsupported + PlatformInternal, // InternalError: Fastly host/runtime internal fault, not caller input + ProviderTimeout, // DnsTimeout: no Fastly SDK DNS-timeout setter + Transport, // ConnectionTerminated | IoError + Unknown, // Custom, and any future #[non_exhaustive] variant + Unreachable, // DnsError | Destination* | Connection{Refused,LimitReached} | Tls* + UpstreamProtocol, // HttpIncompleteResponse | Http*TooLarge | HttpStatusInvalid | Http2StreamError | ... + } + + // 2. The POLICY: pure, total, unit-testable, zero SDK dependency. Native policy tests + // assert one case per SendFailure, constructed directly. It receives the absolute deadline, + // selected cause, and an injected observation instant. `BudgetedTimeout` is always attributed + // because EdgeZero configured Fastly's connect/response phase timer from that budget. + // `ProviderTimeout` is unattributed before the absolute deadline because Fastly exposes no + // DNS-timeout setter; at/after the deadline, deadline precedence attributes any result. The + // other arms select `BadGatewayReason::{Protocol, Transport, Unreachable, Unspecified}` + // from `failure` when observed in budget. Injecting the instant makes the boundary deterministic and avoids + // a second clock read inside the classifier. + pub(crate) fn classify( + failure: SendFailure, + deadline: Deadline, + cause: BudgetSource, + observed_at: MonotonicInstant, + ) -> EdgeError { /* per the table above */ } + + // 3. The BOUNDARY: known SDK variants are directly tested. A `_` arm is + // MANDATORY (#[non_exhaustive]); future variants map to `Unknown` -> narrow 502. + fn cause_to_failure(cause: &SendErrorCause) -> SendFailure { /* match … , _ => Unknown */ } + ``` + + Tier 2 retains native + **`classify(SendFailure::X, deadline, cause, observed_at)`** tests and adds SDK-gated, + in-crate tests constructing every pinned public `SendErrorCause` variant. Assert both + `cause_to_failure` classification and the composed error's status/kind/reason, including + all budget-source values for both timeout classes before, exactly at, and after the + absolute deadline, plus the exact `BadGatewayReason` for every 502 bucket. These are + Fastly adapter tests, not core + Tier 1 tests; execute the SDK-gated tests through the Fastly WASM `--lib` gate (§5.5). + Only hypothetical future variants use cross-crate compilation and review coverage + for `_ => Unknown => 502`; never fabricate them with unsafe code. Tier 3 retains + real-failure end-to-end coverage of the SDK-produced `SendError` wrapper. + + **Consequence for the "internal is legal on only three paths" assertion (§5.4):** that + claim is now **wrong** and must be widened — `internal` is also correct for the + invariant and platform-internal variants above. The §5.4 row asserts `internal` appears + **only** + for: (a) `BATCH_DISPATCH_SLACK_MAX` overshoot, (b) the unfilled-slot harvest + invariant, (c) the `NameInUse` external-registration case, **(d) the + clamp/name/encoding `BackendCreationError` variants, (e) `SendFailure::LocalInvariant`, + and (f) `SendFailure::PlatformInternal`**. Cases (a)–(e) are EdgeZero-invariant + violations; case (f) is the separately classified Fastly host/runtime internal fault. + Neither class includes an origin transport/protocol failure. + + **`DnsTimeout` is 504, not 502, but early DNS timeout provenance is unspecified.** It names DNS + (transport-shaped, which reads 502) but it **is a fired timer**. Classify by + **"did a timer fire?"**, not by which subsystem reported it. A DNS answer of "no such + host" is 502; a DNS lookup that ran out of time is 504. Fastly exposes no DNS-timeout + setter, so a `DnsTimeout` observed before EdgeZero's absolute deadline uses + `BudgetSource::Unspecified`; at/after the deadline, the deadline wins and carries the + selected cause. Retry policy remains an application decision and is not encoded by this + mapping. Live characterization records which provider cause each configured phase + produces but does not replace the deterministic boundary tests. + + Rationale: a fired host timer is a **deadline** outcome (504) and must be + distinguishable from an upstream that was unreachable (502) — the fan-out caller + retries those differently. `EdgeError::internal` **is** correct for a **narrow** set + of send-stage causes: the `SendFailure::LocalInvariant` group + (`HttpRequestUriInvalid`, `HttpRequestCacheKeyInvalid`, `HttpCache*`) means EdgeZero + built an invalid request, while `SendFailure::PlatformInternal` represents Fastly's + explicit host/runtime `InternalError`. It is **never** correct for a + *transport/upstream* cause + (those are 502/504). (An earlier draft said `internal` is never correct for any + send-stage failure — that contradicts the `LocalInvariant` row of the §4.3 table, + which is authoritative.) A completed exchange, including any non-2xx, is `Ok`. + + **`BackendCreationError::Disallowed`** (dynamic backends not enabled on the + service) is the one creation error that gets its own Rust-side diagnostic: map to + `EdgeError::bad_gateway_with_reason(DYNAMIC_BACKENDS_DISABLED_MESSAGE, BadGatewayReason::Unspecified)`, + so application diagnostics can point at the deployment fix. Rendering that error as an + HTTP response still emits only `"bad gateway"`; the provider-specific text is never a + wire message. + `DYNAMIC_BACKENDS_DISABLED_MESSAGE` is the one shared exact diagnostic: + `"Fastly dynamic backends are not enabled on this service; enable them in the service configuration"`. + + There is no `BackendSlot::Building` / `Failed` variant and no condvar. There **is** an + uncontended `Mutex` (the field is `Mutex>`, required for `Send + Sync`), but + it exists only to satisfy that bound — the Fastly guest is **single-threaded**, so there + is no concurrency for a state machine to guard, and **the lock is never held across the + `finish()` host call** (the *Cache ownership* protocol releases it, then re-acquires to + insert). Nothing can observe an intermediate state because nothing else runs while the + lock is held, and the lock isn't held across the one call that could block. The race the + round-34 review flagged is structurally impossible for that reason. (Earlier drafts + variously said "no lock", "hold the `Mutex` across the host call", and used finer-grained + per-name reservations; all are superseded by the single model in *Cache ownership*.) The protocol applies + to: + + - **`send_all`** — each slot looks up its name; if the name already maps to its own + identity, reuse; if it maps to a *different* identity, fail closed with + `EdgeError::internal("dynamic backend name collision — refusing to reuse")`. + - **Single `send`** — same lookup path; same fail-closed behaviour. + - **Across calls within ONE request context — a session-scoped + `Mutex>` field.** Fastly dynamic-backend registration names are + session-scoped. The cache is therefore a field on the request-context + `FastlyOutboundClient`, fresh with that client — + it exists to dedup **within** a single session's fan-out (multiple `send_all` slots / + multiple `send`s in one handler to the same host+budget reuse one registration), and + is discarded when the request context ends. **It MUST be a `Mutex>`, NOT a + `RefCell`:** `OutboundHttpClient: Send + Sync` (the handle stores `Arc` in `http::Extensions`, which require `Sync`), and `RefCell` is + `!Sync` — a `RefCell` field would fail to compile. The `Mutex` is **uncontended** on + the single-threaded WASM guest (it exists to satisfy `Sync`, not to serialize), and + the lock is never held across a host call. A SHA-256-128 collision against an earlier + registration in this session is still caught. *(Two earlier drafts got this wrong for + the same root cause — the false belief that registration names persist across requests: one made + the cache a cross-request `thread_local!`, another used a non-`Sync` `RefCell`. Both + are superseded by this request-context `Mutex` field. Connection pooling remains at the + Fastly SDK default because reuse requires the same name and exact same settings; the + deterministic identity/settings prevent cross-budget or cross-TLS reuse.)* + - **`Backend::builder` returns `NameInUse`** — the adapter cannot fully verify + the registered identity. Fastly's `Backend::from_name` returns a handle to the + existing backend but its public getters do not round-trip every builder field + (SNI hostname / certificate hostname are notably opaque per the + `BackendBuilder` / `Backend` docs). So the adapter **fails closed** with + `EdgeError::internal("Fastly Backend::builder returned NameInUse for a name \ + not in this adapter's collision map — refusing to reuse an externally \ + registered backend")`. Names already in the adapter's own map are reused + cheaply with no `Backend::builder` call (the in-memory `Backend` handle is + already present); only an *external* registration of a colliding name + triggers this path, and the safest response is to surface it rather than + guess. This makes the adapter's collision map authoritative. + + Backends are deduplicated by full identity within and across calls. Requires + dynamic backends enabled on the service (surfaced via the `outbound-http` + capability and the service prerequisite below). +- Requests in `send_all` are required to have buffered request bodies AND buffered + response mode per the trait contract (§3.1.1). A `Body::Stream` request body + yields `out[i].outcome = Err(EdgeError::bad_request(..))`; a request with + `response_mode = Streamed` also yields + `out[i].outcome = Err(EdgeError::bad_request(..))`. + This removes unbounded application **source pulls** from Fastly's + dispatch-all-then-harvest model and removes the cross-slot streamed-response + deadline-lifetime problem (§3.1.1), identically on every adapter. It does not + prevent a non-empty buffered body from blocking in Fastly's untimed host-write + interval; that separate limitation is footnotes 1/4. +- **Streamed request bodies in single `send`.** The single-request path accepts + `Body::Stream` and uses `Request::send_async_streaming(&backend) -> + Result<(StreamingBody, PendingRequest), SendError>`. Classify an initial send error + through `SendFailure` (§4.3); on success, feed chunks from the core stream to the + `StreamingBody`, with these rules: + - **Byte count cap.** Pre-append checked accounting against + `req.max_request_body_bytes` (default 8 MiB). Over-cap → `bad_request` (400) — + the `StreamingBody` is dropped without `finish()`, the `PendingRequest` is + dropped, and the slot returns the error. + - **Upload ownership and terminal results.** Keep both SDK handles owned until an + explicit terminal branch. Check `budget.deadline.is_expired()` before each source + pull and immediately after it returns, including EOF or a source error. Expiry + drops both handles without `finish()` and returns + `gateway_timeout_caused(.., budget.cause)`. A source `EdgeError` observed before + expiry is preserved unchanged, with the same cleanup. For every `write_all` / + `flush()` result, recheck the deadline before interpreting success or failure; + otherwise an I/O failure maps to `bad_gateway` reason `Transport` and drops both + handles. + On clean source EOF within budget, consume the `StreamingBody` with **exactly one + `finish()` call**. Fastly 0.12.1's `finish(self) -> io::Result<()>` can flush buffered + bytes and fail; dropping the body instead aborts an otherwise successful upload. + Recheck the absolute deadline after `finish()` returns, before interpreting its + result: expiry wins with the attributed 504; an in-budget finish error is 502 reason + `Transport`. + Either error drops `PendingRequest` without calling `wait()`. Only an in-budget + successful finish advances to the response phase. `finish()` consumes the body + even on error, so it is never retried. Dropping the entire send future before + finalization also drops both owned handles without a successful finish. + - **Deadline enforcement has two phases with different bounds:** + - *Source-stream yield* (`stream.next().await`): **unbounded on Fastly** — no + guest async primitive can preempt a stalled `stream.next()` waiting for the + app's source stream to yield. This is the `BestEffort` aspect of + `streamed-upload-deadlines` on Fastly. Apps that need real-time enforcement + against an untrusted upload source must pass a buffered request body + (`Body::Once`) where the bytes are already in hand and no `stream.next().await` + is involved. **This removes only the SOURCE-PULL stall.** It does not bound the + host write: a `Body::Once` is still pushed to the origin in the untimed window + between `connect` and `first_byte`, so a slow-reading origin stalls a buffered + upload too. Buffering is not a remedy for the write path — only a different + adapter is. + - *Host write* (`StreamingBody::write_all` / `flush()` / terminal `finish()`): these + are synchronous host calls. **Fastly's `between_bytes_timeout` applies only to + received bytes (the gap between bytes Fastly receives from the origin), not + to guest-to-origin writes** — see the [Fastly Backend API + docs](https://www.fastly.com/documentation/reference/api/services/backend/), + which describe `between_bytes_timeout` as "maximum duration … that Fastly will + wait while receiving no data on a download from a backend." No published + Fastly backend-timeout field bounds the host-side write of guest-supplied + bytes to origin. **BestEffort** for the write phase: a `StreamingBody::write_all` + whose host TCP buffer is full because origin stopped acking has no + adapter-configurable timeout. The adapter's only recourse is the + `budget.deadline.is_expired()` checks around source pulls, writes, and finalization + described below; these checks cannot interrupt a blocked synchronous host call. + Apps that need real-time enforcement against a slow + origin **read path** rely on `between_bytes_timeout` once the response body + starts flowing. Apps that need real-time enforcement against a slow origin + **write path** must target a different adapter. `max_request_body_bytes` bounds + bytes and memory, not elapsed write time: even one small `write_all` can block + without a documented finite bound, so no cap value creates a wall-clock guarantee. + - *Around each chunk*: the pre-pull and post-ready checks above prevent further + source work after an observed expiry. The post-ready check also prevents a + source that stalled past the deadline from writing the chunk it eventually + yielded. Recheck after each host write/flush, including errors, and after + terminal `finish()`. These cooperative checks detect expiry only once the + synchronous call returns; finalization has the same unbounded host-write gap. + + Net: the capability matrix entry `streamed-upload-deadlines = BestEffort` for + Fastly reflects **both** phases — **source-stream yield AND host write are each + unbounded** on the guest side. There is **no** `BoundedCooperative` write-side + bound: `between_bytes_timeout` is documented as **receive-side only** (it bounds the + gap between bytes *received from origin*) and does **not** bound guest-to-origin + writes. (An earlier draft claimed the write phase was `BoundedCooperative` via + `between-bytes-timeout` and that only source-yield was the "worst phase" — both are + wrong; footnote 2 and §8 risk 7 are correct.) The only adapter-side bound on either + phase is the cooperative `budget.deadline.is_expired()` check around source pulls, + writes, and finalization described above. + - **Response phase: host timeouts are *not* adjustable mid-flight.** The Fastly + SDK sets connect / first-byte / between-bytes timeouts once before `send_async` + (§3.3.4) and does not expose post-dispatch mutation. For + `send_async_streaming`, dispatch happens **before** chunks are fed, so the + response-phase host timeouts are locked to the phase-split values computed at + dispatch (`first_byte_ms` for the headers wait, `between_ms` for inter-chunk + gaps once the response body flows). After the upload `finish()`es the adapter + checks `budget.deadline.remaining()` cooperatively before calling `wait()` — + if `None`, drop the `PendingRequest` and return `gateway_timeout` without + waiting. **If the upload leaves a tiny positive remaining budget**, the + cooperative check at this boundary passes, and the host then waits up to + its dispatch-time `first_byte_ms` for headers even though only the tiny + remainder of batch budget is left. **The headers wait is bounded by at + most one dispatch-time `first_byte_ms` interval past `budget.deadline`** — + a single, one-shot overshoot, not a per-chunk accumulator. + + Once headers arrive, the **response body** flows through the cooperative + deadline-aware wrapper (§4.3 "Streamed-response wrapping"), whose + `is_expired()` check fires before and after **each** underlying read. + Because the wrapper checks after the read that delivered the first body + chunk, and the deadline is already expired by construction in this + scenario, **the very next deadline-check yields `Err(gateway_timeout)`** — + the wrapper does **not** wait another `between_bytes_timeout` per chunk + indefinitely. **After an upload finalizes within budget, response-phase + post-deadline overshoot is bounded by `first_byte_ms` + (the headers wait) plus one `between_bytes_timeout` (the worst-case + interval during which the host is mid-read of the *first* body chunk + when the wrapper fires)** — a closed-form bound, not a per-chunk + accumulator. The previous "plus one between-bytes-timeout per body-chunk + gap" wording in earlier drafts was wrong; the wrapper preempts after the + first post-deadline read returns. This bound excludes the unbounded source pulls, + host writes, and `finish()` call; it is not an end-to-end upload bound. + + This is a deliberate, documented Fastly-specific behaviour of streamed uploads. + Passing `Body::Once` removes only the source-pull stall and avoids this post-upload + timer-staleness shape; it does **not** bound Fastly's host write of those buffered bytes. + Apps that require a strict end-to-end wall-clock bound for any non-empty upload must + target an adapter whose upload path is Native. +- `capability()` per §3.5.2: `outbound-http` = `BestEffort` (footnote 9: adapter + support depends on a service entitlement the static gate cannot verify), + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-header-fidelity` = `Native`, `outbound-deadlines` = + **`BestEffort`** (footnote 1 — a warm zero-body path has documented partial bounds, but + every non-empty request retains an unbounded host-write interval and the FIRST request + to a new host calls `Backend::builder(..).finish()`, which can also overshoot before the + guard runs; since `capability()` is static and cannot distinguish those paths, + the honest value is `BestEffort`, so a `required outbound-deadlines` correctly hard-fails + on Fastly rather than fooling the gate), + `outbound-flexible-phase-budget` = `BestEffort` (footnote 5 — rigid 1/4 connect + + 3/4 first-byte split per §4.3 can fail a request that would have fit within the + total budget), `send-all-slot-isolation` = `BestEffort` (footnote 4 — sequential + cold registration can delay sibling dispatch; unresolved background request writes and + buffered response-body harvest can delay sibling result observation), + `streamed-upload-deadlines` = `BestEffort` (footnote 2 — no preemption of a + stalled `stream.next().await`), `lazy-streamed-response-passthrough` = + `BestEffort` (footnote 6 — Fastly's `Response::stream_to_client()` is + incompatible with `#[fastly::main]`, so the default scaffold falls back to + buffered passthrough; lazy streaming requires a non-`#[fastly::main]` entry). + This is the exact outbound tuple `Adapter::capability()` returns on Fastly. + +**Streamed-response wrapping.** Even without a guest async timer, the Fastly adapter +wraps streamed response bodies with a **cooperative deadline-aware stream**. Each +`Stream::next` checks `budget.deadline.is_expired()` **both before issuing the +underlying body read and again after it returns** (including the read that +discovers EOF and would otherwise complete the stream cleanly). On expiry at +either check it yields `Err(EdgeError::gateway_timeout_caused(.., budget.cause))` instead of `Ok(chunk)` +or stream-end. This applies to *every* consumer of the wrapped body — +`into_bytes_bounded`, `into_bytes_bounded_until`, `into_response()` proxy +passthrough — so the deadline cannot be bypassed by choosing a non-helper +consumption path or by riding the final blocking read to EOF. Bounded-cooperative +semantics apply: a chunk gap (including the gap before EOF) is bounded by the +host's `between-bytes-timeout` (set to `budget.duration` at dispatch), so per-gap +overshoot ≤ one between-bytes-timeout interval. + +**Limitation, stated explicitly.** The harvest loop blocks the single-threaded guest in +`wait()`. This is correct and concurrent (all requests progress at the host in parallel), +but the guest cannot do other work while blocked — the intended behaviour for a fan-out batch. +`wait()` parks efficiently; there is no busy-polling. + +**Service prerequisite — dynamic backends.** Fastly outbound HTTP to arbitrary hosts +requires **dynamic backends to be enabled on the Fastly service**. That is a +deployment-time service configuration, not adapter code, and the adapter itself cannot +turn it on. EdgeZero handles the gap as: + +1. **Build / deploy:** the static CLI cannot prove this account/service entitlement. The + Fastly adapter therefore reports `outbound-http = BestEffort`, not `Native`; a required + declaration fails closed, while an optional declaration emits the standard degradation + warning. Here `optional` is an explicit application contract that the handler tolerates + an outbound dispatch failure when the deployment prerequisite is absent; it is not a + promise that every selected service can issue the request. An application whose behavior + requires outbound success cannot use this escape hatch and must select a deployment whose + capability check passes. EdgeZero deliberately does not pretend an informational reminder + is admission control. +2. **Runtime:** if dispatch fails because dynamic backends are disabled, the adapter + surfaces `EdgeError::bad_gateway_with_reason(DYNAMIC_BACKENDS_DISABLED_MESSAGE, + BadGatewayReason::Unspecified)` using the exact shared constant defined in the creation + error mapping above. Apps can inspect/log the Rust-side diagnostic before response + conversion; an HTTP response remains the fixed category-only 502 contract. + +Fastly can be promoted to `Native` only when a separately reviewed deploy-time prerequisite +can prove dynamic-backend enablement for the selected service before publication. Phase 6 +still runs a trusted live-host characterization against one enabled disposable service and +one disabled disposable service: the enabled probe must reach arbitrary canonical +destinations, while the disabled probe must produce the exact typed diagnostic above. That +evidence proves adapter behavior but does not make the static gate deployment-aware, so the +current matrix remains `BestEffort`. + + + +### 4.4 Spin — `crates/edgezero-adapter-spin` + +- `SpinProxyClient` → `SpinOutboundClient` (stays stateless). +- `send_all` first snapshots `let batch_now = MonotonicInstant::now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 *before* `send_one_validated` is invoked. It passes that entry snapshot to every + `send_one_validated(req, batch_now)`. Buffered-mode buffered-body survivors are fanned out + via `join_all` over `send_one_validated` (each of which drives the hand-built `wasi:http` + request + `wasip3::http::client::send` — see below and §4.4); the wasi async reactor + fans out. Concurrency materialises only under the real Spin/wasi executor — see + §5.3 for the test consequence. +- A public single `send` calls `validate_for_dispatch(&req)` exactly once immediately after + its method-entry snapshot, then invokes the same private already-validated flow as batch + survivors. `send_one_validated(req, now)` computes the budget via the core helper + `dispatch_budget(req, now)` (§3.3.2) before consuming the request into parts, then build + the hand-built `wasi:http` request (§4.4 — all body kinds, buffered and streamed); race + the **whole** operation + (send **and**, in `Buffered` mode, body collect) against a wasi monotonic-clock + timer for **`budget.deadline.remaining()` at the moment the race starts** — + *not* the snapshot-time `budget.duration`. The two differ by however long + preflight + builder construction took since `batch_now`; using `remaining()` + pins the SDK timer to the absolute batch deadline, matching Axum/CF (§4.1 / + §4.2 step 4). If `remaining()` is `None`, return + `gateway_timeout_caused(.., budget.cause)` without + issuing the request. Single `send` snapshots `now = MonotonicInstant::now()` at its + public method entry before preflight. +- **Streamed responses honour the effective-budget deadline — STREAMED MODE ONLY.** This + is the second phase for `Streamed` mode, where `to_core` returns `Body::Stream` **without + draining** (the single exchange race covered upload + headers only). Wrap that + `Body::Stream` with a per-chunk race against a wasi monotonic-clock timer bounded by + `budget.deadline`; the wrapper yields a `gateway_timeout` (attributed via `budget.cause`, §3.3.2) error chunk past the deadline so + the streamed body honours the deadline end-to-end per §3.3.3. **BUFFERED mode does NOT get + this second race:** there, `to_core` `.await`s the full body drain **inside `exchange`**, + which is already bounded by the single deadline race below — the buffered body is consumed + once, under one race, never twice. (So the design is: one exchange race covering + upload+headers+*buffered*-body; for *streamed* bodies the exchange race stops at headers and + this per-chunk wrapper takes over. No path drains the same body under two races.) +- **ALL uploads — including BUFFERED — use the hand-built `wasi:http` request, not + `spin_sdk::http::send`.** `spin_sdk::http::send`'s `IntoRequest` conversion spawns a + **detached, uncancellable** body pump (§4.4) even for a *buffered* `Body::Once`: a + finite buffer still blocks on **host backpressure** if the origin reads slowly, and + dropping the `send` future does not cancel that pump — so a buffered upload could block + past the deadline. Therefore buffered bodies also go through the hand-built request + + owned in-race pump below (a buffered body is just a one-shot stream of a single + chunk), which lets the timer select a guest-visible 504 for both body kinds without an + unowned pump. It does **not** prove bounded host teardown; `streamed-upload-deadlines` + therefore remains `BestEffort` for Spin (footnote 8). + +- **Streamed request bodies — hand-built `wasi:http` request (SDK 6 / WASI 0.3).** + + > **⚠️ Corrected against verified SDK source.** Earlier drafts of this section + > prescribed a WASI-**0.2** loop — `OutputStream::subscribe()` → `Pollable`, + > `check_write()` for a permitted byte count, then `write()`. **That API does not + > exist in Spin SDK 6.** WASI 0.3 deletes `wasi:io` entirely: there is no + > `pollable`, `output-stream`, `check-write`, or `subscribe` anywhere in `wasip3`'s + > WIT or in spin-sdk 6's Rust, and `wasi:http@0.3.0`'s `request.new` takes + > `contents: option>` (a component-model stream), not an `OutputStream` + > resource. The old algorithm was not merely deprecated — it was **unimplementable**. + > + > **Nor can the upload go through `spin_sdk::http::send`.** Its `IntoRequest` impl + > (`http_into_wasi_request`) hands a streaming `http_body::Body` to a **detached, + > uncancellable** pump (`wit_bindgen::spawn`; the runtime's own docs: *"cannot be + > cancelled or monitored"*). Dropping the `send` future cancels the **subtask** but + > **not the pump** — so a *stalled source* (precisely what this capability exists to + > bound) leaves the pump parked in `poll_frame` forever, and the export executor + > will not exit until spawned tasks drain. That is not a weak timing guarantee; it + > **pins the component task alive indefinitely**. Routing streamed uploads through + > the SDK's high-level `send` would make Spin `BestEffort` *and* leak. + + The adapter therefore **builds the `wasi:http` request by hand** and keeps every + request-body and completion handle inside the raced exchange. This avoids the SDK's + detached pump, but it does **not** create a documented synchronous host-teardown bound; + that limitation is why Spin is `BestEffort` for the two deadline capabilities. All of + this uses public API re-exported by `spin_sdk`: `wasip3`, `wit_stream`, and `wit_future`. + + ```rust + use spin_sdk::wasip3::{http::{types, client}, wit_stream, wit_future}; + use futures::future::{poll_fn, select, Either}; + use std::task::Poll; + + let (mut writer, contents_rx) = wit_stream::new::(); + // Dropping an unwritten FutureWriter writes its DEFAULT asynchronously. The default + // must therefore be failure, never a false clean completion. + let (trailers_tx, trailers_rx) = wit_future::new(|| { + Err(types::ErrorCode::InternalError(Some( + "outbound request body producer dropped before completion".into(), + ))) + }); + + // The pinned wasip3 API has TWO error shapes. Request component setters return + // `Result<(), ()>`; RequestOptions timer setters return + // `Result<(), types::RequestOptionsError>`. Do not route both through one closure. + let bad_request_component = |()| { + EdgeError::internal(anyhow::anyhow!("invalid outbound request component")) + }; + let opts = types::RequestOptions::new(); + // Set every available host transport/response timer from a nonzero nanosecond + // snapshot of the remaining effective budget. Ignoring a setter result or leaving + // first-byte/between-bytes at host defaults can weaken those fallback bounds. Even when + // a setter is accepted, an early WASI timeout variant does not prove which timer caused + // it and remains `BudgetSource::Unspecified`; only absolute expiry attributes the selected + // cause. The outer absolute race below remains authoritative and re-reads `remaining()` + // immediately before `select`. + let Some(options_remaining) = budget.deadline.remaining() else { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired before upload setup", budget.cause)); + }; + // `Deadline` is clamped to seven days, so this conversion cannot overflow u64; + // keep the checked conversion so violating that invariant fails locally and loudly. + let transport_ns = u64::try_from(options_remaining.as_nanos()) + .map_err(|_| EdgeError::internal(anyhow::anyhow!( + "outbound deadline exceeds WASI duration range" + )))? + .max(1); + let mut unsupported_timer_option = false; + let mut apply_timer_option = |option_result| { + match option_result { + Ok(()) => Ok(()), + // WASI explicitly permits a host not to implement an option. The outer + // monotonic race still provides the guest-visible deadline, so this is the + // documented BestEffort fallback rather than a request-fatal 500. + Err(types::RequestOptionsError::NotSupported) => { + unsupported_timer_option = true; + Ok(()) + } + Err(types::RequestOptionsError::Immutable) => Err(EdgeError::internal( + anyhow::anyhow!( + "WASI outbound request options unexpectedly immutable" + ), + )), + Err(types::RequestOptionsError::Other(detail)) => Err(EdgeError::internal( + anyhow::anyhow!( + "WASI outbound request option failed: {detail:?}" + ), + )), + } + }; + apply_timer_option(opts.set_between_bytes_timeout(Some(transport_ns)))?; + apply_timer_option(opts.set_connect_timeout(Some(transport_ns)))?; + apply_timer_option(opts.set_first_byte_timeout(Some(transport_ns)))?; + if unsupported_timer_option { + log::warn!( + "Spin host does not support one or more WASI HTTP timeout options; \ + retaining the outer EdgeZero deadline race" + ); + } + + // Bound as `wasi_req`, NOT `req` — the WASI request must not shadow the OUTBOUND + // request `req`, whose `max_request_body_bytes` / `body` we read below. + let (wasi_req, request_done) = + types::Request::new(headers, Some(contents_rx), trailers_rx, Some(opts)); + wasi_req.set_method(&method).map_err(bad_request_component)?; + wasi_req.set_scheme(scheme.as_ref()).map_err(bad_request_component)?; + wasi_req.set_authority(auth).map_err(bad_request_component)?; + wasi_req.set_path_with_query(pq).map_err(bad_request_component)?; + + // The pump lives INSIDE the raced future — no `wit_bindgen::spawn`. + // `max_req` carries the portable body cap for both body kinds. **`parts` is the + // `OutboundRequestParts` from `req.into_parts()`** — the adapter is a SEPARATE crate and + // cannot read `OutboundRequest`'s PRIVATE fields, so it destructures into the pub-field + // `OutboundRequestParts` first (this also gives it `method`/`uri`/`headers`/`body` used + // to build `wasi_req` above). `max_req` is **`u64`** (the cap type, §3.1.3) and is always + // `parts.max_request_body_bytes`. A buffered `Body::Once` rides this same pump for + // cancellability (§4.4), re-expressed as a one-shot single-chunk stream, and must fail before + // its first write when its known length exceeds the cap. + let max_req: u64 = parts.max_request_body_bytes; + // Force one scheduler boundary after each accepted chunk, even when both the + // source and host writer are continuously ready. Without this, an async `while` + // loop over empty/immediately-ready chunks can monopolize one poll and prevent + // both `client::send` and the outer deadline timer from being polled. + async fn cooperative_yield_once() { + let mut yielded = false; + poll_fn(move |cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }).await + } + + let upload_deadline = budget.deadline; + let upload_cause = budget.cause; + enum PumpCompletion { Complete, ReaderGone } + let pump = async move { + let mut sent: u64 = 0; // u64 accounting vs the u64 cap (`max_req`) — usize is u32 + // on wasm32, so a usize counter could wrap below the cap. + // `source` yields `Option>` + // (the error-type change). The item MUST be unwrapped — a source error is a real + // failure (`bad_gateway` from the wrapped stream, or a `gateway_timeout` chunk), not + // a `Bytes`. Dropping it would silently upload a truncated body. + loop { + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + let next = source.next().await; // cancellable + // Absolute post-ready check: timeout outranks a chunk, EOF, or source error + // that becomes ready at the deadline. + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + let Some(item) = next else { break }; + let chunk: Bytes = item?; // propagate source error + // pre-append cap check against max_request_body_bytes (u64; no `as`, use try_from) + let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX); + if sent.checked_add(chunk_len).is_none_or(|total| total > max_req) { + return Err(EdgeError::bad_request("request body exceeded max_request_body_bytes")); + } + // checked: bare `+=` trips `clippy::arithmetic_side_effects` (denied). + sent = sent.saturating_add(chunk_len); + let unwritten = writer.write_all(chunk.to_vec()).await; // backpressure; cancellable + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + if !unwritten.is_empty() { + // READER GONE is NOT an error. The origin stopped reading — almost always + // because it is about to send (or already sent) an EARLY FINAL response + // (413 Payload Too Large, 401, a redirect, …). Returning an error here + // would DISCARD that valid response and report 502 instead, violating + // "a completed exchange, including non-2xx, is Ok". So end the pump + // cleanly and let `send` surface the response. + drop(writer); + let _ = trailers_tx.write(Ok(None)).await; + return Ok::(PumpCompletion::ReaderGone); + } + cooperative_yield_once().await; + } + drop(writer); // EOF + match trailers_tx.write(Ok(None)).await { // completion signal + Ok(()) => Ok::(PumpCompletion::Complete), + // The host dropped the completion reader. Treat this exactly like an origin + // that stopped consuming the request: preserve the response path and never + // poll `request_done`. + Err(_future_write_error) => Ok(PumpCompletion::ReaderGone), + } + }; + + // `run_exchange` is an ORDERED state machine, NOT `join!`. `join!` would delay an + // already-available response behind a stalled source and could let a moot upload + // override an early final response. `request_done` is the request-transmission result + // returned by Request::new; it is load-bearing and must not be discarded. + // `client::send` resolves to `Result` — the + // LOW-LEVEL WASI response, NOT core `OutboundResponse`. Each success arm must (a) map the + // transport `ErrorCode` via `map_spin_send_err`, THEN (b) convert to core via + // **`to_core`, which is `async`** — `.and_then(to_core)` does NOT compile because WASI + // body collection is asynchronous. Before request fields move into the WASI request and + // upload pump, `req.into_parts()` also produces an owned `SpinResponsePolicy` containing + // `request_method`, `response_mode`, and all five response-resource settings + // (`max_brotli_window_bits`, `max_chunk_bytes`, encoded bytes, header bytes, and header + // count). `to_core` wraps status + `Fields`→multi-value + // `HeaderMap` synchronously, then: **Streamed mode** wraps the body as `Body::Stream` + // and returns immediately (no drain); **Buffered mode** `.await`s the FULL body drain + + // incremental decompress + `max_response_bytes` cap + EOF **inside `exchange`**, so the + // whole collection is bounded by the outer deadline race below (a slow buffered body + // cannot outlive the deadline). This is the hand-rolled equivalent of the SDK's + // `Response::from_response`, done here because we bypass the high-level `send` (§4.4). + // Header/resource enforcement and method-aware body disposition consume the policy; + // no value is reconstructed from the native response. + // `to_core: async fn(wasip3::http::types::Response, SpinResponsePolicy) + // -> Result`. + // Upload completion protocol (the normative transition table follows this snippet): + // - `Uploading` polls one pump step first, then `send`. A ready source/cap/deadline + // failure therefore wins over a send result ready in the same poll. + // - `Complete` transitions to `AwaitingRequestDone`; keep polling `send` for host + // progress, but retain any ready result without accepting it until `request_done` + // succeeds. + // - `ReaderGone` transitions to `ReaderGone`; retain but never poll `request_done`, and + // wait for the early response/error from `send`. + // - if `send` is ready after the pump returned Pending, it is authoritative. Drop the + // pump and `request_done` before response conversion. + // - source/cap/deadline failure does not write success to trailers. Dropping + // `trailers_tx` writes its default Err while the original EdgeError is returned. + // `client::send`, `pump`, and `request_done` are owned by one exchange; no detached task + // exists. Both WASI result sites pass through + // `map_spin_send_err(err, budget.deadline, budget.cause)`. + let exchange = run_exchange( + client::send(wasi_req), + pump, + request_done, + budget.deadline, + budget.cause, + move |native_response| to_core(native_response, response_policy), + ); + + // Response completion is a separate two-way WASI protocol, not just a trailers await. + // Create a caller-result future whose default is Err(InternalError), pass its reader to + // `Response::consume_body`, and retain its writer together with the returned body stream + // and trailers future. The body itself is a component `stream`, not an `ErrorCode` + // result. Its clean EOF/termination is handled by the stream bridge. After body EOF, await + // the response trailers/completion future; an `ErrorCode` from that future is the THIRD + // `map_spin_send_err(err, budget.deadline, budget.cause)` site, alongside `client::send` + // and `request_done`, so response-side timeout codes remain 504 rather than collapsing to + // 502. Only clean trailers permit writing `Ok(())` to the caller-result writer. That write + // returns `Result<(), FutureWriteError<_>>`, not `ErrorCode`: rejection means the host + // dropped its result reader. While a consumer is still waiting, classify that failed + // completion handshake as protocol 502; during wrapper drop, perform cleanup only because + // no consumer remains to receive an error chunk. The absolute deadline still wins and produces the + // attributed gateway_timeout (504). A local deadline/decode/cap failure or early consumer + // drop leaves or explicitly writes Err, never a false clean completion. Buffered conversion + // completes this protocol inside `exchange`; Streamed conversion stores all three handles + // in the `Body::Stream` wrapper until terminal EOF or drop. §5.4 pins clean EOF, + // truncated-response, and early-drop cases. + + // `remaining` is Option, NOT Result — `?` here would not compile in a + // Result-returning fn. An already-expired budget must become gateway_timeout + // explicitly, matching the "expiry before dispatch" contract above. + let Some(race_remaining) = budget.deadline.remaining() else { + // Attribute via the budget's cause — every budget timeout is caused (§3.3.2). + return Err(EdgeError::gateway_timeout_caused( + "deadline expired before upload dispatch", budget.cause)); + }; + + match select(pin!(exchange), pin!(spin_sdk::time::sleep(race_remaining))).await { + // RECHECK the absolute deadline for BOTH arms (deadline-wins, §3.4.1). `select` + // polls `exchange` first, so on *simultaneous* readiness (exchange completes exactly + // as the timer fires) it would return a result produced at/after the deadline — + // whether that result is `Ok` OR a decode/transport `Err`. An expired deadline + // outranks both, so a completed-at-deadline exchange becomes a 504 timeout, never a + // success and never a simultaneously-ready 502. A §5.4 boundary test drives exactly- + // simultaneous readiness (success AND failure) and asserts 504 for each. + Either::Left((resp, _)) => { + if budget.deadline.is_expired() { + Err(EdgeError::gateway_timeout_caused("deadline expired during upload", budget.cause)) + } else { + resp // Ok(within deadline) or a real Err (e.g. bad_gateway) passes through + } + } + Either::Right(_) => Err(EdgeError::gateway_timeout_caused("deadline expired during upload", budget.cause)), + } + ``` + + **`run_exchange` state machine (normative).** The helper owns pinned `send`, `pump`, + and `request_done` futures. “Poll first” is an explicit biased poll order, not merely + source-code order passed to a fairness-rotating selector. + + | State | Polling and transition | Request-side ownership boundary | + | --- | --- | --- | + | `Uploading` | Poll `pump` first for one cooperative step, then poll `send`. `pump = Err(e)` returns `e`; this includes source, cap, and upload-deadline failures. `pump = Complete` transitions to `AwaitingRequestDone` without accepting a simultaneous send result. `pump = ReaderGone` transitions to `ReaderGone`. A send result is authoritative only when that pump poll returned `Pending`. | All three futures remain owned. On authoritative send success/error, drop `pump` and `request_done` before mapping/converting the send result. On pump error, drop `send` and `request_done`; the request trailers writer defaults to failure. | + | `AwaitingRequestDone` | Poll `request_done` first, then poll `send` when its result has not already been retained. A ready send response/error is stored, not returned, so polling can continue to drive host progress. A `request_done` error is mapped with `map_spin_send_err(err, budget.deadline, budget.cause)` and wins over any stored send result. On `request_done` success, consume/drop it, then use the stored send result or continue polling `send`; map its error with the same three arguments or run async `to_core` on success. Only a successful request-trailers completion write enters this state; `FutureWriteError` transitions to `ReaderGone`. | `send` and any ready low-level response remain owned while transmission completion is established. No response conversion or caller-visible send error occurs before `request_done` succeeds. | + | `ReaderGone` | Do not poll `request_done`; poll `send` until its response/error is ready. That result is authoritative because the peer ended request consumption. | Retain `request_done` only until `send` resolves to response headers or error, then drop it **before** `to_core`. It is never stored in the response body wrapper. | + + The cooperative pump boundary after every accepted chunk is part of this state machine: + an immediately-ready or empty-chunk source cannot monopolize one poll, so `send` and the + outer timer are polled. A pump failure already ready in the same poll wins; a later source + failure after an authoritative send result is moot. For `Streamed` responses, the returned + `Body::Stream` owns only response-side body/trailer/caller-result handles described below; + no request-side writer, pump, or `request_done` handle crosses the header-return boundary. + + **Response decoder fairness.** The request pump's one-step boundary does not protect a + decoder that consumes continuously ready raw input without producing output, or decoded + output that remains continuously ready. Wrap both raw decoder input and decoded output + with independent finite, nonzero ready-item quotas no greater than 64; empty items count. + At quota exhaustion, preserve the counters across stream calls, self-wake, and return + `Poll::Pending` so `run_exchange`/the outer monotonic timer and sibling work are polled + before the next item. This self-wake is sufficient on Spin because the WASI monotonic + clock is not frozen by guest CPU work; unlike Cloudflare, no claim depends on a JS host + event advancing `performance.now()`. Recheck the original absolute deadline before + accepting the next item and before terminal EOF/error/cap decisions. Never reanchor it. + The quotas are scheduling guarantees, not byte or allocation caps. + + **Why this remains `BestEffort`.** Before response headers, dropping `client::send` + requests cancellation of its canonical-ABI subtask. After headers, that subtask is + complete; cancellation instead drops/cancels the response stream and trailers handles + and resolves the response-result protocol with `Err`. These are the correct cooperative + Component Model operations, but neither establishes synchronous host teardown or a + one-monotonic-tick bound. In addition, dropping an unwritten `FutureWriter` schedules its + default write rather than completing synchronously. The timer can select a 504 result, + but host work may outlive that selection, so both deadline capabilities stay + `BestEffort` until runtime evidence proves a finite teardown bound. + + **Completion signalling.** Clean request EOF or reader-gone explicitly writes + `Ok(None)` to the trailers future; every other drop defaults to `Err`. The separate + `request_done` result is awaited on full upload and retained on reader-gone as described + above. The `max_request_body_bytes` cap (default 8 MiB) is enforced with pre-append + checked accounting inside the pump loop, `bad_request` on overflow. + + **`RequestOptions` do not bound the upload.** WASI 0.3 keeps `set-connect-timeout` / + `set-first-byte-timeout` / `set-between-bytes-timeout`, but these are transport / + response-side only — the WIT states they are *"separate from any the user may use to + bound an asynchronous call."* They are **not** a substitute for the race above. Attempt + to set all three to the ceiled remaining effective budget and classify every + `RequestOptionsError`: `NotSupported` logs one degradation warning and proceeds under + the authoritative outer race; `Immutable` and `Other(..)` are internal setup failures. + When all three are accepted, the host's connect, first-byte, and between-bytes phases use + the configured budget. Other host timeout classes such as DNS and connection-write have + no corresponding WASI setter and may still fire independently. When one is unsupported, + the already-declared Spin BestEffort deadline limitation also covers the unsupported + phase's host default; the guest still observes the outer race if EdgeZero's absolute + deadline wins. + + **This applies to STREAMED response mode ONLY.** In **Buffered** mode `to_core` already + drained the full response body **inside** `exchange` (bounded by the outer deadline race), + so there is **no** second response race — the exchange result IS the finished response. + In **Streamed** mode, `to_core` returned `Body::Stream` without draining, so after the + exchange completes the adapter re-reads `budget.deadline.remaining()`; if `None`, the + streamed response is dropped and the slot returns `gateway_timeout` — no response wait; + otherwise the remaining duration bounds the per-chunk streamed-body wrapper race (the + §3.3.3 wrapper), so upload time is included in the batch budget rather than added on top. + (An earlier draft prescribed this second race unconditionally, contradicting the buffered + single-race flow — corrected to Streamed-only here.) +- Existing gzip/br decompression is kept; the independent decoded-output cap is enforced + incrementally for identity and EdgeZero-decoded output (§3.4.1), while the final + Buffered cap remains separate. `Streamed` mode wraps the response body as `Body::Stream`. +- **Errors — `map_spin_send_err(err, deadline, cause)` classifies the WASI `ErrorCode`, + mirroring Fastly's deadline-aware `classify` policy (§4.3) but without inventing + provenance.** At the mapping instant it checks `deadline.is_expired()` first. If true, + timeout precedence yields `gateway_timeout_caused(.., cause)` for **any** simultaneous + SDK failure. If false, one of WASI's five provider timeout variants yields bare + `gateway_timeout(..)` and therefore `BudgetSource::Unspecified`: DNS and + connection-write timeouts cannot be configured through `RequestOptions`, and no host + timeout that fires early proves which EdgeZero input selected it. The caller passes all + three values and never stamps `budget.cause` onto a provider event solely because its + variant contains `Timeout`. The `send` future (`wasip3::http::client::send` for all + body kinds — buffered and streamed both go through the hand-built request, §4.4) + fails with a `wasi:http` `ErrorCode`. **The match lists EVERY known variant explicitly and + has NO `_` arm** — so a future SDK/WIT variant **breaks the build** and *forces* the + implementer to classify it (fail-loud, the point of a security/behaviour-relevant + classifier). This resolves the earlier waffle ("keep `_` only if reachable, otherwise + drop"): with `-D warnings`, an `_` arm over an *exhaustive* enum is an + `unreachable_patterns` error anyway, so the no-`_` exhaustive match is the only form that + passes. The pinned `wasip3 = 0.6.0` generated enum is verified exhaustive and is not + `#[non_exhaustive]`; dependency upgrades must update this match and its table in the same + change. Mapping: + - **Timeout variants — ALL FIVE** (verified against wasip3): `DnsTimeout`, + `ConnectionTimeout`, `ConnectionReadTimeout`, `ConnectionWriteTimeout`, + `HttpResponseTimeout` → **`gateway_timeout` (504)**. At/after the absolute deadline the + result carries `budget.cause`; before it, the result carries + `BudgetSource::Unspecified`. **None may fall through to the generic 502.** An exhaustive + classifier unit test against the pinned SDK exercises every timeout before and after + deadline expiry. + - DNS resolution failure, connection refused, TLS/certificate establishment errors, + destination-not-found/unavailable before a response head → + **`bad_gateway_with_reason(.., Unreachable)` (502)**. Connection termination/read/write + failure after connection progress uses **`Transport`**. + - Caller-controlled request-policy/size failures → **`bad_request` (400)**: + `HttpRequestDenied`, `HttpRequestBodySize`, `HttpRequestUriTooLong`, + `HttpRequestHeaderSectionSize`, and `HttpRequestHeaderSize`. These can be caused by + a requested target or limits exposed through the outbound API; reporting 500 would + misclassify caller input as an EdgeZero invariant failure. + - Adapter/core invariant failures → **`internal` (500)**: + `HttpRequestLengthRequired` (the adapter owns framing), + `HttpRequestMethodInvalid` / `HttpRequestUriInvalid` (core preflight must reject + these), request-trailer size errors (EdgeZero emits no request trailers), + and `ConfigurationError` (the adapter constructed the request options). + - Upstream response framing/protocol variants → + **`bad_gateway_with_reason(.., Protocol)` (502)**. Shared decoder/JSON failures use + `Decode(Gzip)` / `Decode(Brotli)` / `Decode(Json)`; they do not enter this WASI + classifier. + - Host/runtime catch-all `InternalError` → + **`bad_gateway_with_reason(.., Unspecified)` (502)**. WASI defines this as the fallback + when no specific code fits; it is not evidence that an EdgeZero invariant failed. The + locally generated default `InternalError` used by request/response + completion writers is not reclassified through this arm: source/cap/deadline failures + preserve and return their original typed `EdgeError` while the default only tells the + host that completion was not clean. + - **Future/unknown variant handling.** **Every variant in the pinned + `wasi:http@0.3.0` `ErrorCode` is explicitly named** in one of four outcome buckets (504 + timeout / 502 upstream+transport+protocol+host catch-all / 400 caller-controlled / + 500 locally-invalid), so the match is + exhaustive over the pinned enum with **NO `_`** — a future WIT variant then *breaks the + build* and forces classification (the intended fail-loud behaviour). There is no `_` + arm for the pinned exhaustive enum. If a future binding changes the enum to + `#[non_exhaustive]`, that dependency upgrade must explicitly revise this contract rather + than silently adding a wildcard. The pinned + enum has ~30+ variants — `DNS-error`, `destination-{not-found,unavailable,IP-prohibited, + IP-unroutable}`, `connection-{refused,terminated,timeout,read-timeout,write-timeout, + limit-reached}`, the `TLS-*` errors, the `HTTP-request-*` / `HTTP-response-*` size/format + errors, `HTTP-response-incomplete`, `HTTP-{upgrade-failed,protocol-error}`, + `loop-detected`, `configuration-error`, `internal-error`, … Assignment rule: the five + timeouts → 504; the explicitly named caller-controlled request variants → 400; the + explicitly named adapter/core invariants → 500; **everything else + upstream/transport/protocol** → 502. The **exhaustive classifier + test enumerates every pinned variant** and asserts its bucket, so an added SDK variant + that we forgot to map trips the test (via a `#[deny(unreachable_patterns)]`-style + round-trip or an explicit variant list), not silently defaults. + - The separate wasi-timer we race the exchange against (§4.4) also yields + `gateway_timeout` on expiry. **request**-body over-cap → `bad_request` (400); + **response**-body over-cap (decompressed) → `response_too_large` (distinct kind, 502, §3.4.1). Any + completed exchange (incl. non-2xx) → `Ok`. +- Spin requires `allowed_outbound_hosts`; the adapter renders it from + `[capabilities.outbound].hosts` per §3.5.4 when generating `spin.toml`. +- `capability()` per §3.5.2 reports the exact eight outbound-cell tuple (additional + non-outbound capability variants are owned by their respective specifications): + `outbound-http` = `Native`, `outbound-header-fidelity` = `Native`, + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-deadlines` = `BestEffort` (footnote 8), + `outbound-flexible-phase-budget` = `BestEffort` (footnote 5: request-option setters may + be unsupported and leave earlier host defaults), `send-all-slot-isolation` = `Native`, + `streamed-upload-deadlines` = `BestEffort` (footnote 8), and + `lazy-streamed-response-passthrough` = `BestEffort` (footnote 7). +- **Response-out passthrough is buffered (BestEffort), not lazy.** Spin's public + response surface is `Response>` (`SpinFullResponse`, used by + `AppExt::dispatch` / `request::dispatch*` / `from_core_response` / `run_app`), so + lazy passthrough would require a breaking public-API migration plus a WASI-0.3 + rewrite — deferred (footnote 7, §8 risk 13). The converter therefore drains the + wrapped `Body::Stream` to `Bytes` within `SPIN_RESPONSE_STREAM_BUFFER_BYTES` + (16 MiB); over-cap → `response_too_large` (502, §3.4.1). The hand-built **outbound + streamed-upload** path above remains the implementation mechanism, with the + `BestEffort` cancellation classification in footnote 8. + +## 5. Test plan + +Tests are split by what they can actually prove. A core mock proves portable semantics; an +adapter seam proves conversion/classification; only a live runtime can prove host behavior. + +### 5.1 Tier 1 — core contract, no platform runtime + +Colocated `edgezero-core` tests use `MockOutboundClient`, scripted streams, and injected +monotonic instants. They must not claim platform cancellation or wire behavior. + +Required coverage: + +- Builder defaults and overrides, including buffered vs streamed mode, request and response + caps, timeout, absolute deadline, all HTTP methods, and mutually exclusive body/json + setters. +- Body constructors prove the non-overlapping error contract: `from_stream` preserves + typed `bad_gateway`, `gateway_timeout`, and `response_too_large` chunks exactly; + `from_external_stream` maps arbitrary source errors to `internal`; `stream` remains + infallible; `Bytes` and the existing buffered input types convert to `Body::Once`. +- `OutboundRequest::from_request` preserves the supplied body and exact method, including + DELETE and HEAD. Repeated normalization is idempotent and does not rewrite the method. +- URI preflight rejects unsupported schemes, missing authority, userinfo, fragments, and + invalid method/URI combinations before any adapter work. WHATWG canonicalization covers + dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA, empty paths, and + query characters in addition to DNS names, default/non-default ports, bracketed IPv6, + and backend-target/Host/host-only/SNI/certificate values. App-visible and + adapter-visible serializations are equal. +- Request normalization strips standard hop-by-hop fields plus every field nominated by + each visible `Connection` value, case-insensitively and across repeated field lines. + Empty or invalid nomination tokens fail the whole request as 400; valid prefixes are not + partially honored. Native-fidelity raw non-UTF-8 `Connection` is also a 400. + Cloudflare's portable baseline is tested over the visible normalized strings. +- Valid non-ASCII UTF-8 custom header values survive on Native-fidelity adapters; + forbidden controls reject at construction, and invalid UTF-8 introduced through + `headers_mut` is dropped except for the fail-closed `Connection` case. +- Response normalization performs the same hop-by-hop and nomination stripping before + `Content-Encoding`/`Content-Length` interpretation. `Connection: content-encoding` and + `Connection: content-length` cannot influence decode/cap logic. Every adapter classifies + malformed visible nomination syntax as 502; Native adapters also classify malformed + raw/non-UTF-8 `Connection` as 502. Cloudflare is tested only to its BestEffort raw-header + fidelity contract. +- `OutboundResponse` construction and `into_parts` round-trip request method, status, + normalized headers, and body. Adapters apply bodyless handling for HEAD, every 1xx, 204, + and 304 before content decoding or streaming. For 205, clean EOF completes normally while + a positive `Content-Length` or non-empty body bytes abort the remaining native body; + a visible empty stream item also aborts conservatively without a second read. Byte-read + SDKs use their EOF result and do not attempt to reconstruct invisible frame boundaries. + Spin's caller-result protocol never reports false clean completion. Output bodies are + empty and framing metadata is normalized according to §3.4.1. `into_response` re-runs + normalization idempotently rather than owning the first pass. +- Content decoding covers gzip, br, stacked/repeated encodings, mixed case, unknown + encodings, and a repeated field set with one malformed raw value. Native-fidelity + adapters preserve/pass through a malformed encoding set as specified; Cloudflare asserts + only visible-value behavior. +- Completion tests cover two concatenated gzip members in one chunk and split across + chunks, empty members, cumulative cap overflow in a later member, corrupt/truncated + later members, and trailing garbage. Brotli trailing bytes/concatenated streams reject, + including read-ahead bytes already held by the recovered buffered reader. For both + codecs, a complete encoded payload followed by a typed source error preserves that + error; a payload followed by a pending native EOF cannot report successful completion. +- Response resource tests cover exact-limit and first-byte/entry-over boundaries for raw + encoded body bytes, decoded bytes, header bytes, and header count. They exercise repeated + fields, checked-`u64` overflow/conversion, passthrough encodings, cleanup, deadline + precedence, and exact `ResponseLimitReason` preservation. Before any body poll, a + payload-bearing identity `Content-Length` is compared with both encoded and decoded caps; + a compressed/passthrough-coded length is compared only with the encoded cap. Tests cover + exact/over values, malformed/comma/conflicting lengths, no-poll rejection, and bodyless/205 + exclusions after nomination stripping. Brotli tests cover every + standard WBITS encoding plus the large-window extension, fragmented/empty prefixes, + truncation, replay fidelity, and zero decoder-constructor calls when the window rejects. +- `OutboundRequest::into_parts` / `from_parts` round-trip every budget and response-resource + setting without replacing configured values with defaults. Adapter conversion tests set + all controls together and prove the post-dispatch response path receives each one; the + Fastly batch test inspects the pending response policy after `OutboundRequest` is consumed. +- Rechunk tests cover disabled behavior, an oversized source chunk, bounded emitted items, + lazy source pulls, error order, early drop, and deadline/fairness behavior. They explicitly + retain the source-allocation term rather than claiming the wrapper caps RSS. +- The decoder error carrier restores the exact original `EdgeError` variant/status/kind. + Raw decompressor failures become `bad_gateway` reason `Decode(Gzip)` or + `Decode(Brotli)`; over-cap becomes `response_too_large`; no path stringifies a typed + error. +- The deadline wrapper around **decoded output** covers: compressed input that stalls before + first decoded output, stalls after output, stalls at terminal EOF/trailer validation, and + yields an error concurrently with deadline expiry. It checks every yield and terminal + result; deadline/cancellation wins the simultaneous terminal race. +- Pre-append accounting rejects a chunk that would exceed either request or decompressed + response limits without first allocating/appending it. Exact-limit success and + `u64`/platform-size conversion boundaries are covered. +- `dispatch_budget` covers timeout-only, deadline-only, both orders, equal bounds, + already-expired deadline, synthetic default, overflow clamp, and one shared `now` for + `send_all`. `BudgetSource` asserts only selected-input provenance, never timer phase + or retry/abandonment semantics. +- Every timeout path carries `budget.cause`; bare inner/caller deadlines use + `Unspecified`. JSON does not serialize the cause. +- Every `BadGateway` path carries the narrowest available `BadGatewayReason`; the bare + constructor uses `Unspecified` only when no producer-specific origin is known. + `ResponseTooLarge` also maps to 502 but carries `ResponseLimitReason`, not + `BadGatewayReason`. Tests cover every reason and prove the JSON wire shape does not expose + `reason` for either variant. +- `HttpClient::send_all` delegates the complete input to its injected client and returns + its index-aligned results unchanged, including empty input and mixed success/error + results. These core/mock tests establish handle delegation only; adapter batch behavior + is required separately by the Tier 2 `send_all` row. +- Shared preflight tests pin the GET/HEAD streamed-body diagnostic. Tier 2 verifies each + adapter invokes that validator before its batch-only streamed-body rejection. +- Buffered and streamed response drains enforce caps and deadlines, recheck after EOF, and + preserve typed 502/504/response-too-large outcomes. +- Manifest parsing rejects unknown/duplicate capability names, required/optional overlap, + misplaced nested `capabilities` tables at every array/table depth, and every mixed-case + spelling of the reserved key outside the exact lowercase top-level location. Runtime TOML + and baked JSON run the same reserved-key policy before typed deserialization, then the same + validation/finalization path. Invalid host grammar and non-canonicalizable ports/authorities + also fail. Canonical rendering preserves the scheme of every atomic wildcard: + the absent https-only default renders exactly `https://*:*`, while explicit bare `*` + renders separate http and https entries. Baked JSON runs the same validate/finalize + pipeline and distinguishes Absent from Malformed. + +### 5.2 Tier 2 — adapter contracts, no external network + +Each adapter crate tests its shipped conversion and classification seams. + +| Surface | Required assertions | +| --- | --- | +| Capability metadata | All four adapters return the exact eight **outbound** cells in §3.5.2, including complete resource accounting = Unsupported everywhere, Fastly outbound HTTP = BestEffort, Cloudflare header fidelity = BestEffort, and Spin deadline/upload/flexible-phase-budget = BestEffort. Tests do not assert that the shared enum has only eight global variants; non-outbound cells belong to their own specs. A fixture adapter that relies on the trait default returns Unsupported. Because each adapter crate matches core's non-exhaustive `Capability` enum across a crate boundary, normal adapter compilation requires a wildcard; review asserts that its result is `_ => Unsupported`. A hypothetical future variant is a structural fail-closed invariant, not a value current Rust code can safely construct at runtime. | +| Request conversion | Method/body/headers/full canonical URI survive conversion; normalized hop-by-hop fields cannot reappear; buffered and streamed request caps map to 400. Dot-segment/percent/numeric-host/IDNA cases use the exact core serialization rather than adapter reconstruction. Typed `EdgeError` request chunks survive adapter conversion; in-tree paths never route them through `from_external_stream`. | +| Deadline anchoring and clock propagation | Every standard adapter installs its outbound client with the exact `App::monotonic_clock()` clone used by ingress; explicit low-level constructors use a documented default clock. Every adapter captures one stored-clock snapshot as the first operation in `send`/`send_all`, before normalization, preflight, or builder work. An injected clock advances during preparation and proves that elapsed time consumes the valid request's original budget; no path re-anchors or bypasses the client clock. Preflight slot elapsed, provider-error precedence, post-ready expiry, deferred upload/response streams, returned `OutboundResponse` bounded-until collection, and response-converter egress checks use that same handle. A backwards clock cannot enlarge `budget.duration`; a backwards terminal sample produces zero elapsed plus an internal invariant outcome. Invalid-request precedence remains the shared validator's result because validation still runs before budget selection. | +| `send_all` on every adapter | Run the production batch orchestration with injected transport/clock seams for Axum, Cloudflare, Fastly, and Spin. Empty input returns empty without dispatch; mixed valid/invalid slots retain exact input indices; preflight failures dispatch no work for that slot and never poll rejected source streams; GET/HEAD body errors precede batch-only errors; transport errors, cap failures, timeouts, and non-2xx responses preserve sibling outcomes without cancelling siblings. Assert every slot carries its own elapsed time from the one method-entry snapshot through that slot's terminal point; advance the injected clock during preflight to prove that time is included. A same-tick terminal result may legitimately report zero. A slow sibling's later completion must not overwrite an earlier slot's elapsed value. Valid one-slot buffered batches match single-send outcome semantics while elapsed is asserted independently. Script reverse completion order on concurrent adapters; assert every eligible exchange is polled before a stalled sibling finishes. Fastly tests dispatch-before-harvest ordering, samples elapsed immediately after each harvest/preflight/dispatch terminal result, and retains its documented serial timing caveats without claiming Native slot isolation. Backwards injected time produces zero plus an internal outcome, distinguishable from legitimate zero by outcome. | +| Streamed fan-out usage | On Axum/Cloudflare/Spin, join per-request tasks containing both `send` and body consumption. Script a fast response whose body can finish before its deadline while a sibling's headers remain pending beyond it. Assert the fast body is consumed and succeeds before those sibling headers arrive. Joining only sends and delaying all body consumption must fail this regression. Fastly is excluded from this non-portable usage pattern. | +| Response conversion | Every adapter enforces guest-visible header limits before normalization, then normalizes before body/decode caps, calls the shared four-state content-encoding classifier, passes the originating method and retained clock into `OutboundResponse`, and settles native body handles for framing-bodyless and 205 responses; repeated `Set-Cookie` survives. HEAD/304 malformed, conflicting, comma-list, and `u64`-overflow `Content-Length` fail as protocol 502 before body polling while valid representation lengths are retained; 1xx/204 remove the field. Effective identity includes absent or exactly one bare `identity`, and its decoded over-cap `Content-Length` rejects before body polling. Encoded, decoded, header-byte/count, and Brotli-window failures preserve typed reasons and cleanup. | +| 205 settlement | Test declared-body immediate abort, one in-budget clean EOF, observed non-empty bytes, read failure, and deadline precedence. Where the SDK exposes empty items, one empty item aborts without another read; on Fastly a non-empty read buffer returning zero is EOF. Cloudflare additionally tests null-body host suppression with absent/zero and positive visible lengths without claiming hidden-byte visibility. Assert native-handle cleanup and Spin completion signalling using observable results, never synthetic wire-frame visibility. | +| Header fidelity | Axum/Fastly/Spin preserve repeated outbound request field lines and exercise raw malformed response nomination/encoding lines. Cloudflare tests request list semantics and the visible response-string baseline without asserting unavailable octets/line boundaries. Cloudflare encoded passthrough uses `EncodeBody::Manual`; its streamed downstream `Content-Length` is asserted only to the documented BestEffort scope. | +| Decoder integration | Each adapter uses the shared decoder/carrier/deadline pipeline; gzip/br stalls before output, midstream, and after codec completion but before native EOF produce attributed 504 rather than hanging or degrading to 502/500. Exercise both Buffered and Streamed modes, multi-member gzip/cumulative caps, trailing data, and late typed source/completion errors. No guard disarm or Spin caller-result success occurs solely because a decoder reached its end marker. | +| Buffered response-out fallback | Axum/Fastly/Spin enforce their 16 MiB adapter cap and synthesize the standard JSON envelope with the original `EdgeError` status and kind. | +| Axum response-out scheduling | A one-worker Tokio runtime converts a non-`Send` stream that awaits a Tokio timer inside the prescribed `block_in_place` + `Handle::block_on` boundary. It completes within the cap while an independent timer task progresses; no nested `futures::executor::block_on` remains on the reactor thread. | +| Lazy response-out | Cloudflare yields bytes before source EOF. The other three report BestEffort and are tested only for bounded buffering. | +| Axum timeout | Fake-time/transport seams prove the remaining budget is armed once and timeout errors preserve provenance. | +| Cloudflare raw fetch | The constructed Request has `RequestRedirect::Manual`; separately, the actual final fetch initializer contains `encodeResponseBody: "manual"` and the guard's abort signal together without resetting redirect. Property-set exception or false returns `internal` without dispatch. The helper uses the checked response conversion and absolute-deadline precedence from §4.2. Native tests assert the exact canonical string supplied to the Web request; they do not call it the final wire URL. WASM compilation verifies the worker 0.8.3 re-exported bindings; SDK tests inspect both boundaries. Effective origin-observed WHATWG target/Host behavior is verified in Tier 3. | +| Cloudflare cancellation | The production guard's injected abort operation fires on timeout, buffered cap overflow (early length rejection and incremental decoded overflow), normalization/decode/read failure, early 205 disposition, send-future cancellation, streamed decode/read failure, and early consumer drop. Explicit abort plus drop fires once; normal completed bodies disarm; a streamed response transfers guard ownership. Preserve 502/504/response-too-large/empty-205 outcomes and absolute-deadline precedence. A bare dropped Rust future is insufficient. | +| Streamed request deadline boundary | Axum and Cloudflare check expiry before every pull and after every ready source result. Fake-time streams cover a chunk, EOF, and source error becoming ready exactly at expiry, plus always-ready empty chunks. Cloudflare's clock fixture remains frozen until the injected host-event yield completes: assert the ready-item quota is finite/nonzero and no greater than 64, survives polls/stream yields, counts empty items, and permits no next item before the quota-triggered host yield. Ready-only terminal success/error/cap paths also yield before their expiry decision. Assert attributed 504 without reanchoring the deadline. | +| Cloudflare decoder fairness | With the same frozen clock, test ready-empty raw input that never produces decoded output, continuously ready decoded output, and native-EOF validation after codec completion. Both input and output quotas force host-event progress; terminal EOF/error and adapter-owned cap decisions at expiry yield attributed 504. Generic streamed-consumer caps after a within-budget yield retain §3.4.1's narrower ownership rule. Dropping during a host yield cleans up the owned subrequest guard. An independently advancing fake clock alone cannot establish these invariants. | +| Timeout provenance | Each adapter's actual timed-out result covers timeout-wins, deadline-wins, and synthetic-default input selection in single send, buffered fan-out, and streamed error chunks; no adapter emits a bare un-attributed timeout **for an EdgeZero-owned budget timer**. Spin separately proves that an early WASI provider timeout is 504 with `BudgetSource::Unspecified`, while an error observed at/after absolute expiry is attributed to the selected budget. Fastly proves configured connection/response phase timers retain the selected cause, an early unconfigured DNS timeout is `Unspecified`, and absolute expiry wins every simultaneous result. | +| Fastly stages | Backend identity/canonical host/TLS/SNI inputs, phase-timer rounding, cold registration, serial harvest, and streamed-upload cooperative checks match §4.3. The feature-gated overhead seam proves the slack invariant. `SendFailure` distinguishes budgeted phase timeout from unconfigured provider timeout; both map to 504, with the exact provenance boundary above. Pre-response DNS/connect/TLS establishment -> 502 reason `Unreachable`, later transport -> 502 reason `Transport`, upstream protocol -> 502 reason `Protocol`, unknown/registration rejection -> 502 reason `Unspecified`, local invariants -> 500, and the separately named Fastly platform-internal class -> 500. SDK-gated in-crate tests construct every known `SendErrorCause` and assert both `cause_to_failure` and composed status/kind/reason, including before/at/after deadline provenance; the Fastly WASM `--lib` gate executes them. Only hypothetical future variants remain compile/review coverage. | +| Fastly upload finalization | Empty-source EOF and multi-chunk success call `finish()` exactly once before response wait. Source/cap failure and pre-finish expiry drop both handles without finishing; a typed source error is preserved. Write/flush/finish errors within budget are 502; success or failure returning at expiry is attributed 504. A finish failure or post-finish expiry never waits on `PendingRequest`, and dropping the send future settles its owned handles without successful finalization. Tests use the production upload driver with a scripted writer/pending-handle seam; they do not claim a finite host-write bound. | +| Spin request protocol | Exercise every `run_exchange` transition and ownership boundary: after full upload, `send` continues to be polled but a ready result is retained until `request_done` succeeds; a `request_done` error wins over that stored result and is mapped; reader-gone retains but never polls `request_done` until `send` resolves, then drops it before response conversion; send-first drops all request handles; clean EOF/reader-gone writes `Ok(None)` trailers; source/cap/deadline failure leaves the default `Err`. Biased simultaneous readiness makes an already-ready pump failure beat `send`, while send ready before a later source failure remains authoritative. An always-ready empty-chunk source yields between chunks so send/timer polling cannot starve, and no request-side handle enters a streamed response wrapper. The target-neutral injected RequestOptions suite covers all setters accepted, any setter NotSupported (warn once and retain the outer race), Immutable, and Other. The real SDK-resource suite invokes each setter and asserts only the pinned host's actual result. | +| Spin response protocol | `consume_body` receives the caller-result reader; stream/trailer handles retain the writer; clean EOF/trailers writes `Ok`; body/decode/deadline failure writes or defaults to `Err`; no handle is dropped before its terminal branch. Ready-empty raw input and continuously ready decoded output hit finite input/output quotas, return `Pending`, poll the outer timer/siblings, and retain counters across calls. | +| Spin error classifier | Enumerate every pinned `ErrorCode` with no wildcard. At/after absolute expiry every simultaneous SDK result -> attributed 504. Before expiry, the five provider timeout variants -> 504 with `BudgetSource::Unspecified`; caller-controlled request denied/body/URI/header-size variants -> 400; demonstrated length/method/URI/trailer/config invariants -> 500; pre-response DNS/connect/TLS establishment -> 502 reason `Unreachable`; later connection/read/write failure -> 502 reason `Transport`; upstream response framing/protocol -> 502 reason `Protocol`; host `InternalError`/forced future fallback -> 502 reason `Unspecified`. Shared decode failures separately assert the exact coding reason. Test the three actual `ErrorCode` sites: `client::send`, `request_done`, and the response trailers/completion future. Test component-stream termination and caller-result `FutureWriteError` through their distinct policies; synthetic setter outcomes stay in the injected suite because the real host controls them. | +| Spin timer | Timer selection returns guest-visible 504 and drops owned guest handles. Tests do **not** claim bounded host teardown; that remains Tier 3 characterization and an upgrade criterion. | +| Simultaneous terminal race | For decoder, transport, and Spin exchange seams, a result becoming ready at the absolute deadline yields attributed 504 whether the competing result is success or error. | +| CLI runtime gates | Table-drive every constructible ladder state: required Native/BoundedCooperative succeeds; required BestEffort/Unsupported fails; optional degradation warns and proceeds. Cover missing-registry empty, optional-only, and required manifests; `ManifestContract::None` proceeds while Malformed fails closed. Normal CLI compilation matches core's non-exhaustive `CapabilitySupport` and `ManifestContract` across the crate boundary and therefore requires wildcard arms; review asserts that required-support and contract wildcards return errors, while the optional-support wildcard warns. Hypothetical future variants are not fabricated with unsafe runtime construction. Build/serve/deploy/deploy-staged gate before shell dispatch through both `execute_runtime` and `execute_capture_runtime`, and demo gates before startup. Auth/version/healthcheck/rollback remain on the existing exempt dispatcher and never construct `ResolvedRuntime`. Both outbound-scoped dispatchers derive adapter/action from the owned runtime, so no caller-supplied identity can diverge between gate and side effect. Provision/config commands are outside this spec and are not asserted. | +| Runtime resolver | Nested and descendant apps, competing targets, explicit relative/absolute `EDGEZERO_MANIFEST`, missing/malformed input, genuine manifestless targets, and containment/symlink failures resolve one adapter/action/target/contract unit for build/serve/deploy/deploy-staged only. Both outbound-scoped dispatchers gate that contract exactly once and execute that target without cwd/env mutation or rediscovery; shell overrides remain rooted at the selected manifest. Registry dispatch receives only typed `AdapterExecutionTarget`, never synthetic path args; conflicting cwd fixtures prove every in-tree `execute_target` honors the pinned app root/platform manifest/component and the default implementation fails closed. Fixture adapters prove the canonical adapter and action captured by the resolver are exactly those observed by capability lookup and final dispatch. Operational actions reject this resolver and retain their existing path. | +| Spin host drift | Build/serve/deploy resolve explicit/single/ambiguous component selection and compare only the selected component's canonicalized set before shell dispatch. Equivalent spelling/order passes; actual drift reports the manifest, component, and expected canonical list without writing files. Rendering `None`/`https://*:*` must not produce bare `*` or any `http` atomic; explicit input `*` produces the two scheme-specific entries. | + +### 5.3 Tier 3 — live host behavior + +- **Axum:** a loopback origin verifies real methods/bodies/headers, gzip/br decode stacks, + bodyless responses, non-2xx pass-through, response stalls, upload stalls, cap errors, + timeout provenance, and deterministic request/body/source future drop. HTTP/1 closure and + HTTP/2 stream reset are tested separately; pooled-connection teardown and bounded origin + observation are not portable promises. Deployed HTTP/2, and HTTP/3 when enabled, is + optional characterization only. +- **Cloudflare:** a pinned workerd-compatible harness executes real Worker SDK response + conversion, including the Workers-only `Headers.getAll("set-cookie")` boundary and + repeated `Set-Cookie`, then verifies AbortController cancellation + from the origin's point of view on timer expiry, buffered over-cap/decode failure, + send-future cancellation, and early streamed-body drop, plus lazy streamed-response + delivery. Use origins that keep sending or stall after the triggering result so the + test distinguishes cancellation from normal EOF. In both Buffered and Streamed modes, + gzip/br decode exactly once and respect decompressed caps; unknown, parameterized, + stacked, and repeated visible encodings preserve the original wire bytes and visible + encoding/length metadata through the portable policy. Pin the harness version and + compatibility settings, including the production template's exact compatibility date/flags + and support for the final-fetch raw-response option; commit the standalone fixture's own + Cargo lock and assert Worker 0.8.3 from that graph before building; + verify encoded downstream passthrough separately from upstream raw-byte receipt. A + runtime that ignores the raw-response option fails this acceptance gate. This is + evidence for + Cloudflare's Native deadline/cancellation behavior; raw malformed-header fidelity is not + asserted because workerd does not expose it. + Exercise the selected production yield primitive with a frozen-clock injection matching + deployed semantics for ready-only upload/raw/decode loops. Observe another host event and + clock advancement before the next quota of items, then verify terminal timeout + precedence. Run one deployed timing probe with the pinned compatibility settings. A + zero-duration `worker::Delay` is accepted only if that probe proves the property; local + workerd's freely advancing clock alone is not evidence. +- **Spin:** a pinned `spin up` harness and external origin record whether stalled upload + and response work is eventually cancelled and whether any component work remains. + Current capability values stay BestEffort regardless of a single passing run. Promotion + to Native requires a documented finite bound, repeatable host-observed tests, and the + corresponding matrix/spec update. +- **Fastly:** a protected manually dispatched job exercises one disposable service with + dynamic backends enabled and one with them disabled. It proves arbitrary canonical + destinations on the enabled service and the exact typed 502/no-origin-contact behavior on + the disabled service, plus records timeout/upload/streaming observations. Phase 6 owns a + standalone, independently locked probe crate because app-demo does not migrate until + Phase 7. The protected workflow builds its fixed package before exposing secrets, and the + driver can deploy only that package. The result + characterizes the `BestEffort` cell; it cannot make the static capability gate aware of + selected-service entitlement. + +### 5.4 Required test-case index + +The Tier 1 bullets, Tier 2 table, and Tier 3 runtime list above are the required test-case +matrix referenced throughout §§3–4. A reference to “the §5.4 row” means the matching +surface in those lists; implementation plans may split one surface into multiple focused +tests, but may not weaken its tier or substitute a mock for a host-observed claim. + +### 5.5 Executable test seams and CI impact + +Integration tests that need deterministic adapter states use a narrowly feature-gated +`test-utils` seam rather than `#[cfg(test)]`: external `tests/*.rs` crates do not see +the library's `cfg(test)` items. Seams inject clocks, transport results, or documented +adapter-stage delays; they do not duplicate the behavior being tested or expose platform +SDK types to core Tier 1 tests. + +**Feature and target contract.** Each of the four adapter `Cargo.toml` files declares +`test-utils = []`. It exposes only the injection accessors and SDK-independent production +drivers needed by these tests; it does not enable `axum`, `cloudflare`, `fastly`, `spin`, +or `cli`, and does not change existing default features. Production adapters call those +same drivers with real clock/transport/handle operations. Tests inject below the behavior +under test; copied algorithms and a core mock client are not adapter contract coverage. +Platform SDK bindings remain behind their existing feature/target gates. Public test-only +callbacks retained by a `Send + Sync` client must preserve those bounds. + +Add Axum's `tests/contract.rs`. In the existing Cloudflare, Fastly, and Spin contract files, +move the whole-file platform/WASM gate onto the SDK-test module and add a native +`test-utils` module. Otherwise the commands below can succeed while running zero tests. +Gate injection-dependent tests on `test-utils`; each native contract target must execute +the shared `send_all` cases and its applicable adapter-driver rows in §5.2. SDK-dependent +conversion tests run with a supported host harness, not by calling WASM imports on a native +host. Cloudflare's ordinary browser WASM runner is limited to portable Web APIs and bridge +construction because Worker 0.8.3's uncaught `Headers.getAll()` binding is Workers-only; +its real response conversion runs under workerd. Compile checks alone do not substitute for +test execution. + +**Explicit execution gates**, added to `.github/workflows/test.yml` when each adapter is +implemented (after the existing locked dependency fetch): + +```sh +scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract +scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract +scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract +scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract +``` + +These execute the no-network native seams; Axum's command explicitly enables its native +runtime. `scripts/run_test_nonzero.sh [--ignored] ` lists the selected +suite first, requires the exact sentinel and at least one listed test, then runs the same +selection; every native/WASM/SDK-resource/CLI suite in CI uses it. Workerd/deployed probes +instead require exact probe IDs plus a positive summary count in their own driver. These +gates become runnable as the features and test modules land, not in the design/Phase 1a +change set. Extend the existing WASM contract matrix's feature argument to +`--features "${{ matrix.adapter }},test-utils"`. Retain the Cloudflare and Fastly target +and runner settings. **Spin's bare `wasmtime run` is not an SDK-resource execution gate.** +Pinned Wasmtime 44.0.1 requires `-S http=y` and `-S p3=y` to register Preview 3 HTTP imports +([linker setup](https://github.com/bytecodealliance/wasmtime/blob/v44.0.1/src/commands/run.rs#L1290), +[Preview 3 default](https://github.com/bytecodealliance/wasmtime/blob/v44.0.1/src/common.rs#L20)). +Those switches are necessary for that version, not a verified sufficient runner command. +Phase 5 Task 0 is therefore a characterization gate, not evidence that the remainder of +Phase 5 is executable. Before implementing Task 1, verify and record an exact compatible +runtime version, flags, Rust target/component setup, and async test harness by executing real +SDK-resource tests, including `Fields` and `RequestOptions` construction/setters. The plan and +implementation index remain explicitly blocked beyond Task 0 until that evidence exists. +Task 0 updates the crate-local runner configuration and CI; all later Phase 5 commands consume +that recorded configuration rather than repeating an unverified candidate literal. +Compilation, native fake-resource tests, and zero-test success are not substitutes. +The full configuration remains runtime-unverified until that compatibility gate passes; +it is separate from Tier 3's origin-cancellation characterization. +GitHub delivers `workflow_dispatch` only when the workflow file exists on the default +branch ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow_dispatch)). Before adapter implementation starts, a standalone infrastructure bootstrap PR must +therefore land inert protected dispatchers for both Cloudflare and Fastly. They publish no +runtime/API/capability change, have no automatic secret-bearing trigger, and only check out a +maintainer-reviewed exact input SHA proven reachable from the fixed repository-owned +`refs/heads/outbound-probe-reviewed` ref before running that tree's probe driver. This bootstrap +is the sole permitted intermediate merge in the otherwise branch-only implementation series. +The reviewed ref is protected against force pushes and deletion; evidence commits advance it +only by fast-forward, and the protected run/job name displays the exact input SHA for the +environment reviewer. +Each provider dispatcher uses a fixed concurrency group with cancellation disabled. Fastly's +driver records and restores both services' prior active versions, removes only versions that +its own run created, and deletes only its own origin-observation namespace. + +Cloudflare's deployed probe uses that default-branch `workflow_dispatch` workflow bound to a protected +`outbound-cloudflare-probe` environment, never `pull_request_target`. It verifies an exact +input commit SHA before exposing disposable-account secrets +`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, +`OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`. The driver creates a unique +Worker and origin-observation namespace per run, requires named probe IDs and a positive +count, and deletes both in `finally`; assertion and cleanup failures fail the job. The +observable origin exposes authenticated arm, case, observation, and delete endpoints for +raw bytes, stalls, EOF, and disconnect facts. Untrusted forks run workerd only; after review, +a maintainer reproduces the exact tree on `refs/heads/outbound-probe-reviewed` and dispatches the protected +job for that SHA. Phase 4 specifies the concrete commands and origin protocol. + +Fastly WASM gates assert repository-pinned Viceroy 0.17.0 and run from the adapter crate so +its committed runner, including the demo `fastly.toml`, is used without an environment +override. Its default-branch deployed entitlement dispatcher follows the same exact-SHA, +protected-environment, disposable-resource, positive-probe, and cleanup rules in Phase 6. +It validates and builds Phase 6's independently locked fixture without secrets, then the +secret-bearing driver deploys only the fixed prebuilt package path. Keep ordinary production +feature/target checks and the Fastly WASM `--lib` gate. SDK +conversion checks and native fake-transport tests do not establish host cancellation; +the Tier 3 jobs still provide that evidence. + +`#[non_exhaustive]` future variants are a special case: stable Rust cannot safely construct +a variant that does not exist in the pinned enum. Tests therefore exercise every known +state; normal cross-crate compilation forces the production wildcard arms, and review pins +their fail-closed result (`Unsupported` or an error). They must not use `unsafe` discriminant +fabrication or add test-only public enum variants, which would alter serialization and the +macro crate's textually included manifest definitions. The pinned Spin/WASI `ErrorCode` is +an exhaustive enum and is therefore the opposite case: its classifier has no wildcard, so +an SDK variant addition fails compilation and must be assigned explicitly. When a real variant is added, it +becomes a normal table-driven case in the same change. + +Required gates for implementation changes: + +1. Repository `CLAUDE.md` format, test, clippy, feature-combination, and Spin WASM checks. +2. Per-adapter WASM target checks and no-network contract tests. +3. Generated-project build and the excluded `examples/app-demo` build. +4. Published-doc link/navigation verification. +5. Cloudflare host-observed cancellation job for its Native claim. +6. Fastly enabled/disabled dynamic-backend characterization for actual adapter behavior; + the static cell remains BestEffort. +7. Spin host-observed characterization job when available; failure preserves BestEffort + and blocks only a proposed promotion to Native. + +Version-specific behavior is a manifest contract, not merely a property of whichever lockfile +was inspected. The implementation phases pin `async-compression = "=0.4.43"`, +`reqwest = "=0.13.4"`, `worker = "=0.8.3"`, `spin-sdk = "=6.0.0"`, the enabled direct +constraint `wasip3 = "=0.6.0"`, and `fastly = "=0.12.1"` in the workspace manifests that +own those dependencies, refresh both the root and excluded app-demo lockfiles, and assert +both resolved graphs. The direct `wasip3` constraint is enabled with the Spin feature so the +real `spin_sdk::wasip3::http::types::ErrorCode` table cannot drift within Spin SDK's compatible +range. Adapter manifests inherit the corresponding workspace dependency rather than declaring +a broader compatible range. An SDK enum/table or raw bridge may not be published from a graph +that can silently select a later compatible release. + +The spec and Phase 1a plan themselves are documentation changes, so review-time +verification is Markdown structure, internal-link/anchor consistency, scoped terminology, +and `git diff --check`; Rust builds are required when an implementation plan is executed. + +## 6. Migration impact + +No compatibility shims are introduced for the outbound API rename. + +| Before | After | +| --- | --- | +| `crates/edgezero-core/src/proxy.rs` | `crates/edgezero-core/src/outbound.rs` | +| `ProxyClient` | `OutboundHttpClient` | +| `ProxyHandle` | `HttpClient` | +| `ProxyRequest` | `OutboundRequest` | +| `ProxyResponse` | `OutboundResponse` | +| `ProxyService` | removed; use `HttpClient` | +| `RequestContext::proxy_handle()` | `RequestContext::http_client()` | +| adapter `*ProxyClient` | adapter `*OutboundClient` | + +Outbound-facing changes: + +- `OutboundRequest` and `OutboundResponse` continue to use the unified core `Body` + type. Buffered responses remain the default; `stream_response()` opts into a streamed + response. `OutboundRequest::from_request` preserves the source method, normalized + headers, and supplied body without requiring any inbound extractor or body-state + redesign. +- `OutboundResponse` carries the originating request method through construction and + `into_parts` so HEAD and other bodyless-response rules remain enforceable at the final + conversion boundary. +- `ProxyHandle::client()`, outbound request/response `body_mut()`, and outbound + `extensions()` / `extensions_mut()` are removed. `HttpClient` exposes only + `send` / `send_all`; the new request and response values are builder-style and do + not carry `Extensions`. +- `PROXY_HEADER` and the observable `x-edgezero-proxy: ` response header are + preserved. The constant moves to `outbound.rs` without changing its value. +- `Body::Stream` changes its chunk error from `anyhow::Error` to `EdgeError` so + deadline, gateway, and over-cap outcomes survive decoder and adapter boundaries. + `Body::from_stream` now accepts only streams already returning `EdgeError`; + callers with arbitrary external errors use `Body::from_external_stream`, which + deliberately maps them to `internal`. `Body::into_stream` returns the exact typed + stream. These signatures are public breaking changes. Every existing + `Body::from_stream`, `Body::into_stream`, and direct `Body::Stream` call site must be + audited: EdgeZero-owned outbound/decoder/deadline producers use the typed constructor; + platform inbound-body sources that previously relied on the generic `anyhow` mapping + use `from_external_stream` to preserve their existing behavior. That mechanical + constructor migration is required for compilation but does not redesign inbound body + extraction, caps, or state. +- `EdgeError` gains `BadGateway { reason: BadGatewayReason }`, + `GatewayTimeout { cause: BudgetSource }`, and + `ResponseTooLarge { reason: ResponseLimitReason }`. Their JSON wire shape remains the + existing error envelope and does not serialize any reason/provenance field. Their wire + messages, plus `Internal`, are fixed category strings; detailed provider diagnostics and + request targets are not serialized. +- `Manifest` gains the eight outbound capabilities and + `[capabilities.outbound].hosts`. Existing non-outbound capability and store schemas are + unchanged. The depth-independent misplaced-`capabilities` rejection is intentionally + fail-closed. +- `Adapter` gains the defaulted `capability()` method. Each in-tree adapter returns all + eight matrix values and ends its non-exhaustive match with + `_ => CapabilitySupport::Unsupported`. +- Capability enforcement uses outbound-scoped `execute_runtime(..)` and + `execute_capture_runtime(..)`, which share one helper that gates `build` / `serve` / `deploy` / + `deploy --staging` exactly once before shell or registry dispatch, and `run_demo` gates + the Axum demo from its baked manifest. `auth *`, version emission, healthcheck, + rollback, provision, and config commands are not changed by this outbound specification. +- Build/serve/deploy resolve one `ResolvedRuntime` containing the action, a pinned canonical + adapter identity, a paired adapter target, and an optional `ResolvedManifest`. Dispatch + accepts no separate adapter/action arguments: the selected contract is gated exactly once + and the selected target is executed without reparsing, rediscovery, or process-wide + cwd/env mutation. +- Scaffolding, `examples/app-demo`, and public proxying docs migrate to the renamed + outbound types. Portable multi-adapter examples declare `optional = ["outbound-http"]` + because Fastly's service entitlement is not statically provable; single-target apps may + declare it required on an adapter whose matrix cell satisfies the gate. + Spin's generated platform manifest consumes the canonical outbound host list. +- `docs/guide/capabilities.md` documents all eight outbound capabilities, the support + matrix, and each BestEffort caveat, and is linked from the VitePress sidebar because + runtime diagnostics point to that published page. + +Repository-wide completion sweeps cover `ProxyClient`, `ProxyHandle`, +`ProxyRequest`, `ProxyResponse`, `ProxyService`, `proxy_handle`, and the four +adapter `*ProxyClient` names across Rust sources, examples, templates, and published +docs. A separate compile-driven sweep covers every `Body::from_stream`, +`Body::into_stream`, and direct `Body::Stream` construction/consumer so no typed +`EdgeError` is accidentally erased at the new explicit external-error boundary. A +generated-project build verifies that scaffold templates compile against the new outbound +API. Inbound body extraction/state and store/config lifecycle behavior remain owned by +their dedicated designs and are not migration prerequisites here. + +## 7. File-by-file change summary + +Exact filenames should follow the current tree at implementation time; function names are +the durable anchors. + +**`crates/edgezero-core`** + +- `src/lib.rs` exports `body`, `compression`, `outbound`, and `time`, plus root re-exports + `BodyStream`, `ContentEncoding`, `Deadline`, `DispatchBudget`, `HttpClient`, `OutboundHttpClient`, + `OutboundRequest`, `OutboundResponse`, `OutboundSlotResult`, `ResponseBodyDisposition`, + `ResponseHeaderLimiter`, `ResponseMode`, `BROTLI_DECODER_FIXED_CHARGE_BYTES`, + `brotli_decoder_memory_charge`, `classify_content_encoding`, `collect_response_stream`, + `decode_brotli_stream`, `decode_gzip_stream`, + `dispatch_budget`, + `enforce_payload_content_length`, `limit_decoded_stream`, `limit_encoded_stream`, + `normalize_response_headers`, + `rechunk_stream`, `validate_for_dispatch`, and the outbound capability types; remove proxy exports. Adapter crates + use these root re-exports as their stable import surface. +- `src/proxy.rs` becomes `src/outbound.rs`. It owns request construction and + validation, canonical URI accessors, the private `BudgetInputs` accessor consumed by + `time.rs`, `HttpClient::send` / `send_all`, bounded response drains, hop-by-hop + normalization, response-bodyless rules, and the preserved `PROXY_HEADER`. + `OutboundResponse::new` accepts the originating request method and + `into_parts` returns it with status, headers, and body. +- The root workspace and `edgezero-core` manifests add a direct pinned `url` dependency. + URI construction canonicalizes once through `url::Url`, stores the resulting `http::Uri`, + and adapters transmit that exact serialization rather than reparsing it. +- `src/body.rs` changes streamed chunk errors to `EdgeError`, adds + `From`, and implements pre-append checked accounting. + `Body::from_stream` is the typed `EdgeError` constructor used by adapters, + decoders, and deadline wrappers; `Body::from_external_stream` is the explicit + arbitrary-error boundary and maps every source error to `internal`. +- `src/compression.rs` keeps one shared gzip/br decoder implementation. An exact typed + carrier crosses the `io::Error`-based decoder boundary and restores the original + `EdgeError`. Extend the helpers for concatenated gzip members, strict Brotli/trailing + data handling, and native-EOF validation without losing buffered read-ahead or late + source errors. The absolute deadline/cancellation wrapper sits **outside decoded + output and completion validation** and checks every yield plus terminal EOF/error. +- `src/error.rs` adds `BadGateway { message, reason }`, + `GatewayTimeout { message, cause }`, `ResponseTooLarge { message, reason }`, + `BadGatewayReason`, `BudgetSource`, and `ResponseLimitReason`; update every exhaustive + match and preserve the current JSON envelope. Phase 1a is specified by + `docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md`. +- `src/time.rs` adds `Deadline` and the three budget constants with `web-time`. + Phase 1b adds `DispatchBudget` and `dispatch_budget` here as the independently testable + value/arithmetic layer; they consume `OutboundRequest::budget_inputs()` without owning + request construction or platform behavior. +- `src/app.rs` adds the three-state baked-manifest accessor to `Hooks`; update both + handwritten in-core `Hooks` implementations and the macro-generated implementation. +- `src/context.rs` performs only the outbound handle rename + `proxy_handle()` -> `http_client()`. Inbound request/body state is unchanged by + this specification; any local test fixture affected by the global `Body::from_stream` + signature change switches to the appropriate explicit constructor without changing + behavior. +- `src/manifest.rs` adds the eight outbound capabilities, + `ManifestCapabilities`, `ManifestOutboundCapability`, host validation, misplaced + nested-`capabilities` rejection, and the three-state baked-manifest contract. + Runtime and baked parse paths run the same validation/finalization logic. + +**`crates/edgezero-macros`** + +- `src/app.rs` bakes the validated manifest JSON and emits the per-app + `manifest_json()` / `manifest()` accessors. A per-implementation `OnceLock` + prevents cross-app cache sharing. Its raw TOML parse path runs the shared + depth-independent `reject_misplaced_capabilities` scan before typed deserialization; + compile-fail coverage rejects misplaced `capabilities` blocks at multiple depths. + +**`crates/edgezero-adapter`** + +- `Cargo.toml` adds `edgezero-core = { workspace = true }`. The registry trait's + public `capability(Capability) -> CapabilitySupport` signature uses types owned and + re-exported by core, so the adapter crate can no longer remain dependency-free from + `edgezero-core`. This creates no cycle: core depends on macros, while neither core nor + macros depends on `edgezero-adapter`. +- `src/registry.rs` adds the defaulted `Adapter::capability()` method. In-tree + overrides return all eight outbound matrix cells and use a final + `_ => Unsupported` arm for the non-exhaustive enum. Update the trait rustdoc that + currently promises the crate remains dependency-free from core. No store/provision + API changes belong to this spec. + +**`crates/edgezero-adapter-{axum,cloudflare,fastly,spin}`** + +- Each adapter imports EdgeZero-owned `MonotonicClock`/`MonotonicInstant` timing types from + core and adds `test-utils = []` to `[features]`. Production outbound code does not take + direct process-global snapshots; only explicit default-clock constructors may select the + default source. Keep the adapter test feature independent of runtime/CLI features and + preserve current defaults (§5.5). +- `tests/contract.rs` gains the native adapter-driver cases and SDK-specific test modules + described in §5.5. `.github/workflows/test.yml` explicitly executes the native commands + and enables `test-utils` in the existing WASM contract matrix in the same adapter phase. +- Rename each outbound provider module/client from `proxy` / `*ProxyClient` to + `outbound` / `*OutboundClient`; implement both response modes, request preflight, + method-aware `OutboundResponse` construction, request and response normalization, + independent encoded/decoded/final-buffer caps, typed decoder errors, and the exact eight outbound-cell + capability tuple. Before the native request consumes `OutboundRequestParts`, retain the + originating method, response mode, and every response-resource setting in the owned + response-conversion state; no adapter may reconstruct or default those values after send. +- Adapter response converters destructure request method, status, headers, and body. + They normalize raw upstream headers and settle bodyless responses before content + decoding/capping and `OutboundResponse` construction, then reapply normalization + idempotently before final platform conversion. + Cloudflare alone provides Native lazy streamed-response passthrough; Axum, Fastly, + and Spin use their documented 16 MiB bounded buffered fallback and preserve each + `EdgeError` status/kind when a drain fails. +- Do not change inbound platform-request buffering or `RequestContext` construction; + those are owned by the inbound-body design. The one allowed inbound-side edit is the + compile-required, behavior-preserving constructor migration from generic + `Body::from_stream` to `Body::from_external_stream` (or an explicit `EdgeError` map + followed by typed `from_stream`) at existing platform-body conversion sites. + +Adapter-specific work: + +- **Axum:** use the remaining `DispatchBudget` for reqwest's whole-operation timeout; + race every streamed request-source pull and apply the absolute post-ready deadline + check before accepting chunk/EOF/error; disable reqwest auto gzip/br; use the shared + decoder and normalization. Keep header fidelity Native. +- **Cloudflare:** add the private `fetch_raw_with_signal` bridge using worker's existing + JS/Web API re-exports. Set `encodeResponseBody: "manual"` and the guard's signal on the + final fetch options so the shared decoder receives raw bytes. Use an owning abort + guard plus `worker::Delay` for send/body cancellation on every early exit; use the same + pre-pull/post-ready absolute checks and bounded host-event yield quotas for request + sources, raw decoder input, decoded output, and terminal decisions (§4.2); stream + response output lazily; and classify header fidelity + BestEffort because workerd exposes normalized strings rather than raw malformed field + lines. Set `EncodeBody::Manual` when forwarding already encoded passthrough bytes so + Workers does not transform the payload. Replace the crate-local stale Preview-1 Cargo + config with `wasm32-unknown-unknown` plus `wasm-bindgen-test-runner`, and execute one + crate-local nonzero WASM gate without target/runner overrides in CI. +- **Fastly:** derive deterministic dynamic-backend names; configure connect, + first-byte, and between-bytes host timers from the remaining budget; retain the + documented service-entitlement, cold-registration, upload-write, and serial-harvest + BestEffort gaps. `outbound-http` remains BestEffort until a reviewed deployment + prerequisite can prove dynamic-backend enablement for the selected service. + Native tests cover `SendFailure`; SDK-gated tests construct every known public + `SendErrorCause` and check the boundary mapping as well as composed status/kind and + timeout provenance. Only `SendError`'s private wrapper and hypothetical future enum + variants are not directly constructed. `PlatformInternal` is pinned separately from `LocalInvariant` even though both + currently map to 500, so a future policy change cannot silently conflate their causes. + Add an independently locked standalone deployed-probe crate under the adapter's test + fixtures; build its fixed package before protected-workflow secrets are exposed, and make + the live driver reject an absent or alternate package rather than compiling during deploy. +- **Spin:** use the hand-built WASI HTTP request for buffered and streamed uploads. + Implement the biased `run_exchange` state machine and cooperative per-chunk yield; + retain or consume `request_done` at the exact state boundaries in §4.4; explicitly + resolve request trailers on clean EOF/reader-gone; use the response `consume_body` + caller-result protocol; own every future/reader/writer until its terminal branch; and + classify all three `ErrorCode` sites: `client::send`, `request_done`, and the response + trailers/completion future. Component-stream termination and caller-result + `FutureWriteError` follow their separately specified policies rather than entering that + classifier. + Caller-controlled request policy/size variants map to 400, core/adapter invariants to + 500, provider timeouts before absolute expiry to unattributed 504, errors observed at or + after absolute expiry to attributed 504, and upstream failures to 502. The monotonic race + returns a guest-visible timeout, while deadline and upload cancellation remain + BestEffort until host-observed tests establish a finite bound. + Replace the adapter-local buffered `src/decompress.rs` path with the shared core + streaming decoder; remove direct production `brotli` / `flate2` dependencies if no + remaining Spin-only use exists (retain only dependencies still required by tests). + +**`crates/edgezero-cli`** + +- The `run_build`, `run_serve`, and `run_deploy` command entry points capture the invocation + directory, resolve one target/contract pair, and pass the resulting `ResolvedRuntime` to + `src/adapter.rs::execute_runtime` or `execute_capture_runtime`. A shared private action gate runs exactly + once at the start of both outbound-scoped dispatch functions and gates `Build` / `Serve` / + `Deploy` / `DeployStaged` before either function's shell-command branch or registry + lookup. It executes the pinned target without rediscovery or reparsing. Auth, + `EmitVersion`, `Healthcheck`, and `Rollback` keep the existing dispatcher and resolution + behavior; they do not construct `ResolvedRuntime`. +- `has_manifest_command` and pre-dispatch adapter-manifest selection are folded into the + action-specific target resolver. Callers inspect the borrowed `ResolvedAdapterTarget` + when deciding which built-in-only arguments to add; they do not repeat shell-command or + platform-manifest discovery before handing the owned pair to the dispatcher. +- `src/manifest_source.rs` (or the existing manifest-loading module) adds the exact shell and + registered `ResolvedAdapterTarget` variants, `ResolvedManifest { loader, path }`, and + `ResolvedRuntime { action, adapter, contract, target }`. The registry crate adds the exact + private-field `AdapterExecutionTarget { app_root, component, platform_manifest }` handoff + plus `Adapter::execute_target`; every in-tree registered adapter uses it without cwd + discovery, and the default fails instead of delegating to `execute`. The runtime pins the canonical adapter + name and resolved action; outbound-scoped dispatcher signatures accept no + duplicate identity arguments. Explicit `EDGEZERO_MANIFEST` is authoritative + relative to the captured invocation directory. Default discovery applies the existing + ancestor/workspace-descendant target domain to paired app candidates; malformed, + ambiguous, mismatched, or containment-invalid candidates fail closed. Genuine + target-associated contract absence alone yields `contract: None`. Deploy's version + fallback remains on the existing operational dispatcher and is not part of this resolver. +- `src/demo_server.rs::run_demo` gates Axum against the baked manifest before startup. +- The Spin build/serve/deploy pre-dispatch path validates only the selected component's + canonical `allowed_outbound_hosts` before shell overrides can bypass it. Drift is + reported with the manifest/component/expected list and repaired manually; generation + initializes new projects but does not rewrite existing user-owned `spin.toml` files. +- Provision and config command implementations are unchanged by this outbound spec. + +**Templates, examples, and docs** + +- Rename generated proxy APIs in Rust and Handlebars sources. The portable root scaffold + and `examples/app-demo` declare `optional = ["outbound-http"]` so their shared manifest + remains usable with Fastly's BestEffort service prerequisite; a single-target app can + promote it to `required` when its selected adapter satisfies the matrix. Spin's generated + platform manifest renders the canonical outbound host list. +- Update public proxying, handler, architecture, streaming, and adapter docs. Add + `docs/guide/capabilities.md` with all eight outbound capabilities and sidebar + navigation. +- Build one generated project and the excluded `examples/app-demo` workspace so + template/example drift cannot hide behind the root workspace build. The generated + project gate explicitly runs `cargo test -p scaffold-probe-core --lib`; a workspace + build alone does not execute those core fixture tests. + +**Tests and CI** + +- Core colocated tests cover builders, URI validation, normalization, bodyless rules, + typed versus external body-stream constructors, budget selection/provenance, bounded + drains, typed decoder errors, decoded-output deadlines, and `send_all` + index/partial-failure semantics. +- Each adapter has no-network contract tests for request conversion, response + conversion, error classification, capability metadata, and platform-specific + timeout mechanics. +- Axum loopback tests prove native wire behavior. Cloudflare's runtime cancellation + test is blocking evidence for its Native cancellation claim. Spin host-observed + tests characterize cancellation and are the criterion for a later BestEffort -> + Native upgrade; they are not a prerequisite for the current BestEffort claim. + Fastly local runtime tests remain conditional on a supported harness, but the protected + enabled/disabled deployed characterization is required before its BestEffort row is + published. +- Required local gates are the repository `CLAUDE.md` commands, all adapter WASM + target checks, the generated-project compile plus explicit nonzero core-test execution, + and the app-demo build. Phase 7 adds deterministic legacy-API and published-capability + document checks; the latter compares all eight rows/support values and requires exactly + one VitePress sidebar link. Documentation-only edits use Markdown/link/diff verification + rather than rebuilding Rust. + +## 8. Open questions / risks + +1. **`DEFAULT_MAX_RESPONSE_BYTES` = 1 MiB.** Trivially overridable per request via + `max_response_bytes`. Confirm the default suits expected target responses. +2. **Tier 3 CI runtimes.** Viceroy / `workerd` / `spin` jobs add CI cost and + maintenance. The design degrades safely (Tier 1 + Tier 2 always run); the risk is + schedule, not correctness. +3. **Cloudflare cancellation — RESOLVED.** A timed-out subrequest is cancelled via + the guard's `worker::AbortController`, whose signal is included in the final options + passed by `fetch_raw_with_signal` (§4.2). Dropping the underlying fetch future alone + leaves the subrequest running; dropping the guarded send future aborts through the + guard's `Drop`. Tier 3 CF tests keep the origin active after timeout/cap/decode/drop + triggers and verify that it observes cancellation; an origin that already closed before + a transport/completion error is not used for that assertion. +4. **Fastly active response-drain overshoot.** Once an individual warm-path slot is + actively draining its response, that read-phase overshoot is bounded by one + between-bytes-timeout interval (§3.3.4). This does not bound cold backend registration, + request-body writes, or time spent waiting behind earlier `send_all` harvest work; those + gaps are owned by footnotes 1/2/4 and risks 7/8. If a stricter active-drain guarantee is + ever required, the adapter would need to cap total body-read attempts — out of scope here. +5. **Naming.** `OutboundHttpClient` (trait) vs. `HttpClient` (handle) are close. They + never co-occur in app code — handlers see only `HttpClient` — so the overlap is + low-risk, but a rename of the handle is cheap if preferred. +6. **Axum lazy streaming follow-up.** The Axum response converter buffers `Body::Stream` + into `Bytes` because core `Body::Stream = LocalBoxStream` is non-Send and Axum's + `Body::from_stream` requires `Send + 'static` (§3.5.2 footnote 3, §4.1, §7). A real + bridge — e.g. a `tokio::task::spawn_local` driving a `tokio::sync::mpsc` Send channel + read by Axum — is implementable but non-trivial and is **deferred**. Apps that need + lazy streaming on Axum declare the `lazy-streamed-response-passthrough` capability + required and get a hard build failure today; lifting the limitation is a separate + future change with its own design + tests. +7. **Fastly streamed-upload write-phase has no SDK-configurable bound.** + Fastly's `between_bytes_timeout` is documented as receive-side only — it + bounds the gap between bytes received from origin, not the host-side write + of guest-supplied bytes to origin (Fastly Backend API docs; round 50). No + published Fastly backend-timeout field bounds the guest-to-origin write + direction. Streamed-upload write-phase is therefore `BestEffort` on + Fastly (alongside the source-stream-yield `BestEffort`); the cooperative + `budget.deadline.is_expired()` check **between** chunks is the only + adapter-side bound. Apps that need real-time enforcement against a slow + origin on the write path must **target a different adapter**. (Passing a buffered + `Body::Once` does *not* fix this: it removes the source-pull stall but the host still + writes those bytes in the untimed `connect`→`first_byte` window. The write-side gap is + a property of Fastly's timeout model, not of `StreamingBody`.) If a future + Fastly platform release adds a documented guest-write timeout, it would close the + write-side gap only; the capability would remain BestEffort until source-pull + preemption also has a documented bound. Track Fastly host docs. +8. **Fastly buffered-body-drain serialization in `send_all`.** Harvest reads bodies in + slot order, so wall-clock = `max(header_arrivals) + Σ buffered_body_drain_times` + on Fastly vs. `max(header_arrivals + body_drain_times)` on Axum/CF/Spin (§3.3.4). + For small JSON bodies the response-drain term alone is usually negligible; cold backend + registration and unresolved request writes remain separate unbounded slot-isolation + gaps. For multi-MiB responses Fastly's serial drain is suboptimal. **There is no current EdgeZero mitigation** — + and Streamed mode is not the workaround (it's rejected by `send_all` preflight + per §3.1.1, and even via single `send` Fastly has no concurrent + chunk-consumption primitive). Apps that need concurrent large-body fan-out on + Fastly should (a) target a different adapter for that workload, (b) restructure + the topology so parallel large-body drains aren't required, or (c) wait for the + interleaved-drain follow-up. The follow-up — interleaved chunk reads across + in-flight Fastly `Response` bodies, driven from a single guest harvest loop — is + non-trivial without an async reactor and is **deferred**. The + `send-all-slot-isolation` capability (§3.5.1 footnote 4) lets apps declare the + requirement explicitly and get a hard build failure on Fastly until this lands. +9. **Fastly configurable phase split.** The fixed 1/4 connect + 3/4 first-byte + split (§4.3) produces premature connect failures for slow-connect upstreams + even when the total budget would have sufficed. Apps that hit this require + `outbound-flexible-phase-budget` (§3.5.1 footnote 5) and fall through to the + hard build failure on Fastly. The follow-up would either expose a per-request + `fastly_phase_split(connect_ratio: f32)` setter, a per-`OutboundRequest` + configuration field, or a per-adapter config knob on `FastlyOutboundClient`. + Each option has a memory-model and capability impact, so it's left **deferred** + pending a real use case. +10. **Spin target documentation drift.** Implementation verification uses + `wasm32-wasip2` for Spin SDK 6. Any remaining comment that associates Spin with + `wasm32-wasip1` should be corrected when the implementation touches that file, but + this documentation-only cleanup is not a prerequisite for the outbound design. +11. **Per-batch transient-memory cap against adversarial chunking — PARTIALLY + RESOLVED.** §3.4.1's + `current_chunk.len()` term is source-controlled — an upstream peer that + yields one large `Bytes` produces a transient resident footprint equal to + that chunk size plus the persistent buffer cap. The design now includes opt-in + `OutboundRequest::max_chunk_bytes(NonZeroU64)` and a lazy consumer-side rechunker + (§3.4.5), with no cost when unset. This bounds app-visible item size and preserves + ordering, errors, deadlines, and cancellation. It does **not** bound transport-side + source allocation or process RSS: splitting `Bytes` can retain the original backing + allocation, and a copied slice may coexist with it. A true transport allocation cap + would require an adapter/provider boundary that refuses an oversized frame before + materialization; that remains unavailable on some targets and must not be inferred + from `max_chunk_bytes`. +12. **Fastly lazy-streamed-response-passthrough via non-`#[fastly::main]` + entry point.** Today's Fastly scaffold uses `#[fastly::main]`, which + implicitly calls `Response::send_to_client()` on the returned response. + Fastly's `Response::stream_to_client()` — the only API that flushes + response bytes to the client lazily — is documented as incompatible + with `#[fastly::main]`. As a result, the Fastly adapter currently + falls back to buffered passthrough (drain `Body::Stream` to `Bytes` + within `FASTLY_RESPONSE_STREAM_BUFFER_BYTES` (16 MiB) before returning — + the per-request `max_response_bytes` is not available at the response + converter), and + `lazy-streamed-response-passthrough` is `BestEffort` on Fastly per + footnote 6. The follow-up would either: (a) scaffold a non-attribute + entry (`fn main() { let req = Request::from_client(); … resp.stream_to_client() … }`) + and route the EdgeZero handler through it, with `stream_to_client()` + feeding chunks from the wrapped `Body::Stream`; (b) keep + `#[fastly::main]` for buffered handlers and add a separate + `#[edgezero::stream_main]` attribute that expands to the + non-attribute form when the manifest declares + `lazy-streamed-response-passthrough` required; (c) leave the + `BestEffort` downgrade and document the migration path. Each option + affects scaffolding templates, `edgezero new`, and contributor + docs. **Deferred** until an app explicitly requires lazy Fastly + passthrough; the §3.5.2 footnote 6 documents the exact constraint + so adopters aren't surprised. +13. **Spin lazy-streamed-response-passthrough via a streamable public response + surface.** Spin's response path is buffered by construction today: + `spin_sdk::http::FullBody` backs the `SpinFullResponse` alias + (`Response>`), which appears in `AppExt::dispatch`, + `request::dispatch*`, `from_core_response`, and `run_app`. Delivering lazy + passthrough is therefore **not** an outbound-client change — it is a **breaking + public-API migration** of those aliases and signatures to a streamable response + shape. **The platform is not the blocker:** Spin SDK 6 already supports lazy + response streaming (`IncomingBody: http_body::Body`, 16 KiB `poll_frame`, + `IncomingBodyExt::stream()`), and the adapter simply *chooses* `.bytes()` today + (`spin/proxy.rs`). Lifting Spin to `Native` is therefore a **pure EdgeZero + refactor**, not a platform lift — unlike Fastly's risk 12, which is a real + platform constraint. (Separately: the WASI-0.2 `check_write()` shape that earlier + drafts used for the *request-upload* path does not exist in SDK 6 at all; that is + now corrected in §4.4 via a hand-built `wasi:http` request.) Because the alias + migration carries its own design, migration, and test surface — and would + ripple into `examples/app-demo`, the Spin scaffold templates, and every + downstream consumer of `SpinFullResponse` — Spin is **`BestEffort`** for this + capability in the current change (footnote 7), with a bounded buffered fallback + through `SPIN_RESPONSE_STREAM_BUFFER_BYTES` (16 MiB) identical in shape to Axum's + and Fastly's. **Cloudflare remains the only `Native` adapter** for lazy + passthrough; apps that require it declare the capability and target CF, getting a + hard build failure elsewhere. Lifting Spin to `Native` is **deferred** to its own + change. This affects response-out independently of the hand-built outbound upload + path; Spin still reports `streamed-upload-deadlines` as `BestEffort` because guest + cancellation has no documented finite host-teardown bound (footnote 8). +14. **`outbound-deadlines-exact` capability — not needed.** No adapter reports + `BoundedCooperative` for `outbound-deadlines`; a plain required declaration is + accepted only on the Native adapters (Axum and Cloudflare) and hard-fails on Fastly + and Spin. The support ladder already expresses the required distinction. +15. **Spin deadline promotion criterion.** The hand-built WASI protocol prevents an + unowned detached pump and the monotonic timer can select a guest-visible 504, but + Component Model cancellation and default `FutureWriter` completion remain + cooperative. A Tier 2 test that observes dropped guest handles is insufficient for + Native. Promotion requires repeatable live-runtime evidence of a documented finite + teardown bound for stalled request upload, response body, and completion-future + paths, followed by an explicit matrix and footnote update. diff --git a/docs/superpowers/specs/2026-06-16-blob-app-config.md b/docs/superpowers/specs/2026-06-16-blob-app-config.md index 0d03c408..e9420a0c 100644 --- a/docs/superpowers/specs/2026-06-16-blob-app-config.md +++ b/docs/superpowers/specs/2026-06-16-blob-app-config.md @@ -1,12 +1,18 @@ # Blob App Config — Design Spec **Date:** 2026-06-16 -**Status:** v1 — Plan-ready (twenty-three reviewer passes complete; reviewer cleared for plan authoring at round 24) +**Status:** v1 — Plan-ready; typed extraction and bounded-read amendment incorporated 2026-09-08 **Author:** Aram Grigoryan **Related branches:** `feature/extensible-cli` (current baseline) ## v1 changelog +**Cross-contract amendment (2026-09-08):** typed app-config reads now terminate in the +inspectable `StoreExtractionReason` contract from §6.3 and use the one-deadline bounded APIs +from §6.3.2. This amendment supersedes historical changelog entries below that describe +direct `ConfigOutOfDate`/`ServiceUnavailable` variants or unbounded `get`/`require_str` +inside `AppConfig`; those entries remain only as the review history of earlier drafts. + Initial draft after twenty-four review rounds — one author self-review, twenty-three reviewer passes against the current branch's code. Round 24 cleared the spec for plan authoring; @@ -741,19 +747,18 @@ runtime accuracy + parser fidelity:** to detect any nested reference. §12.17 extended to cover all six generic-wrap shapes plus the multi-line derive case plus the malformed-input case (exit 2). -- **§6.3.1 `EdgeError::config_out_of_date` split into - two constructors.** Round 10 had two contradictory +- **§6.3.1 typed deserialization gained a reason-bearing + constructor.** Round 10 had two contradictory signatures: secret walk called `config_out_of_date(msg, field_path)` (round 10 sketch); §6.3.1 declared the constructor takes a - `serde_path_to_error::Error`. Aligned: two + `serde_path_to_error::Error`. The intermediate draft aligned this with two constructors, `config_out_of_date(message, field_path)` for explicit-pair callers (secret walk + validator - path) and `config_out_of_date_from_serde(err)` for - the deserialise path. Extractor sketch updated to - use the serde constructor; secret-walk + validator - paths use the explicit-pair form. + path) and a serde-specific helper for the deserialise path. The final hard-cut API replaces + the latter with `store_deserialization_from_serde(err)` so typed store extraction always + exposes `StoreExtractionReason::Deserialization`; no compatibility alias remains. - **§3.2.2 + Q10 — `--exit-code` does not mask errors.** Round 10 §12.11 test asserted that without `--exit-code`, a remote-read network @@ -1170,8 +1175,7 @@ validate --help`). populated. Step 5 now wraps `Value::into_deserializer()` with `serde_path_to_error::deserialize` and maps the - error via `config_out_of_date_from_serde` per - the round-11 two-constructor split. Without + error via `store_deserialization_from_serde`. Without this, §12.6's `field_path` assertion would fail. - **Q6 + §12.10 Fastly cap stated as ONE number (64 KiB).** Q6 earlier called it ~8 KiB @@ -2690,30 +2694,54 @@ async fn extract(req: &RequestContext) -> Result where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // 1. Fetch the envelope JSON string from the adapter via ConfigStore::get. - // Missing blob maps to ConfigOutOfDate (Q3 (d) per round-18 M-2) — - // re-running ` config push` resolves the case, which is - // exactly what ConfigOutOfDate means. - let raw = config_store.get(&resolved_key).await? - .ok_or_else(|| EdgeError::config_out_of_date( - format!("missing typed app-config blob at key `{resolved_key}` — run ` config push` for this deploy"), - String::new(), - ))?; - let envelope: BlobEnvelope = serde_json::from_str(&raw)?; - envelope.verify_sha()?; + // 1. Fetch under the one extraction-wide byte/time budget (§6.3.2). + let mut budget = ConfigExtractionBudget::start(req.config_extraction_limits())?; + let read = config_store + .get_bounded( + &resolved_key, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) + .await + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value.ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, + format!("missing typed app-config blob at key `{resolved_key}` — run ` config push` for this deploy"), + None, + ))?; + budget.charge_value(raw.len())?; + let envelope: BlobEnvelope = serde_json::from_str(&raw).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::MalformedEnvelope, + "typed app-config envelope is invalid", + None, + ) + })?; + envelope.verify_sha().map_err(|_| EdgeError::store_extraction( + StoreExtractionReason::IntegrityMismatch, + "typed app-config integrity check failed", + None, + ))?; let mut data: serde_json::Value = envelope.into_data(); // 2. Walk SECRET_FIELDS. For each KeyInDefault / KeyInNamedStore // entry, look up the named secret store, fetch the value, swap // it into `data[field.name]`. StoreRef entries are untouched. let data_obj = data.as_object_mut() - .ok_or_else(|| EdgeError::internal("blob `data` is not a JSON object"))?; + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, + "blob `data` is not a JSON object", + None, + ))?; for field in C::SECRET_FIELDS { let key_name = data_obj.get(field.name) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); // For KeyInDefault, resolve to a BOUND default store via @@ -2729,14 +2757,15 @@ where let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = req.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, format!( "secret field `{}` has kind KeyInDefault but \ no default secret store is registered (check \ [stores.secrets].default or declare a single id)", field.name, ), - field.name.to_owned(), + Some(field.name.to_owned()), ) })?; let id = bound.store_name().to_owned(); @@ -2746,22 +2775,45 @@ where SecretKind::KeyInNamedStore { store_ref_field } => { let store_id_str = data_obj.get(store_ref_field) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing store_ref `{store_ref_field}` for secret field `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let bound = req.secret_store(&store_id_str).ok_or_else(|| { - EdgeError::config_out_of_date( - format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), - field.name.to_owned(), + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!("secret field `{}` references an unregistered store (identifier redacted)", field.name), + Some(field.name.to_owned()), ) })?; (bound, store_id_str) } }; - let secret = bound.require_str(&key_name).await + let read = bound + .get_bytes_bounded( + &key_name, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) + .await .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; + budget.charge_backend(read.backend_bytes)?; + let secret_bytes = read.value.ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!("the secret referenced by `{}` was not found in its store (identifier redacted)", field.name), + Some(field.name.to_owned()), + ))?; + budget.charge_value(secret_bytes.len())?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::InvalidSecretValue, + format!("the secret referenced by `{}` is not valid UTF-8", field.name), + Some(field.name.to_owned()), + ) + })?; data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); } @@ -2769,7 +2821,7 @@ where // the field-path) + validate. use serde::de::IntoDeserializer as _; let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; + .map_err(EdgeError::store_deserialization_from_serde)?; cfg.validate().map_err(|err| { // The secret walk above replaced `#[secret]` fields with their // RESOLVED values, and `validator`'s params echo the rejected value, @@ -2781,7 +2833,11 @@ where } else { format!("app config failed validation for field `{field}`") }; - EdgeError::config_out_of_date(message, field) + EdgeError::store_extraction( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), + ) })?; Ok(cfg) } @@ -2853,30 +2909,21 @@ RESOLVED value, populated by the extractor's walk. #### 3.3.6 What this means for failure modes -Current `SecretError` -(`crates/edgezero-core/src/secret_store.rs:113`) has four -variants: `NotFound`, `Validation`, `Internal`, `Unavailable`. -`require_str` (line 269) maps invalid-UTF-8 bytes from the -store to `SecretError::Internal`. The extractor wraps each -into an `EdgeError` variant based on what action the operator -can take. The mapping below is comprehensive — every -`SecretError` variant has a documented landing. - -| Extractor failure | `SecretError` | `EdgeError` | Why | -| -------------------------------------------------- | -------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Secret-store ID unknown (no `[stores.secrets].id`) | none — caught before secret call | `ConfigOutOfDate` | Manifest declares the wrong store name OR the manifest wasn't redeployed. Re-push fixes it. | -| Secret key not found in the named store | `SecretError::NotFound` | `ConfigOutOfDate` | Operator forgot to provision the secret value. Re-run secret provisioning. | -| Store rejected the key shape (length, charset) | `SecretError::Validation { .. }` | `ConfigOutOfDate` | The blob's key name is invalid for the named store (e.g. Fastly Secret Store keys are constrained). Operator either renames the secret-store key or fixes the `.toml` value. | -| Secret value is bytes, not UTF-8 | `SecretError::Internal` (from `require_str`) | `Internal` | The store CONTAINS the key but the bytes aren't a `String`. Data quality at rest; not deploy-related. Operator audits the secret-store entry directly. | -| Secret store unreachable (transient network) | `SecretError::Unavailable` | `ServiceUnavailable` | A flaky backend is not an out-of-date config — retry is the right response. Surfaces as HTTP 503; per §6.3.1 the `Retry-After: 60` header is NOT set on `ServiceUnavailable` in v1 (audit of existing producers showed too many non-retryable cases reuse the variant). | -| Any other `SecretError::Internal` | `SecretError::Internal` | `Internal` | Unexpected store-side failure (an adapter bug, a wire-format change). Pages oncall; not actionable by deploy. | - -The boundary between `ConfigOutOfDate` and `Internal` is: -**does re-running ` config push` (or its sibling -secret-provisioning step) fix the situation?** If yes → -`ConfigOutOfDate`. If no → `Internal`. The invalid-UTF-8 case -falls on the wrong side of that boundary (re-push doesn't fix -non-string bytes in the store), so it stays on `Internal`. +Bounded extraction extends `SecretError` with `DeadlineExceeded` and `ValueTooLarge`. +The extractor wraps every lower-level outcome in `EdgeError::StoreExtraction`; the stable +reason remains inspectable even when several reasons share one wire kind. + +| Extractor failure | Lower-level outcome | `StoreExtractionReason` | HTTP / kind | +| --- | --- | --- | --- | +| No default secret registry | caught before read | `MissingRegistry` | 500 / `internal` | +| Referenced named store is unknown | caught before read | `UnknownStore` | 500 / `internal` | +| Secret key not found | bounded read returns `value: None` | `MissingSecret` | 503 / `config_out_of_date` | +| Store rejects key shape | `SecretError::Validation(..)` | `InvalidKey` | 400 / `bad_request` | +| Secret bytes are not UTF-8 | caught after bounded byte read | `InvalidSecretValue` | 500 / `internal` | +| Secret store unavailable | `SecretError::Unavailable` | `SecretBackendUnavailable` | 503 / `service_unavailable` | +| Bounded read deadline expires | `SecretError::DeadlineExceeded` | `DeadlineExceeded` | 503 / `service_unavailable` | +| Per-secret or cumulative byte cap is exceeded | `SecretError::ValueTooLarge` or budget charge | `ValueTooLarge` | 500 / `internal` | +| Any other backend failure | `SecretError::Internal(..)` | `BackendFailure` | 500 / `internal` | The extractor's wrapper around the secret-store call materialises this mapping ONCE near the call site: @@ -2884,10 +2931,12 @@ materialises this mapping ONCE near the call site: ```rust // Matches the actual SecretError shape at // crates/edgezero-core/src/secret_store.rs:113: +// DeadlineExceeded // unit, added by §6.3.2 // Internal(#[from] anyhow::Error) // NotFound { name: String } // struct-like // Unavailable // unit // Validation(String) // tuple +// ValueTooLarge // unit, added by §6.3.2 // SECURITY: the stored key NAME, the store id, and the provider's // message/source are deliberately UNUSED. They are blob- or // provider-controlled strings that can reveal the secret or the @@ -2902,38 +2951,43 @@ fn map_secret_error( _key_name: &str, ) -> EdgeError { match err { - SecretError::NotFound { .. } => EdgeError::config_out_of_date( + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, format!("the secret referenced by `{field_name}` was not found in its store (identifier redacted)"), - field_name.to_owned(), + Some(field_name.to_owned()), ), - SecretError::Validation(_msg) => EdgeError::config_out_of_date( + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), + ), + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, format!("the secret referenced by `{field_name}` was rejected by its store (details redacted)"), - field_name.to_owned(), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!("the secret referenced by `{field_name}` exceeds its configured byte limit"), + Some(field_name.to_owned()), ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "the secret store for `{field_name}` is unreachable" - )), - SecretError::Internal(_source) => EdgeError::internal(anyhow::anyhow!( - "secret resolution for `{field_name}` failed (details redacted)" - )), } } ``` -**`Retry-After: 60` is NOT set on `ServiceUnavailable` in -v1.** An earlier draft extended the header from -`ConfigOutOfDate` to `ServiceUnavailable`; round-10 audit -of existing producers (KV size limits at -`crates/edgezero-core/src/key_value_store.rs:708`, missing -named KV store at -`examples/app-demo/.../handlers.rs:185`, missing default -secret store at `.../handlers.rs:285`) found that the -variant is reused for several non-retryable failure modes -where the header would mislead clients into a tight retry -loop. §6.3.1 documents the narrowed rule in detail: -header on `ConfigOutOfDate` ONLY; future v2 work may -split `ServiceUnavailable` so each producer site picks -the right variant. +`Retry-After: 60` is emitted only for the effective `config_out_of_date` kind. In this +table that is `MissingSecret`; `SecretBackendUnavailable` and `DeadlineExceeded` are +`service_unavailable` without a retry header. §6.3.1 centralizes that policy. #### 3.3.7 Sha-canonicalisation interaction @@ -3070,12 +3124,12 @@ extractor doesn't touch TOML on disk at all): | --------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `config push` / `config diff` / generated CLI `config validate` | `.toml` on disk | `deserialize_app_config_with_options(path, app_name, opts)` → `validate_excluding_secrets(&cfg)` → §3.3.2 structural checks | | Bundled raw `edgezero config validate` | `.toml` on disk | `load_app_config_raw(path, app_name)` (TOML round-trip, no `C`, no validators) | -| Runtime extractor (`AppConfig`) | Envelope JSON STRING from `ConfigStore::get(key)` | envelope parse → SHA verify → secret walk (per §3.3.3) → `serde_path_to_error::Deserializer` over the JSON `data` field → `Validate::validate(&cfg)`. The runtime path does NOT load TOML and does NOT apply the env overlay; the env overlay is a CLI-side notion that the operator's push run resolved into the blob. | +| Runtime extractor (`AppConfig`) | Envelope JSON string from `ConfigStore::get_bounded(key, deadline, caps)` | bounded read → envelope parse → SHA verify → bounded secret walk (per §3.3.3) → `serde_path_to_error::Deserializer` over the JSON `data` field → `Validate::validate(&cfg)`. The runtime path does not load TOML and does not apply the env overlay; the operator's push run resolves that CLI-side overlay into the blob. | The CLI paths all share a `build_and_validate` helper in `crates/edgezero-cli/src/config.rs` (sketch in §3.3.2). The runtime extractor's path is sketched in §3.3.3 and -uses the §6.3.1 `EdgeError::config_out_of_date_from_serde` +uses the §6.3.1 `EdgeError::store_deserialization_from_serde` constructor for serde failures (preserving the `field_path` from `serde_path_to_error`). @@ -3345,7 +3399,7 @@ the rule honest, the `#[derive(AppConfig)]` macro enforces: the canonicaliser's "sort keys by UTF-8 byte order of the field identifier" rule (the field identifier isn't the key any more) and complicates `serde_path_to_error` - paths in `EdgeError::ConfigOutOfDate`. Operators who + paths in `EdgeError::StoreExtraction`. Operators who need a flat shape define it explicitly. - **`#[serde(rename_all)]` is already rejected by the existing per-secret-field policy at @@ -3511,15 +3565,10 @@ version, .. }`. Unknown envelope versions error with a pointer at the migration guide. 2. Recompute `canonical_data_sha256(&data)`. 3. If it doesn't match the stored `sha256`, return - `ConfigStoreError::internal("blob sha mismatch: stored {hex} -!= computed {hex}")` (the existing `internal(...)` - constructor at `config_store.rs:180`; see §6.3 for the - reasoning behind keeping this on `Internal` instead of - adding a new variant) and DO NOT proceed. The runtime - gives up on this request rather than silently honouring a - tampered blob. The extractor maps - `ConfigStoreError::Internal` to `EdgeError::Internal` per - existing convention. + `EdgeError::StoreExtraction { reason: IntegrityMismatch, .. }` and DO NOT proceed. The + runtime gives up on this request rather than silently honouring a tampered blob. This is + an extractor-owned integrity decision, not a generic `ConfigStoreError::Internal`, so + callers retain the stable origin without parsing a diagnostic. 4. **Secret walk (per §3.3.3).** Iterate over `C::SECRET_FIELDS`. For each `KeyInDefault` or `KeyInNamedStore` field, look up `data[field.name]` in the @@ -3533,16 +3582,15 @@ version, .. }`. Unknown envelope versions error with a `serde_json::Value::into_deserializer()` (NOT `serde_json::from_value` directly — that path discards the field-path information that - `EdgeError::ConfigOutOfDate.field_path` requires per + `EdgeError::StoreExtraction.field_path` requires per §6.3.1). The wrapper accumulates the JSON path of any deserialise failure (e.g. `"feature.new_checkout"`); the extractor maps the resulting `serde_path_to_error::Error` to - `EdgeError::config_out_of_date_from_serde(err)` per - §6.3.1's two-constructor split, which populates + `EdgeError::store_deserialization_from_serde(err)` per + §6.3.1, which sets reason `Deserialization` and populates `field_path` from `err.path()`. Without this wrapper, a - schema mismatch surfaces as a generic - `EdgeError::ConfigOutOfDate { message, field_path: "" }` + schema mismatch surfaces as a generic untyped error without field context and §12.6's field_path assertion fails. 6. Run `Validate::validate(&cfg)` per §6.2.2 — full validation including secret-bearing fields, now that they hold @@ -3831,21 +3879,42 @@ async fn extract(req: &RequestContext, override_key: Option<&str>) -> Result< where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { + let mut budget = ConfigExtractionBudget::start(req.config_extraction_limits())?; let binding = req .config_store_default_binding() - .ok_or_else(|| EdgeError::internal("no default config store registered"))?; + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered", + None, + ))?; let key = override_key.unwrap_or(&binding.default_key); - // Missing blob maps to ConfigOutOfDate per §6.3 / Q3 (d) — a re-run - // of ` config push` is the actionable response, not a 500. - let raw = binding.handle.get(key).await? // ConfigStore::get - .ok_or_else(|| EdgeError::config_out_of_date( + let read = binding + .handle + .get_bounded( + key, + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) + .await + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), + None, ))?; - // ... envelope parse, sha verify, secret walk, deserialise + validate per §4.3 + budget.charge_value(raw.len())?; + // ... envelope parse, sha verify, secret walk with &mut budget, + // deserialise + validate per §4.3 } ``` +`ConfigExtractionBudget` is extractor-private state implementing §6.3.2. The secret walk +receives `&budget`; it passes the same absolute deadline to every bounded secret read and +charges each returned value before insertion. + `RequestContext` grows two helpers mirroring the existing `config_store_default()` / `config_store(id)`: @@ -4052,8 +4121,8 @@ validate --strict` runs at push time, so env-overlay drift Once the extractor deserialises `data` into `C`, it calls `Validate::validate(&cfg)`. Validation failures map to: -- `EdgeError::ConfigOutOfDate` — naming ONLY the field that - violated its constraint. Same surface as the +- `EdgeError::StoreExtraction` with reason `Validation`, naming ONLY the field that + violated its constraint. It has the same wire surface as the deserialise-failure case (§6.3): operator action is "re-push the typed config; the deployed `.toml` is out of bounds for the deployed code". @@ -4125,67 +4194,118 @@ key)`. ### 6.3 Errors -The extractor surfaces: +The typed classification is fixed before extractor implementation. Callers never parse +`message` to discover an extraction failure's origin: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum StoreExtractionReason { + BackendFailure, + BackendUnavailable, + DeadlineExceeded, + Deserialization, + IntegrityMismatch, + InvalidKey, + InvalidSecretValue, + MalformedEnvelope, + MissingBlob, + MissingRegistry, + MissingSecret, + SecretBackendUnavailable, + UnsupportedVersion, + UnknownStore, + Validation, + ValueTooLarge, +} -- `EdgeError::ServiceUnavailable` — store unreachable, network +// One EdgeError variant delegates status/kind/header policy to `reason`. +StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, +} +``` + +`StoreExtractionReason` and the containing `EdgeError` variant are `#[non_exhaustive]`. +The reason survives every `AppConfig`/`from_store`/secret-walk mapping and is available +through `EdgeError::store_extraction_reason() -> Option` without a +downcast. It is Rust-side diagnostic metadata only: the response JSON does not serialize a +`reason` field. `field_path` is `Some` only when the failure is tied to a typed-config path; +an empty path is represented as `None` and omitted from JSON. + +The mapping is total and centralized in `EdgeError::{status_code, kind, response_headers}`: + +| Reason | HTTP / kind | `Retry-After` | `field_path` | +| --- | --- | --- | --- | +| `BackendUnavailable`, `DeadlineExceeded`, `SecretBackendUnavailable` | 503 / `service_unavailable` | absent | optional only for a secret field | +| `Deserialization`, `MissingBlob`, `MissingSecret`, `Validation` | 503 / `config_out_of_date` | `60` | optional for typed-data/validation/secret failures; absent for a missing root blob | +| `InvalidKey` | 400 / `bad_request` | absent | absent | +| `BackendFailure`, `IntegrityMismatch`, `InvalidSecretValue`, `MalformedEnvelope`, `MissingRegistry`, `UnknownStore`, `UnsupportedVersion`, `ValueTooLarge` | 500 / `internal` | absent | optional only when a typed field selected the store/value | + +`ValueTooLarge` covers any per-blob, per-secret, or cumulative extraction byte cap from +§6.3.2; `DeadlineExceeded` covers the one absolute extraction deadline. A source error that +arrives simultaneously with deadline expiry is classified as `DeadlineExceeded`. +Tests enumerate every known reason, assert status/kind/header/field-path policy, and prove +the JSON envelope omits the typed reason. + +The extractor surfaces the following wire outcomes through `EdgeError::StoreExtraction`: + +- `service_unavailable` — store unreachable, network errors. Maps to HTTP 503. -- `EdgeError::ConfigOutOfDate { message, field_path }` — +- `config_out_of_date` — typed-struct deserialise failure: the blob is present and the envelope parses, but `data` doesn't fit the runtime's `C` type. Almost always means "the code shipped before the matching ` config push`". See the contract below. -- `EdgeError::Internal` — sha mismatch (drift / corruption), +- `internal` — sha mismatch (drift / corruption), envelope parse failure (envelope `version` unrecognised or shape unexpected). Maps to HTTP 500. These are genuinely unexpected and should page the operator. -- **Key missing from the store** (i.e. - `ConfigStore::get(key)` returned `Ok(None)`) maps to - `EdgeError::ConfigOutOfDate` (Q3 (d) per round-18 - M-2, restated here for §6.3 hard-cutoff). HTTP 503 - with `Retry-After: 60`. Message: `missing typed -app-config blob at key \`\` — run \` - config push\` for this deploy`. Rationale: a missing -typed-app-config blob is operationally -indistinguishable from "the operator didn't run -`config push`yet" — which is exactly the`ConfigOutOfDate`class ("re-run config push fixes -it"). Mapping to`Internal`would page oncall on a -push-fixable condition; mapping to`NotFound`(404) -would imply the URL is wrong, which it isn't. A -future`MaybeAppConfig`→`Option`extractor -(Q3 (c)) could remap this for endpoints that want -explicit defaults; v1 ships with`ConfigOutOfDate` - and no opt-out. +- **Key missing from the store** (`get_bounded` returned `value: None`) becomes + `StoreExtractionReason::MissingBlob`, whose effective outcome is + `config_out_of_date`: HTTP 503 with `Retry-After: 60`. Message: + `missing typed app-config blob at key \`\` — run \` config push\` for + this deploy`. A missing typed-config blob is operationally indistinguishable from a + skipped config push. Mapping it to `internal` would page on-call for a push-fixable + condition; mapping it to `not_found` would imply the request URL is wrong. A future + `MaybeAppConfig -> Option` extractor could provide explicit defaults; v1 has no + opt-out. **Implementation note — `ConfigStoreError` to `EdgeError` -mapping.** `ConfigStoreError` (`config_store.rs:165`) has only -three variants today: `Internal`, `InvalidKey`, `Unavailable`. -The extractor maps: - -| ConfigStoreError | EdgeError | HTTP | Notes | -| ------------------------------------- | -------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Unavailable` | `ServiceUnavailable` | 503 | Transient backend issue. | -| `Internal` (sha mismatch) | `Internal` | 500 | Drift or corruption — the stored sha doesn't match canonical recompute. | -| `Internal` (envelope parse failure) | `Internal` | 500 | Envelope `version` unrecognised or shape unexpected. | -| `InvalidKey` | `BadRequest` | 400 | Adapter rejected the key shape. | -| _missing key_ (`Ok(None)` from `get`) | `ConfigOutOfDate` | 503 | NOT a `ConfigStoreError` variant — caught at the extractor's `ok_or_else` after `ConfigStore::get` returns `Ok(None)`. Round-18 M-2 reversal: was `Internal` in earlier drafts; the new mapping matches Q3 (d) + §3.3.3's extractor sketch. | - -Plus the new `ConfigOutOfDate` variant `EdgeError` gains as part -of this work; no new variant on `ConfigStoreError` is needed. - -**Why `ConfigOutOfDate` is separate from `Internal`:** the -operator's response is different. `Internal` means "investigate -what's wrong with our store / blob"; `ConfigOutOfDate` means -"re-run ` config push` for the deployed code -revision". A single generic 500 conflates both and trains -operators to ignore the class of error that's most actionable. - -### 6.3.1 `ConfigOutOfDate` concrete contract - -The current `EdgeError` response shape carries `status` + -`message` only (`crates/edgezero-core/src/error.rs:159`). The -blob model needs more structure so dashboards can route -`ConfigOutOfDate` to a different oncall than generic 503s. -Two specific extensions: +mapping.** The current three variants are `Internal`, `InvalidKey`, and `Unavailable`; +§6.3.2 adds `DeadlineExceeded` and `ValueTooLarge` for bounded reads. The extractor maps: + +| ConfigStore outcome | Store extraction reason | HTTP | Notes | +| ------------------------------------- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Unavailable` | `BackendUnavailable` | 503 | Transient backend issue. | +| `DeadlineExceeded` | `DeadlineExceeded` | 503 | The shared absolute extraction deadline expired; expiry wins simultaneous readiness. | +| SHA mismatch | `IntegrityMismatch` | 500 | Drift or corruption — the stored sha doesn't match canonical recompute. | +| Envelope parse/shape failure | `MalformedEnvelope` | 500 | The stored value is not a syntactically valid supported envelope. | +| Envelope version/discriminator failure | `UnsupportedVersion` | 500 | The envelope `version` or `edgezero_kind` is not understood by this build; redeploy an updated build. | +| `InvalidKey` | `InvalidKey` | 400 | Adapter rejected the key shape. | +| `Internal` | `BackendFailure` | 500 | Unexpected backend/adapter failure whose lower layer cannot classify more narrowly. | +| `ValueTooLarge` | `ValueTooLarge` | 500 | Per-value or remaining backend-byte allowance was exceeded. | +| _missing key_ (`value: None` from `get_bounded`) | `MissingBlob` | 503 | NOT a `ConfigStoreError` variant — caught at the extractor's `ok_or_else` after the bounded read returns no value. Round-18 M-2 reversal: was `Internal` in earlier drafts; the new mapping matches Q3 (d) + §3.3.3's extractor sketch. | + +`ConfigStoreError` does not duplicate semantic envelope/schema/registry reasons. It exposes +only failures owned by the backend read boundary. The extractor converts each lower-level +store/secret result once at its ownership boundary and returns the typed +`EdgeError::StoreExtraction` end state. + +**Why the `config_out_of_date` kind is separate from `internal`:** the operator's response +is different. `internal` means "investigate the store/blob/runtime"; +`config_out_of_date` means "re-run ` config push` or provision the referenced +secret for the deployed revision." The typed reason preserves the more precise origin +inside each wire class. + +### 6.3.1 `config_out_of_date` concrete wire contract + +The `config_out_of_date` outcome applies both to the existing +`EdgeError::ConfigOutOfDate` variant and to `EdgeError::StoreExtraction` +when its reason maps to that kind in §6.3. The blob model needs enough +wire structure for dashboards to route this outcome differently from +generic 503s. Three specific extensions: **1. Response-body shape.** Today's `EdgeError::IntoResponse` at `crates/edgezero-core/src/error.rs:159` writes: @@ -4225,7 +4345,7 @@ Concretely: ONLY on `config_out_of_date` (and any future field-anchored variant). Other variants omit it. Clients that don't care can ignore. -- Status code: 503 for `ConfigOutOfDate` (not 500). The +- Status code: 503 for the `config_out_of_date` outcome (not 500). The semantic is "service can't honor this request because the deployed config + code are out of sync; retry after redeploy". @@ -4236,8 +4356,9 @@ to the response body — operators who parse the body shape on the client side need to update. Per §1's hard-cutoff stance, no compat shim. -**2. Send `Retry-After: 60`** on the new `ConfigOutOfDate` -variant ONLY. Earlier drafts extended the header to all +**2. Send `Retry-After: 60`** only when the effective kind is +`config_out_of_date`. This includes `ConfigOutOfDate` and the three +store-extraction reasons mapped to that kind in §6.3. Earlier drafts extended the header to all `ServiceUnavailable` responses; an audit of in-tree `ServiceUnavailable` producers shows that the variant is ALREADY used for several non-config-related conditions @@ -4255,7 +4376,8 @@ Adding `Retry-After: 60` to every `ServiceUnavailable` would lie to clients in three of four cases. The blob model takes the narrower stance: -- **`ConfigOutOfDate`** — header sent. The new variant is +- **`ConfigOutOfDate` or a `StoreExtraction` reason mapped to + `config_out_of_date`** — header sent. This outcome is shaped specifically for "deployed config + code are out of sync; redeploy converges in <60s", which is exactly what the header tells clients. @@ -4268,14 +4390,11 @@ model takes the narrower stance: each producer site picks the right variant; out of scope here. -Added via -`Response::headers_mut().insert("retry-after", -HeaderValue::from_static("60"))` inside the -`IntoResponse` impl branch for `ConfigOutOfDate` only. -Other variants (`ServiceUnavailable`, `Internal`, -`BadRequest`, etc.) do NOT set the header. +Added via the centralized error response-header policy rather than a +variant-only `matches!` check. Other outcomes (`service_unavailable`, +`internal`, `bad_request`, etc.) do NOT set the header. -**3. `field_path` for the variant's payload** comes from +**3. `field_path` for the error payload** comes from `serde_path_to_error::Track::path()` (or equivalent), wrapped around the blob's `data` field's deserialiser. Plain serde errors only report position-in-input, not field-path; the @@ -4284,8 +4403,8 @@ errors only report position-in-input, not field-path; the the deserialise itself. The dep is small (~500 LOC, no transitive deps) and locked-in for the variant. -> **Runtime redaction (security).** In the HTTP `ConfigOutOfDate` -> response the path's STRING segments are redacted to `` +> **Runtime redaction (security).** In an HTTP `config_out_of_date` response produced by +> `StoreExtractionReason::Deserialization`, the path's STRING segments are redacted to `` > while structure (dots and sequence indices) is kept — e.g. > `.`. A `serde_path_to_error` segment for a struct > field is indistinguishable from a MAP KEY, and a map key is stored @@ -4296,11 +4415,10 @@ transitive deps) and locked-in for the variant. > secret-walk/validator constructor still supplies an explicit static > path, since those are code-supplied field names, not stored data. -**Variant declaration (sketch).** Two constructors — -one for the serde-deserialise path (which has rich -field-path data from `serde_path_to_error`), one for -the secret-walk and validator paths (which already -have an explicit `(message, field_path)` pair): +**Variant declaration (sketch).** The fixed `ConfigOutOfDate` variant remains available +for non-store producers. Typed store extraction uses the reason-bearing variant from §6.3 +and two constructors: one for explicit path/reason data and one for the serde-deserialise +path: ```rust pub enum EdgeError { @@ -4309,22 +4427,23 @@ pub enum EdgeError { message: String, // e.g. "missing field `new_checkout`" field_path: String, // e.g. "feature" }, + StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, + }, } impl EdgeError { - /// Construct from an explicit (message, field_path) pair. - /// Used by the secret walk (§3.3.3) and the validator path - /// (§6.2.2). `field_path` SHOULD be a dotted path naming - /// the offending field (e.g. `"feature.new_checkout"`); - /// pass `String::new()` when no specific field is anchored - /// (the response simply omits the `field_path` key). - pub fn config_out_of_date( + pub fn store_extraction( + reason: StoreExtractionReason, message: impl Into, - field_path: impl Into, + field_path: Option, ) -> Self { - Self::ConfigOutOfDate { + Self::StoreExtraction { + reason, message: message.into(), - field_path: field_path.into(), + field_path, } } @@ -4342,7 +4461,7 @@ impl EdgeError { /// when the local TOML still matches what was deployed — it /// reads the local source, not the deployed blob, so a drift /// between them is not recoverable this way. - pub fn config_out_of_date_from_serde( + pub fn store_deserialization_from_serde( serde_err: serde_path_to_error::Error, ) -> Self { let category = match serde_err.inner().classify() { @@ -4351,9 +4470,10 @@ impl EdgeError { Category::Eof => "unexpected end of input", Category::Io => "i/o error while reading", }; - Self::ConfigOutOfDate { + Self::StoreExtraction { + reason: StoreExtractionReason::Deserialization, message: format!("typed app-config is out of date ({category}; value redacted)"), - field_path: redact_serde_path(serde_err.path()), + field_path: Some(redact_serde_path(serde_err.path())), } } } @@ -4362,9 +4482,9 @@ impl EdgeError { Caller sites: - Secret walk (§3.3.3) and validator path (§6.2.2): - `EdgeError::config_out_of_date(msg, field_path)`. + `EdgeError::store_extraction(reason, msg, Some(field_path))`. - Blob deserialise (§3.3.3, §6.2.2): - `EdgeError::config_out_of_date_from_serde(err)`. + `EdgeError::store_deserialization_from_serde(err)`. The validator path (§6.2.2) wraps a `validator::ValidationErrors`. **On the RUNTIME path this runs @@ -4382,7 +4502,11 @@ let message = if field.is_empty() { } else { format!("app config failed validation for field `{field}`") }; -EdgeError::config_out_of_date(message, field) // NO validation_err.to_string() +EdgeError::store_extraction( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), +) // NO validation_err.to_string() ``` The `extract_first_field` helper picks the first @@ -4396,10 +4520,169 @@ is dropped entirely rather than logged. mentioning because PR #269 fought several "do we add this dep" rounds. Verdict: yes, this one is small enough, single purpose, and the only realistic way to give operators a -useful field-path hint. Without it, every `ConfigOutOfDate` +useful field-path hint. Without it, every schema-mismatch response says "missing field" with no anchor — operators have to grep the typed struct manually. +### 6.3.2 Bounded, cancellable extraction reads + +Typed app-config extraction never calls the unbounded convenience methods +`ConfigStoreHandle::get` or `SecretHandle::get_bytes`. It uses bounded variants under one +budget captured when the extractor starts: + +```rust +pub const DEFAULT_CONFIG_BLOB_BYTES: u64 = 8 * 1024 * 1024; +pub const DEFAULT_CONFIG_BACKEND_BYTES: u64 = 16 * 1024 * 1024; +pub const DEFAULT_CONFIG_EXTRACTION_BYTES: u64 = 16 * 1024 * 1024; +pub const DEFAULT_CONFIG_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_CONFIG_SECRET_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, Copy, Debug)] +pub struct ConfigExtractionLimits { + pub max_backend_bytes: u64, + pub max_blob_bytes: u64, + pub max_secret_bytes: u64, + pub max_total_bytes: u64, + pub timeout: Duration, +} + +pub struct BoundedStoreRead { + pub backend_bytes: u64, + pub value: Option, +} + +#[async_trait(?Send)] +pub trait ConfigStore: Send + Sync { + async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError>; + + // Preserved for hand-managed reads; it makes no extraction-bound claim. + async fn get(&self, key: &str) -> Result, ConfigStoreError>; +} + +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; + + // Existing unbounded convenience method, not used by AppConfig. + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError>; +} + +impl ConfigStoreHandle { + pub async fn get_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError>; +} + +impl SecretHandle { + pub async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; +} + +impl BoundSecretStore { + pub async fn get_bytes_bounded( + &self, + key: &str, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; +} +``` + +The bounded methods add `DeadlineExceeded` and `ValueTooLarge` to both lower-level store +error enums. Those variants are produced at the adapter boundary without formatting away +the cause and map directly to the same-named extraction reasons (`ValueTooLarge` maps to +`StoreExtractionReason::ValueTooLarge`). Existing unbounded methods need not synthesize +these variants. + +`ConfigExtractionLimits::default()` returns the five constants above. `App` owns one +config-extraction-limits value, set through `Hooks::configure`; the adapter copies it into +`RequestContext`. All values must be finite and nonzero, and +`max_total_bytes >= max(max_blob_bytes, max_secret_bytes)`. `timeout` must not exceed +`DEADLINE_FAR_FUTURE`; invalid startup configuration fails before serving requests. + +At the first `AppConfig`/`named`/`from_store` call, core snapshots +`extraction_started_at = web_time::Instant::now()` and computes exactly one absolute +deadline with checked `extraction_started_at + limits.timeout` and +`Deadline::at_instant(..)`. It does not call `Deadline::after` after the first snapshot. +The deadline is not reset between the root blob read, +Fastly pointer/chunk reads, secret resolution, deserialization, or validation. Every adapter +checks it before entering a host read and after the host read becomes ready; a native async +adapter additionally races the pending read with the remaining budget and drops/aborts its +native operation on expiry. Equality is expired, and expiry wins simultaneous readiness. + +Each bounded method applies both remaining extraction-wide backend-byte allowance and its +per-value output cap. `backend_bytes` counts every byte exposed to guest code while +satisfying that logical read, including an adapter-private pointer and every chunk used to +reconstruct one Fastly value; it does not count opaque provider work. The adapter uses +checked accounting and never reports a value whose length exceeds `max_value_bytes` or +whose guest-visible read cost exceeds `max_backend_bytes`. A Native implementation rejects +before retaining the first byte beyond either allowance; an Unsupported host-allocation +implementation may first receive a provider-materialized value, then discards it. + +The extractor verifies the returned count, including `backend_bytes >= value.len()` whenever +a value is present, uses checked addition to charge it against the single extraction-wide +`max_backend_bytes`, and separately charges each returned logical blob/secret length against +`max_total_bytes` before retaining or inserting it. It passes the remaining backend +allowance into the next read. Duplicate references to the same secret are charged for every +actual backend read; §6.4 deliberately defines no extraction cache. A backend that reports +more bytes than the allowance, a value larger than the supplied per-value limit, or a byte +count smaller than its returned value fails closed as `BackendFailure`. Any legitimate cap +failure detected by the adapter or extraction budget maps to +`StoreExtractionReason::ValueTooLarge`; expiry maps to +`StoreExtractionReason::DeadlineExceeded`. Partial values are discarded and never parsed, +hashed, deserialized, validated, logged, or returned. + +These are distinct guarantees: + +- **Guest-visible bound:** every target enforces per-value and cumulative byte caps before + EdgeZero retains or processes the returned value. +- **Host-allocation bound:** only a target whose host API supports streaming/size-aware reads + can prevent the provider SDK from first materializing an oversized value. +- **Cancellation bound:** cooperative pre/post checks give a typed eventual result, but only + a target with a cancellable async read can claim a finite wall-clock bound. + +The capability ladder therefore gains two config-owned cells: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `config-read-allocation-bounds` | Unsupported while startup JSON is preloaded; promote only after the local-file reader caps before allocation | Unsupported until the SDK exposes/proves a pre-materialization bound | Unsupported until host API documentation and a probe prove it | Unsupported until host API documentation and a probe prove it | +| `config-read-deadlines` | BestEffort while request-time reads are synchronous map clones with pre/post checks | BestEffort until host cancellation is observed | BestEffort; synchronous host reads are not guest-preemptible | BestEffort until host cancellation is observed | + +Apps that require a strict RSS or elapsed-time guarantee declare the corresponding Native +capability and fail before startup/deploy on weaker targets. Capability documentation must +name provider-side materialization, parser allocation, SDK copies, and uninterruptible host +calls as exclusions; it must not imply that a post-return `String::len()` check bounded host +RSS. One deployed timing probe per non-Axum target records whether cancellation/return occurs +within the documented tolerance before any capability cell is upgraded. + ### 6.4 Per-request caching **v1 stance: no caching. Every `AppConfig` extractor @@ -4446,8 +4729,9 @@ scope per §3.2. ### 6.5 What happens to the existing `ConfigStore` trait `ConfigStore::get(key)` stays — it's still useful for ad-hoc -lookups against the same backends. But the `AppConfig` extractor -is the ONLY supported way to read the typed app-config. +lookups against the same backends, but it carries none of §6.3.2's extraction guarantees. +The `AppConfig` extractor uses only `get_bounded` and is the ONLY supported way to read +the typed app-config. Hand-written `ctx.config_store_default()?.get("greeting")` against the typed app-config's store will now find a JSON blob under "greeting"-ish keys, not raw strings, and fail to deserialise into @@ -4853,8 +5137,8 @@ pub trait Adapter: Sync + Send { pub enum ReadConfigEntry { /// The store and key both exist; the inner string is the raw - /// blob envelope JSON (the same shape `ConfigStore::get` - /// returns at runtime per `crates/edgezero-core/src/config_store.rs:214`). + /// blob envelope JSON (the same logical value returned by runtime + /// `ConfigStore::{get,get_bounded}`). /// Caller parses + verifies. **Round-27 reviewer flagged an /// earlier `Vec` draft as inconsistent with the rest of /// §9.1-§9.4's per-adapter wording, all of which describes @@ -4987,22 +5271,17 @@ parsing inside the adapter. to `.tmp`, fsync, rename). The entry value is the envelope serialised then `serde_json::to_string`-ed and inserted as a string value. -- Runtime: the extractor stays adapter-neutral and only calls - `ConfigStore::get(key) -> Option` per - `crates/edgezero-core/src/config_store.rs:214`. Axum's - `ConfigStore` impl wraps the file map: - - ```rust - impl ConfigStore for AxumConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - let map: BTreeMap = self.map.read().await.clone(); - Ok(map.get(key).cloned()) - } - } - ``` - - The string form keeps the per-adapter `ConfigStore::get -> -Option` contract intact across all four adapters — +- Runtime: the extractor stays adapter-neutral and calls only `get_bounded`; raw + hand-managed callers may still call `get`. Axum's production bounded path reads the + selected map entry incrementally under the supplied deadline and refuses the first byte + beyond the smaller of the per-value and remaining backend allowances. It must not first + deserialize/clone the whole file map. The existing in-memory `from_map` test path checks + length before cloning the selected value. The unbounded compatibility `get` method + remains for hand-managed callers and delegates to the same selected-entry reader without + extraction limits; it is never called by `AppConfig`. + + The string form keeps the per-adapter `ConfigStore` logical value contract intact across + all four adapters — storing nested JSON objects would force the Axum store to re-serialise on every `get`, AND would require the extractor to know Axum's file shape. The string form sidesteps both. @@ -5039,8 +5318,10 @@ bulk put --namespace-id= --remote` `wrangler kv key get --binding --remote` (Wrangler 4.x's four-segment subcommand path; the older three-segment `wrangler kv get` is deprecated). -- Runtime: `CloudflareConfigStore::get(key)` returns the JSON - string; the `AppConfig` extractor parses + verifies. +- Runtime: `CloudflareConfigStore::get_bounded` returns the same JSON string and enforces + guest-visible byte/deadline checks from §6.3.2; the host may materialize the value before + guest code can reject it, so allocation support remains Unsupported. The `AppConfig` + extractor parses and verifies only after charging the bounded read. - `--local` push: `wrangler kv bulk put --binding --local` — lands in `.wrangler/state`. Local push deliberately addresses by @@ -5077,9 +5358,11 @@ bulk put --namespace-id= --remote` chunk pointer, read/verify/concatenate chunks and return the reconstructed normal envelope string. Missing/corrupt chunks are a read error, not "missing key". -- Runtime: `FastlyConfigStore::get(key)` performs the same - direct-or-pointer resolution and returns the normal envelope JSON - value; the core extractor does not know whether Fastly used chunks. +- Runtime: `FastlyConfigStore::get_bounded` performs the same direct-or-pointer resolution, + passes one absolute deadline/remaining allowance to every read, and returns the normal + envelope JSON value. Its `backend_bytes` includes the pointer and every fetched chunk; + the core extractor does not know which physical form Fastly used. The preserved unbounded + `get` follows the same resolver for hand-managed callers. - Local server: `[local_server.config_stores.]` now has ONE active root entry — the blob key. PR #269 F6 already moved local- server seeding to `config push --local`. Local seeding must mirror @@ -5166,8 +5449,9 @@ key-value set --stdin` or `--from-file`. keeps the "no multi-blob merge" stance — splitting is operator-side schema work. -- Runtime: `SpinConfigStore::get(key)` returns the JSON value; - extractor parses. +- Runtime: `SpinConfigStore::get_bounded` returns the JSON value and applies the cooperative + guest-visible checks from §6.3.2; the host read is not claimed preemptible or + pre-allocation-bounded. The extractor parses only after the bounded read is charged. ## 10. Migration @@ -6227,25 +6511,24 @@ What happens when the store has no entry at the requested key? - (b) Return `EdgeError::ServiceUnavailable`. - (c) Add a sibling extractor `MaybeAppConfig` returning `Option` for endpoints that have a sensible default. -- (d) Return `EdgeError::ConfigOutOfDate` — matches the - "re-run ` config push` fixes it" rule - spelled out for `ConfigOutOfDate` at §6.3. +- (d) Return `EdgeError::StoreExtraction` with reason `MissingBlob`; §6.3 maps that reason + to the `config_out_of_date` wire contract because re-running ` config push` + fixes it. - **default:** (d) for v1 (round-18 M-2 reversal of the - earlier (a) pick). The §6.3 rationale defines - `ConfigOutOfDate` as "the situation a re-run of + earlier (a) pick). The §6.3 rationale defines the + `config_out_of_date` wire class as "the situation a re-run of ` config push` resolves" — and a missing blob is squarely in that bucket: the deployed code expects a blob, the store doesn't have one, an operator push fixes it. Mapping this to `Internal` would page oncall (a 500-class signal) when the actionable response is "push the config", which - `ConfigOutOfDate` (503 with `Retry-After: 60`) - already encodes. The §3.3.3 extractor's + `StoreExtractionReason::MissingBlob` (503 / `config_out_of_date` with + `Retry-After: 60`) already encodes. The §3.3.3 extractor's `ok_or_else(|| EdgeError::internal("missing typed app-config blob"))` call site changes to - `EdgeError::config_out_of_date("missing typed -app-config blob at key ``— run` - config push`", String::new())` accordingly. (c) is + `EdgeError::store_extraction(StoreExtractionReason::MissingBlob, "missing typed +app-config blob at key `` — run ` config push`", None)` accordingly. (c) is still tracked as a follow-up for endpoints that want explicit `Option` semantics. @@ -6582,14 +6865,14 @@ length, charset) on the operator-typed KEY NAME. - Returns the deserialised struct for a valid blob. - Errors on sha mismatch (manually-edited blob). - **Missing key (per Q3 (d) / §6.3 — round-19/20).** - Fixture: `ConfigStore::get` returns `Ok(None)`. - Assert the extractor produces - `EdgeError::ConfigOutOfDate { message, field_path }` + Fixture: `ConfigStore::get_bounded` returns `BoundedStoreRead { value: None, .. }`. + Assert the extractor produces `EdgeError::StoreExtraction` with reason `MissingBlob`, + no field path, and a `message` where `message` contains the literal `key -\`\``AND the literal`run +\`\`` and the literal `run \` config push\``. Render the resulting `Response`; assert HTTP status 503 and -the `Retry-After: 60`header is present. NOT`EdgeError::Internal` (the round-18 reversal that +the `Retry-After: 60` header is present. Not `EdgeError::Internal` (the round-18 reversal that round-19 then propagated through §6.3). - `named(key)` reads a different key from the same store. - `BlobEnvelope` deserialise: @@ -6759,35 +7042,23 @@ strip or revert the resolution direction: store-ref field, unchanged). This is the user-facing Model A contract — the framework swaps key NAME for resolved VALUE before the handler sees `cfg`. -- **Missing secret at extract time.** Same fixture, mock - secret store configured to `NotFound` on lookup. Assert - the extractor produces `EdgeError::ConfigOutOfDate` (per - §3.3.6) — NOT `Unavailable` (that's reserved for - store-backend network errors) and NOT `Internal` (which - today's `SecretError::NotFound → EdgeError::Internal` - mapping in `crates/edgezero-core/src/secret_store.rs:131` - produces). The blob model's missing-secret behaviour - requires a DELIBERATE deviation from the current mapping: - the extractor wraps `SecretError::NotFound` into - `ConfigOutOfDate` BEFORE it bubbles, so the dashboards - signal "deploy is incomplete" rather than "we 500'd". - Calls out: the `secret_store.rs` mapping itself doesn't - change for raw handler-side callers (still maps to - `Internal`); the extractor's `?` operator catches it and - re-wraps. Test asserts the resulting `EdgeError` variant - AND the `Retry-After: 60` header per §6.3.1. +- **Missing secret at extract time.** Same fixture, mock secret store returning no value. + Assert the extractor produces `EdgeError::StoreExtraction` with reason `MissingSecret` + (per §3.3.6), not `SecretBackendUnavailable` and not `BackendFailure`. The typed + extractor adds this context at its ownership boundary instead of relying on the generic + raw-store conversion. Assert the reason, `config_out_of_date` wire kind, field path, and + `Retry-After: 60` header per §6.3.1. - **Missing secret-store id at extract time.** Fixture: manifest declares no `[stores.secrets].ids = ["missing"]`, but a `#[secret(store_ref = "vault")]` field's value is - `"missing"`. Assert extractor surfaces - `EdgeError::ConfigOutOfDate` with the actionable message - "blob declared store_ref `missing` but [stores.secrets] - has no such id". + `"missing"`. Assert the extractor produces `EdgeError::StoreExtraction` with reason + `UnknownStore`, HTTP 500 / `internal`, a field path, and a redacted message that does not + repeat the stored identifier. - **Secret-store unreachable at extract time.** Same fixture, mock store configured to error with a network - failure (NOT NotFound). Assert extractor surfaces - `EdgeError::ServiceUnavailable` (per §3.3.6 — a flaky - backend is not an out-of-date config). + failure (not a missing value). Assert the extractor produces + `EdgeError::StoreExtraction` with reason `SecretBackendUnavailable` and the + `service_unavailable` wire kind (per §3.3.6). - **` config validate --strict` — structural checks ONLY.** Three sub-tests, each asserting on a structural property the validator can verify without @@ -6822,14 +7093,12 @@ strip or revert the resolution direction: - **Validate runs on every extract.** Fixture: a blob that serdes cleanly but violates a `#[validate(range(min=1, -max=10000))]` rule. Extract. Assert - `EdgeError::ConfigOutOfDate` with `field_path` naming the - offending field. +max=10000))]` rule. Extract. Assert `EdgeError::StoreExtraction` with reason + `Validation` and `field_path` naming the offending field. - **Validate-OK is the happy path.** Fixture: a blob in bounds. Extract. Assert the typed struct comes back with no error. -- **Validator violation report format.** Assert the - `ConfigOutOfDate` response body matches the nested +- **Validator violation report format.** Assert the `Validation` response body matches the nested envelope documented in §6.3.1 exactly: `{ "error": { "status": 503, "kind": "config_out_of_date", "message": "<…>", "field_path": "" } }`. Assert @@ -6838,74 +7107,36 @@ max=10000))]` rule. Extract. Assert fixture). Assert the response carries the `Retry-After: 60` header. -#### 12.6.1 `kind` strings on every `EdgeError` variant + header policy (round-23 M-1) - -§6.3.1 adds a stable `kind: String` field to the response -body for EVERY `EdgeError` variant (`bad_request`, -`internal`, `method_not_allowed`, `not_found`, -`not_implemented`, `service_unavailable`, `validation`, -and the new `config_out_of_date`). The earlier test plan -only asserted the `config_out_of_date` body; this section -covers the other six and the cross-cutting -header / field-presence rules so a future refactor can't -silently drop or rename a `kind` string. - -- **Stable `kind` strings.** One fixture per variant - that triggers it (e.g. a handler that returns - `Err(EdgeError::not_found("…"))` for `not_found`). - Render the `Response`. Parse the body as JSON. - Assert `body.error.kind` equals the exact string - documented in §6.3.1. Variant → expected string: - - | Variant | Expected `kind` string | - | -------------------- | ----------------------- | - | `BadRequest` | `"bad_request"` | - | `Internal` | `"internal"` | - | `MethodNotAllowed` | `"method_not_allowed"` | - | `NotFound` | `"not_found"` | - | `NotImplemented` | `"not_implemented"` | - | `ServiceUnavailable` | `"service_unavailable"` | - | `Validation` | `"validation"` | - | `ConfigOutOfDate` | `"config_out_of_date"` | - -- **`field_path` is OMITTED outside `ConfigOutOfDate`.** - For each non-`ConfigOutOfDate` variant fixture above, - assert `body.error.get("field_path")` is `None` (the - field is absent from the JSON, not present with an - empty string). Per §6.3.1's "ONLY on - `config_out_of_date`" rule. - -- **`Retry-After: 60` is PRESENT on `ConfigOutOfDate` - ONLY.** Fixture per variant: render the response, - assert `response.headers().get("retry-after")`: - - `ConfigOutOfDate`: `Some("60")`. - - `ServiceUnavailable`: `None`. Round-10 H-3 audit - found the variant is reused for several - non-retryable cases (KV size limit, missing - named KV store, missing default secret store); - sending the header would mislead clients into a - tight retry loop. The §6.3.1 narrowing pins - `ServiceUnavailable` to no `Retry-After`. - - All other variants: `None`. - -- **Status codes match the documented variant → HTTP - mapping.** Fixture per variant: assert - `response.status()`: - - | Variant | HTTP status | - | -------------------- | ----------- | - | `BadRequest` | 400 | - | `Internal` | 500 | - | `MethodNotAllowed` | 405 | - | `NotFound` | 404 | - | `NotImplemented` | 501 | - | `ServiceUnavailable` | 503 | - | `Validation` | 422 | - | `ConfigOutOfDate` | 503 | - - (The two 503-class variants are deliberately - distinguished by the `kind` string + the - `Retry-After` header, not the status code.) +#### 12.6.1 End-state `EdgeError` wire matrix and store-reason policy + +The implementing branch enumerates every then-present `EdgeError` variant without a +wildcard. Final integration includes variants owned by the outbound and inbound specs as +well as this config design: + +| Variant | Expected `kind` | HTTP | +| --- | --- | --- | +| `BadGateway` | `"bad_gateway"` | 502 | +| `BadRequest` | `"bad_request"` | 400 | +| `ConfigOutOfDate` | `"config_out_of_date"` | 503 | +| `GatewayTimeout` | `"gateway_timeout"` | 504 | +| `Internal` | `"internal"` | 500 | +| `MethodNotAllowed` | `"method_not_allowed"` | 405 | +| `NotFound` | `"not_found"` | 404 | +| `NotImplemented` | `"not_implemented"` | 501 | +| `RequestTimeout` | `"request_timeout"` | 408 | +| `ResponseTooLarge` | `"response_too_large"` | 502 | +| `ServiceUnavailable` | `"service_unavailable"` | 503 | +| `StoreExtraction` | reason-dependent per §6.3 | reason-dependent per §6.3 | +| `Validation` | `"validation"` | 422 | + +For every ordinary variant, render a response and assert status, kind, and omission of the +Rust-only typed reason. For every known `StoreExtractionReason`, assert the exact §6.3 +status/kind/`Retry-After`/field-path row. `field_path` is omitted outside +`ConfigOutOfDate` and `StoreExtraction`; it is also omitted when either carrier has no +non-empty path. `Retry-After: 60` appears only on an effective `config_out_of_date` outcome: +the existing `ConfigOutOfDate` variant plus `Deserialization`, `MissingBlob`, +`MissingSecret`, and `Validation`. A plain `ServiceUnavailable` and store reasons mapped to +`service_unavailable` carry no retry header. ### 12.7 Env-var key override (§5.2) @@ -7397,9 +7628,8 @@ default_key }` on `ConfigRegistry`. `Config::default()` the load-bearing commit; everything it touches has to land together per §10's hard-cutoff rule: - `AppConfig` extractor + secret resolution - wired into all four adapters' `ConfigStore::get` - read path. Missing-blob maps to - `EdgeError::ConfigOutOfDate` per Q3 (d). + wired into all four adapters' bounded config-read path. Missing blob maps to + `EdgeError::StoreExtraction` with reason `MissingBlob` per Q3 (d). - `config push` rewrite — single-blob writers per adapter (Axum file map / Cloudflare bulk-put / Fastly `--upsert --stdin` / Spin direct-write). diff --git a/docs/superpowers/specs/2026-08-22-inbound-body-design.md b/docs/superpowers/specs/2026-08-22-inbound-body-design.md new file mode 100644 index 00000000..242cb511 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-inbound-body-design.md @@ -0,0 +1,857 @@ +# EdgeZero Inbound Request-Body Design + +> **Status:** Draft. Core admission, deadline-bound body handling, and adapter entry seams are +> partially implemented. The raw-parser requirements in §1.2 are future certification criteria; +> every current raw-ingress capability cell remains `Unsupported`. +> +> Extracted from the +> [outbound-HTTP design](2026-05-21-outbound-http-design.md) so that specification stays +> focused on outbound HTTP. This document owns the **inbound request-body** contract: +> `RequestContext` body reading, adapter ingress, extractor limits, and the `BodyCell` +> state machine. Outbound streaming proxy-forward consumes the resulting +> `BodyCell`/`into_request()` contract but does not own it. + +## 1. Scope + +The goal is to stop adapters from pre-buffering unbounded inbound bodies while preserving +the existing `FromRequest::from_request(&RequestContext, ..)` extractor signature. A body +is consumed from the platform at most once, bounded helpers cache successful bytes, and a +partially consumed failed drain becomes sticky poison. This document does not define +outbound response caps, outbound deadlines, or provider send behavior. + +The total `StoredError` match below targets the end-state `EdgeError` surface and therefore +depends on the outbound error work landing first: `BudgetSource`, `BadGateway`, +`GatewayTimeout`, and `ResponseTooLarge` are defined by the outbound design, not here. If +the body-state migration is split across earlier commits, each commit must still compile +against the variants present at that point. This design additionally owns +`RequestTimeout` (408) for an admitted inbound body whose absolute read deadline expires; +the final `StoredError` match covers every variant then present without a wildcard. + +Rust snippets show interfaces and state shapes; omitted bodies are implementation work. +Implementations must pass the workspace's strict Clippy gate: keep items, enum variants, +and methods alphabetically ordered, add `#[inline]` to public methods, and use checked +arithmetic for drain accounting. State-transition order in prose does not dictate enum +declaration order. + +### 1.1 Adapter ingress seam and admission + +Every adapter entry point has one ingress seam **before middleware, extractor execution, +EdgeZero-owned body buffering, or a `BodyCell::Initial` transition**. Route resolution is +the one deliberate exception: the router resolves once before admission so policy sees the +canonical route identity, but it does not dispatch middleware or a handler. The seam +performs this ordered protocol exactly once per platform request: + +1. Stamp `request_start` from the `App`-owned `MonotonicClock` at the earliest + EdgeZero-owned entry point by calling `App::monotonic_now()`. It means "EdgeZero + received control", not when the client sent its first byte and not when route execution + began. The same cloneable clock handle is attached to the admitted request. +2. Enforce parser-level request-target and header limits, then validate raw request framing, + where the platform exposes a raw boundary (§1.2). Target overflow returns 414, header + byte/count overflow returns 431, and framing rejection returns 400. Every rejection + closes/resets that request without route resolution, app admission, or body polling. +3. Convert only request-head data, retain the unread native body separately, and call + `RouterService::resolve(method, path)` once. Resolution returns an opaque dispatch token + plus `RouteResolution::{Matched, MethodNotAllowed, NotFound}`. It performs no middleware, + handler, body poll, or request-extension injection. The token owns the exact match and + path parameters so admitted dispatch does not rerun matching. +4. Construct a normalized `IngressHead` containing the method, target, version, visible + headers, `IngressHeadAccounting`, `IngressFraming`, adapter request metadata, public + `request_start`, and the stable route resolution. The accounting and framing values say + whether EdgeZero validated the raw boundary or the host owns those decisions. +5. Invoke the one admission policy stored on `App`. It returns `Refuse(Response)`, + `Admit { grant, read_deadline }`, or `ReadBodyBeforeFallback { grant, + max_body_bytes, read_deadline, on_exceeded, on_timeout }`. The callback is synchronous + and body-blind: it can inspect the head but cannot obtain or poll the body. A refusal + bypasses resolved dispatch, terminates the unread platform body with the strongest + target-specific primitive, drops any policy-local resources, and converts that response + normally. +6. For `Admit`, wrap the still-unread platform body with the absolute deadline and native + cancellation owner, construct the core `Request`, and call + `RouterService::dispatch_resolved` with the exact token from step 3. For `Matched`, the + router constructs `RequestContext` with `request_start`, route metadata, and the + request-owned grant before middleware. For `MethodNotAllowed`/`NotFound`, ordinary + admission retains immediate 405/404 handling without polling the body; no middleware or + handler runs and the grant is dropped exactly once. The wrapper is installed for + `Body::Once` and `Body::Stream`; a content-type path must not pre-buffer before this point. +7. `ReadBodyBeforeFallback` is valid only for the pre-resolved `MethodNotAllowed` and + `NotFound` outcomes. It preserves the exact resolved token, installs the same absolute + deadline wrapper, holds the supplied grant, and drains the body to EOF while discarding its + contents. Clean EOF at or below `max_body_bytes` produces the canonical 405/404; the first + byte over the cap produces the exact buffered `on_exceeded` response, and deadline expiry + produces the exact buffered `on_timeout` response, both before the routing response. A zero + cap accepts only an empty body. The grant remains live through every body poll and is dropped + exactly once after the drain terminates and before response conversion; dropping the core + drain future also releases it. This path invokes no middleware, handler, or `RequestContext` + and never rematches. Returning this decision for `Matched` fails closed before native body + construction. Adapter-level cancellation follows the target caveats in §1.3; in particular, + Axum's current blocking bridge is deadline-bounded but not promptly cancellable. + +Route identity is stable and structural, never a registration ordinal or randomized hash: + +```rust +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct RouteId { /* private canonical method + registered pattern */ } + +impl RouteId { + pub fn method(&self) -> &Method; + pub fn pattern(&self) -> &str; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RouteMetadata { /* private RouteId + optional opaque class */ } + +impl RouteMetadata { + pub fn class(&self) -> Option<&str>; + pub fn id(&self) -> &RouteId; + pub fn method(&self) -> &Method; + pub fn pattern(&self) -> &str; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RouteResolution { + Matched(RouteMetadata), + MethodNotAllowed { allowed: Arc<[RouteMetadata]> }, + NotFound, +} +``` + +`RouteId` equality/hash is the canonical `(method, registered route pattern)` pair. Dynamic +path parameter values and route class never enter it. `RouteMetadata` owns exactly that +`RouteId` plus optional opaque manifest metadata from `class = "auction"`; its +method/pattern accessors delegate to the id, so independently mutable duplicate fields +cannot disagree. Class is for application admission policy and is neither globally unique +nor interpreted by EdgeZero. `RouterBuilder::route_with_class(path, method, class, handler)` +is the generated-route path; existing route builders produce `class() == None`. +`MethodNotAllowed.allowed` contains every matched +pattern/method candidate sorted by method then pattern; callers derive an Allow set without +depending on `HashMap` order. `RouteInfo` reuses `RouteMetadata` rather than defining a +second identity. Duplicate method/pattern registration remains the existing build-time +error. + +The resolution/dispatch split is an explicit router API, not an adapter-side reimplementation +of matching: + +```rust +pub struct ResolvedDispatch { /* private router identity + path parameters */ } + +impl ResolvedDispatch { + pub fn resolution(&self) -> &RouteResolution; +} + +impl RouterService { + pub fn resolve(&self, method: &Method, path: &str) -> ResolvedDispatch; + + pub async fn dispatch_resolved( + &self, + resolved: ResolvedDispatch, + request: Request, + ingress: AdmittedIngress, + ) -> Result; +} +``` + +`ResolvedDispatch` is opaque, single-use, and bound to the `RouterService` instance that +created it. `dispatch_resolved` rejects a token from another router as `Internal`, consumes +the token, and never matches the method/path again. The `Request` method and path must equal +the values captured by the token; a mismatch is also `Internal`. This prevents middleware or +adapter conversion from admitting one route and dispatching another. `AdmittedIngress` +contains the captured start, finite deadline, paired monotonic clock, and grant; it has no +public constructor so only the ingress protocol can claim admission occurred. + +`IngressGrant` is an opaque request-owned lease carrier. It is intentionally not `Clone`: + +```rust +pub struct IngressGrant { /* Option> */ } + +impl IngressGrant { + pub fn downcast(self) -> Result; + pub fn empty() -> Self; + pub fn new(value: T) -> Self; +} + +#[derive(Clone, Debug)] +pub struct BufferedIngressResponse { /* exact status + HeaderMap + Bytes */ } + +impl BufferedIngressResponse { + pub fn new>(status: StatusCode, headers: HeaderMap, body: B) -> Self; + pub fn text>(status: StatusCode, text: S) -> Self; + pub fn status(&self) -> StatusCode; + pub fn headers(&self) -> &HeaderMap; + pub fn body(&self) -> &[u8]; +} + +#[non_exhaustive] +pub enum AdmissionDecision { + Admit { + grant: IngressGrant, + read_deadline: Deadline, + }, + ReadBodyBeforeFallback { + grant: IngressGrant, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + }, + Refuse(Response), +} +``` + +`BufferedIngressResponse` is deliberately finite and transport-neutral: it cannot carry a +streaming body or deferred provider work. `new` preserves the application-selected status, +complete header map, and exact bytes. `text` additionally supplies UTF-8 plain-text content +type and exact content length. + +The matched `RequestContext` stores the grant in an unsynchronized one-shot cell because +extractors receive `&RequestContext`. `take_ingress_grant(&self)` removes and returns it at +most once; a second call returns `None`. If application code never takes it, context drop +releases it. A refusal never creates a request-owned grant. An ordinary admitted 404/405 owns +the grant only until the resolved terminal response, then drops it. The bounded fallback path +holds its grant through the body drain and drops it exactly once before converting the canonical +or application-selected terminal response. It never exposes the grant through a request context. +The carrier exposes no type-id string, serializer, or platform handle API; application code +knows the concrete lease type it inserted. + +`IngressHead::request_start()` and `RequestContext::request_start()` return the captured +`MonotonicInstant`. Both are the same value and clock domain used by `Deadline`; neither +accessor snapshots a new instant. `PreparedIngress::monotonic_clock()` and +`RequestContext::monotonic_clock()` return clones of the exact app clock handle attached at +admission. `IngressHead::route_resolution()` exposes the value above, and a matched +`RequestContext::route_metadata()` returns that exact matched metadata. + +```rust +pub type MonotonicInstant = web_time::Instant; + +#[derive(Clone)] +pub struct MonotonicClock { /* Arc MonotonicInstant + Send + Sync> */ } + +impl MonotonicClock { + pub fn new(now: Now) -> Self + where Now: Fn() -> MonotonicInstant + Send + Sync + 'static; + pub fn now(&self) -> MonotonicInstant; +} + +impl IngressHead { + pub fn read_deadline_after(&self, duration: Duration) -> Deadline; +} + +impl App { + pub fn monotonic_clock(&self) -> MonotonicClock; + pub fn monotonic_now(&self) -> MonotonicInstant; + pub fn set_monotonic_clock(&mut self, clock: MonotonicClock); +} +``` + +`MonotonicClock::default()` uses `web_time::Instant::now`. Injection is application-wide: +standard adapters take `request_start` from the app before head conversion, admission +stores that same handle, and core plus adapter body checks use +`Deadline::{remaining_at,is_expired_at}(clock.now())`. Admission policies should construct +relative deadlines with `IngressHead::read_deadline_after`, which uses the captured start +and clamps to `DEADLINE_FAR_FUTURE`. Calling the global-clock `Deadline::after` from a policy +while a custom app clock is installed is a caller error; explicit low-level APIs likewise +cannot validate that two bare `MonotonicInstant` values came from the same source. + +`Hooks::configure(&mut App)` is the existing app-owned configuration point and uses a new +`App` admission-policy setter. Manifest-generated and hand-written apps that do not install +one use the portable default: admit with `grant = IngressGrant::empty()` and +`read_deadline = request_start + DEFAULT_INBOUND_READ_BUDGET`, where +`DEFAULT_INBOUND_READ_BUDGET = 30 s`. Admission cannot return "no deadline". The adapter +normalizes every admitted deadline to no later than +`request_start + DEADLINE_FAR_FUTURE` using checked addition; overflow fails closed as an +internal policy error. An app that needs a longer upload than the default sets a later +absolute deadline within that bound. The decision is computed per request and is never +cached globally. + +The adapter retains the complete `App` admission policy when constructing its request +service. Paths that currently clone only `app.router()` must also carry that policy; +`App::into_router()` remains usable for low-level callers but does not silently install or +claim ingress admission. Hand-built adapter services expose the same explicit policy setter +and otherwise use the portable default. + +Parser limits are startup policy, not admission output: the parser must know them before it +can safely materialize `IngressHead`. `App` therefore also owns immutable +`IngressHeadLimits`, copied into the adapter service during finalization. The default is +finite on every target: + +```rust +pub const DEFAULT_MAX_REQUEST_HEADER_BYTES: u64 = 65_536; +pub const DEFAULT_MAX_REQUEST_HEADER_COUNT: u64 = 100; +pub const DEFAULT_MAX_REQUEST_TARGET_BYTES: u64 = 8_192; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct IngressHeadLimits { + max_request_header_bytes: u64, + max_request_header_count: u64, + max_request_target_bytes: u64, +} + +impl IngressHeadLimits { + pub fn max_request_header_bytes(&self) -> u64; + pub fn max_request_header_count(&self) -> u64; + pub fn max_request_target_bytes(&self) -> u64; + pub fn with_max_request_header_bytes(self, max: u64) -> Result; + pub fn with_max_request_header_count(self, max: u64) -> Result; + pub fn with_max_request_target_bytes(self, max: u64) -> Result; +} +``` + +All three values must be nonzero. Validation runs when `App` is finalized and before a +listener/guest service starts. Invalid configuration is `Internal`; it never silently means +unlimited. Limits are inclusive and use checked `u64` accounting. + +This seam starts when EdgeZero receives control. It cannot reject or time-bound bytes a +provider accepted or buffered before invoking guest code; adapter capability documentation +must state that host-side exposure rather than attributing it to the guest deadline. + +### 1.2 Raw request-head limits and framing policy + +At a raw parser boundary, request-target bytes are the exact octets between the first and +second spaces of the HTTP/1 request line, before URI normalization. Header bytes are the raw +field-section octets from the first field-name through the terminating empty line, including +colons, optional whitespace, line endings, and the final line ending. Header count is the +number of field lines before duplicate coalescing. The parser rejects on the first byte or +field above the configured value; it does not read an unbounded head and check afterward. +For multiplexed protocols, equivalent accounting is performed on the encoded field section +only when the adapter exposes a bounded pre-materialization hook. + +The raw parser returns `EdgeError::UriTooLong { message }` (414) for target overflow and +`EdgeError::RequestHeaderFieldsTooLarge { message }` (431) for header byte/count overflow. +Neither diagnostic includes target or header contents. These failures happen before route +resolution and therefore never enter `StoredError` through a body drain, but those variants +are still included in its total match. Malformed request syntax remains `BadRequest` (400). + +Admission can inspect whether the configured parser contract actually ran: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressHeadAccounting { + HostManaged, + RawValidated { + request_header_bytes: u64, + request_header_count: u64, + request_target_bytes: u64, + }, +} +``` + +`IngressHead::head_accounting()` returns this value. `RawValidated` is emitted only when all +three values were measured before normalized request construction and found within the exact +`IngressHeadLimits` installed for that service. A normalized `HeaderMap` recount may reject +visible data as defense in depth but must report `HostManaged`; it cannot satisfy the raw +capability or recover field-line/request-target octets discarded by the platform. + +`IngressFraming` distinguishes raw-validated framing from a host-managed request whose raw +boundary EdgeZero cannot inspect: + +```rust +#[non_exhaustive] +pub enum IngressFraming { + Chunked, + ContentLength(u64), + HostManaged, + NoBody, + ProtocolManaged, +} +``` + +`NoBody`, `ContentLength`, `Chunked`, and `ProtocolManaged` are produced only after raw +validation and record an unambiguous result. `HostManaged` is required when the adapter's +`raw-ingress-framing-validation` support is `Unsupported`: it makes no assertion that +ambiguous framing was rejected and must not be treated by admission policy as trusted +length/framing evidence. No variant is reconstructed from a normalized `HeaderMap`. +For HTTP/1.x, EdgeZero rejects at the parser/connection boundary, before a body reader is +created, when any of these hold: + +- both `Content-Length` and `Transfer-Encoding` are present, in either field order; +- `Content-Length` is malformed, signed, overflows `u64`, appears more than once, or uses a + comma-list form, including repeated identical values; +- `Transfer-Encoding` is malformed, repeats `chunked`, does not end in exactly one + `chunked`, or contains a transfer coding the adapter does not implement; or +- framing is otherwise invalid for the request's HTTP version. HTTP/2 and HTTP/3 reject a + `Transfer-Encoding` field rather than treating it as HTTP/1 framing (`TE: trailers` is a + separate field and rule). + +The rejection response is 400; HTTP/1 closes the connection and multiplexed transports +reset only the affected stream. EdgeZero does not drain an ambiguously framed body and does +not invoke the admission callback. This strict duplicate-`Content-Length` policy deliberately +chooses one parser interpretation instead of accepting RFC-permitted identical duplicates. + +Axum is the first required raw-boundary implementation. Hyper 1.10.1 can discard a +`Content-Length` encountered after `Transfer-Encoding`, so checking the resulting +`Request`/`HeaderMap` is insufficient and must not be described as smuggling +protection. The Axum adapter needs an audited pinned Hyper parser patch or an upstream parser +hook that rejects the strict policy while Hyper still has ordered `httparse` field lines. +After that parser has rejected ambiguity, the adapter may derive `IngressFraming` from the +surviving normalized headers plus HTTP version; the derivation is trusted only because the +raw parser decision preceded it. A second independent socket pre-parser is not accepted: it +can disagree with Hyper and cannot safely locate subsequent pipelined request heads without +also owning HTTP body framing. Raw-socket tests in §4 pin the parser behavior. Platform SDKs that expose +only normalized requests cannot claim Native framing validation based on assumed host +behavior; they remain Unsupported until a documented/testable raw rejection seam exists. + +### 1.3 Absolute body-read deadline and cancellation + +The admitted `read_deadline` and its paired `MonotonicClock` are carried with the native +body and govern the full inbound +read lifetime: waiting for the first byte, every inter-chunk wait, drain completion, and +late EOF/error. It is not reset by routing, middleware, extractors, repeated cached reads, +or transfer into `OutboundRequest::from_request`. Before each platform read and immediately +after any chunk/EOF/error becomes ready, compare the same absolute deadline. At simultaneous +read completion and expiry, expiry wins. + +When expiry is observable at a guest boundary, the adapter drops or aborts the owned reader +with its strongest available platform primitive and yields +`EdgeError::RequestTimeout { message }` (408). If `BodyCell` is draining, its existing +capture path transitions exactly once to `Poisoned(StoredError::RequestTimeout { .. })`; +the first and every later accessor observe the same status/kind/message and the source is +never polled again. Expiry after a clean cached drain does not retroactively poison cached +bytes. Dropping a caller future without deadline expiry keeps the separate documented +`Internal("inbound body drain cancelled")` poison. + +The public deadline contract is portable; the teardown strength is not. Add four +non-outbound capability cells to the shared capability ladder: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `ingress-admission` | Native | Native | Native | Native | +| `inbound-read-deadlines` | Native | BestEffort until a deployed cancellation probe passes | BestEffort (synchronous host reads are not guest-preemptible) | BestEffort until host-observed cancellation is bounded | +| `raw-ingress-head-limits` | Unsupported | Unsupported | Unsupported | Unsupported | +| `raw-ingress-framing-validation` | Unsupported | Unsupported | Unsupported | Unsupported | + +BestEffort implementations still use pre-read/post-ready checks and release ownership when +expiry is observed; they do not claim a finite bound around an uninterruptible or opaque host +call. Fastly cannot preempt its synchronous read, and Spin cannot race its opaque body stream +against a guest-owned cancellation primitive. A manifest +that requires Native support fails before startup/deploy through the same capability ladder +used elsewhere. Raw head limits, raw framing, and body-read deadline are separate cells: a +platform host may impose undocumented limits or reject malformed framing without exposing +evidence, and may expose a cancellable body while hiding raw target/field-line bytes. +All current standard adapters pass `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. Axum is the first planned raw-boundary implementation, but its +two raw capability cells remain `Unsupported` until the parser-boundary implementation and +raw-socket evidence land. + +Axum can preempt its asynchronous native body read at the configured deadline, which supports +the `Native` deadline cell. Its current Tower adapter nevertheless drives the core's non-`Send` +dispatch future through `block_in_place` plus a nested runtime `block_on`; Tokio cannot cancel +that blocking closure. Aborting the outer service task therefore does not promptly release a +fallback grant. The inner drain continues until body completion or the finite absolute read +deadline, then releases the grant and native body source exactly once before returning its +terminal response. The adapter suite pins this distinction; the capability is a read-deadline +claim, not a prompt caller-cancellation claim. + +Cloudflare performs one `Delay::from(Duration::ZERO).await` cooperative yield before +selecting the body read against its host timer. A deliberately frozen injected clock means +clock-based post-ready checks cannot independently observe elapsed time; the real host timer +still uses the last positive `remaining_at` snapshot. This is deterministic test support, +not a claim that production time can freeze. Native promotion requires one deployed timing +probe that records runtime version, configured deadline, observed cancellation latency, and +tolerance; local mocked timers are insufficient. + +## 2. Bounded Context Helpers + +Wrap the existing `Body::into_bytes_bounded` with context-level helpers: + +```rust +// crates/edgezero-core/src/context.rs +impl RequestContext { + /// Read the inbound request body into `Bytes`, bounded by `max`. + /// Over-limit yields `Err(EdgeError::bad_request(..))` (400). + /// + /// **Takes `&self`** — `RequestContext` carries an internal body cache + /// (an `unsync::OnceCell` style cell; single-threaded + /// request, no `tokio` dep). This is deliberate so that existing + /// `FromRequest` extractors that take `&RequestContext` (e.g. `Json`, + /// `ValidatedJson`) can call it without a trait-signature breaking + /// change. The first call drains the underlying `Body::Stream` into + /// the cell; later calls return a cheap clone. The cached size is + /// re-validated against `max` on every call, so a later, stricter cap + /// is still enforced after buffering. The network body is read at most + /// once. + #[inline] + pub async fn body_bytes(&self, max: usize) -> Result; + + /// Call `body_bytes(max)` then deserialize as `application/x-www-form-urlencoded`. + /// Default cap from extractors: `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` + /// (forms are typically small). Malformed form data → `bad_request` (400). + /// Same `&self` cache semantics as `body_bytes`. + #[inline] + pub async fn form_within(&self, max: usize) + -> Result; + + /// Call `body_bytes(max)` then deserialize as JSON. Malformed inbound + /// JSON yields `Err(EdgeError::bad_request(..))` (a client bug → 400, + /// in contrast to outbound `OutboundResponse::json` which maps to 502). + /// Same `&self` cache semantics as `body_bytes`. + #[inline] + pub async fn json_within(&self, max: usize) + -> Result; +} +``` + +## 3. Context and Adapter Migration + +The memory guarantee in §3.1 only holds if the adapter does not pre-buffer the +inbound request body before core can apply a cap. Today every adapter pre-buffers +(`crates/edgezero-adapter-axum/src/request.rs:24` buffers JSON with `usize::MAX`; +`crates/edgezero-adapter-cloudflare/src/request.rs:60` calls `req.bytes()`; +the Fastly and Spin paths fully materialize the body too). This migration changes that: + +- **Adapter request conversion** stops pre-buffering. Inbound `Request` is exposed to + core with a `Body::Stream` (or `Body::Once` only when the platform genuinely owns + the bytes already — e.g. an in-process Axum body that arrived buffered). Each + adapter's `request.rs` is updated to wrap the platform body as a stream rather than + drain it eagerly. +- **`RequestContext` is restructured** — today it holds a plain `Request`, which cannot + be safely mutated through `&self`. The new shape: + + ```rust + struct BodyCell(/* unsync */ RefCell); + + /// Non-consuming snapshot of cell state for app inspection. + pub enum BodyKind { + Cached { len: usize }, + Draining, + Initial, + Poisoned, + Taken, + } + + enum BodyState { + Cached(Bytes), // body drained successfully + Draining, // body taken out, drain in progress + Initial(Body), // never read; the platform body is still owned + Poisoned(StoredError), // drain failed (over-cap, stream error, drop) + Taken, // body consumed via take_body / into_request + } + + pub struct RequestContext { + body: BodyCell, // interior-mutable + parts: http::request::Parts, // method, uri, version, headers, extensions + path_params: PathParams, + } + ``` + + **`StoredError` — why the cell cannot just store an `EdgeError`.** The poison + contract requires that *every* subsequent access (`body_bytes`, `json_within`, + `form_within`, `into_request`) returns **the same error**. That needs the error to be + reproducible — but **`EdgeError` is not `Clone`**: its `Internal` variant wraps + `anyhow::Error` (`error.rs`: `Internal { #[from] source: AnyError }`), which is + deliberately not clonable. Storing an `EdgeError` and handing out copies therefore + does not compile. `StoredError` is the clonable, reconstructable **essence** of the + error that poisoned the cell: + + It **must be a variant-specific snapshot enum, NOT `{ kind, message }`** — that flat + shape cannot rebuild `EdgeError` faithfully, on two counts a compiler forces: + (a) `EdgeError::Internal`'s `message()` already renders as `"internal error: {source}"`, + so rebuilding via `internal(anyhow!(message))` **doubles the prefix**; (b) + `ConfigOutOfDate` (`field_path`), `MethodNotAllowed` (`method`, `allowed`), and + `NotFound` (`path`) carry structured payloads a single `message` string cannot hold. So + `StoredError` mirrors the variants and captures each payload: + + The [blob-app-config design](2026-06-16-blob-app-config.md) §6.3 classification is + finalized. `StoredError` mirrors its reason-bearing `StoreExtraction` variant and must + continue to mirror every future `EdgeError` payload before this state machine lands. + + ```rust + #[derive(Clone)] + enum StoredError { + BadGateway { message: String, reason: BadGatewayReason }, + BadRequest { message: String }, + ConfigOutOfDate { field_path: String, message: String }, + GatewayTimeout { cause: BudgetSource, message: String }, // preserve typed cause + Internal { rendered: String }, // ALREADY-rendered source; no re-prefixing + MethodNotAllowed { allowed: String, method: Method }, // no fallible method parse + NotFound { path: String }, + NotImplemented { message: String }, + RequestTimeout { message: String }, // admitted inbound read deadline expired + ResponseTooLarge { message: String, reason: ResponseLimitReason }, + ServiceUnavailable { message: String }, + StoreExtraction { + field_path: Option, + message: String, + reason: StoreExtractionReason, + }, + Validation { message: String }, + } + + impl StoredError { + /// Capture an EdgeError's essence at poison time (total match — cannot silently + /// drop a variant). For `Internal`, store `source.to_string` (already rendered), + /// NOT `err.message`, so reconstruction does not re-add the "internal error: " + /// prefix. + fn capture(err: &EdgeError) -> Self { /* one arm per variant */ } + /// Rebuild an equivalent `EdgeError` — same variant, same fields, same status. + /// `Internal { rendered }` → `EdgeError::internal(anyhow!(rendered))`. + fn to_edge_error(&self) -> EdgeError { /* inverse of capture */ } + } + ``` + + **Decomposition happens once, at poison time.** The drain's `EdgeError` is captured + into `StoredError` and the cell returns `stored.to_edge_error()` — so *even the first* + read gets a reconstructed error, and all later reads are identical. Every accessor's + signature stays `Result<_, EdgeError>` (no `Rc` leaking into the public API). + + **Documented loss:** for the `Internal` variant the **`anyhow` source chain and + backtrace are not preserved** — only the rendered string. A reconstructed `internal` + error's `inner()` yields a fresh `anyhow::Error` carrying that string, not the original + chain. Accepted trade: the alternatives are `EdgeError: Clone` (impossible without + dropping `anyhow`) or `Rc` on every accessor (an API wart for a + diagnostic-only benefit). A platform stream that needs the original chain for + diagnostics must log at the stream-error production boundary, before yielding the typed + error. *(A `BodyCell` drain only ever produces `bad_request` / `bad_gateway` / + `gateway_timeout` / `request_timeout` / `internal`; the other structured variants are + still covered so the enum is total and `capture` never needs a lossy fallback arm.)* + + **Cancelled drain.** A drain future dropped while `Draining` transitions the cell to + `Poisoned(StoredError::Internal { rendered: "inbound body drain cancelled".into() })` + via a drop guard (§4), so a cancelled read is indistinguishable in shape from any + other poison — the next access returns that stored error rather than silently + re-reading a half-consumed body. + + `RefCell` (unsync) is fine because a `RequestContext` is owned per-request and + EdgeZero's async traits already use `?Send`. No `tokio` dependency in core. + + **Construction contract — `RequestContext::new(Request, PathParams)` is PRESERVED.** + `parts` and `body` are **private**, and `BodyCell` / `BodyState` are **not public + types**. Adapters therefore do **not** — and cannot — construct the context from + "parts + a body cell"; earlier drafts said they should, which both leaks an internal + type and misassigns ownership (adapters build a `Request`; the **router** builds the + `RequestContext`). The existing signature is kept verbatim: + + ```rust + impl RequestContext { + #[inline] + pub fn new(request: Request, params: PathParams) -> Self { + Self::new_low_level(request, params, MonotonicInstant::now()) + } + + #[inline] + pub fn request_start(&self) -> MonotonicInstant; + + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock; + + #[inline] + pub fn route_metadata(&self) -> Option<&RouteMetadata>; + + #[inline] + pub fn take_ingress_grant(&self) -> Option; + } + ``` + + So the migration is **source-compatible for every caller of `new(..)`** — adapters + and the router keep passing a `Request` exactly as they do today, and the + parts/body split becomes an implementation detail. For this low-level constructor, + `request_start` is snapped when `new` is called, `route_metadata()` returns `None`, and + `take_ingress_grant()` returns `None`. That path makes + no `ingress-admission` claim. Resolved router dispatch uses a crate-private constructor + that consumes `AdmittedIngress` and installs its original start, matched metadata, and + grant before middleware. What adapters *do* change is + **what they put in that `Request`**: a lazy `Body::Stream` instead of a + pre-buffered body (first bullet above). `BodyCell` never appears in any public + signature; the only new public surface is the accessor set (`parts()`, + `parts_mut()`, `body_kind()`, `body_bytes`, `json_within`, `form_within`, + `take_body`, `into_request`). + + **Async drain protocol.** A naive "borrow_mut across .await" implementation would + panic on reentrant access or hold the borrow indefinitely if the future is dropped + mid-drain. The implementation is therefore: + + 1. Briefly borrow the cell, `mem::replace` the state with `Draining` while taking + ownership of the `Body`, drop the borrow. (No borrow held across any `.await`.) + 2. Drive the async drain on the owned `Body`. A drop guard wraps the drain such + that, on success, the cell is set to `Cached(bytes)`; on stream error or cap + overflow, the cell is set to `Poisoned(stored_err)`; on **future-cancellation** + (the drain future is dropped), the guard's `Drop` sets the cell to + `Poisoned(StoredError::cancelled())`. The network body is partially consumed and + unrecoverable in every failure case — poison is sticky. + 3. While the cell is in `Draining`, any reentrant `body_bytes` / `json_within` call + observes that state and returns `Err(EdgeError::internal("body read already in + progress"))` rather than panicking; this would only occur in programmer-error + scenarios but must not crash the host. + + Tested in §4: a scripted stream first returns `Pending`, allowing a second accessor to + observe `Draining`; the second call returns `internal` without a panic, then the first + drain is either resumed to success or dropped to exercise cancellation poison. + +- **Public methods become coherent with the cache.** Their post-cache behaviour is + explicit so middleware → handler → proxy-forward chains compose: + + | Method | Behaviour | + | --- | --- | + | `method()` / `uri()` / `headers()` / `extensions()` | from `parts` — unaffected by body state | + | `headers_mut()` / `extensions_mut()` | mutates `parts` — unaffected by body state | + | `parts() -> &http::request::Parts` / `parts_mut() -> &mut http::request::Parts` | direct access to the underlying `Parts` for middleware that needs the full snapshot; same body-state-irrelevance as the granular accessors above. These are the migration target for call sites currently doing `ctx.request()` / `ctx.request_mut()` (§5 sweep). | + | `body_kind() -> BodyKind` | a non-consuming snapshot of the cell state — variants enumerated above (`Initial \| Draining \| Cached { len } \| Poisoned \| Taken`). There is **no** `body() -> &Body` / `body() -> Body` accessor — a `&Body` reference cannot span the cell's interior mutability, and a value-returning getter would either consume the stream (single-shot) or require a tee. Callers either buffer via `body_bytes`/`json_within` or consume via `take_body`/`into_request`. | + | `take_body() -> Result` | consume the body out of the context: `Initial` → `Ok(Body::Stream(..))`, set state to `Taken`; `Cached(bytes)` → `Ok(Body::Once(bytes))`, set state to `Taken`; `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error); `Poisoned(err)` → `Err(err.to_edge_error())`; `Taken` → `Ok(Body::empty())`. After a successful `take_body`, the body cannot be re-read or buffered. | + | `body_bytes(max)` / `json_within(max)` / `form_within(max)` | from `Initial`: drains → `Cached`, returns clone (or → `Poisoned(err)` on drain failure, then returns that error). From `Cached`: re-validates `max` and returns a clone. From `Poisoned`: returns a fresh `EdgeError` reproduced from the stored error. From `Draining`: `Err(EdgeError::internal("body read in progress"))` — programmer error. From `Taken`: `Err(EdgeError::internal("body already consumed via take_body"))` — buffered helpers cannot resurrect a body that was handed out. | + | `into_request() -> Result` | reassembles a `Request` from `parts` + the cell's body via the same rules as `take_body`: `Cached` → `Ok(Body::Once(bytes))`, `Initial` → `Ok(Body::Stream(..))`, `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error), `Poisoned(err)` → `Err(err.to_edge_error())` — **not** `Body::empty()`, because a poisoned read silently turning into an empty proxy-forward would violate the "poison is sticky" rule below, `Taken` → `Ok(Body::empty())` (the caller consumed via `take_body`, the empty is intentional). This is what `OutboundRequest::from_request(ctx.into_request()?, uri)?` uses, so streaming proxy-forward still works **even after middleware has buffered the body** (the cached `Bytes` flow through), and a permissive proxy-forward cannot mask a stricter middleware's poisoned read. | + + The legacy `request()` / `request_mut()` accessors are removed (they leaked the + whole `Request` and made the body cell incoherent); call sites switch to + `parts()` / `parts_mut()` for headers/method/uri/extensions, `body_kind()` for + state inspection, `body_bytes(max)` / `json_within(max)` for buffered consumption, + `take_body()` for one-shot consumption, and `into_request()` for proxy-forward + reassembly. + +- **Poison semantics on failed body reads.** If `body_bytes` fails mid-drain — the cap + is exceeded, the stream errors, or a future cancellation interrupts the drain — the + network body has already been partially consumed and cannot satisfy any later call. + The body cell transitions to `Poisoned(stored_err)`, where `stored_err` is enough + metadata to reproduce a fresh `EdgeError` on every subsequent call (since `EdgeError` + is not `Clone`). All later `body_bytes`/`json_within` calls return that error; + `body_kind()` reports `Poisoned`; `take_body()` and `into_request()` both return + `Err(stored)` — the latter explicitly fallible so a poisoned read cannot silently + become an empty proxy-forward. The network body is **not** + retried. This is the most defensible contract: silently re-reading is impossible, and + silently succeeding with a larger-cap call would let a permissive extractor mask a + stricter middleware's enforcement. The poisoned error variant matches the first + failure (e.g. an over-cap drain returns `bad_request` on call N+1 too). + +- **Existing extractors.** All extractors that consume the inbound body are migrated to + the bounded helpers: + + | Extractor (today) | After migration | + | --- | --- | + | `Json` (uses `ctx.json()`, assumes buffered body) | delegates to `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)` — `DEFAULT_INBOUND_JSON_BYTES = 8 MiB` | + | `ValidatedJson` | as above + `validator` pass; sibling `ValidatedJsonWithin` for explicit caps | + | `Form` (uses `ctx.form()`, also rejects streams today — `crates/edgezero-core/src/extractor.rs:375`, `crates/edgezero-core/src/context.rs:31`) | delegates to a new `ctx.form_within(max)` helper, default `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` (forms are typically small) | + | `ValidatedForm` | as above + `validator` pass; sibling `ValidatedFormWithin` for explicit caps | + + The legacy `RequestContext::json()` and `RequestContext::form()` are removed; both + required `Body::Once` and would break once adapters stop pre-buffering. + +- **Extractor trait.** No change required — `FromRequest::from_request(&RequestContext, + ..)` continues to take `&RequestContext`, which works because `body_bytes` is now + `&self`-callable through the cache. + +Net effect: per-inbound-body memory is bounded at the boundary of the bounded helper +that actually reads the body; failed reads are sticky so a permissive caller cannot +silently bypass a stricter one; streaming proxy-forward works whether or not middleware +already buffered the body. The memory cap is independent of §1.3's time bound: both apply +to the same first drain, so an under-cap slow trickle still expires. + +**Sticky poison is scoped to READ/DRAIN failures, NOT cap-rechecks on an already-`Cached` +body — stated to remove an apparent contradiction.** Once the body is `Cached { bytes }`, +a `body_bytes(cap)` call is a **stateless length check** (`bytes.len() <= cap`) that does +**not** mutate the cell: an over-cap result returns an error but leaves the state `Cached`, +so a later `body_bytes(larger_cap)` where the cached length fits **legitimately succeeds**. +This is intended and is **not** a violation of stickiness: stickiness governs a body that +was **consumed/failed while draining** (`Initial → Draining → Poisoned`) — there the cell +is poisoned and every subsequent access (any cap) returns the stored error. The rule +"a permissive caller cannot bypass a stricter one" is about a **poisoning drain**, not +about re-reading an intact cache at different caps. (The security property still holds: +the *first* reader that actually drains sets the cache/poison; a cap check against an +existing cache reveals nothing a caller couldn't compute from the already-materialized +bytes.) §4 pins this: permissive read (caches) → stricter `body_bytes` (over-cap error, +cell stays `Cached`) → permissive retry (succeeds) — asserting the stricter failure does +**not** poison an intact cache. + +### 3.1 Memory Bound + +*(Moved here from the outbound spec's §3.4.4 batch-memory model — this is the inbound-body +half; the outbound spec keeps only the per-response and batch terms.)* + +- **Per-inbound-body.** *Persistent* memory — the cached `Bytes` after a successful drain — + is bounded by the `max` passed to `body_bytes(max)` / `json_within(max)` / + `form_within(max)`. *Transient* worst-case during the drain is the same shape: + `max + current_chunk.len()`, with the in-flight chunk source-controlled. Outbound's + `OutboundResponse::into_bytes_bounded` mirrors this same accounting. + +### 3.2 Extractor Migration + +*(Moved here from the outbound spec's §7 file-by-file migration — this is inbound-only.)* + +- `src/extractor.rs` — extractor migration: `Json` / `ValidatedJson` route through + `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)`; `Form` / `ValidatedForm` route + through `ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)`; add `ValidatedJsonWithin` + and `ValidatedFormWithin` for explicit caps. Constants exposed: + `pub const DEFAULT_INBOUND_JSON_BYTES: usize = 8 * 1024 * 1024;` and + `pub const DEFAULT_INBOUND_FORM_BYTES: usize = 1024 * 1024;`. + +## 4. Test Plan + +Core tests use scripted local streams and `futures::executor::block_on`; they require no +platform runtime or network. + +| Surface | Required assertions | +| --- | --- | +| Bounded drain | `Body::Once` and multi-chunk streams succeed at and below the cap; the first byte above the cap returns `bad_request` without unchecked accounting overflow. | +| Successful cache | The platform stream is polled only once; repeated reads clone the same bytes. A permissive read followed by a stricter cached read returns an over-cap error while leaving the cell `Cached`; a later permissive read succeeds. | +| Failed drain | Source error and initial-drain cap overflow transition to `Poisoned`; every later buffered accessor, `take_body`, and `into_request` reconstructs the same variant, status, message, and structured fields. The source is never polled again. | +| Parser-level head limits (future acceptance criterion) | Exact-limit request targets, header bytes, and header counts pass at a raw parser boundary. One byte/field over rejects before route resolution, admission, request construction, or body polling with 414/431 and redacted diagnostics. Raw accounting includes duplicate field lines and framing syntax; checked overflow fails closed. Current host-normalized recounts never report `RawValidated` and do not satisfy this row. | +| Admission ordering and route identity | Raw validation runs first where available. Route resolution then runs exactly once without middleware or body polling, and the callback sees the resulting stable `RouteResolution`. Admission runs exactly once before resolved dispatch/body polling. A matched dispatch uses the admitted route and path parameters without rematching; method/path mutation or a foreign token fails closed. Route class reaches matched and 405 metadata but never changes `RouteId`. Refusal polls no body, invokes no middleware/handler, terminates the native reader, and preserves the chosen response. The default policy supplies an empty grant and one finite deadline derived from `request_start`. | +| Bounded fallback precedence | For pre-resolved 404/405 requests, ordinary `Admit` retains no-poll routing behavior. Opt-in `ReadBodyBeforeFallback` holds the application grant while draining a lengthless or chunked body to EOF under one absolute deadline: exact cap preserves the canonical 404/405, the first byte over returns the exact `on_exceeded` status/header/body response first, and expiry returns the exact `on_timeout` response first. The grant is live during every poll and drops exactly once before response conversion, when the core drain future is dropped, or when the finite deadline terminates it. No path rematches or invokes middleware, a handler, or `RequestContext`; `Refuse` remains zero-read; selecting fallback drain for a matched route fails before body construction. Every adapter suite covers both 404 and 405 exact-cap cases plus exact custom terminal responses at its honestly reported deadline capability. Axum additionally proves that an outer abort request cannot cancel its blocking bridge but that the read deadline still releases the source and grant exactly once. | +| Request ingress metadata | `IngressHead` and matched `RequestContext` expose the same captured `request_start`, paired app clock, and route metadata. `take_ingress_grant()` returns the non-clone grant exactly once, then `None`; untaken, refused, 404, and 405 grants each drop exactly once. The preserved low-level context constructor exposes no route and makes no admission claim. | +| Absolute read deadline | A 1-byte-per-step under-cap stream cannot extend its lifetime: first-byte, inter-chunk, EOF, and source-error races use one absolute deadline and the admitted app clock; expiry wins simultaneous readiness, cancels native ownership, and poisons a draining cell as `request_timeout` (408). A manually advanced injected clock proves the tie behavior without wall-clock sleeps. Cached success is not retroactively poisoned. | +| Cancellation | A stream held at `Pending` leaves the cell `Draining`; dropping the first `body_bytes` future transitions it to the documented cancellation poison, and the next access returns that stored internal error. | +| Reentrancy | While the first scripted drain is pending, a second body accessor returns `internal("body read already in progress")` without a `RefCell` panic. Resuming the first future can still complete and cache bytes. | +| Consumption | `take_body` and `into_request` cover Initial, Cached, Draining, Poisoned, and Taken. Initial preserves a stream; Cached reassembles `Body::Once`; Taken deliberately produces an empty body only where specified. | +| Extraction | JSON/form success, malformed input, default caps, explicit `Within` caps, and validator failures preserve their documented 400/validation behavior. Multiple extractors share the one cache. | +| Stored errors | `StoredError::capture` exhaustively covers all end-state `EdgeError` variants. Round-trips preserve variant fields, including `BadGatewayReason`, `BudgetSource`, `ResponseLimitReason`, and `StoreExtractionReason` plus its optional field path; `RequestTimeout` remains 408; `Internal` has the one documented source-chain loss without duplicating the `internal error:` prefix. | +| Raw HTTP/1 framing (future acceptance criterion) | Axum raw-socket cases cover CL+TE in both orders, duplicate equal/unequal CL, comma-list CL, overflow/signed/malformed CL, repeated/non-final/unsupported TE, valid single CL, and valid terminal chunked framing. Rejections return 400, close the connection, invoke no admission callback, and poll no body. A `HeaderMap`-only unit test is insufficient, and no current adapter satisfies this row. | + +Each adapter contract test supplies a body stream whose first poll is observable and +asserts that raw validation, when supported, route resolution, and admission complete before +that poll, while middleware and handler dispatch do not begin until after admission. Every +current standard adapter, including Axum, passes `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`; their tests prove admission cannot accidentally upgrade those +values to a validated claim from normalized headers. A future Axum raw-boundary implementation +may pass validated variants only after the parser-boundary and raw-socket suite land. The same adapter test +then passes the converted core request through `RequestContext`, buffers it as middleware +would, and reassembles it through `into_request`; the outbound-facing request receives the +cached bytes unchanged and retains the original absolute read deadline. Tests must not +substitute an already-buffered body for this lazy ingress assertion. Cloudflare and Spin +need deployed/host-observed cancellation probes before upgrading their BestEffort cells; +Fastly tests cooperative checks without asserting preemption of a synchronous host read. Axum +tests native deadline preemption separately from outer Tower-task cancellation, which cannot +interrupt its current blocking bridge and therefore relies on the finite read deadline for +eventual release. + +## 5. File-by-File Change Summary + +- `crates/edgezero-core/src/context.rs`: split requests internally into private parts plus + `BodyCell`; add the state machine, bounded helpers, body-state accessors, `take_body`, and + fallible `into_request`; store the captured request start, optional route metadata, and + one-shot ingress grant; carry the admitted clock into every body deadline check; remove + whole-request borrow accessors. +- `crates/edgezero-core/src/app.rs`: add the synchronous, body-blind admission policy and + immutable `IngressHeadLimits` plus the injectable `MonotonicClock` to `App`, its default + finite inbound read budget, and the normalized ingress-head/decision/accounting types. + The macro continues to use + `Hooks::configure(&mut App)` as the app-owned setter point. +- `crates/edgezero-core/src/error.rs`: add `RequestHeaderFieldsTooLarge { message }` with + status 431 and kind `request_header_fields_too_large`, `RequestTimeout { message }` with + status 408 and kind `request_timeout`, and `UriTooLong { message }` with status 414 and + kind `uri_too_long`; update every exhaustive match and wire-shape matrix. +- `crates/edgezero-core/src/manifest.rs`, `crates/edgezero-core/src/router.rs`, and + `crates/edgezero-adapter/src/registry.rs`: add optional trigger `class`, carry it as + non-identity route metadata, add the four inbound capability cells, and retain fail-closed + required-capability handling. Keep these counts separate from the outbound design's + eight-cell tuple. +- `crates/edgezero-core/src/extractor.rs`: route JSON/form extractors through bounded + helpers, add explicit-cap variants, and add the two public default constants. +- `crates/edgezero-core/src/body.rs`: retain the core bounded-drain primitive used by the + context and use checked pre-append accounting if it has not already landed through the + outbound body work. +- `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}` request/dispatch services: stamp + `request_start`, apply normalized defense-in-depth checks while reporting the raw boundary + as host-managed, resolve once, and run app admission before middleware/handler dispatch or + body polling; stop eager collection; dispatch with the opaque resolved token; and wrap each + platform request body with its absolute deadline and strongest available read-ownership + primitive. Keep `Body::Once` only when the platform already owns bounded bytes and admission + has already run. +- Future `crates/edgezero-adapter-axum` server connection work: enforce raw request-target/header + limits and validate raw HTTP/1 field lines before Hyper can discard framing evidence; + reject/close on overflow or ambiguity. The normalized + `Request` conversion is not the enforcement point. +- `crates/edgezero-core` call sites and tests: migrate `request()` / `request_mut()` users + to parts or body-specific accessors and update the now-fallible `into_request()` calls. +- Adapter contract and host tests: prove admission ordering, one absolute deadline, + target-specific cancellation strength, and the §1.2 raw-framing behavior/capability. + No outbound send or provider error-classification behavior is owned by this + specification. diff --git a/docs/superpowers/specs/2026-09-08-response-egress-design.md b/docs/superpowers/specs/2026-09-08-response-egress-design.md new file mode 100644 index 00000000..45df759f --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-response-egress-design.md @@ -0,0 +1,273 @@ +# EdgeZero Response Egress Design + +> **Status:** Draft implementation gate for bounded client-response writes. +> **Owner:** Core response lifecycle plus adapter response converters. + +## 1. Scope and boundary + +This design owns the lifetime after router dispatch returns a core `Response` and before the +adapter has either completed the platform response handoff or terminally aborted it. It +covers absolute write deadlines, backpressure, client disconnect, abort behavior, and one +terminal completion notification. + +It does not extend an outbound fetch deadline, classify upstream failures, or change +outbound response decoding. Those end when the outbound adapter returns an +`OutboundResponse`, as defined by the +[outbound HTTP design](2026-05-21-outbound-http-design.md). Inbound request admission and +body-read deadlines are owned by the +[inbound-body design](2026-08-22-inbound-body-design.md). + +Current converters implement the core policy/observer guard and converter-level absolute deadline, +but they do not provide the full transport lifecycle: Axum and Fastly synchronously collect a core +stream, Spin materializes it into `FullBody`, and Cloudflare adopts a stream without a certified +transport completion boundary. Axum emits `ResponseReturned` +with zero written bytes after conversion but before returning the response to Hyper. Returning +a platform response object is not evidence that Hyper accepted it or that bytes reached the +client. + +## 2. Core contract + +### 2.1 Policy and timing + +`ResponseEgressEnvelope` retains the exact `App::monotonic_clock()` clone used for ingress and +standard outbound dispatch. `begin()` snapshots `egress_started_at` from that clock immediately +before its first response-conversion operation and returns the same clock with the response, +policy, and attempt. The adapter then obtains one finite absolute deadline: + +```rust +pub const DEFAULT_RESPONSE_WRITE_BUDGET: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressPolicy { + pub write_deadline: Deadline, +} +``` + +`App` owns a synchronous response-egress policy callback configured through +`Hooks::configure`. The callback receives immutable response-head metadata plus the captured +request start and optional route metadata; it cannot consume the body. The head exposes +status, version, visible headers, `request_start`, and `Option<&RouteMetadata>` through +borrowed accessors. It contains no body handle and cannot mutate the response. The portable +default is `Deadline::at_instant(egress_started_at + DEFAULT_RESPONSE_WRITE_BUDGET)`. + +The adapter normalizes every callback result to no later than +`egress_started_at + DEADLINE_FAR_FUTURE`, using checked addition. An already-expired result +is a valid immediate deadline. Arithmetic overflow while deriving the clamp fails before +commit as `ConversionError`; it does not silently create an unbounded write. + +The deadline is absolute and never reset by response conversion, first-byte wait, +backpressure, chunk boundaries, flushes, trailers, or platform finish. Before every source +poll/platform write and immediately after either side becomes ready, the adapter compares +the same deadline. Equality is expired; deadline expiry wins simultaneous readiness. +Every callback sample, deadline comparison, body-collection check, and terminal report uses the +returned clock. Low-level converters that bypass `App` explicitly use `MonotonicClock::default()`; +they are not app-clock propagation paths. Wall-clock time is not used. + +### 2.2 Terminal report + +The app may install one observer. The adapter invokes it synchronously exactly once per +response conversion attempt: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressOutcome { + ClientDisconnected, + Completed, + ConversionError, + DeadlineExceeded, + HostHandoff, + ResponseReturned, + SourceError, + TransportError, + Unspecified, +} + +#[derive(Clone, Debug)] +pub struct ResponseEgressReport { + pub bytes_written: u64, + pub elapsed: Duration, + pub outcome: ResponseEgressOutcome, + pub request_start: MonotonicInstant, + pub route: Option, +} + +pub trait ResponseEgressObserver: Send + Sync + 'static { + fn complete(&self, report: &ResponseEgressReport); +} +``` + +`bytes_written` is payload bytes successfully handed across the strongest guest-visible +platform write boundary. It is not TCP bytes, does not include headers/framing, and does not +claim peer acknowledgement. Checked addition is mandatory; arithmetic overflow terminates +as `TransportError`. `elapsed` is terminal monotonic time minus `egress_started_at`. If an +injected test clock moves backward, report zero, classify `Unspecified`, log the clock fault, +and still complete exactly once. + +The observer must not panic and cannot affect the client response. Adapter code wraps the +terminal call in the project's platform-appropriate panic/error containment where available; +observer failure is logged after the lifecycle state is terminal and never triggers a second +notification. + +`HostHandoff` is the honest terminal outcome when guest code can observe only that the +platform accepted a response object/body, not delivery or finish. It still closes the guest +attempt and triggers exactly one report, but it is not `Completed` and does not satisfy a +Native `response-egress-completion` requirement. + +`ResponseReturned` is weaker: guest code constructed and returned the platform response, +but the host-owned send begins later or is otherwise unobservable. It reports +`bytes_written = 0`, does not imply host acceptance, and cannot satisfy any of the four +response-egress capabilities. Fastly's `#[fastly::main]` and Spin's `#[http_service]` +entrypoint wrappers are in this category because delivery occurs after the generated guest +function returns. + +### 2.3 Exactly-once state machine + +Each conversion owns one public, non-clone, adapter-facing completion guard. The type may be +`#[doc(hidden)]`, but it cannot be crate-private because adapter crates own and drive it: + +```text +Initial -> Writing -> Completed +Initial -> Terminal(failure) +Writing -> Terminal(failure) +``` + +The terminal transition stores one `ResponseEgressReport` before invoking the observer. +EOF/finish, source error, transport error, deadline, explicit cancellation, client +disconnect, converter failure, and guard drop all compete through that one transition. +Later terminal signals are ignored. Dropping an `Initial` guard is `ConversionError`; +dropping a `Writing` guard before successful finish is `ClientDisconnected` when the host +reported cancellation/disconnect, otherwise `TransportError`. A guard disarmed by successful +completion performs no work in `Drop`. + +One report is required for empty and `Body::Once` responses too. A converter failure before +the platform accepts response headers may synthesize the adapter's minimal 500 response, but +the original attempt reports `ConversionError`. The fallback must use an adapter-private +minimal platform response and must not recursively enter the guarded converter or create a +second observed attempt. + +## 3. Write and abort semantics + +### 3.1 Commit boundary + +Each adapter documents its response-header commit point. Before commit, a source/conversion +error or already-expired deadline may be replaced by a typed 500/504 response. After commit, +status and headers are immutable: the adapter aborts/resets/closes the response using the +strongest platform primitive and reports the terminal cause. It must not append an error body +to a partially written success response. + +Deadline expiry maps to a 504 only when no response bytes/headers have committed. The 504 is +constructed through an adapter-private minimal fallback and handed to the host without +starting a second observed attempt or recursively applying another write deadline. The +original attempt reports `DeadlineExceeded` exactly once. If that immediate fallback cannot +be constructed/accepted, the adapter aborts; it does not replace the original report with a +second terminal cause. + +### 3.2 Backpressure and memory + +For streaming adapters, platform demand drives source polling. At most one source chunk plus +documented platform staging may be retained by the EdgeZero wrapper. A source is not polled +again until the previous chunk is accepted or released. `Pending` must arrange a wake for +both platform readiness and deadline expiry; a frozen application clock or a source that +never wakes cannot suppress the adapter's independent timer wake. + +`Body::Once` remains one already-materialized core allocation. This design does not impose a +new response-size cap; the producer's existing body limits remain responsible for that +allocation. An adapter that must buffer `Body::Stream` cannot claim streaming backpressure +and must enforce a separate finite converter collection cap before allocation grows. + +### 3.3 Cancellation and finish + +On deadline, disconnect, source error, or write error, the adapter: + +1. stops polling the core body; +2. drops/cancels the source stream; +3. aborts, resets, or closes the platform response with its strongest documented primitive; +4. releases current/staged chunks; and +5. transitions the guard exactly once. + +`Completed` means core EOF (and trailers if support is later added) was accepted by the +strongest guest-visible platform finish boundary. It does not mean the remote peer received +or acknowledged every byte. A target that cannot observe finish cannot claim Native +completion notification. + +## 4. Adapter requirements and capabilities + +The shared capability ladder gains four response-egress cells: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `response-egress-abort` | Unsupported until connection-level reset/drop tests pass | Unsupported until deployed cancel is observed | Unsupported; guest returns before host delivery | Unsupported; guest returns before host delivery | +| `response-egress-backpressure` | Unsupported until the non-`Send` bridge and socket-pressure tests pass | Unsupported until deployed pull/cancel behavior is observed | Unsupported while converter collects the stream | Unsupported while converter collects into `FullBody` | +| `response-egress-completion` | Unsupported until a proved transport completion boundary exists | Unsupported until a deployed finish/cancel probe passes | Unsupported | Unsupported | +| `response-write-deadlines` | Unsupported until a connection-level abort timer is proved | Unsupported until a deployed timer/abort probe passes | Unsupported | Unsupported | + +These are the initial capability declarations. A cell may move to BestEffort or Native only +after its implementation and named evidence land. An app requiring Native support fails during build/serve/deploy/demo before +handling traffic. A target may expose a typed report at BestEffort while clearly documenting +that an unobservable host send can outlive guest completion. + +### 4.1 Axum + +The current Axum service emits `ResponseReturned` with zero written bytes after +`into_axum_response` finishes but before the converted response is returned to Hyper. This is +converter completion only: it does not observe Hyper acceptance, socket transmission, client +receipt, disconnect, or abort. It therefore provides no evidence for any of the four +response-egress capability cells, which remain `Unsupported`. + +Future Axum certification work must account for core's non-`Send` `Body::Stream` while Axum's +erased response body requires `Send`; a local-executor/channel bridge is therefore required +before collection can be removed. A timer inside `poll_frame` observes Hyper demand, not socket +acceptance or flush, and cannot alone prove a response-write deadline. Native +deadline/abort/completion claims require connection-level ownership that can reset or close the +connection independently when the absolute deadline fires. That implementation must retain +the guard from header conversion until the connection-owned path accepts responsibility. +Raw-socket tests must cover a slow reader, disconnect before first byte, disconnect mid-body, +source error, deadline while Hyper holds a frame without repolling, empty body, and normal EOF. + +### 4.2 Cloudflare + +Wrap the worker stream rather than merely mapping chunks. The wrapper uses one independent +platform timer plus cooperative yielding, implements stream cancellation, drops the Rust +source on cancel/expiry, and completes once. Because local JavaScript/WASM clocks and mocks +may freeze unless the event loop yields, tests must include a frozen-clock fixture and the +wrapper must yield cooperatively before rechecking time. One deployed probe records +first-byte, midstream backpressure, deadline, cancel, and finish timing before any Native +claim. + +### 4.3 Fastly and Spin + +Their current converters materialize a body before returning it to the host and cannot +observe client delivery/finish. They enforce a finite converter collection cap and can report +pre-return source/conversion errors. Successful platform response construction reports +`ResponseReturned` exactly once with zero written bytes, but they remain Unsupported for the +four lifecycle capabilities. It must not be reported as `HostHandoff`: the generated host +entrypoint performs delivery only after guest dispatch returns. +Cooperative deadline checks around synchronous host work do not create a finite write bound. +Upgrading requires a documented host streaming/abort/finish API plus a deployed probe; local +collection tests are insufficient. + +## 5. Test and evidence matrix + +| Surface | Required proof | +| --- | --- | +| Timing | One absolute deadline covers conversion, first byte, inter-chunk waits, backpressure, and finish. Equality expiry wins; no per-chunk reset. | +| Completion | Empty, buffered, streamed EOF, source failure, transport failure, timeout, disconnect, conversion failure, and guard drop each notify exactly once. Competing terminal signals still produce one report. | +| Accounting | Exact payload byte totals, zero-byte response, checked overflow, and no header/framing bytes included. | +| Commit | Pre-commit failures may synthesize an error response; post-commit failures abort and never rewrite status or append an error body. | +| Backpressure | A scripted sink holding one chunk prevents a second source poll and keeps retained guest memory to one chunk. | +| Teardown | Source drop and native abort/reset/close are observed for every failure path supported by the adapter. | +| Capability | Parse/display/round-trip and fail-closed Native requirements match the four-row matrix. | +| Cloudflare timing | Frozen-clock test, cooperative yield, and one deployed timing/cancel/finish artifact. | +| Unsupported hosts | Fastly/Spin tests prove only bounded pre-return collection and never label response-object creation as client completion. | + +## 6. Security and observability + +Observer reports contain no body bytes, header values, target address, or unbounded error +strings. Route metadata uses the registered pattern, never dynamic path values. Platform +logs may include a bounded static outcome label and byte count; source error details remain +in local diagnostic logs subject to existing redaction rules. + +No adapter may claim that an egress deadline bounds bytes buffered by the provider before +guest entry, kernel/socket buffers after guest handoff, or peer acknowledgement. Capability +documentation lists those exclusions explicitly. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 32b4c3cd..4ab81d04 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -89,7 +89,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -100,7 +100,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -369,9 +369,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -497,7 +497,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -703,6 +703,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ + "edgezero-core", "toml", ] @@ -711,6 +712,7 @@ name = "edgezero-adapter-axum" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", @@ -738,6 +740,7 @@ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -788,6 +791,7 @@ name = "edgezero-adapter-spin" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -807,6 +811,7 @@ dependencies = [ "toml", "toml_edit", "walkdir", + "wasip3", ] [[package]] @@ -859,6 +864,7 @@ dependencies = [ "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -906,7 +912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2150,7 +2156,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2207,7 +2213,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2495,7 +2501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2628,7 +2634,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3123,9 +3129,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -3181,7 +3187,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3585,9 +3591,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" dependencies = [ "async-trait", "bytes", @@ -3616,9 +3622,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" dependencies = [ "async-trait", "proc-macro2", @@ -3626,15 +3632,16 @@ dependencies = [ "strum", "syn 2.0.119", "wasm-bindgen", + "wasm-bindgen-futures", "wasm-bindgen-macro-support", "worker-sys", ] [[package]] name = "worker-sys" -version = "0.8.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" dependencies = [ "cfg-if", "js-sys", diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index 8ef1cedf..14d81662 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -29,8 +29,8 @@ edgezero-adapter-fastly = { path = "../../crates/edgezero-adapter-fastly" } edgezero-adapter-spin = { path = "../../crates/edgezero-adapter-spin" } edgezero-cli = { path = "../../crates/edgezero-cli" } edgezero-core = { path = "../../crates/edgezero-core" } -spin-sdk = { version = "6", default-features = false } -fastly = "0.12" +spin-sdk = { version = "=6.0.0", default-features = false } +fastly = "=0.12.1" futures = { version = "0.3", default-features = false, features = ["std", "executor"] } log = "0.4" once_cell = "1" @@ -45,7 +45,7 @@ simple_logger = "4" tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" -worker = { version = "0.8", default-features = false, features = ["http"] } +worker = { version = "=0.8.3", default-features = false, features = ["http"] } [profile.release] debug = 1 diff --git a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs index e7b60869..df165c89 100644 --- a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs +++ b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs @@ -12,6 +12,8 @@ use app_demo_core::config::AppDemoConfig; use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; +use edgezero_core::manifest::ManifestLoader; +use edgezero_core::Capability; use std::fs; use std::path::{Path, PathBuf}; @@ -120,6 +122,35 @@ fn push_args(manifest: &Path, adapter: &str, dry_run: bool) -> ConfigPushArgs { args } +#[test] +fn app_demo_manifest_declares_outbound_http_optional() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let manifest = ManifestLoader::from_path(&root.join("edgezero.toml")) + .expect("parse shipped app-demo manifest"); + assert_eq!( + manifest.manifest().capabilities.optional, + [Capability::OutboundHttp] + ); + let hosts = manifest + .manifest() + .capabilities + .outbound + .hosts + .as_deref() + .expect("app-demo outbound hosts"); + assert_eq!(hosts, ["https://*:*"]); + + let spin = fs::read_to_string(root.join("crates/app-demo-adapter-spin/spin.toml")) + .expect("read shipped Spin manifest"); + assert_eq!( + spin.matches("allowed_outbound_hosts = [\"https://*:*\"]") + .count(), + 1, + "app-demo Spin manifest must grant exactly the canonical HTTPS wildcard" + ); + assert!(!spin.contains("allowed_outbound_hosts = [\"http://")); +} + #[test] fn config_validate_strict_passes_against_app_demo_config() { // Typed validator runs the raw checks (manifest schema, store diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index ae112669..d48e08b4 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -1,5 +1,8 @@ use std::env; +use std::io::Error as IoError; +use std::num::NonZeroU64; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use edgezero_core::action; @@ -9,27 +12,66 @@ use edgezero_core::error::EdgeError; use edgezero_core::extractor::{ AppConfig, Headers, Json, Kv, Path, Query, Secrets, State, ValidatedPath, }; -use edgezero_core::http::{self, Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{self, Method, Response, StatusCode, Uri}; +use edgezero_core::outbound::{OutboundRequest, OutboundSlotResult}; use edgezero_core::response::Text; +use edgezero_core::time::Deadline; use futures::{stream, StreamExt as _}; -use crate::config::AppDemoConfig; +use crate::{config::AppDemoConfig, AdmissionLease}; const ALLOWED_CONFIG_KEYS: &[&str] = &["greeting", "feature.new_checkout", "service.timeout_ms"]; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const MAX_BROTLI_DECODER_BYTES: u64 = 0x0200_0000; +const MAX_BROTLI_WINDOW_BITS: u8 = 24; /// Maximum request body size (25 MB, matches KV value limit). const MAX_BODY_SIZE: usize = 25 * 1024 * 1024; +const MAX_DECODED_RESPONSE_BYTES: u64 = 0x0010_0000; +const MAX_ENCODED_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_FANOUT_INPUT_BYTES: usize = 0x4000; +const MAX_FANOUT_REQUESTS: usize = 8; +const MAX_FINAL_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_OUTBOUND_REQUEST_BODY_BYTES: u64 = 0x0010_0000; +const MAX_RESPONSE_CHUNK_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_COUNT: u64 = 100; // 512 (KV key limit) - 5 (len of "note:") = 507 const MAX_NOTE_ID_LEN: u64 = 507; +const OUTBOUND_BATCH_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const SMOKE_SECRET_MISSING_NAME: &str = "SMOKE_SECRET_MISSING"; const SMOKE_SECRET_NAME: &str = "SMOKE_SECRET"; +#[derive(serde::Serialize)] +struct AdmissionView { + grant_consumed_once: bool, + route_class: Option, +} + #[derive(serde::Deserialize)] struct ConfigParams { name: String, } +#[derive(serde::Deserialize)] +struct FanoutRequest { + paths: Vec, +} + +#[derive(serde::Serialize)] +struct FanoutSlot { + elapsed_ms: u128, + index: usize, + outcome: FanoutSlotOutcome, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum FanoutSlotOutcome { + Error { category: &'static str, status: u16 }, + Response { status: u16 }, +} + #[derive(serde::Deserialize)] pub struct EchoBody { pub name: String, @@ -93,22 +135,122 @@ pub async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } +#[action] +pub async fn admission(RequestContext(ctx): RequestContext) -> Result { + let route_class = ctx + .route_metadata() + .and_then(|metadata| metadata.class()) + .map(str::to_owned); + let grant = ctx + .take_ingress_grant() + .ok_or_else(|| EdgeError::internal(IoError::other("admission grant was not installed")))?; + let lease = grant + .downcast::() + .map_err(|_grant| EdgeError::internal(IoError::other("admission grant type mismatch")))?; + if lease.route_class != route_class { + return Err(EdgeError::internal(IoError::other( + "admission grant route class mismatch", + ))); + } + + json_response(&AdmissionView { + grant_consumed_once: ctx.take_ingress_grant().is_none(), + route_class, + }) +} + +#[action] +pub async fn fanout(RequestContext(ctx): RequestContext) -> Result { + let Some(client) = ctx.http_client() else { + return proxy_not_available_response(); + }; + let input: FanoutRequest = ctx.json_within(MAX_FANOUT_INPUT_BYTES).await?; + if input.paths.len() > MAX_FANOUT_REQUESTS { + return Err(EdgeError::validation(format!( + "fanout accepts at most {MAX_FANOUT_REQUESTS} paths" + ))); + } + + let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); + let now = ctx.monotonic_clock().now(); + let deadline = Deadline::at_instant(now.checked_add(OUTBOUND_BATCH_BUDGET).unwrap_or(now)); + let source_uri = Uri::from_static("/fanout"); + let requests = input + .paths + .into_iter() + .map(|path| { + let target = build_proxy_target(&base, &path, &source_uri)?; + Ok(outbound_policy(OutboundRequest::new(Method::GET, target)?).deadline(deadline)) + }) + .collect::, EdgeError>>()?; + let slots = client.send_all(requests).await; + let output = slots + .into_iter() + .enumerate() + .map(|(index, slot)| fanout_slot(index, slot)) + .collect::>(); + + json_response(&output) +} + #[action] pub async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { let params: ProxyPath = ctx.path()?; - let proxy_handle = ctx.proxy_handle(); - let request = ctx.into_request(); + let http_client = ctx.http_client(); + let request = ctx.into_request()?; let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); let target = build_proxy_target(&base, ¶ms.rest, request.uri())?; - let proxy_request = ProxyRequest::from_request(request, target); + let outbound_request = outbound_policy(OutboundRequest::from_request(request, target)?); - if let Some(handle) = proxy_handle { - handle.forward(proxy_request).await + if let Some(client) = http_client { + client.send(outbound_request).await?.into_response() } else { proxy_not_available_response() } } +fn error_category(status: StatusCode) -> &'static str { + match status { + StatusCode::BAD_REQUEST => "bad_request", + StatusCode::BAD_GATEWAY => "bad_gateway", + StatusCode::GATEWAY_TIMEOUT => "gateway_timeout", + StatusCode::REQUEST_TIMEOUT => "request_timeout", + StatusCode::UNPROCESSABLE_ENTITY => "validation", + _ => "error", + } +} + +fn fanout_slot(index: usize, slot: OutboundSlotResult) -> FanoutSlot { + let outcome = match slot.outcome { + Ok(response) => FanoutSlotOutcome::Response { + status: response.status().as_u16(), + }, + Err(error) => FanoutSlotOutcome::Error { + category: error_category(error.status()), + status: error.status().as_u16(), + }, + }; + FanoutSlot { + elapsed_ms: slot.elapsed.as_millis(), + index, + outcome, + } +} + +fn outbound_policy(request: OutboundRequest) -> OutboundRequest { + request + .max_brotli_decoder_bytes(MAX_BROTLI_DECODER_BYTES) + .max_brotli_window_bits(MAX_BROTLI_WINDOW_BITS) + .max_decoded_response_bytes(MAX_DECODED_RESPONSE_BYTES) + .max_encoded_response_bytes(MAX_ENCODED_RESPONSE_BYTES) + .max_request_body_bytes(MAX_OUTBOUND_REQUEST_BODY_BYTES) + .max_response_bytes(MAX_FINAL_RESPONSE_BYTES) + .max_chunk_bytes(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + .max_response_header_bytes(MAX_RESPONSE_HEADER_BYTES) + .max_response_header_count(MAX_RESPONSE_HEADER_COUNT) + .timeout(OUTBOUND_REQUEST_TIMEOUT) +} + fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result { let mut target = base.trim_end_matches('/').to_owned(); let trimmed_rest = rest.trim_start_matches('/'); @@ -130,9 +272,7 @@ fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result Result { - let body = Body::text( - "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", - ); + let body = Body::text("outbound HTTP is not enabled for this adapter build"); http::response_builder() .status(StatusCode::NOT_IMPLEMENTED) .header("content-type", "text/plain; charset=utf-8") @@ -140,6 +280,18 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +fn json_response(value: &T) -> Result +where + T: serde::Serialize, +{ + let body = Body::json(value).map_err(EdgeError::internal)?; + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + fn text_response(status: StatusCode, message: impl Into) -> Result { http::response_builder() .status(status) @@ -224,7 +376,7 @@ pub async fn kv_note_put( let store = kv .named("cache") .ok_or_else(|| EdgeError::service_unavailable("KV store `cache` is not registered"))?; - let body = ctx.into_request().into_body(); + let body = ctx.into_request()?.into_body(); let body_bytes = body.into_bytes_bounded(MAX_BODY_SIZE).await?; store .put_bytes(&format!("note:{}", path.id), body_bytes) @@ -319,6 +471,11 @@ pub async fn state_demo( #[cfg(test)] mod tests { + #![expect( + clippy::missing_trait_methods, + reason = "legacy config fixtures intentionally exercise the bounded-read compatibility default" + )] + use super::*; use async_trait::async_trait; use edgezero_core::blob_envelope::BlobEnvelope; @@ -326,15 +483,19 @@ mod tests { use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; - use edgezero_core::http::{request_builder, Method, StatusCode, Uri}; + use edgezero_core::http::{request_builder, HeaderMap, Method, StatusCode, Uri}; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::outbound::{ + HttpClient, OutboundHttpClient, OutboundRequestParts, OutboundResponse, OutboundSlotResult, + ResponseMode, + }; use edgezero_core::params::PathParams; - use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use edgezero_core::store_registry::{ ConfigRegistry, ConfigStoreBinding, KvRegistry, StoreRegistry, }; + use edgezero_core::BudgetSource; use futures::executor::block_on; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -346,7 +507,7 @@ mod tests { data: Mutex>, } - struct TestProxyClient; + struct TestOutboundClient; struct UnavailableConfigStore; @@ -420,11 +581,37 @@ mod tests { } #[async_trait(?Send)] - impl ProxyClient for TestProxyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, uri, _headers, _body, _) = request.into_parts(); - assert!(uri.to_string().contains("status/201")); - Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + impl OutboundHttpClient for TestOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + let parts = request.into_parts(); + assert_eq!(parts.method, Method::POST); + assert!(parts + .uri + .path_and_query() + .is_some_and(|value| value.as_str() == "/status/201?source=demo")); + assert!(parts.deadline.is_none()); + assert_outbound_policy(&parts); + response_for(parts) + } + + async fn send_all(&self, requests: Vec) -> Vec { + let mut deadline = None; + let mut results = Vec::with_capacity(requests.len()); + for (index, request) in requests.into_iter().enumerate() { + let parts = request.into_parts(); + assert_outbound_policy(&parts); + let slot_deadline = parts.deadline.expect("batch deadline"); + if let Some(expected) = deadline { + assert_eq!(slot_deadline.instant(), expected); + } else { + deadline = Some(slot_deadline.instant()); + } + let elapsed = Duration::from_millis( + u64::try_from(index).expect("slot index").saturating_add(1), + ); + results.push(OutboundSlotResult::new(elapsed, response_for(parts))); + } + results } } @@ -444,6 +631,65 @@ mod tests { } } + fn assert_outbound_policy(parts: &OutboundRequestParts) { + assert_eq!(parts.timeout, Some(OUTBOUND_REQUEST_TIMEOUT)); + assert_eq!( + parts.max_request_body_bytes, + MAX_OUTBOUND_REQUEST_BODY_BYTES + ); + assert_eq!( + parts.max_encoded_response_bytes, + Some(MAX_ENCODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.max_decoded_response_bytes, + Some(MAX_DECODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: MAX_FINAL_RESPONSE_BYTES, + } + ); + assert_eq!( + parts.max_response_header_bytes, + Some(MAX_RESPONSE_HEADER_BYTES) + ); + assert_eq!( + parts.max_response_header_count, + Some(MAX_RESPONSE_HEADER_COUNT) + ); + assert_eq!( + parts.max_chunk_bytes, + Some(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + ); + assert_eq!(parts.max_brotli_window_bits, MAX_BROTLI_WINDOW_BITS); + assert_eq!(parts.max_brotli_decoder_bytes, MAX_BROTLI_DECODER_BYTES); + } + + fn response_for(parts: OutboundRequestParts) -> Result { + if parts.uri.path() == "/fail" { + return Err(EdgeError::gateway_timeout_caused( + "provider URL https://user:token@example.invalid", + BudgetSource::BatchDeadline, + )); + } + + let status = match parts.uri.path() { + "/status/201" => StatusCode::CREATED, + "/status/204" => StatusCode::NO_CONTENT, + _ => StatusCode::OK, + }; + let mut headers = HeaderMap::new(); + headers.insert("x-outbound-test", HeaderValue::from_static("preserved")); + Ok(OutboundResponse::new( + parts.method, + status, + headers, + Body::text("outbound-response"), + )) + } + #[test] fn introspection_routes_are_wired() { let router = crate::build_router(); @@ -905,15 +1151,15 @@ mod tests { } #[test] - fn proxy_demo_uses_injected_handle() { + fn app_demo_outbound_client_preserves_method_and_uri() { let mut request = request_builder() - .method(Method::GET) - .uri("/proxy/status/201") - .body(Body::empty()) + .method(Method::POST) + .uri("/proxy/status/201?source=demo") + .body(Body::text("request-body")) .expect("request"); request .extensions_mut() - .insert(ProxyHandle::with_client(TestProxyClient)); + .insert(HttpClient::with_client(TestOutboundClient)); let mut params = HashMap::new(); params.insert("rest".to_owned(), "status/201".to_owned()); @@ -921,15 +1167,77 @@ mod tests { let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-outbound-test"], "preserved"); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("buffered") + .as_ref(), + b"outbound-response" + ); + } + + #[test] + fn fanout_reports_positional_elapsed_and_typed_outcomes() { + let ctx = fanout_context(r#"{"paths":["status/200","status/204","fail"]}"#); + let response = block_on(fanout(ctx)).expect("fanout response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert!( + !String::from_utf8_lossy(response.body().as_bytes().expect("buffered")) + .contains("token@example.invalid") + ); + + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload[0]["index"], 0_i64); + assert_eq!(payload[0]["elapsed_ms"], 1_i64); + assert_eq!(payload[0]["outcome"]["kind"], "response"); + assert_eq!(payload[0]["outcome"]["status"], 200_i64); + assert_eq!(payload[1]["index"], 1_i64); + assert_eq!(payload[1]["elapsed_ms"], 2_i64); + assert_eq!(payload[1]["outcome"]["status"], 204_i64); + assert_eq!(payload[2]["index"], 2_i64); + assert_eq!(payload[2]["elapsed_ms"], 3_i64); + assert_eq!(payload[2]["outcome"]["kind"], "error"); + assert_eq!(payload[2]["outcome"]["category"], "gateway_timeout"); + assert_eq!(payload[2]["outcome"]["status"], 504_i64); + } + + #[test] + fn fanout_with_empty_input_returns_an_empty_array() { + let ctx = fanout_context(r#"{"paths":[]}"#); + let response = block_on(fanout(ctx)).expect("fanout response"); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload, serde_json::json!([])); } #[test] - fn proxy_demo_without_handle_returns_placeholder() { + fn fanout_rejects_more_than_eight_paths() { + let ctx = fanout_context(r#"{"paths":["1","2","3","4","5","6","7","8","9"]}"#); + let error = block_on(fanout(ctx)).expect_err("oversized fanout must fail"); + assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn proxy_demo_without_client_returns_placeholder() { let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); } + fn fanout_context(json: &str) -> RequestContext { + let mut request = request_builder() + .method(Method::POST) + .uri("/fanout") + .body(Body::from(json)) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(TestOutboundClient)); + RequestContext::new(request, PathParams::default()) + } + #[test] fn root_returns_static_body() { let ctx = empty_context("/"); @@ -983,7 +1291,8 @@ mod tests { .to_vec(), ) .expect("utf8"); - assert!(body.contains("required secret is not configured")); + assert!(body.contains("internal server error")); + assert!(!body.contains("required secret is not configured")); assert!(!body.contains("SMOKE_SECRET_MISSING")); } diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index 9735fbaa..92f82dbc 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -9,6 +9,21 @@ pub mod config; pub mod handlers; use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use edgezero_core::app::App as EdgeZeroApp; +use edgezero_core::http::StatusCode; +use edgezero_core::{AdmissionDecision, BufferedIngressResponse, IngressGrant, RouteResolution}; + +const DEFAULT_INGRESS_READ_BUDGET: Duration = Duration::from_secs(30); +const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; +const FALLBACK_INGRESS_READ_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_INGRESS_READ_BUDGET: Duration = Duration::from_secs(10); + +#[derive(Debug, Eq, PartialEq)] +struct AdmissionLease { + route_class: Option, +} /// App-owned shared state for the `app!(..., state = ...)` demonstration, /// handed to handlers via `State>`. @@ -18,6 +33,39 @@ pub struct DemoState { pub greeting: String, } +/// Installs request-lifecycle policy before any adapter begins polling a body. +fn configure_app(app: &mut EdgeZeroApp) { + app.set_ingress_admission_policy(|head| match head.route_resolution().clone() { + RouteResolution::Matched(metadata) => { + let route_class = metadata.class().map(str::to_owned); + let read_budget = if route_class.as_deref() == Some("outbound") { + OUTBOUND_INGRESS_READ_BUDGET + } else { + DEFAULT_INGRESS_READ_BUDGET + }; + AdmissionDecision::Admit { + grant: IngressGrant::new(AdmissionLease { route_class }), + read_deadline: head.read_deadline_after(read_budget), + } + } + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound | _ => { + AdmissionDecision::ReadBodyBeforeFallback { + grant: IngressGrant::new(AdmissionLease { route_class: None }), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + read_deadline: head.read_deadline_after(FALLBACK_INGRESS_READ_BUDGET), + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + } + } + }); +} + /// Returns the shared app state, referenced by `app!(..., state = crate::app_state())`. /// /// IMPORTANT: `app!(state = )` emits this call inside the macro-generated @@ -38,4 +86,191 @@ pub fn app_state() -> Arc { })) } -edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); +edgezero_core::app!( + "../../edgezero.toml", + configure = crate::configure_app, + state = crate::app_state() +); + +#[cfg(test)] +mod lifecycle_tests { + use bytes::Bytes; + use edgezero_core::app::{App as EdgeZeroApp, Hooks as _}; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, HeaderMap, Method, Response, StatusCode, Version}; + use edgezero_core::ingress::{IngressBeginOutcome, IngressHeadParts}; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + use futures::stream::iter; + use std::time::Duration; + + #[test] + fn manifest_route_classes_reach_route_resolution() { + let resolved = crate::build_router().resolve(&Method::GET, "/proxy/status/200"); + let RouteResolution::Matched(metadata) = resolved.resolution().clone() else { + panic!("proxy route must resolve"); + }; + assert_eq!(metadata.class(), Some("outbound")); + } + + #[test] + fn configured_admission_uses_finite_class_aware_deadlines() { + let app = super::App::build_app(); + let start = MonotonicInstant::now(); + + let outbound = begin_ingress(&app, "/proxy/status/200", start); + assert_eq!( + outbound.read_deadline().instant(), + start + .checked_add(Duration::from_secs(10)) + .expect("deadline") + ); + + let health = begin_ingress(&app, "/", start); + assert_eq!( + health.read_deadline().instant(), + start + .checked_add(Duration::from_secs(30)) + .expect("deadline") + ); + + let fallback = begin_ingress(&app, "/missing", start); + assert_eq!( + fallback.read_deadline().instant(), + start.checked_add(Duration::from_secs(5)).expect("deadline") + ); + } + + #[test] + fn admission_handler_consumes_the_typed_grant_once() { + let app = super::App::build_app(); + let start = MonotonicInstant::now(); + let prepared = begin_ingress(&app, "/admission", start); + let request = request_builder() + .method(Method::GET) + .uri("/admission") + .body(Body::empty()) + .expect("request"); + + let response = block_on(app.dispatch_admitted(prepared, request)) + .expect("dispatch") + .into_response(); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload["route_class"], "diagnostic"); + assert_eq!(payload["grant_consumed_once"], true); + } + + #[test] + fn configured_admission_preserves_fallback_status_at_exact_cap() { + let app = super::App::build_app(); + for (method, path, expected) in [ + (Method::POST, "/missing", StatusCode::NOT_FOUND), + (Method::POST, "/", StatusCode::METHOD_NOT_ALLOWED), + ] { + let request = request_builder() + .method(method) + .uri(path) + .body(Body::stream(iter([Bytes::from(vec![b'a'; 4_096])]))) + .expect("request"); + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"); + assert_eq!(response.status(), expected); + } + } + + #[test] + fn configured_admission_returns_exact_overflow_response_for_both_fallbacks() { + let app = super::App::build_app(); + for (method, path) in [(Method::POST, "/missing"), (Method::POST, "/")] { + let request = request_builder() + .method(method) + .uri(path) + .body(Body::stream(iter([ + Bytes::from(vec![b'a'; 4_096]), + Bytes::from_static(b"b"), + ]))) + .expect("request"); + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"); + + assert_plain_text_response( + &response, + StatusCode::BAD_REQUEST, + b"request body too large\n", + ); + } + } + + #[test] + fn configured_admission_returns_exact_timeout_response() { + let app = super::App::build_app(); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::from_stream(iter([Err::( + EdgeError::request_timeout("adapter read deadline exceeded"), + )]))) + .expect("request"); + let response = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"); + + assert_plain_text_response(&response, StatusCode::REQUEST_TIMEOUT, b"request timeout\n"); + } + + fn assert_plain_text_response(response: &Response, status: StatusCode, body: &[u8]) { + let expected_length = body.len().to_string(); + assert_eq!(response.status(), status); + assert_eq!( + response + .headers() + .get("content-type") + .expect("content type"), + "text/plain; charset=utf-8" + ); + assert_eq!( + response + .headers() + .get("content-length") + .expect("content length") + .to_str() + .expect("ASCII content length"), + expected_length + ); + assert_eq!(response.body().as_bytes().expect("buffered body"), body); + } + + fn begin_ingress( + app: &EdgeZeroApp, + path: &str, + start: MonotonicInstant, + ) -> edgezero_core::PreparedIngress { + let head = IngressHeadParts::new( + Method::GET, + path.parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + match app.begin_ingress(head, start).expect("begin ingress") { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(_) => panic!("demo policy must admit request"), + _ => panic!("unknown admission outcome"), + } + } +} diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 123e5903..a661f236 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -7,12 +7,19 @@ version = "0.1.0" kind = "http" entry = "crates/app-demo-core" +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://*:*"] + [[triggers.http]] id = "root" path = "/" methods = ["GET"] handler = "app_demo_core::handlers::root" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "health" description = "Default health-check endpoint" [[triggers.http]] @@ -52,6 +59,14 @@ methods = ["POST"] handler = "app_demo_core::handlers::echo_json" adapters = ["axum", "cloudflare", "fastly", "spin"] +[[triggers.http]] +id = "admission" +path = "/admission" +methods = ["GET"] +handler = "app_demo_core::handlers::admission" +adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" +description = "Confirms route-aware admission and one-time grant consumption" [[triggers.http]] id = "proxy_demo" @@ -59,6 +74,16 @@ path = "/proxy/{*rest}" methods = ["GET", "POST"] handler = "app_demo_core::handlers::proxy_demo" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "outbound" + +[[triggers.http]] +id = "fanout" +path = "/fanout" +methods = ["POST"] +handler = "app_demo_core::handlers::fanout" +adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "outbound" +description = "Concurrent positional outbound batch with per-slot elapsed time" [[triggers.http]] id = "config_get" @@ -117,6 +142,7 @@ path = "/_app-demo/manifest" methods = ["GET"] handler = "edgezero_core::introspection::manifest" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "App manifest as JSON" [[triggers.http]] @@ -125,6 +151,7 @@ path = "/_app-demo/config" methods = ["GET"] handler = "edgezero_core::introspection::config" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "Effective app config (secret-safe)" [[triggers.http]] @@ -133,6 +160,7 @@ path = "/_app-demo/routes" methods = ["GET"] handler = "edgezero_core::introspection::routes" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "Registered route table" # -- Secrets demo route -------------------------------------------------------- diff --git a/scripts/check_adapter_feature_matrix.sh b/scripts/check_adapter_feature_matrix.sh new file mode 100644 index 00000000..87d78a84 --- /dev/null +++ b/scripts/check_adapter_feature_matrix.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +adapter="$1" +target="$2" + +case "${adapter}:${target}" in + axum:native | cloudflare:native | fastly:native | spin:native) ;; + cloudflare:wasm32-unknown-unknown | fastly:wasm32-wasip1 | spin:wasm32-wasip2) ;; + *) + echo "unsupported adapter/target pair: ${adapter}:${target}" >&2 + exit 2 + ;; +esac + +feature_sets=( + "" + "${adapter}" + "cli" + "test-utils" + "${adapter},cli" + "${adapter},test-utils" + "cli,test-utils" + "${adapter},cli,test-utils" +) + +for features in "${feature_sets[@]}"; do + args=( + cargo check + --offline + --locked + --package "edgezero-adapter-${adapter}" + --no-default-features + --all-targets + ) + if [[ "${target}" != "native" ]]; then + args+=(--target "${target}") + fi + label="none" + if [[ -n "${features}" ]]; then + args+=(--features "${features}") + label="${features}" + fi + + echo "==> edgezero-adapter-${adapter} ${target} features=[${label}]" + "${args[@]}" +done diff --git a/scripts/check_no_legacy_typed_reads.sh b/scripts/check_no_legacy_typed_reads.sh index 2bf4407e..d2a5fd06 100755 --- a/scripts/check_no_legacy_typed_reads.sh +++ b/scripts/check_no_legacy_typed_reads.sh @@ -69,6 +69,21 @@ done < <( 2>/dev/null || true ) +# ------------------------------------------------------------------ +# Pattern 4: superseded serde extraction constructor +# ------------------------------------------------------------------ +while IFS= read -r hit; do + printf '%s: violation: superseded config serde constructor — use store_deserialization_from_serde\n' "${hit}" + VIOLATIONS=$((VIOLATIONS + 1)) +done < <( + grep -rn --include="*.rs" \ + --exclude-dir=target \ + 'config_out_of_date_from_serde' \ + "${REPO_ROOT}/crates" \ + "${REPO_ROOT}/examples" \ + 2>/dev/null || true +) + if [ "${VIOLATIONS}" -gt 0 ]; then printf '\n%d violation(s) found. Migrate to the AppConfig blob-model extractor.\n' "${VIOLATIONS}" >&2 exit 1 diff --git a/scripts/check_outbound_docs_contract.mjs b/scripts/check_outbound_docs_contract.mjs new file mode 100644 index 00000000..47186724 --- /dev/null +++ b/scripts/check_outbound_docs_contract.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' + +const capabilityPath = 'docs/guide/capabilities.md' +const sidebarPath = 'docs/.vitepress/config.mts' +const expectedHeader = [ + 'Capability', + 'Axum', + 'Cloudflare', + 'Fastly', + 'Spin', +] +const expectedIngressRows = [ + ['ingress-admission', 'Native', 'Native', 'Native', 'Native'], + [ + 'inbound-read-deadlines', + 'Native', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'raw-ingress-head-limits', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'raw-ingress-framing-validation', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], +] +const expectedOutboundRows = [ + ['outbound-http', 'Native', 'Native', 'BestEffort', 'Native'], + [ + 'outbound-complete-resource-accounting', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + ['outbound-header-fidelity', 'Native', 'BestEffort', 'Native', 'Native'], + ['outbound-deadlines', 'Native', 'Native', 'BestEffort', 'BestEffort'], + [ + 'outbound-flexible-phase-budget', + 'Native', + 'Native', + 'BestEffort', + 'BestEffort', + ], + ['send-all-slot-isolation', 'Native', 'Native', 'BestEffort', 'Native'], + [ + 'streamed-upload-deadlines', + 'Native', + 'Native', + 'BestEffort', + 'BestEffort', + ], + [ + 'lazy-streamed-response-passthrough', + 'BestEffort', + 'Native', + 'BestEffort', + 'BestEffort', + ], +] +const expectedResponseEgressRows = [ + [ + 'response-egress-abort', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'response-egress-backpressure', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'response-egress-completion', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'response-write-deadlines', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], +] +const expectedLimitHeader = ['Control', 'Scope', 'Default'] +const expectedLimitRows = [ + [ + 'max_request_body_bytes', + 'Buffered or streamed request bytes', + '8 MiB', + ], + [ + 'max_encoded_response_bytes', + 'Upstream transport bytes before decoding', + 'Unset', + ], + [ + 'max_decoded_response_bytes', + 'Identity or EdgeZero-decoded gzip/Brotli output', + 'Unset', + ], + [ + 'max_response_bytes', + 'Final buffered response, including raw passthrough', + '1 MiB', + ], + [ + 'max_response_header_bytes', + 'Cumulative guest-visible header name/value bytes', + 'Unset', + ], + [ + 'max_response_header_count', + 'Cumulative guest-visible header fields', + 'Unset', + ], + [ + 'max_brotli_window_bits', + 'Brotli stream header checked before decoder allocation', + '24', + ], + [ + 'max_brotli_decoder_bytes', + 'Pinned policy charge for Brotli decoder state', + '32 MiB', + ], + [ + 'max_chunk_bytes', + 'Maximum emitted item size after decoding or passthrough', + 'Unset', + ], +] + +function fail(message) { + process.stderr.write(`outbound docs contract: ${message}\n`) + process.exit(1) +} + +function cells(line) { + const trimmed = line.trim() + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null + return trimmed + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()) +} + +function normalizeCapability(value) { + return value.startsWith('`') && value.endsWith('`') + ? value.slice(1, -1) + : value +} + +function normalizeSupport(value) { + return value + .replace(/\[\^[^\]]+\]$/u, '') + .replace(/[¹²³⁴⁵⁶⁷⁸⁹]+$/u, '') + .trim() +} + +let capabilitySource +try { + capabilitySource = readFileSync(capabilityPath, 'utf8') +} catch (error) { + fail(`cannot read ${capabilityPath}: ${error.message}`) +} + +const lines = capabilitySource.split(/\r?\n/u) + +function findMatrixHeader(sectionTitle, name) { + const headingIndex = lines.findIndex( + (line) => line.trim() === `## ${sectionTitle}`, + ) + if (headingIndex === -1) { + fail(`${name} capability section is missing`) + } + const nextHeadingIndex = lines.findIndex( + (line, index) => index > headingIndex && line.startsWith('## '), + ) + const sectionEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex + const headerIndex = lines.findIndex( + (line, index) => + index > headingIndex && + index < sectionEnd && + JSON.stringify(cells(line)) === JSON.stringify(expectedHeader), + ) + if (headerIndex === -1) { + fail(`${name} capability matrix is missing`) + } + return headerIndex +} + +function readMatrix(headerIndex, name) { + const separator = cells(lines[headerIndex + 1] ?? '') + if ( + separator === null || + separator.length !== expectedHeader.length || + !separator.every((value) => /^:?-{3,}:?$/u.test(value)) + ) { + fail(`${name} capability matrix is missing its five-column separator row`) + } + + const actualRows = [] + for (let index = headerIndex + 2; index < lines.length; index += 1) { + const row = cells(lines[index]) + if (row === null) break + if (row.length !== expectedHeader.length) { + fail(`${name} capability matrix row ${index + 1} has ${row.length} cells, expected 5`) + } + actualRows.push([ + normalizeCapability(row[0]), + ...row.slice(1).map(normalizeSupport), + ]) + } + return actualRows +} + +const matrices = [ + [ + 'ingress', + expectedIngressRows, + readMatrix(findMatrixHeader('Ingress Matrix', 'ingress'), 'ingress'), + ], + [ + 'response egress', + expectedResponseEgressRows, + readMatrix( + findMatrixHeader('Response Egress Matrix', 'response egress'), + 'response egress', + ), + ], + [ + 'outbound', + expectedOutboundRows, + readMatrix(findMatrixHeader('Outbound Matrix', 'outbound'), 'outbound'), + ], +] +for (const [name, expectedRows, actualRows] of matrices) { + if (JSON.stringify(actualRows) !== JSON.stringify(expectedRows)) { + fail( + `${name} capability matrix mismatch\nexpected=${JSON.stringify(expectedRows)}\nactual=${JSON.stringify(actualRows)}`, + ) + } +} + +const limitsHeadingIndex = lines.findIndex( + (line) => line.trim() === '## Limits And Accounting', +) +if (limitsHeadingIndex === -1) { + fail('limits and accounting section is missing') +} +const limitsSectionEnd = lines.findIndex( + (line, index) => index > limitsHeadingIndex && line.startsWith('## '), +) +const limitsHeaderIndex = lines.findIndex( + (line, index) => + index > limitsHeadingIndex && + (limitsSectionEnd === -1 || index < limitsSectionEnd) && + JSON.stringify(cells(line)) === JSON.stringify(expectedLimitHeader), +) +if (limitsHeaderIndex === -1) { + fail('outbound limits table is missing') +} +const limitsSeparator = cells(lines[limitsHeaderIndex + 1] ?? '') +if ( + limitsSeparator === null || + limitsSeparator.length !== expectedLimitHeader.length || + !limitsSeparator.every((value) => /^:?-{3,}:?$/u.test(value)) +) { + fail('outbound limits table is missing its three-column separator row') +} +const actualLimitRows = [] +for (let index = limitsHeaderIndex + 2; index < lines.length; index += 1) { + const row = cells(lines[index]) + if (row === null) break + if (row.length !== expectedLimitHeader.length) { + fail( + `outbound limits row ${index + 1} has ${row.length} cells, expected 3`, + ) + } + actualLimitRows.push([normalizeCapability(row[0]), row[1], row[2]]) +} +if (JSON.stringify(actualLimitRows) !== JSON.stringify(expectedLimitRows)) { + fail( + `outbound limits mismatch\nexpected=${JSON.stringify(expectedLimitRows)}\nactual=${JSON.stringify(actualLimitRows)}`, + ) +} + +const sidebarSource = readFileSync(sidebarPath, 'utf8') +const sidebarLinks = sidebarSource.match(/link:\s*['"]\/guide\/capabilities['"]/gu) ?? [] +if (sidebarLinks.length !== 1) { + fail( + `expected exactly one /guide/capabilities sidebar link, found ${sidebarLinks.length}`, + ) +} diff --git a/scripts/check_outbound_legacy_api.sh b/scripts/check_outbound_legacy_api.sh new file mode 100755 index 00000000..a0c425df --- /dev/null +++ b/scripts/check_outbound_legacy_api.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -uo pipefail + +if matches="$(git grep -nE \ + 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ + -- crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ + .claude/agents/code-architect.md)"; then + printf '%s\n' "$matches" + exit 1 +else + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + exit "$status" +fi diff --git a/scripts/run_test_nonzero.sh b/scripts/run_test_nonzero.sh new file mode 100755 index 00000000..bff1bd08 --- /dev/null +++ b/scripts/run_test_nonzero.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +set -eu + +ignored= +if [ "${1:-}" = "--ignored" ]; then + ignored=--ignored + shift +fi + +if [ "$#" -lt 2 ]; then + echo "usage: $0 [--ignored] " >&2 + exit 2 +fi + +sentinel=$1 +shift +listing=$(mktemp "${TMPDIR:-/tmp}/edgezero-tests.XXXXXX") +trap 'rm -f "$listing"' EXIT HUP INT TERM + +if [ -n "$ignored" ]; then + "$@" -- --ignored --list >"$listing" +else + "$@" -- --list >"$listing" +fi + +awk -v sentinel="$sentinel" ' + /: test$/ { + count += 1 + name = $0 + sub(/: test$/, "", name) + if (name == sentinel || name ~ ("::" sentinel "$")) { + found = 1 + } + } + END { + if (count == 0) { + print "selected test suite contains zero tests" > "/dev/stderr" + exit 1 + } + if (!found) { + print "required sentinel test not listed: " sentinel > "/dev/stderr" + exit 1 + } + } +' "$listing" + +if [ -n "$ignored" ]; then + "$@" -- --ignored +else + "$@" +fi diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 9d957fbc..4ad6d1f7 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -14,7 +14,7 @@ command -v rustup >/dev/null 2>&1 || { exit 1 } -for target in wasm32-wasip1 wasm32-wasip2; do +for target in wasm32-unknown-unknown wasm32-wasip1 wasm32-wasip2; do if ! rustup target list --installed | grep -Fxq "$target"; then echo "$target target is not installed. Run 'rustup target add $target' before re-running this script." >&2 exit 1 @@ -35,9 +35,30 @@ section() { section "Workspace Tests" run cargo test --workspace --all-targets +section "Outbound Contract Tests" +run scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract +run scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract +run scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract +run scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract + +section "Outbound Capability Tests" +for adapter in axum cloudflare fastly spin; do + run scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p "edgezero-adapter-${adapter}" --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts +done + section "Workspace Feature Compilation" run cargo check --workspace --all-targets --features "fastly cloudflare spin" +section "Adapter Native Feature Matrices" +for adapter in axum cloudflare fastly spin; do + run bash scripts/check_adapter_feature_matrix.sh "${adapter}" native +done + +section "Adapter Wasm Feature Matrices" +run bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown +run bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1 +run bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2 + section "Fastly CLI Tests" run cargo test -p edgezero-adapter-fastly --no-default-features --features cli @@ -53,6 +74,9 @@ section "Fastly Wasm Tests" section "Spin Wasm Compile Check" run cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 +section "Generated Project" +run scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds + # `examples/app-demo` is excluded from the root workspace # (per `exclude = ["examples/app-demo"]`), so the workspace # test above doesn't cover it. Stage 8.6 wired this gate into @@ -60,7 +84,14 @@ run cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 section "app-demo Workspace Tests" ( cd examples/app-demo - run cargo test --workspace --all-targets + run cargo test --locked --workspace --all-targets + run cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare + run cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly + run cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin ) +section "Outbound Documentation Contracts" +run bash scripts/check_outbound_legacy_api.sh +run node scripts/check_outbound_docs_contract.mjs + echo "All tests completed successfully."