From 499d5c93d597f01b5e0497e332af82f2aa633277 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 5 Oct 2026 21:11:03 +0530 Subject: [PATCH 01/11] Add pre-dispatch hooks and explicit request ingress fidelity --- .github/workflows/test.yml | 73 +- .../edgezero-adapter-axum/src/dev_server.rs | 161 +- crates/edgezero-adapter-axum/src/request.rs | 214 +- crates/edgezero-adapter-cloudflare/Cargo.toml | 1 + .../src/request.rs | 76 +- .../tests/contract.rs | 185 +- crates/edgezero-adapter-fastly/src/lib.rs | 3 +- crates/edgezero-adapter-fastly/src/request.rs | 370 ++- crates/edgezero-adapter-spin/src/context.rs | 77 + crates/edgezero-adapter-spin/src/request.rs | 4 +- crates/edgezero-core/src/lib.rs | 1 + crates/edgezero-core/src/request.rs | 485 ++++ crates/edgezero-core/src/router.rs | 346 +++ docs/guide/adapters/axum.md | 15 + docs/guide/adapters/cloudflare.md | 17 + docs/guide/adapters/fastly.md | 30 + docs/guide/adapters/overview.md | 63 +- docs/guide/adapters/spin.md | 25 +- docs/guide/middleware.md | 8 + docs/guide/routing.md | 53 + ...026-10-05-pre-dispatch-request-fidelity.md | 390 +++ ...05-pre-dispatch-request-fidelity-design.md | 246 ++ scripts/test-request-fidelity.sh | 30 + .../request-fidelity-runner/Cargo.lock | 346 +++ .../request-fidelity-runner/Cargo.toml | 16 + .../request-fidelity-runner/src/main.rs | 1175 +++++++++ tests/fixtures/request-fidelity/.gitignore | 1 + tests/fixtures/request-fidelity/Cargo.lock | 2097 +++++++++++++++++ tests/fixtures/request-fidelity/Cargo.toml | 37 + tests/fixtures/request-fidelity/README.md | 52 + tests/fixtures/request-fidelity/fastly.toml | 4 + .../request-fidelity/observations.json | 1425 +++++++++++ .../request-fidelity/package-lock.json | 1503 ++++++++++++ tests/fixtures/request-fidelity/package.json | 7 + tests/fixtures/request-fidelity/spin.toml | 10 + .../request-fidelity/src/bin/fastly.rs | 34 + .../request-fidelity/src/cloudflare.rs | 11 + tests/fixtures/request-fidelity/src/lib.rs | 155 ++ tests/fixtures/request-fidelity/src/spin.rs | 13 + tests/fixtures/request-fidelity/wrangler.toml | 6 + 40 files changed, 9702 insertions(+), 63 deletions(-) create mode 100644 crates/edgezero-core/src/request.rs create mode 100644 docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md create mode 100644 docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md create mode 100755 scripts/test-request-fidelity.sh create mode 100644 tests/fixtures/request-fidelity-runner/Cargo.lock create mode 100644 tests/fixtures/request-fidelity-runner/Cargo.toml create mode 100644 tests/fixtures/request-fidelity-runner/src/main.rs create mode 100644 tests/fixtures/request-fidelity/.gitignore create mode 100644 tests/fixtures/request-fidelity/Cargo.lock create mode 100644 tests/fixtures/request-fidelity/Cargo.toml create mode 100644 tests/fixtures/request-fidelity/README.md create mode 100644 tests/fixtures/request-fidelity/fastly.toml create mode 100644 tests/fixtures/request-fidelity/observations.json create mode 100644 tests/fixtures/request-fidelity/package-lock.json create mode 100644 tests/fixtures/request-fidelity/package.json create mode 100644 tests/fixtures/request-fidelity/spin.toml create mode 100644 tests/fixtures/request-fidelity/src/bin/fastly.rs create mode 100644 tests/fixtures/request-fidelity/src/cloudflare.rs create mode 100644 tests/fixtures/request-fidelity/src/lib.rs create mode 100644 tests/fixtures/request-fidelity/src/spin.rs create mode 100644 tests/fixtures/request-fidelity/wrangler.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e629d17a..4a78806f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Add wasm targets run: rustup target add wasm32-wasip1 wasm32-wasip2 wasm32-unknown-unknown @@ -65,6 +66,12 @@ jobs: - name: Nested AppConfig checker tests run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + - name: Verify native request ingress harness + run: | + cargo fmt --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all -- --check + cargo test --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --locked + cargo clippy --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all-targets --locked -- -D warnings + - name: Run workspace tests run: cargo test --workspace --all-targets @@ -119,7 +126,7 @@ jobs: - 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,http=y steps: - uses: actions/checkout@v6 @@ -146,10 +153,35 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Add wasm target run: rustup target add ${{ matrix.target }} + - name: Set up matching browser and driver + if: matrix.adapter == 'cloudflare' + id: chrome + uses: browser-actions/setup-chrome@v2 + with: + chrome-version: 154.0.8037.92 + install-chromedriver: true + install-dependencies: true + + - name: Set up Node for local Worker tooling + if: matrix.adapter == 'cloudflare' + uses: actions/setup-node@v6 + with: + node-version: 24.12.0 + cache: npm + cache-dependency-path: tests/fixtures/request-fidelity/package-lock.json + + - name: Bind browser tests to pinned Chrome + if: matrix.adapter == 'cloudflare' + env: + CHROME_BINARY: ${{ steps.chrome.outputs.chrome-path }} + run: | + node -e 'require("node:fs").writeFileSync(require("node:path").join(process.env.RUNNER_TEMP, "edgezero-webdriver.json"), JSON.stringify({"goog:chromeOptions": {binary: process.env.CHROME_BINARY}}))' + - name: Resolve wasm-bindgen CLI version if: matrix.adapter == 'cloudflare' id: wasm-bindgen-version @@ -234,6 +266,8 @@ jobs: - name: Run ${{ matrix.adapter }} wasm tests env: + CHROMEDRIVER: ${{ steps.chrome.outputs.chromedriver-path }} + WASM_BINDGEN_TEST_WEBDRIVER_JSON: ${{ runner.temp }}/edgezero-webdriver.json ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract @@ -247,5 +281,42 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + # Spin's metadata helper runs on the host; its full lib test artifact + # imports Spin-specific interfaces unavailable in plain Wasmtime. + # The real Spin HTTP fixture below executes the converter instead. + - name: Install lock-matched worker-build + if: matrix.adapter == 'cloudflare' + run: cargo install worker-build --version 0.8.3 --locked --force + + - name: Install pinned Spin HTTP runtime + if: matrix.adapter == 'spin' + shell: bash + run: | + version="$(awk '$1 == "spin-cli" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + install_dir="$RUNNER_TEMP/spin" + mkdir -p "$install_dir" + curl -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" + tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir" + echo "$install_dir" >> "$GITHUB_PATH" + "$install_dir/spin" --version + + - name: Check isolated fixture formatting + run: cargo fmt --manifest-path tests/fixtures/request-fidelity/Cargo.toml --all -- --check + + - name: Lint isolated ingress fixture + run: >- + cargo clippy --manifest-path tests/fixtures/request-fidelity/Cargo.toml + --features ${{ matrix.adapter }} --target ${{ matrix.target }} + --all-targets --locked -- -D warnings + + - name: Run local HTTP ingress probes + shell: bash + run: | + if [ "${{ matrix.adapter }}" = "cloudflare" ]; then + WASM_BINDGEN_BIN="$(command -v wasm-bindgen)" + export WASM_BINDGEN_BIN + fi + ./scripts/test-request-fidelity.sh ${{ matrix.adapter }} + - name: Check ${{ matrix.adapter }} wasm target run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1a5405c0..6207bec0 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -6,8 +6,11 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::sync::Arc; +use crate::request::HttpTransport; use anyhow::Context as _; use axum::Router; +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; @@ -299,10 +302,12 @@ async fn serve_with_stores( } service }; - let axum_router = Router::new().fallback_service(service_fn(move |req| { - let mut svc = service.clone(); - async move { svc.call(req).await } - })); + let axum_router = + Router::new().fallback_service(service_fn(move |mut req: AxumRequest| { + req.extensions_mut().insert(HttpTransport); + let mut svc = service.clone(); + async move { svc.call(req).await } + })); let make_service = axum_router.into_make_service_with_connect_info::(); let shutdown = enable_ctrl_c.then_some(async { @@ -745,6 +750,23 @@ mod integration_tests { use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::sleep; + use async_trait::async_trait; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderValue, Method, Request, Response, response_builder}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::request::{CapturedTarget, RequestIngress}; + use edgezero_core::router::PreDispatchHook; + use std::io::{Read as _, Write as _}; + use std::net::TcpStream; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct IngressHook { + calls: Arc, + } + struct IngressMiddleware { + calls: Arc, + } + struct TestServer { _temp_dir: tempfile::TempDir, base_url: String, @@ -756,6 +778,137 @@ mod integration_tests { handle: JoinHandle<()>, } + #[async_trait(?Send)] + impl Middleware for IngressMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + #[async_trait(?Send)] + impl PreDispatchHook for IngressHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + self.calls.fetch_add(1, Ordering::SeqCst); + let ingress = request + .extensions() + .get::() + .expect("should carry adapter metadata"); + assert_eq!( + ingress + .origin() + .expect("should expose HTTP binding") + .scheme(), + "http" + ); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture target") + }; + assert_eq!(target.value(), request.uri().to_string()); + match request.headers()["x-fixture"] + .to_str() + .expect("should read fixed fixture") + { + "repeat" => { + assert_eq!(request.method().as_str(), "EXAMPLE-METHOD"); + assert_eq!(request.headers().get_all("origin").iter().count(), 2); + assert_eq!(request.headers().get_all("x-control").iter().count(), 2); + let cookies: Vec<_> = request + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(cookies, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + } + "length" => assert_eq!( + request.headers().get_all("content-length").iter().count(), + 1 + ), + "te-cl" => assert!(!request.headers().contains_key("content-length")), + "continue" => { + *request.method_mut() = Method::GET; + *request.uri_mut() = "/ordinary".parse().expect("should parse fixed route"); + return Ok(None); + } + "error" => return Err(EdgeError::bad_request("fixture rejection")), + "dot" => assert_eq!(request.uri().path(), "/reserved/%2e/../%2F//"), + _ => panic!("should use fixed fixture"), + } + Ok(Some( + response_builder() + .status(204) + .header("x-hook", "yes") + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + #[action] + async fn ingress_ordinary() -> &'static str { + "continued" + } + + fn raw_ingress_response(addr: SocketAddr, fixture: &[u8]) -> Vec { + let mut connection = TcpStream::connect(addr).expect("should connect listener"); + connection + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("should set timeout"); + connection + .write_all(fixture) + .expect("should send exact request"); + let mut response = Vec::new(); + connection + .read_to_end(&mut response) + .expect("should read response"); + response + } + + #[tokio::test(flavor = "multi_thread")] + async fn ingress_raw_tcp_distinguishes_parser_and_hook_behaviour() { + let hook_calls = Arc::new(AtomicUsize::new(0)); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .middleware(IngressMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook(Arc::new(IngressHook { + calls: Arc::clone(&hook_calls), + })) + .get("/ordinary", ingress_ordinary) + .build(); + let server = start_test_server(router).await; + let addr: SocketAddr = server + .base_url + .strip_prefix("http://") + .expect("should have HTTP binding") + .parse() + .expect("should parse listener address"); + let cases: [(&[u8], &[u8], usize); 7] = [ + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: repeat\r\nCookie: a=\xff\r\nOrigin: https://example.com\r\nX-Control: first\r\nCOOKIE: b=2\r\nOrigin: https://other.example.com\r\nX-Control: second\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", b"HTTP/1.1 400", 0), + (b"GET /reserved/%2e/../%2F// HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: dot\r\n\r\n", b"HTTP/1.1 204", 1), + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: continue\r\n\r\n", b"HTTP/1.1 200", 1), + (b"GET /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: error\r\n\r\n", b"HTTP/1.1 400", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: te-cl\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n0\r\n\r\n", b"HTTP/1.1 204", 1), + ]; + for (fixture, status, calls) in cases { + let before = hook_calls.load(Ordering::SeqCst); + let response = spawn_blocking(move || raw_ingress_response(addr, fixture)) + .await + .expect("should join socket probe"); + assert!( + response.starts_with(status), + "should return expected fixed fixture status" + ); + assert_eq!(hook_calls.load(Ordering::SeqCst) - before, calls); + } + assert_eq!(middleware_calls.load(Ordering::SeqCst), 1); + server.handle.abort(); + } + async fn start_test_server(router: RouterService) -> TestServer { let listener = TokioTcpListener::bind("127.0.0.1:0") .await diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index 9e3f5976..aca848ca 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -4,14 +4,22 @@ use axum::body::{Body as AxumBody, to_bytes}; use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; -use edgezero_core::http::HeaderValue; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::CONTENT_TYPE; +use edgezero_core::http::header::{CONTENT_LENGTH, CONTENT_TYPE, HOST}; +use edgezero_core::http::{HeaderMap, HeaderValue, Uri}; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use crate::context::AxumRequestContext; use crate::proxy::AxumProxyClient; +/// Marks the plain HTTP listener owned by this adapter. +#[derive(Clone, Copy)] +pub(crate) struct HttpTransport; + /// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. /// @@ -19,7 +27,9 @@ use crate::proxy::AxumProxyClient; /// Returns an error if a buffered (`application/json`) body cannot be read into memory. #[inline] pub async fn into_core_request(request: Request) -> Result { - let (parts, axum_body) = request.into_parts(); + let (mut parts, axum_body) = request.into_parts(); + let transport = parts.extensions.remove::(); + let ingress = capture_request_ingress(&parts.uri, &parts.headers, transport); let body = match parts.headers.get(CONTENT_TYPE) { Some(value) if is_json_content_type(value) => { @@ -35,6 +45,7 @@ pub async fn into_core_request(request: Request) -> Result) -> Result, +) -> RequestIngress { + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let common = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + let content_length = HeaderFidelity::new( + Preservation::Unknown, + if transport.is_some() { + Preservation::Transformed + } else { + Preservation::Unknown + }, + Preservation::Unknown, + Preservation::Unavailable, + ); + RequestIngress::new( + target, + inbound_origin(uri, headers, transport), + common, + vec![(CONTENT_LENGTH, content_length)], + ) + .expect("should use unique header overrides") +} + +fn inbound_origin( + uri: &Uri, + headers: &HeaderMap, + transport: Option, +) -> Option { + transport?; + let mut hosts = headers.get_all(HOST).iter(); + let host = hosts.next()?.to_str().ok()?; + if hosts.next().is_some() { + return None; + } + let origin = InboundOrigin::parse("http", host, OriginSource::TransportBinding).ok()?; + if uri + .scheme_str() + .is_some_and(|scheme| !scheme.eq_ignore_ascii_case("http")) + { + return None; + } + if let Some(authority) = uri.authority() { + let supplied = + InboundOrigin::parse("http", authority.as_str(), OriginSource::TransportBinding) + .ok()?; + let parsed_host = format!("http://{host}/").parse::().ok()?; + let parsed_uri = format!("http://{}/", supplied.authority()) + .parse::() + .ok()?; + if !parsed_host.host()?.eq_ignore_ascii_case(parsed_uri.host()?) + || parsed_host.port_u16().unwrap_or(80) != parsed_uri.port_u16().unwrap_or(80) + { + return None; + } + } + Some(origin) +} + fn is_json_content_type(value: &HeaderValue) -> bool { let Ok(raw) = value.to_str() else { return false; @@ -203,3 +289,125 @@ mod tests { ))); } } + +#[cfg(test)] +mod ingress_tests { + use super::{HttpTransport, into_core_request}; + use axum::body::Body as AxumBody; + use edgezero_core::http::{HeaderValue, Method, request_builder}; + use edgezero_core::request::{ + CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, + TargetUnavailable, + }; + + #[tokio::test] + async fn ingress_retains_runtime_parts_without_trusting_headers() { + let mut request = request_builder() + .method(Method::from_bytes(b"EXAMPLE-METHOD").expect("should parse method")) + .uri("/reserved/%2e//%2F?q=a%2Fb") + .header("host", "example.com") + .header("forwarded", "proto=https;host=spoof.example.com") + .body(AxumBody::empty()) + .expect("should build request"); + request.headers_mut().append( + "cookie", + HeaderValue::from_bytes(b"a=\xff").expect("should parse bytes"), + ); + request + .headers_mut() + .append("cookie", HeaderValue::from_static("b=2")); + let core = into_core_request(request) + .await + .expect("should convert request"); + let ingress = core + .extensions() + .get::() + .expect("should insert metadata"); + assert!(ingress.origin().is_none()); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture whole target") + }; + assert_eq!(target.value(), "/reserved/%2e//%2F?q=a%2Fb"); + assert_eq!(core.method().as_str(), "EXAMPLE-METHOD"); + let values: Vec<_> = core + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(values, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + assert_eq!( + ingress + .header_fidelity(&"content-length".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } + + #[tokio::test] + async fn ingress_bound_does_not_change_uri_body_or_extensions() { + let path = format!("/{}", "a".repeat(MAX_TARGET_BYTES)); + let mut request = request_builder() + .uri(&path) + .header("content-type", "application/json") + .body(AxumBody::from("{}")) + .expect("should build request"); + request.extensions_mut().insert(17_u32); + let converted = into_core_request(request) + .await + .expect("should convert bounded metadata"); + assert_eq!(converted.uri().path(), path); + assert_eq!(converted.body().as_bytes(), Some(b"{}".as_slice())); + assert_eq!(converted.extensions().get::(), Some(&17)); + assert!(matches!( + converted + .extensions() + .get::() + .expect("should insert metadata") + .target(), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge) + )); + } + + #[tokio::test] + async fn ingress_transport_origin_requires_consistent_single_authority() { + for (uri, hosts, expected) in [ + ("/reserved", vec!["example.com"], true), + ("http://EXAMPLE.com:80/reserved", vec!["example.com"], true), + ("https://example.com/reserved", vec!["example.com"], false), + ( + "http://other.example.com/reserved", + vec!["example.com"], + false, + ), + ("/reserved", vec!["example.com", "example.com"], false), + ("/reserved", vec!["example.com/path"], false), + ("/reserved", vec![], false), + ] { + let mut request = request_builder() + .uri(uri) + .body(AxumBody::empty()) + .expect("should build request"); + for host in hosts { + request.headers_mut().append( + "host", + HeaderValue::from_str(host).expect("should parse host"), + ); + } + request.extensions_mut().insert(HttpTransport); + let core = into_core_request(request) + .await + .expect("should convert request"); + let ingress = core + .extensions() + .get::() + .expect("should insert metadata"); + assert_eq!(ingress.origin().is_some(), expected); + if let Some(origin) = ingress.origin() { + assert_eq!(origin.scheme(), "http"); + assert_eq!(origin.source(), OriginSource::TransportBinding); + } + assert!(core.extensions().get::().is_none()); + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 5dbb80fc..c2e4dfc5 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -51,6 +51,7 @@ web-sys = { version = "0.3", features = [ "Window", "Response", "Request", + "RequestInit", "Headers", "ReadableStream", "Blob", diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 21c5e1cd..509109fb 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -7,16 +7,18 @@ 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::http::{HeaderValue, Method as CoreMethod, Request, Uri, request_builder}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; -use worker::{ - Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, -}; +use worker::{Context, Env, Error as WorkerError, Request as CfRequest, Response as CfResponse}; use crate::config_store::CloudflareConfigStore; use crate::context::CloudflareRequestContext; @@ -245,19 +247,37 @@ pub async fn into_core_request( env: Env, ctx: Context, ) -> Result { - let method = into_core_method(&req.method()); - let url = req - .url() - .map_err(|err| EdgeError::bad_request(format!("invalid URL: {err}")))?; - let uri: Uri = url - .as_str() + let method = CoreMethod::from_bytes(req.inner().method().as_bytes()) + .map_err(|_error| EdgeError::bad_request("invalid runtime method"))?; + let runtime_url = req.inner().url(); + let target = CapturedTarget::capture( + &runtime_url, + TargetSource::RuntimeUrl, + Preservation::Transformed, + ); + let uri: Uri = runtime_url .parse() - .map_err(|err| EdgeError::bad_request(format!("invalid URI: {err}")))?; + .map_err(|_error| EdgeError::bad_request("invalid runtime URI"))?; + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let fidelity = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + let ingress = RequestIngress::new(target, origin, fidelity, Vec::new())?; let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); for (name, value) in headers.entries() { - builder = builder.header(name.as_str(), value); + let header_value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_error| EdgeError::bad_request("invalid runtime header value"))?; + builder = builder.header(name.as_str(), header_value); } let bytes = req.bytes().await.map_err(EdgeError::internal)?; @@ -266,6 +286,7 @@ pub async fn into_core_request( .body(Body::from(bytes)) .map_err(EdgeError::internal)?; + request.extensions_mut().insert(ingress); CloudflareRequestContext::insert(&mut request, env, ctx); request .extensions_mut() @@ -469,17 +490,6 @@ fn edge_error_to_worker(err: &EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } -fn into_core_method(method: &Method) -> CoreMethod { - let bytes = method.as_ref().as_bytes(); - CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { - log::warn!( - "unknown HTTP method {:?}, defaulting to GET", - method.as_ref() - ); - CoreMethod::GET - }) -} - fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { match CloudflareConfigStore::from_env(env, binding_name) { Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), @@ -568,26 +578,6 @@ fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { } } -#[cfg(test)] -mod tests { - use super::*; - use wasm_bindgen_test::wasm_bindgen_test; - - #[wasm_bindgen_test] - fn into_http_method_defaults_unknown_to_get() { - let method = Method::from("FOO".to_owned()); - assert_eq!(into_core_method(&method), CoreMethod::GET); - } - - #[wasm_bindgen_test] - fn into_http_method_maps_known_methods() { - assert_eq!(into_core_method(&Method::Get), CoreMethod::GET); - assert_eq!(into_core_method(&Method::Post), CoreMethod::POST); - assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); - assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); - } -} - #[cfg(test)] mod synthesis_tests { use std::collections::BTreeMap; diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 249ccefb..6f03ce10 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -25,8 +25,12 @@ mod tests { 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::router::RouterService; + use edgezero_core::http::{Method, Request, Response, StatusCode, response_builder}; + use edgezero_core::request::{ + CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, + TargetUnavailable, + }; + use edgezero_core::router::{PreDispatchHook, RouterService}; use futures::stream; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; use worker::js_sys::Object; @@ -36,6 +40,10 @@ mod tests { wasm_bindgen_test_configure!(run_in_browser); + struct IngressHook { + continue_routing: bool, + } + struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] @@ -45,6 +53,179 @@ mod tests { } } + #[async_trait::async_trait(?Send)] + impl PreDispatchHook for IngressHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!(request.extensions().get::().is_some()); + assert_eq!(request.method().as_str(), "EXAMPLE-METHOD"); + if self.continue_routing { + *request.method_mut() = Method::GET; + *request.uri_mut() = "https://example.com/uri" + .parse() + .expect("should parse fixed URI"); + Ok(None) + } else { + Ok(Some( + response_builder() + .status(418) + .header("allow", "POST") + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + } + + #[wasm_bindgen_test] + async fn ingress_preserves_runtime_extension_methods_and_header_strings() { + for token in [ + "EXAMPLE-METHOD", + "example-method", + "GET", + "POST", + "PUT", + "DELETE", + ] { + let init = web_sys::RequestInit::new(); + init.set_method(token); + let headers = web_sys::Headers::new().expect("should create headers"); + headers + .append("x-octets", "\u{ff}\u{80}\u{c3}\u{a9}") + .expect("should append ByteString header"); + init.set_headers_headers(&headers); + let raw = + web_sys::Request::new_with_str_and_init("https://example.com/reserved", &init) + .expect("should create raw Web Request"); + let expected_method = raw.method(); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert raw request"); + assert_eq!(converted.method().as_str(), expected_method); + assert_eq!( + converted.headers()["x-octets"].as_bytes(), + "\u{ff}\u{80}\u{c3}\u{a9}".as_bytes() + ); + } + } + + #[wasm_bindgen_test] + async fn ingress_metadata_tracks_runtime_url_origin_and_coalescing() { + let oversized = format!("https://example.com/{}", "a".repeat(MAX_TARGET_BYTES)); + for url in [ + "https://example.com/reserved/../ordinary?q=a%2Fb", + "https://example.com/reserved/%2e%2e/ordinary", + "https://example.com/reserved//%2F", + oversized.as_str(), + ] { + let raw = web_sys::Request::new_with_str(url).expect("should construct Web Request"); + raw.headers() + .append("x-repeat", "first") + .expect("should append header"); + raw.headers() + .append("x-repeat", "second") + .expect("should append header"); + raw.headers() + .set("origin", "https://spoof.example.com") + .expect("should set spoofed origin"); + raw.headers() + .set("forwarded", "proto=http;host=spoof.example.com") + .expect("should set spoofed forwarding header"); + let exposed = raw.url(); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert Web Request"); + let ingress = converted + .extensions() + .get::() + .expect("should insert metadata"); + match ingress.target() { + CapturedTarget::Complete(target) => { + assert_eq!(target.value(), exposed); + assert_eq!(target.fidelity(), Preservation::Transformed); + } + CapturedTarget::Unavailable(reason) => { + assert_eq!(*reason, TargetUnavailable::TooLarge); + } + } + assert_eq!( + matches!(ingress.target(), CapturedTarget::Complete(_)), + exposed.len() <= MAX_TARGET_BYTES + ); + let origin = ingress.origin().expect("should expose runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!(converted.headers().get_all("x-repeat").iter().count(), 1); + assert_eq!(converted.headers()["x-repeat"], "first, second"); + assert_eq!( + ingress + .header_fidelity(&"x-repeat".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } + } + + #[wasm_bindgen_test] + async fn ingress_non_http_runtime_url_has_no_origin() { + let raw = web_sys::Request::new_with_str("ftp://example.com/reserved") + .expect("should construct runtime URL"); + let (env, ctx) = test_env_ctx(); + let request = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert exposed URI"); + assert!( + request + .extensions() + .get::() + .expect("should insert metadata") + .origin() + .is_none() + ); + } + + #[wasm_bindgen_test] + async fn ingress_hook_intercepts_and_continues_through_service() { + for continue_routing in [false, true] { + let app = App::new( + RouterService::builder() + .pre_dispatch_hook(Arc::new(IngressHook { continue_routing })) + .get("/uri", capture_uri_for_ingress) + .build(), + ); + let init = web_sys::RequestInit::new(); + init.set_method("EXAMPLE-METHOD"); + let raw = web_sys::Request::new_with_str_and_init("https://example.com/missing", &init) + .expect("should create extension request"); + let (env, ctx) = test_env_ctx(); + let response = CloudflareService::new(&app) + .dispatch(CfRequest::from(raw), env, ctx) + .await + .expect("should dispatch hook"); + assert_eq!( + response.status_code(), + if continue_routing { 200 } else { 418 } + ); + if !continue_routing { + assert_eq!( + response + .headers() + .get("allow") + .expect("should read header") + .as_deref(), + Some("POST") + ); + } + } + } + + #[edgezero_core::action] + async fn capture_uri_for_ingress() -> &'static str { + "continued" + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { let body = Body::text(ctx.request().uri().to_string()); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 36161a35..3c982708 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -176,6 +176,7 @@ where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions), { + let ingress = request::capture_request_ingress(&req); let stores = A::stores(); let env = runtime_env_config(stores); let logging = FastlyLogging::from(&env); @@ -184,7 +185,7 @@ where init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores, &env, extend) + request::dispatch_with_registries_and_ingress(&app, req, stores, &env, ingress, extend) } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index ea1a0077..b8cf0a68 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,6 +1,7 @@ use std::collections::{HashSet, VecDeque}; use std::fmt::Display; use std::io::Read as _; +use std::str::from_utf8; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; @@ -8,9 +9,14 @@ 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}; +use edgezero_core::http::header::HOST; +use edgezero_core::http::{Extensions, HeaderValue, Request, Uri, request_builder}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetUnavailable, +}; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -100,6 +106,7 @@ pub struct FastlyService<'app> { app: &'app App, config: ConfigSource, kv: Option, + request_ingress: Option, secrets: SecretSource, } @@ -123,6 +130,9 @@ impl<'app> FastlyService<'app> { /// the underlying handler returns an error. #[inline] pub fn dispatch(self, req: FastlyRequest) -> Result { + let ingress = self + .request_ingress + .unwrap_or_else(|| capture_request_ingress(&req)); 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() }, + ingress, |_req, _extensions| {}, ) } @@ -164,6 +175,7 @@ impl<'app> FastlyService<'app> { app, config: ConfigSource::None, kv: None, + request_ingress: None, secrets: SecretSource::Off, } } @@ -238,6 +250,16 @@ impl<'app> FastlyService<'app> { self } + /// Retain metadata captured before native request mutations or shortcuts. + /// + /// The caller must establish the supplied snapshot's provenance and fidelity. + #[must_use] + #[inline] + pub fn with_request_ingress(mut self, ingress: RequestIngress) -> Self { + self.request_ingress = Some(ingress); + self + } + /// Enable the Fastly Secret Store and inject its handle. /// Non-required by default: an absent store leaves no secret /// handle in extensions and dispatch continues. Pair with @@ -303,6 +325,7 @@ fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, + ingress: RequestIngress, extend: F, ) -> Result where @@ -310,8 +333,9 @@ 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))?; + let mut core_request = convert_request(req).map_err(|err| map_edge_error(&err))?; core_request.extensions_mut().extend(scratch); + core_request.extensions_mut().insert(ingress); dispatch_core_request(app, core_request, stores) } @@ -341,6 +365,30 @@ pub fn dispatch_with_registries( env: &EnvConfig, extend: F, ) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let ingress = capture_request_ingress(&req); + dispatch_with_registries_and_ingress(app, req, stores, env, ingress, extend) +} + +/// Dispatch with manifest store selectors and an already captured ingress snapshot. +/// +/// The snapshot is inserted after the extension callback, preventing scratch +/// metadata from replacing adapter-captured facts. Store semantics match +/// [`dispatch_with_registries`]. +/// +/// # Errors +/// Returns an error if a declared KV store cannot be opened or dispatch fails. +#[inline] +pub fn dispatch_with_registries_and_ingress( + app: &App, + req: FastlyRequest, + stores: StoresMetadata, + env: &EnvConfig, + ingress: RequestIngress, + extend: F, +) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { @@ -356,6 +404,7 @@ where secret_registry, ..Default::default() }, + ingress, extend, ) } @@ -485,10 +534,103 @@ fn build_secret_registry( StoreRegistry::from_parts(by_id, meta.default.to_owned()) } +/// Capture available ingress facts without consuming the native request. +/// +/// Fastly SDK 0.12.1 does not expose a safe borrowed raw target accessor. +/// Record target unavailability before reading the SDK URL for the canonical +/// origin. That URL may normalize the path and is never used as an original +/// target. Client requests require exactly one validated Host matching the +/// runtime URL authority. No forwarded headers or missing TLS facts supply a +/// scheme. This function does not clone handles or change client provenance. +/// +/// # Panics +/// The SDK URL accessor can panic if its runtime URI cannot be parsed. +/// The static empty override list is asserted to be valid; this invariant +/// cannot fail under the current metadata constructor. +#[expect( + clippy::expect_used, + reason = "an empty override list cannot contain duplicate names" +)] +#[inline] +#[must_use] +pub fn capture_request_ingress(req: &FastlyRequest) -> RequestIngress { + let target = CapturedTarget::Unavailable(TargetUnavailable::NotExposed); + let origin = if req.is_from_client() { + parse_uri(req.get_url_str()).ok().and_then(|uri| { + origin_from_runtime_uri(&uri, req.get_header_all(HOST).map(HeaderValue::as_bytes)) + }) + } else { + None + }; + RequestIngress::new( + target, + origin, + HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ), + Vec::new(), + ) + .expect("should use unique header overrides") +} + +fn origin_from_runtime_uri<'header>( + uri: &Uri, + mut hosts: impl Iterator, +) -> Option { + let host = from_utf8(hosts.next()?).ok()?; + if hosts.next().is_some() { + return None; + } + let scheme = uri.scheme_str()?; + let _runtime_origin = + InboundOrigin::parse(scheme, uri.authority()?.as_str(), OriginSource::RuntimeUri).ok()?; + let origin = InboundOrigin::parse(scheme, host, OriginSource::RuntimeUri).ok()?; + let host_uri = Uri::builder() + .scheme(origin.scheme()) + .authority(origin.authority()) + .path_and_query("/") + .build() + .ok()?; + let default_port = if origin.scheme() == "https" { 443 } else { 80 }; + if !uri.host()?.eq_ignore_ascii_case(host_uri.host()?) + || uri.port_u16().unwrap_or(default_port) != host_uri.port_u16().unwrap_or(default_port) + { + return None; + } + Some(origin) +} + +/// Convert a native request, capturing ingress before native mutation. +/// /// # Errors -/// Returns [`EdgeError::Internal`] if the Fastly request cannot be reconstituted into a core request (e.g., method or URI conversion failure). +/// Returns an error if method, URI or body conversion fails. #[inline] -pub fn into_core_request(mut req: FastlyRequest) -> Result { +pub fn into_core_request(req: FastlyRequest) -> Result { + let ingress = capture_request_ingress(&req); + into_core_request_with_ingress(req, ingress) +} + +/// Convert a native request while retaining the supplied bounded snapshot. +/// +/// The caller must establish the supplied snapshot's provenance and fidelity. +/// Conversion retains its facts even if the native request has since changed. +/// +/// # Errors +/// Returns an error if method, URI or body conversion fails. +#[inline] +pub fn into_core_request_with_ingress( + req: FastlyRequest, + ingress: RequestIngress, +) -> Result { + let mut request = convert_request(req)?; + request.extensions_mut().insert(ingress); + Ok(request) +} + +fn convert_request(mut req: FastlyRequest) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -801,3 +943,223 @@ mod synthesis_tests { ); } } + +#[cfg(test)] +mod ingress_tests { + use super::{ + FastlyService, capture_request_ingress, dispatch_with_registries_and_ingress, + into_core_request_with_ingress, origin_from_runtime_uri, + }; + use crate::context::FastlyRequestContext; + use async_trait::async_trait; + use edgezero_core::app::{App, StoresMetadata}; + use edgezero_core::body::Body; + use edgezero_core::env_config::EnvConfig; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderValue, Request, Response, Uri, response_builder}; + use edgezero_core::proxy::ProxyHandle; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, OriginSource, Preservation, RequestIngress, TargetSource, + TargetUnavailable, + }; + use edgezero_core::router::{PreDispatchHook, RouterService}; + use fastly::Request as FastlyRequest; + use std::sync::Arc; + + #[derive(Clone)] + struct ScratchMarker; + + struct VerifySnapshot { + scratch_expected: bool, + } + + #[async_trait(?Send)] + impl PreDispatchHook for VerifySnapshot { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert_eq!( + request.extensions().get::().is_some(), + self.scratch_expected + ); + let ingress = request + .extensions() + .get::() + .expect("should expose chosen snapshot"); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should retain captured target") + }; + assert_eq!(target.value(), "/reserved/%2e"); + Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + #[test] + fn ingress_runtime_origin_accepts_one_matching_authority() { + for (url, host, expected_scheme) in [ + ("http://example.com/reserved", "EXAMPLE.com:80", "http"), + ("https://example.com/reserved", "example.com:443", "https"), + ( + "https://example.com:8443/reserved", + "example.com:8443", + "https", + ), + ("https://[::1]/reserved", "[::1]:443", "https"), + ] { + let uri: Uri = url.parse().expect("should parse runtime URI"); + let origin = origin_from_runtime_uri(&uri, [host.as_bytes()].into_iter()) + .expect("should recover the matching runtime origin"); + assert_eq!( + origin.scheme(), + expected_scheme, + "should use runtime scheme" + ); + assert_eq!( + origin.authority(), + host, + "should retain validated authority" + ); + assert_eq!( + origin.source(), + OriginSource::RuntimeUri, + "should identify runtime provenance" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_missing_duplicate_and_conflicting_hosts() { + let uri: Uri = "https://example.com/reserved" + .parse() + .expect("should parse URI"); + for hosts in [ + vec![], + vec![b"example.com".as_slice(), b"example.com".as_slice()], + vec![b"other.example.com".as_slice()], + vec![b"example.com:80".as_slice()], + vec![b"example.com, other.example.com".as_slice()], + vec![b"example.com:invalid".as_slice()], + vec![b"a=\xff".as_slice()], + ] { + assert!( + origin_from_runtime_uri(&uri, hosts.into_iter()).is_none(), + "should not invent an origin from unavailable or inconsistent authority" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_invalid_runtime_authorities() { + for url in [ + "https://user@example.com/reserved", + "https://example.com:invalid/reserved", + "https://example.com:65536/reserved", + ] { + let uri: Uri = url + .parse() + .expect("should represent invalid runtime authority"); + assert!( + origin_from_runtime_uri(&uri, [b"example.com".as_slice()].into_iter()).is_none(), + "should validate the complete runtime authority before comparing host or default port" + ); + } + } + + fn snapshot() -> RequestIngress { + RequestIngress::new( + CapturedTarget::capture( + "/reserved/%2e", + TargetSource::TransportRequestTarget, + Preservation::Unknown, + ), + None, + HeaderFidelity::default(), + Vec::new(), + ) + .expect("should build snapshot") + } + + #[test] + fn ingress_current_sdk_reports_missing_target_and_no_synthetic_origin() { + let native = FastlyRequest::get("https://example.com/reserved"); + let ingress = capture_request_ingress(&native); + assert!(matches!( + ingress.target(), + CapturedTarget::Unavailable(TargetUnavailable::NotExposed) + )); + assert!(ingress.origin().is_none()); + } + + #[test] + fn ingress_snapshot_and_header_bytes_survive_conversion() { + let mut native = FastlyRequest::new("EXAMPLE-METHOD", "https://example.com/later"); + native.append_header("cookie", b"a=\xff".as_slice()); + native.append_header("cookie", "b=2"); + let client_ip = native.get_client_ip_addr(); + let core = + into_core_request_with_ingress(native, snapshot()).expect("should convert snapshot"); + assert_eq!( + FastlyRequestContext::get(&core) + .expect("should preserve context") + .client_ip, + client_ip + ); + assert!(core.extensions().get::().is_some()); + assert_eq!(core.method().as_str(), "EXAMPLE-METHOD"); + let values: Vec<_> = core + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(values, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + let CapturedTarget::Complete(target) = core + .extensions() + .get::() + .expect("should carry snapshot") + .target() + else { + panic!("should retain target") + }; + assert_eq!(target.value(), "/reserved/%2e"); + } + + #[test] + fn ingress_registry_dispatch_wins_scratch_collision() { + let app = App::new( + RouterService::builder() + .pre_dispatch_hook(Arc::new(VerifySnapshot { + scratch_expected: true, + })) + .build(), + ); + let response = dispatch_with_registries_and_ingress( + &app, + FastlyRequest::get("https://example.com/later"), + StoresMetadata::default(), + &EnvConfig::default(), + snapshot(), + |native, scratch| { + scratch.insert(capture_request_ingress(native)); + scratch.insert(ScratchMarker); + }, + ) + .expect("should dispatch with chosen snapshot"); + assert_eq!(response.get_status().as_u16(), 204); + let service_app = App::new( + RouterService::builder() + .pre_dispatch_hook(Arc::new(VerifySnapshot { + scratch_expected: false, + })) + .build(), + ); + let service_response = FastlyService::new(&service_app) + .with_request_ingress(snapshot()) + .dispatch(FastlyRequest::get("https://example.com/later")) + .expect("should dispatch service snapshot"); + assert_eq!(service_response.get_status().as_u16(), 204); + } +} diff --git a/crates/edgezero-adapter-spin/src/context.rs b/crates/edgezero-adapter-spin/src/context.rs index 4061d471..88b70e39 100644 --- a/crates/edgezero-adapter-spin/src/context.rs +++ b/crates/edgezero-adapter-spin/src/context.rs @@ -4,6 +4,13 @@ use std::net::SocketAddr; use edgezero_core::http::Request; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::http::Uri; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; /// Platform-specific request context for Spin. /// /// Spin exposes client information via special headers @@ -32,6 +39,34 @@ impl SpinRequestContext { } } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[expect( + clippy::expect_used, + reason = "the empty override list cannot contain duplicate names" +)] +pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let headers = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + RequestIngress::new(target, origin, headers, Vec::new()) + .expect("should use unique header overrides") +} + /// Parse an IP address from a `host:port` string. /// /// Falls back to parsing the raw value as a bare IP (no port) and also @@ -116,3 +151,45 @@ mod tests { assert_eq!(ip, IpAddr::from_str("::1").unwrap()); } } +#[cfg(test)] +mod ingress_tests { + use super::capture_request_ingress; + use edgezero_core::http::Uri; + use edgezero_core::request::{CapturedTarget, OriginSource, Preservation}; + + #[test] + fn ingress_captures_runtime_uri_and_origin_only() { + let uri: Uri = "https://example.com/reserved/%2e//%2F?q=a%2Fb" + .parse() + .expect("should parse URI"); + let ingress = capture_request_ingress(&uri); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture target") + }; + assert_eq!(target.value(), uri.to_string()); + let origin = ingress.origin().expect("should capture runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!( + ingress + .header_fidelity(&"cookie".parse().expect("should parse name")) + .global_field_order(), + Preservation::Unavailable + ); + assert!( + capture_request_ingress(&"/reserved".parse().expect("should parse path")) + .origin() + .is_none() + ); + assert!( + capture_request_ingress( + &"ftp://example.com/reserved" + .parse() + .expect("should parse URI") + ) + .origin() + .is_none() + ); + } +} diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index bf9543bf..9c47e22f 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -5,7 +5,7 @@ use anyhow::Context as _; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; -use crate::context::{SpinRequestContext, parse_client_addr}; +use crate::context::{SpinRequestContext, capture_request_ingress, parse_client_addr}; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; @@ -50,6 +50,7 @@ pub(crate) struct Stores { #[inline] pub async fn into_core_request(req: SpinRequest) -> Result { let (parts, body) = req.into_parts(); + let ingress = capture_request_ingress(&parts.uri); let client_addr = parts .headers @@ -80,6 +81,7 @@ pub async fn into_core_request(req: SpinRequest) -> Result { .body(Body::from(body_bytes.to_vec())) .map_err(|err| EdgeError::bad_request(format!("failed to build request: {err}")))?; + request.extensions_mut().insert(ingress); SpinRequestContext::insert( &mut request, SpinRequestContext { diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa8545..58bb9187 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod manifest; pub mod middleware; pub mod params; pub mod proxy; +pub mod request; pub mod responder; pub mod response; pub mod router; diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs new file mode 100644 index 00000000..8872aef4 --- /dev/null +++ b/crates/edgezero-core/src/request.rs @@ -0,0 +1,485 @@ +//! Confidential adapter-owned ingress facts and explicit preservation limits. +//! +//! Metadata describes what a runtime exposes; it cannot recover information +//! erased before conversion. It is intentionally neither serializable nor +//! printable because captured targets and authorities can contain private data. + +use std::net::Ipv6Addr; +use std::sync::Arc; + +use crate::error::EdgeError; +use crate::http::{HeaderName, Uri}; + +/// Maximum complete target metadata, measured in UTF-8 bytes. +pub const MAX_TARGET_BYTES: usize = 16 * 1024; + +/// A complete bounded target or an explicit acquisition failure. +#[derive(Clone)] +pub enum CapturedTarget { + /// A complete value created only by the bounded capture constructor. + Complete(CompleteTarget), + /// No complete target is available. + Unavailable(TargetUnavailable), +} + +impl CapturedTarget { + /// Capture a whole runtime target without retaining an oversized prefix. + #[inline] + #[must_use] + pub fn capture(value: &str, source: TargetSource, fidelity: Preservation) -> Self { + if value.len() > MAX_TARGET_BYTES { + return Self::Unavailable(TargetUnavailable::TooLarge); + } + Self::Complete(CompleteTarget { + fidelity, + source, + value: Arc::from(value), + }) + } +} + +/// A complete bounded target with explicit provenance. +/// +/// Fields remain private so callers cannot bypass the capture bound. +/// +/// ```compile_fail +/// use edgezero_core::request::{CompleteTarget, Preservation, TargetSource}; +/// let target = CompleteTarget { +/// value: std::sync::Arc::from("http://example.com/"), +/// source: TargetSource::RuntimeUrl, +/// fidelity: Preservation::Unknown, +/// }; +/// ``` +#[derive(Clone)] +pub struct CompleteTarget { + fidelity: Preservation, + source: TargetSource, + value: Arc, +} + +impl CompleteTarget { + /// Return the established target preservation guarantee. + #[inline] + #[must_use] + pub const fn fidelity(&self) -> Preservation { + self.fidelity + } + + /// Return the runtime acquisition source. + #[inline] + #[must_use] + pub const fn source(&self) -> TargetSource { + self.source + } + + /// Borrow the complete captured target. + #[inline] + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + +/// Independent original-field preservation facts. +/// +/// These axes compare original HTTP field values with core headers; successful +/// runtime-to-core copying alone does not establish original-wire preservation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct HeaderFidelity { + field_multiplicity: Preservation, + global_field_order: Preservation, + octets: Preservation, + same_name_order: Preservation, +} + +impl HeaderFidelity { + /// Return original field-count preservation for this header. + #[inline] + #[must_use] + pub const fn field_multiplicity(&self) -> Preservation { + self.field_multiplicity + } + + /// Return original ordering across different field names. + #[inline] + #[must_use] + pub const fn global_field_order(&self) -> Preservation { + self.global_field_order + } + + /// Construct four independent fidelity guarantees. + #[inline] + #[must_use] + pub const fn new( + octets: Preservation, + field_multiplicity: Preservation, + same_name_order: Preservation, + global_field_order: Preservation, + ) -> Self { + Self { + field_multiplicity, + global_field_order, + octets, + same_name_order, + } + } + + /// Return original field-value byte preservation. + #[inline] + #[must_use] + pub const fn octets(&self) -> Preservation { + self.octets + } + + /// Return original value ordering within a single field name. + #[inline] + #[must_use] + pub const fn same_name_order(&self) -> Preservation { + self.same_name_order + } +} + +/// Bounded validated origin facts supplied by a trusted adapter source. +/// +/// Syntax validation does not make a forwarded header trustworthy. Adapters +/// must establish provenance before calling the constructor. +#[derive(Clone)] +pub struct InboundOrigin { + authority: Arc, + scheme: &'static str, + source: OriginSource, +} + +impl InboundOrigin { + /// Borrow the validated authority, including any explicit port. + #[inline] + #[must_use] + pub fn authority(&self) -> &str { + &self.authority + } + + /// Validate a bounded HTTP(S) scheme and authority. + /// + /// # Errors + /// + /// Returns a bounded validation error for invalid or oversized input. + #[inline] + pub fn parse(scheme: &str, authority: &str, source: OriginSource) -> Result { + let normalized_scheme = if scheme.eq_ignore_ascii_case("http") { + "http" + } else if scheme.eq_ignore_ascii_case("https") { + "https" + } else { + return Err(EdgeError::bad_request("invalid ingress origin")); + }; + if authority.is_empty() + || authority.len() > 1024 + || authority + .chars() + .any(|character| matches!(character, '@' | '/' | '?' | '#' | '\\')) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let uri = Uri::builder() + .scheme(normalized_scheme) + .authority(authority) + .path_and_query("/") + .build() + .map_err(|_error| EdgeError::bad_request("invalid ingress origin"))?; + let parsed = uri + .authority() + .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; + let host = parsed.host(); + if host.chars().any(|character| matches!(character, '[' | ']')) + && host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .is_none_or(|inner| inner.parse::().is_err()) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let suffix = authority + .strip_prefix(parsed.host()) + .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; + if parsed.host().is_empty() + || (!suffix.is_empty() + && suffix.strip_prefix(':').is_none_or(|port| { + port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err() + })) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + Ok(Self { + authority: Arc::from(authority), + scheme: normalized_scheme, + source, + }) + } + + /// Return the normalized HTTP(S) scheme. + #[inline] + #[must_use] + pub const fn scheme(&self) -> &str { + self.scheme + } + + /// Return the adapter-owned origin acquisition source. + #[inline] + #[must_use] + pub const fn source(&self) -> OriginSource { + self.source + } +} + +/// Provenance of a validated inbound origin. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OriginSource { + /// Scheme and authority supplied by the runtime request URI. + RuntimeUri, + /// A trusted transport scheme combined with one validated inbound request authority. + /// + /// The authority identifies the requested origin; it does not establish + /// that an operator approves or owns that host. + TransportBinding, +} + +/// Degree of preservation established for a particular fact. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Preservation { + /// Pinned source and transport evidence establish equality. + Preserved, + /// A known transformation changed the original information. + Transformed, + /// The runtime does not expose the required information. + Unavailable, + /// Preservation has not been established. + #[default] + Unknown, +} + +/// Confidential adapter-owned metadata stored in request extensions. +/// +/// Absence means unknown capability, not complete preservation. Existing +/// headers remain in the request `HeaderMap` and are never duplicated here. +#[derive(Clone)] +pub struct RequestIngress { + common_header_fidelity: HeaderFidelity, + origin: Option, + overrides: Vec<(HeaderName, HeaderFidelity)>, + target: CapturedTarget, +} + +impl RequestIngress { + /// Return a header-specific guarantee or the common conservative default. + #[inline] + #[must_use] + pub fn header_fidelity(&self, name: &HeaderName) -> HeaderFidelity { + self.overrides + .iter() + .find_map(|(candidate, fidelity)| (candidate == name).then_some(*fidelity)) + .unwrap_or(self.common_header_fidelity) + } + + /// Construct metadata with unambiguous per-header fidelity overrides. + /// + /// Sources and preservation guarantees are caller assertions. Validation + /// checks override names, not the provenance or fidelity of captured facts. + /// + /// # Errors + /// + /// Returns a bounded validation error for duplicate override names. + #[inline] + pub fn new( + target: CapturedTarget, + origin: Option, + common_header_fidelity: HeaderFidelity, + mut overrides: Vec<(HeaderName, HeaderFidelity)>, + ) -> Result { + overrides.sort_unstable_by(|left, right| left.0.as_str().cmp(right.0.as_str())); + if overrides.windows(2).any(|pair| { + let [left, right] = pair else { return false }; + left.0 == right.0 + }) { + return Err(EdgeError::bad_request("duplicate ingress header override")); + } + Ok(Self { + common_header_fidelity, + origin, + overrides, + target, + }) + } + + /// Borrow the validated origin, when its trusted source is available. + #[inline] + #[must_use] + pub const fn origin(&self) -> Option<&InboundOrigin> { + self.origin.as_ref() + } + + /// Borrow complete target metadata or its explicit unavailability reason. + #[inline] + #[must_use] + pub const fn target(&self) -> &CapturedTarget { + &self.target + } +} + +/// Source of captured target information, independent of fidelity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetSource { + /// A runtime path/query before additional `EdgeZero` parsing. + RuntimePathAndQuery, + /// A runtime-exposed URL; earlier normalization may have occurred. + RuntimeUrl, + /// A transport-exposed request target with evidence of its provenance. + TransportRequestTarget, +} + +/// Why no complete target was retained. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetUnavailable { + /// The runtime offers no safe acquisition API. + NotExposed, + /// Acquisition failed without retaining error text. + ReadFailed, + /// The target exceeded the metadata bound. + TooLarge, +} + +#[cfg(test)] +mod tests { + use crate::http::header::{CONTENT_LENGTH, COOKIE}; + use crate::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, TargetUnavailable, + }; + + #[test] + fn ingress_fidelity_defaults_are_unknown() { + let fidelity = HeaderFidelity::default(); + assert_eq!(fidelity.octets(), Preservation::Unknown); + assert_eq!(fidelity.field_multiplicity(), Preservation::Unknown); + assert_eq!(fidelity.same_name_order(), Preservation::Unknown); + assert_eq!(fidelity.global_field_order(), Preservation::Unknown); + } + + #[test] + fn ingress_header_override_is_field_specific() { + let common = HeaderFidelity::default(); + let folded = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Transformed, + Preservation::Unknown, + Preservation::Unavailable, + ); + let metadata = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + common, + vec![(CONTENT_LENGTH, folded)], + ) + .expect("should accept distinct header overrides"); + assert_eq!(metadata.header_fidelity(&CONTENT_LENGTH), folded); + assert_eq!(metadata.header_fidelity(&COOKIE), common); + assert!(metadata.origin().is_none()); + } + + #[test] + fn ingress_origin_accepts_bounded_http_authorities() { + for (scheme, authority) in [ + ("HTTP", "example.com"), + ("https", "example.com:443"), + ("http", "[::1]:8787"), + ] { + let origin = InboundOrigin::parse(scheme, authority, OriginSource::TransportBinding) + .expect("should accept a validated HTTP origin"); + assert_eq!(origin.scheme(), scheme.to_ascii_lowercase()); + assert_eq!(origin.authority(), authority); + assert_eq!(origin.source(), OriginSource::TransportBinding); + } + } + + #[test] + fn ingress_origin_rejects_untrusted_or_invalid_input() { + for (scheme, authority) in [ + ("ftp", "example.com"), + ("https", "user@example.com"), + ("https", "example.com/path"), + ("https", "example.com?secret=1"), + ("https", "example.com#fragment"), + ("https", "example.com:invalid"), + ("https", "example.com:65536"), + ("https", "example.com:+443"), + ("https", "[::1]:+443"), + ("https", "[]"), + ("https", "[not-an-ip]"), + ("https", "example[bad]"), + ("https", ""), + ("https", "example.com\\evil"), + ] { + assert!( + InboundOrigin::parse(scheme, authority, OriginSource::RuntimeUri) + .err() + .is_some(), + "should reject malformed authority without retaining it", + ); + } + assert!( + InboundOrigin::parse("https", &"a".repeat(1025), OriginSource::RuntimeUri) + .err() + .is_some(), + "should bound origin metadata", + ); + } + + #[test] + fn ingress_rejects_duplicate_overrides() { + let result = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::ReadFailed), + None, + HeaderFidelity::default(), + vec![ + (COOKIE, HeaderFidelity::default()), + (COOKIE, HeaderFidelity::default()), + ], + ); + assert!( + result.err().is_some(), + "should reject ambiguous override selection" + ); + } + + #[test] + fn ingress_target_bound_is_atomic() { + let mut value = "a".repeat(16 * 1024); + let target = + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Transformed); + assert!( + matches!(&target, CapturedTarget::Complete(complete) if complete.value() == value), + "should preserve the entire value at the bound", + ); + value.push('b'); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should retain no truncated prefix", + ); + } + + #[test] + fn ingress_target_counts_utf8_bytes() { + let value = "\u{e9}".repeat(8193); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should bound bytes rather than Unicode characters", + ); + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index d20f35a8..81fd2bc9 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::task::{Context, Poll}; +use async_trait::async_trait; use matchit::Router as PathRouter; use tower_service::Service; @@ -14,6 +15,25 @@ use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; +/// Shared optional interception performed before router lookup and state injection. +pub type BoxPreDispatchHook = Arc; + +/// Inspect or terminate a request before method/path lookup and route middleware. +/// +/// The hook receives adapter extensions but no router-injected state or +/// introspection. It may capture application state in its own implementation. +/// Futures deliberately do not require `Send` so WASM runtimes remain supported. +#[async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + /// Return a terminal response, or continue with the mutated request. + /// + /// # Errors + /// + /// Errors prevent dispatch. [`Service::call`] propagates them, while + /// [`RouterService::oneshot`] uses the existing error-response rendering. + async fn handle(&self, request: &mut Request) -> Result, EdgeError>; +} + struct RouteEntry { handler: BoxHandler, introspection_needs: IntrospectionNeeds, @@ -71,6 +91,7 @@ enum RouteMatch<'route> { pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_info: Vec, routes: HashMap>, /// App state registered via [`RouterBuilder::with_state`], keyed by type. @@ -119,6 +140,7 @@ impl RouterBuilder { route_index, self.manifest_json, self.state_extensions, + self.pre_dispatch_hook, ) } @@ -172,6 +194,18 @@ impl RouterBuilder { self.route(path, Method::POST, handler) } + /// Install one hook before route lookup, state injection and middleware. + /// + /// Repeated registration replaces the previous hook. Router clones share + /// the same instance. None of the normal route lifecycle runs after a + /// terminal response; outer adapter/application finalizers may still run. + #[must_use] + #[inline] + pub fn pre_dispatch_hook(mut self, hook: BoxPreDispatchHook) -> Self { + self.pre_dispatch_hook = Some(hook); + self + } + #[must_use] #[inline] pub fn put(self, path: &str, handler: H) -> Self @@ -221,6 +255,7 @@ impl RouterBuilder { struct RouterInner { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_index: Arc<[RouteInfo]>, routes: HashMap>, state_extensions: Extensions, @@ -228,6 +263,11 @@ struct RouterInner { impl RouterInner { async fn dispatch(&self, mut request: Request) -> Result { + if let Some(hook) = &self.pre_dispatch_hook + && let Some(response) = hook.handle(&mut request).await? + { + return Ok(response); + } let method = request.method().clone(); let path = request.uri().path().to_owned(); @@ -330,11 +370,13 @@ impl RouterService { route_index: Arc<[RouteInfo]>, manifest_json: Option>, state_extensions: Extensions, + pre_dispatch_hook: Option, ) -> Self { Self { inner: Arc::new(RouterInner { manifest_json, middlewares, + pre_dispatch_hook, route_index, routes, state_extensions, @@ -947,3 +989,307 @@ mod tests { assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); } } + +#[cfg(test)] +mod pre_dispatch_tests { + use std::io::Error as IoError; + use std::mem::take; + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + use bytes::Bytes; + use futures::executor::block_on; + use futures::future::ready; + use futures::stream; + use tower_service::Service as _; + + use crate::action; + use crate::body::Body; + use crate::context::RequestContext; + use crate::error::EdgeError; + use crate::http::{Method, Request, Response, StatusCode, request_builder, response_builder}; + use crate::introspection::{ManifestJson, RouteTable}; + use crate::middleware::{Middleware, Next}; + use crate::router::{PreDispatchHook, RouterService}; + + struct CountedState { + clones: Arc, + } + + impl Clone for CountedState { + fn clone(&self) -> Self { + self.clones.fetch_add(1, Ordering::SeqCst); + Self { + clones: Arc::clone(&self.clones), + } + } + + fn clone_from(&mut self, source: &Self) { + *self = source.clone(); + } + } + + struct CountingMiddleware { + calls: Arc, + } + + #[async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + #[derive(Clone)] + struct HookMarker; + + struct InspectHook; + + #[async_trait(?Send)] + impl PreDispatchHook for InspectHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + let local = Rc::new(7_u32); + let body = take(request.body_mut()); + body.into_bytes_bounded(0).await?; + ready(()).await; + assert_eq!(*local, 7_u32, "should support a non-Send hook future"); + Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + struct RewriteHook; + + #[async_trait(?Send)] + impl PreDispatchHook for RewriteHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!(request.extensions().get::().is_none()); + *request.method_mut() = Method::POST; + *request.uri_mut() = "/after".parse().expect("should parse fixed URI"); + request.headers_mut().insert( + "x-hook", + "present".parse().expect("should parse fixed header"), + ); + request.extensions_mut().insert(HookMarker); + *request.body_mut() = Body::text("changed"); + Ok(None) + } + } + + struct TerminalHook { + calls: Arc, + fail: bool, + } + + #[async_trait(?Send)] + impl PreDispatchHook for TerminalHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + self.calls.fetch_add(1, Ordering::SeqCst); + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + if self.fail { + return Err(EdgeError::bad_request("hook rejected request")); + } + Ok(Some( + response_builder() + .status(418) + .header("allow", "POST") + .header("www-authenticate", "Basic") + .body(Body::text("local")) + .map_err(EdgeError::internal)?, + )) + } + } + + #[action] + async fn after_handler(context: RequestContext) -> Result { + assert!(context.request().extensions().get::().is_some()); + assert!( + context + .request() + .extensions() + .get::() + .is_some() + ); + assert_eq!(context.request().headers()["x-hook"], "present"); + assert_eq!( + context.request().body().as_bytes(), + Some(b"changed".as_slice()) + ); + response_builder() + .body(Body::text("continued")) + .map_err(EdgeError::internal) + } + + #[action(manifest, routes)] + async fn unreachable_handler() -> &'static str { + panic!("should short-circuit before handler"); + } + + fn input(method: Method, path: &str, body: Body) -> Request { + request_builder() + .method(method) + .uri(path) + .body(body) + .expect("should build hook input") + } + + #[test] + fn pre_dispatch_continuation_uses_mutated_request() { + let clones = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .pre_dispatch_hook(Arc::new(RewriteHook)) + .post("/after", after_handler) + .build(); + let response = block_on(router.oneshot(input(Method::GET, "/before", Body::empty()))) + .expect("should dispatch rewritten request"); + assert_eq!(response.body().as_bytes(), Some(b"continued".as_slice())); + assert_eq!(clones.load(Ordering::SeqCst), 1); + } + + #[test] + fn pre_dispatch_errors_stop_routing_in_both_service_apis() { + let clones = Arc::new(AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: true, + })) + .post("/after", after_handler) + .build(); + let error = block_on( + router + .clone() + .call(input(Method::POST, "/after", Body::empty())), + ) + .expect_err("should propagate hook error from Service"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + let response = block_on(router.oneshot(input(Method::POST, "/after", Body::empty()))) + .expect("should render hook error from oneshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_hook_is_shared_across_router_clones() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .build(); + for service in [router.clone(), router] { + let response = block_on(service.oneshot(input(Method::GET, "/missing", Body::empty()))) + .expect("should reuse shared hook"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_inspects_empty_stream_without_send_bound() { + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::new()), Ok(Bytes::new())]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should inspect clean streamed EOF"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[test] + fn pre_dispatch_registration_replaces_previous_hook() { + let first = Arc::new(AtomicUsize::new(0)); + let second = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&first), + fail: true, + })) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&second), + fail: false, + })) + .build(); + let response = block_on(router.oneshot(input(Method::GET, "/unknown", Body::empty()))) + .expect("should run replacement hook only"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(first.load(Ordering::SeqCst), 0); + assert_eq!(second.load(Ordering::SeqCst), 1); + } + + #[test] + fn pre_dispatch_rejects_nonempty_or_failed_streams() { + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::from_static(b"x"))]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should render nonempty body rejection"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let failure = stream::iter([Err::(IoError::other("fixture failure"))]); + let failure_response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(failure)))) + .expect("should render stream failure"); + assert_eq!(failure_response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn pre_dispatch_terminal_response_precedes_method_path_and_lifecycle() { + let calls = Arc::new(AtomicUsize::new(0)); + let clones = Arc::new(AtomicUsize::new(0)); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .with_manifest_json("{}") + .middleware(CountingMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .post("/after", unreachable_handler) + .build(); + for (method, path) in [ + (Method::POST, "/after"), + (Method::GET, "/after"), + ( + Method::from_bytes(b"EXAMPLE-METHOD").expect("should parse extension method"), + "/after", + ), + (Method::GET, "/missing"), + ] { + let response = block_on(router.oneshot(input(method, path, Body::empty()))) + .expect("should intercept before method/path lookup"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(response.headers()["allow"], "POST"); + assert_eq!(response.headers()["www-authenticate"], "Basic"); + assert_eq!(response.body().as_bytes(), Some(b"local".as_slice())); + } + assert_eq!(calls.load(Ordering::SeqCst), 4); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index a2136461..c2f93cfd 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -277,3 +277,18 @@ While Axum provides a convenient development environment, always test on actual - Deploy to [Fastly Compute](/guide/adapters/fastly) for production - Deploy to [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative - Explore [Configuration](/guide/configuration) for manifest options + +## Ingress provenance + +Conversion captures runtime URI parts and retains the original HeaderMap and +extensions. JSON buffering and non-JSON streaming are unchanged. The standard +plain HTTP listener supplies a private transport marker. Origin is available +only with that marker and one valid Host consistent with any URI authority and +scheme; forwarded headers do not supply a scheme. Generic conversion without +the marker leaves origin unavailable. + +Raw TCP tests retain extension methods, tested path spelling, same-name +duplicates and FF values. Hyper folds identical Content-Length values and +rejects conflicting lengths before the hook. The established server path +therefore records a Content-Length-specific multiplicity limitation. Global +wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index a09b22dc..4b593e67 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -314,3 +314,20 @@ Configure the Cloudflare adapter in `edgezero.toml`. See [Configuration](/guide/ - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative - Explore the [Axum adapter](/guide/adapters/axum) for local development + +## Runtime request fidelity + +Inbound conversion reads `req.inner().method()` rather than the closed Workers +method enum. Valid runtime extension tokens retain their spelling. +`Headers.entries()` values follow workerd's UTF-8 string convention and are copied through `HeaderValue::from_bytes(value.as_bytes())`. Invalid runtime HeaderValues produce bounded conversion errors. Conversion never comma-splits combined fields. Browser ByteString construction is not evidence of Worker wire-byte preservation. + +Ingress captures `req.inner().url()` before additional parsing. It records a +runtime URL and validated runtime origin. Fetch can already have normalized dot +segments and combined repeated fields; those changes cannot be reversed by +EdgeZero. Header fidelity remains conservative. + +Browser converter tests cover custom methods, high bytes, metadata and service +hook dispatch. Locked local workerd transport tests additionally distinguish +pre-Worker rejections: its HTTP parser returns 501 for extension methods. +Real Worker tests assert valid UTF-8 values C3 A9 and C4 80 survive conversion. Local wire FF becomes U+FFFD in Web Headers before Rust, so its runtime UTF-8 copy EF BF BD cannot prove original bytes. Common octet and multiplicity fidelity is `Unknown`: the observed transformations do not prove every particular field changed. Local absolute-form and duplicate Content-Length requests return 500 without a hook verdict. These transport/runtime limits remain separate from runtime-to-core conversion correctness. +See [the capability matrix](./overview#inbound-request-fidelity). diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index cd34c735..1aab5097 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -360,3 +360,33 @@ Configure the Fastly adapter in `edgezero.toml`. See [Configuration](/guide/conf - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target - Explore [Configuration](/guide/configuration) for manifest details + +## Early ingress snapshots + +Call `request::capture_request_ingress(&req)` immediately after acquiring the +native request and before URL-based shortcuts or native mutation. It borrows +the same request without round-tripping handles. With Fastly SDK 0.12.1, +safe raw-target acquisition is unavailable: the snapshot records `NotExposed` +first, then uses the SDK runtime URL only to recover the canonical origin. +This URL access can normalize the path; it never establishes original-target +preservation. Client requests need one valid Host consistent with the runtime +authority, including equivalent default ports. Origin provenance is +`RuntimeUri`; absent TLS metadata does not establish an HTTP scheme. Synthetic +requests and missing, duplicate, invalid or inconsistent Host have no origin. + +Use `into_core_request_with_ingress(req, ingress)`, +`FastlyService::with_request_ingress(ingress)`, or +`dispatch_with_registries_and_ingress(app, req, stores, env, ingress, extend)` +to retain that snapshot after later native mutations. The registry dispatcher +preserves manifest store IDs, EnvConfig selectors and default keys. It inserts +the snapshot after scratch extensions, so a callback cannot accidentally +replace it. Ordinary runners and existing converters capture once and delegate. +Supplied metadata and fidelity are caller assertions; syntax validation does +not independently establish their provenance. + +Viceroy probes retain tested repeated fields and FF header octets. They also +demonstrate that a normalized native shortcut can receive +`/reserved/../native` as `/native`. The router hook cannot intercept a shortcut +taken earlier. Original-target support and safe shortcut exclusion remain +blocked on the SDK accessor; client/TLS preservation must be proved on the +appropriate ingress. See [the capability matrix](./overview#inbound-request-fidelity). diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index b801d0a1..e415254c 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -13,7 +13,7 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in ## Request Conversion -Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion must: +Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion preserves the runtime-visible request subject to the fidelity limits below. It must: - **Preserve the HTTP method** exactly (`GET`, `POST`, etc.) - **Parse the full URI** (path and query string) into an `http::Uri`. Reject invalid URIs with `EdgeError::bad_request` @@ -110,7 +110,7 @@ Spin's adapter targets `wasm32-wasip2` and its contract suite runs under Wasmtim ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` @@ -154,3 +154,62 @@ logical id under both is a collision that `config validate` rejects. Fastly is the only adapter implementing `gc_config_entries` and the staging lifecycle actions (`DeployStaged`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return an unsupported error for those. + +## Inbound request fidelity + +Adapters insert `edgezero_core::request::RequestIngress` into request extensions. +It records runtime-visible target and trusted origin facts separately from the +primary URI and headers. Missing metadata means unknown capability. + +`CapturedTarget::Complete` holds the whole captured string, its acquisition +source and preservation status. Capture has a 16 KiB UTF-8 byte bound; oversize +input becomes `Unavailable(TooLarge)`, without retaining a prefix. Fastly's +current safe API reports `Unavailable(NotExposed)`. Metadata has borrowed +getters and intentionally has no value-bearing `Debug`, `Display` or +serialization implementation. Do not log or serialize captured values. + +`InboundOrigin` validates an HTTP(S) scheme and an authority of at most 1 KiB, +including IPv6 and port syntax. Parsing validates syntax; the adapter still +establishes provenance. Runtime URI facts use `RuntimeUri`; Axum's plain HTTP +listener uses `TransportBinding` plus one consistent Host. Origin-form Axum +conversion without that binding has no trusted origin. Ordinary Origin, +Forwarded and Spin special headers do not establish ingress origin. + +Fastly records target unavailability before reading its SDK runtime URL for +origin, and requires a client request with one consistent validated Host. +This recovers canonical origin without claiming an original path. + +`HeaderFidelity` has separate octet, field-multiplicity, same-name-order and +global-order axes, with per-name overrides. They concern original received +fields, excluding HTTP framing and header-name spelling. Faithful copying from +a runtime HeaderMap does not establish original-wire preservation. Unproved +axes remain `Unknown`; global field order is `Unavailable` on all four adapters. +Cloudflare's common octet and multiplicity guarantees remain `Unknown`: a +runtime capable of replacement/coalescing does not prove a particular field +changed. The observed transformations below remain capability limitations. + +Local evidence uses locked SDKs and fixed raw HTTP/1.1 fixtures. These results +describe the tested local runtimes; they do not establish production-edge +behavior. + +| Surface | Fastly 0.12.1 / Viceroy 0.17.0 | worker 0.8.3 / workerd 1.20260415.1 | Axum / Hyper 1.10.1 | Spin SDK 6.0.0 / Spin 4.0.0 | +| ----------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | +| Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | +| Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | +| High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | +| Repeated control fields | Tested same-name fields retained | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | +| Global wire order | Unavailable | Unavailable | Unavailable | Unavailable | + +Run `./scripts/test-request-fidelity.sh all` for local transport probes. They +assert pinned observations, including unsupported outcomes; a green regression +run does not turn an unsupported capability into a preservation guarantee. +Fastly raw-target support requires a safe borrowed SDK accessor and new ingress +evidence. Consumers that require original dot spellings or duplicate fields +must separately resolve Fetch normalization/coalescing and platform rejection +limits. EdgeZero does not approve a weaker consumer contract. + +The [pre-dispatch hook](../routing#intercepting-before-dispatch) runs after +adapter conversion. Fastly, Cloudflare and Spin still buffer bodies; Axum still +buffers JSON and streams other bodies. Existing store/capability bootstrap and +outer finalizers retain their behavior. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index ef283b1b..cfbdd287 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -306,11 +306,11 @@ Run contract tests for the Spin adapter: ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` -The tests execute the adapter's real `wasm32-wasip2` request path under Wasmtime. The +These contracts execute under Wasmtime. Incoming conversion is additionally exercised by the actual Spin HTTP fixture. The `wasmtime` version CI uses is pinned in `.tool-versions`. ## Manifest Configuration @@ -324,3 +324,24 @@ Configure the Spin adapter in `edgezero.toml`. See pre-rewrite store schema - [Adapters overview](/guide/adapters/overview) — cross-adapter contracts - [Configuration](/guide/configuration) — full manifest reference + +## Runtime ingress metadata + +Conversion captures SDK URI parts before rebuilding the core request. Origin +comes from HTTP(S) URI scheme/authority, with runtime provenance. +`spin-full-url`, `spin-client-addr`, Origin and forwarding headers do not become +trusted ingress-origin facts. Existing context fields and store/body behavior +remain unchanged. + +The Spin 4.0 HTTP component fixture verifies methods, tested target spelling, +duplicates and hook ordering through the actual converter. Spin 4.0's request +preparation requires UTF-8 header values; a Cookie containing FF returns 500 +before the component starts. This needs an upstream runtime fix and cannot be +restored inside EdgeZero. Same-name runtime values are copied byte-for-byte, +while original-wire fidelity remains conservative and global order unavailable. + +Pure metadata tests execute on the host. The WASM contract suite runs with +Wasmtime's explicit async/P3/HTTP flags. The full WASM library test artifact +also imports Spin-specific host interfaces, so plain Wasmtime cannot execute +that artifact. The real Spin HTTP fixture supplies runtime conversion evidence. +See [the capability matrix](./overview#inbound-request-fidelity). diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index efefe3d3..2ef14a2a 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -232,3 +232,11 @@ If you already hold an `Arc` (`BoxMiddleware`), register it with - Learn about [Streaming](/guide/streaming) for progressive responses - Explore [Proxying](/guide/proxying) for upstream forwarding + +## Work before route lookup + +Ordinary middleware runs after route selection and router-owned extension setup. +For method-independent interception before 404/405, use the +[pre-dispatch hook](./routing#intercepting-before-dispatch). A terminal hook +response or error bypasses ordinary middleware and handlers. A continuing hook +passes its request mutations into the existing middleware chain. diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 9c58c81d..d1e45faf 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -186,3 +186,56 @@ Run the app under the Axum dev server before deploying to an edge target. - Learn about [Handlers & Extractors](/guide/handlers) for processing requests - Explore [Middleware](/guide/middleware) for cross-cutting concerns + +## Intercepting before dispatch + +Use an optional `PreDispatchHook` when handling a request must precede method +and path lookup, including router-generated 404/405 responses. The hook receives +the converted request before router state, introspection, request context, +middleware or handlers. Its future can be non-`Send`. + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Request, Response, response_builder}; +use edgezero_core::router::{PreDispatchHook, RouterService}; + +struct ExampleHook; + +#[async_trait(?Send)] +impl PreDispatchHook for ExampleHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + if request.headers().contains_key("x-example-stop") { + return Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )); + } + Ok(None) + } +} + +let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(ExampleHook)) + .build(); +``` + +`Ok(None)` continues with the mutated request: method, URI, headers, extensions +and remaining body all participate in normal dispatch. Consuming a stream does +not restore it. `Ok(Some(response))` returns that response without normal router +lifecycle work. Return a complete response when exact challenge or hardening +headers matter. `Err(error)` stops dispatch; direct `Service::call` propagates it, +while `RouterService::oneshot` renders it through the existing error response +conversion. + +Repeated registration replaces the hook. Router clones share its `Arc`. +Without a hook, existing routing is unchanged. Adapter buffering, capability +bootstrap and outer response finalizers can still run before or after this +router boundary; the hook does not replace those layers. + +Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) +before classifying a request using its target or duplicate headers. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md new file mode 100644 index 00000000..0f3ffe01 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -0,0 +1,390 @@ +# Pre-dispatch interception and inbound request fidelity implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use @superpowers:subagent-driven-development or @superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add generic method-independent interception before router dispatch and preserve/report inbound request information honestly across all four adapters. + +**Architecture:** One optional Arc-backed non-Send pre-dispatch hook on the existing RouterService. Adapter-owned bounded RequestIngress metadata preserves runtime-visible target/origin information and declares header fidelity without duplicating sensitive headers. Narrow converter fixes and actual local runtime fixtures prove each supported claim. + +**Tech Stack:** Rust 1.95.0, edition 2024, existing async-trait/futures/http facade, Fastly 0.12.1, worker 0.8.3, Axum/Hyper, spin-sdk 6.0.0, Viceroy 0.17.0, wasm-bindgen-test, Wasmtime and VitePress. + +**Source:** [Corresponding spec](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). +**Baseline:** `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Branch:** `spec/pre-dispatch-request-fidelity`. +**Status:** Implementation authorized after independent review. Toolkit work is implemented; verification and external consumer gates are recorded below. + +--- + +## Execution boundary and dependency order + +The user authorized implementation after independent review with no material findings. Work remains on this EdgeZero branch. The downstream approved spec is unchanged; no push, publication or release approval is implied. + +Read AGENTS.md and CLAUDE.md before executing. Keep the feature generic. Do not add trace namespace checks, cookie names, report schemas, authentication policy or consumer finalization into EdgeZero. New handlers use `#[action]` and HTTP aliases come from `edgezero_core::http`. Preserve non-Send futures and avoid new runtime dependencies in core. Apply strict alphabetical/lint/public-documentation conventions at changed sites only. + +Order: P0 capability decisions → P1 ingress contract and P2 hook (independent core work) → P3 Cloudflare / P4 Axum / P5 Spin (independent implementations with central integration) → P6 Fastly after the SDK prerequisite → P7 real ingress fixtures → P8 CI/docs/release evidence. + +Pure core/Cloudflare/Axum/Spin work can proceed while the Fastly SDK getter is unavailable. That is progress on infrastructure, not completed downstream F0. Record every environment prerequisite explicitly; zero selected tests, host-only WASM checks, parser rejection and unavailable runtimes are not passing ingress evidence. + +Default no-hook behavior must remain identical. Cloudflare method/header conversion fixes are intentional correctness changes even without a hook: update existing tests that codify information loss and document the behavior change. + +## File map + +| Action | Exact path | Responsibility | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Modify | `crates/edgezero-core/src/router.rs` | Hook trait/alias, optional builder/inner field, dispatch ordering and colocated tests | +| Create | `crates/edgezero-core/src/request.rs` | Bounded RequestIngress types/constructors/accessors and colocated tests | +| Modify | `crates/edgezero-core/src/lib.rs` | Export request module | +| Modify | `crates/edgezero-adapter-cloudflare/src/request.rs` | Runtime method/URL capture and Worker UTF-8 header conversion | +| Modify | `crates/edgezero-adapter-cloudflare/tests/contract.rs` | Executed browser converter regressions | +| Modify if required | `crates/edgezero-adapter-cloudflare/Cargo.toml` | Explicit test Web RequestInit features | +| Modify | `crates/edgezero-adapter-axum/src/request.rs`, `crates/edgezero-adapter-axum/src/service.rs`, `crates/edgezero-adapter-axum/src/dev_server.rs` | Runtime parts metadata, trusted transport binding, real native parser/hook tests | +| Modify | `crates/edgezero-adapter-spin/src/request.rs`, `crates/edgezero-adapter-spin/tests/contract.rs` | Runtime parts metadata and converter/dispatch regression seams | +| Modify | `crates/edgezero-adapter-fastly/src/request.rs`, `crates/edgezero-adapter-fastly/src/lib.rs`, `crates/edgezero-adapter-fastly/tests/contract.rs` | Early safe capture, pre-captured metadata path, runners and regression coverage | +| Modify only after approved SDK revision | `Cargo.toml`, `Cargo.lock` | Reviewed dependency pin; no imaginary API or version | +| Create | `tests/fixtures/request-fidelity/Cargo.toml`, `tests/fixtures/request-fidelity/Cargo.lock`, `tests/fixtures/request-fidelity/src/lib.rs` | Isolated test-only fixture workspace and shared store-free router | +| Create | `tests/fixtures/request-fidelity/src/bin/fastly.rs`, `tests/fixtures/request-fidelity/src/spin.rs`, `tests/fixtures/request-fidelity/src/cloudflare.rs` | Actual runtime entry points and generic early-shortcut fixture | +| Create | `tests/fixtures/request-fidelity/fastly.toml`, `tests/fixtures/request-fidelity/spin.toml`, `tests/fixtures/request-fidelity/wrangler.toml` | Loopback-only local runtime configuration | +| Create | `tests/fixtures/request-fidelity/package.json`, `tests/fixtures/request-fidelity/package-lock.json` | Exact verified Wrangler/tooling pins and scripts | +| Create | `scripts/test-request-fidelity.sh`, `tests/fixtures/request-fidelity-runner/{Cargo.toml,Cargo.lock,src/main.rs}` | Runtime startup/build/readiness/cleanup and raw socket probes | +| Modify | `.github/workflows/test.yml` | Executed WASM unit/contract and local runtime transport gates | +| Modify | `docs/guide/routing.md`, `docs/guide/middleware.md`, `docs/guide/adapters/overview.md`, `docs/guide/adapters/fastly.md`, `docs/guide/adapters/cloudflare.md`, `docs/guide/adapters/axum.md`, `docs/guide/adapters/spin.md` | Hook API, ordering limits, platform matrix and consumer handoff | + +The fixture is a test-only separate workspace (`[workspace]` in its manifest); do not add it to production workspace members or inherit root workspace dependencies incorrectly. Use explicit local path dependencies and optional platform features; each build selects only its runtime. Any needed fixture-path exclusions/package publication guards must be explicit. Root all-feature clippy must not compile a multi-runtime fixture accidentally. + +## P0: Establish capability decisions before promising downstream compliance + +**Files:** source investigation only, spec capability matrix; dependency files only after an approved pin exists. + +- [x] Reconfirm clean implementation baseline, `git rev-parse HEAD`, `git status --short --branch` and `cargo metadata --format-version 1 --no-deps`. Re-read consumer section 8's explicit pre-buffering allowance. +- [x] Verify pinned SDK/runtime sources from Cargo.lock. Record worker 0.8.3 versus the consumer's 0.8.5, wasm-bindgen 0.2.122, Fastly 0.12.1, Hyper 1.10.1 and Spin SDK 6.0.0. Do not rely on floating package documentation. +- [x] For Fastly, investigate a safe borrowed raw-runtime-URI accessor. Existing get_url/get_path normalize; into_handles/from_handles loses client provenance, and clone_without_body forces lazy URL parsing. Rule out those workarounds. A proposed accessor name is not an existing SDK API. +- [ ] Prepare the external SDK change requirements: bounded native URI acquisition before Url parsing; no request/body consumption or cloning; no second from_client acquisition; client IP/TLS/original-header provenance retained; errors bounded; no fabricated original-wire promise. Obtain a reviewed immutable compatible revision/version before P6's complete-target implementation. +- [x] Record per-field runtime limitations, including Cloudflare Headers combining and Hyper identical Content-Length folding. Decide what the generic metadata can truthfully expose. +- [x] Separate adapter conversion guarantees from original wire guarantees. Where consumer requirements are impossible with the runtime, record an explicit downstream design-resolution item; do not edit or approve that spec from this branch. +- [x] Select the recommended hook plus metadata design over middleware-only or per-adapter policy wrappers; record scope/CI impact. External SDK or contract gates cannot be replaced by synthetic fixture success. +- [x] Checkpoint: approved upstream design before code; unresolved external SDK work may remain pending while independent tasks proceed. + +## P1: Define bounded adapter-owned RequestIngress + +**Files:** create `crates/edgezero-core/src/request.rs`; modify `crates/edgezero-core/src/lib.rs`. + +- [x] Add red tests named `ingress_target_bound_is_atomic`, `ingress_fidelity_defaults_are_unknown`, `ingress_header_override_is_field_specific` and `ingress_origin_rejects_untrusted_or_invalid_input`. +- [x] Run `cargo test -p edgezero-core ingress`. Expected red for missing types/behavior, not an unrelated compile/setup failure; make the initial test harness compile before judging behavioral red. +- [x] Implement the spec's closed enums/types. Exact minimum API: `RequestIngress::new(target, origin, common_header_fidelity, overrides)`, `target()`, `origin()` and `header_fidelity(&HeaderName)`. The CompleteTarget payload is public with private fields and borrowed getters; only CapturedTarget::capture constructs it. Add a compile-fail doctest proving external callers cannot bypass its bound with a struct literal. Use an owned small override list/map and reject duplicate override keys at construction rather than ambiguous selection. No copied header values. +- [x] Add `CapturedTarget::capture(value, source, fidelity)` applying the 16 KiB complete-or-unavailable bound. Add `InboundOrigin::parse(scheme, authority, source)` with a 1 KiB authority bound, HTTP(S)-only scheme and no user-info/path/query/fragment. Include the spec’s OriginSource enum/borrowed source accessor. Parsing validates input; adapter callers must still prove its trusted source. Origin-form requests without transport binding remain unavailable. +- [x] Define header-fidelity axes against original received field values/multiplicity/order, excluding HTTP framing/name spelling. Runtime-to-core copying alone cannot set Preserved. Tests/docs separately report converter-copy guarantees versus pinned parser/runtime/transport evidence, including trimming/coalescing loss. +- [x] Define private fields/borrowed getters and Clone-only value-bearing metadata. Use existing Arc/HTTP facade types. No Serialize or secret-bearing Debug/Display/logging. +- [x] Test exact cap and one byte over, non-ASCII UTF-8 byte counting, read-failure/unavailable states, same-name versus global order, Content-Length override, missing extension and IPv6/default-port authority. Never store a prefix as a complete target. +- [x] Run `cargo test -p edgezero-core ingress`, `cargo test -p edgezero-core` and scoped clippy. Expected green; all tests selected. +- [ ] Planned commit after checks: `Add bounded inbound request metadata and fidelity contracts`. + +## P2: Add the optional pre-dispatch hook to the concrete router + +**Files:** `crates/edgezero-core/src/router.rs`. + +- [x] Add behavioral tests for terminal response on unknown route and `EXAMPLE-METHOD`; continuation that changes method/URI; hook error; no-hook equivalence; absent router state/introspection in hook; state-clone count; cloned-router shared hook; streamed-body inspection; and Rc retained across await. +- [x] Run `cargo test -p edgezero-core pre_dispatch`. Expected meaningful red for route-first ordering, then implement the trait/alias and builder method from spec section 5. +- [x] Add one optional Arc field to builder/inner, thread it through build/new and invoke it before method/path copies. Document repeated registration replacing the existing hook. +- [x] Test mutations of header/extensions/body in addition to method/URI. Continued dispatch uses the remaining mutated body exactly; the hook cannot consume a stream and silently restore it. +- [x] Distinguish error APIs: direct `Service::call` propagates EdgeError; existing `RouterService::oneshot` renders it through IntoResponse. Both stop before normal dispatch. A consumer requiring custom challenge/hardening returns `Some(Response)` with those exact headers. +- [x] Test a terminal custom status/body/Allow/WWW-Authenticate is preserved by core. Document that outer adapter/application response finalizers may still operate. +- [x] Verify every existing core router/introspection/state/middleware test, plus manual `Hooks::routes` build compatibility. Keep macros/App/runner return types unchanged. +- [x] Run `cargo test -p edgezero-core` and `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings`. +- [ ] Planned commit: `Add method-independent router pre-dispatch interception`. + +## P3: Correct Cloudflare runtime method and header conversion + +**Files:** `crates/edgezero-adapter-cloudflare/src/request.rs`, `crates/edgezero-adapter-cloudflare/tests/contract.rs`, optional test feature changes in its Cargo.toml. + +- [x] Add raw Web Request fixtures using `web_sys::RequestInit::set_method`, `Request::new_with_str_and_init` and `CfRequest::from(raw)`. Worker enum-only RequestInit cannot create the regression. Cover uppercase and lowercase extension tokens and existing supported methods. +- [x] Add runtime URL metadata equality tests independently of parsed core URI equality. Use dot/encoded-dot/encoded-separator/repeated-slash/query cases, comparing what Web Request actually exposes rather than requiring it to preserve the input string. +- [x] Add browser converter tests for ASCII/high-byte runtime strings. Assert UTF-8 string copying; browser ByteString semantics are not Worker wire evidence. The incorrect helper-only lib test is removed; actual workerd tests cover valid C3 A9/C4 80 and invalid FF transformation. +- [x] Run the browser contract command in Shared verification. Red/green covers extension-token preservation and the corrected UTF-8 runtime-string convention. +- [x] Replace inbound method enum conversion with `CoreMethod::from_bytes(req.inner().method().as_bytes())` and bounded bad-request errors. Capture `req.inner().url()` before additional parsing and insert RequestIngress after core request construction. +- [x] Populate InboundOrigin from the runtime URL’s HTTP(S) scheme and validated authority with RuntimeUri provenance, after target capture. Never use Origin or forwarding headers for this fact. Add invalid/non-HTTP scheme, user-info/authority bounds and spoofed Origin/forwarding-header tests; missing or untrustworthy facts stay unavailable. Runtime URL parsing for origin must not replace the captured target. +- [x] Construct HeaderValue with `HeaderValue::from_bytes(value.as_bytes())`, following pinned workerd's UTF-8 header convention. Reject invalid runtime HeaderValues with bounded errors; preserve append behavior and validated names. Do not single-byte-cast Unicode scalars, silently omit fields or comma-split combined values. +- [x] Verify exact real Worker wire C3 A9 and C4 80 preservation, and distinguish invalid FF replacement from original-byte availability. Update the baseline only after semantic assertions pass. +- [x] Locate all `into_core_method` uses. Replace/update the old unknown-method→GET test; remove the helper only if unused, retaining supported-method tests through actual inbound conversion. +- [x] Keep buffering, context/proxy/store paths unchanged. Add hook-through-CloudflareService coverage, then run contract tests plus target clippy/check. +- [ ] Planned commit: `Preserve Cloudflare runtime methods and header octets`. + +## P4: Preserve Axum parts and declare parser limitations + +**Files:** Axum `src/request.rs`, `src/service.rs`, `src/dev_server.rs`. + +- [x] Add `ingress` converter tests for method/URI/bytes/repeated same-name values/extensions, origin-form and absolute-form targets, and missing trusted origin. +- [x] Run `cargo test -p edgezero-adapter-axum --features axum ingress`. Expected red for absent metadata. Add metadata around existing parts/from_parts; retain JSON buffering and non-JSON streaming. +- [x] If the standard dev server supplies an HTTP transport binding, insert only that adapter-owned scheme fact before conversion and combine it with exactly one validated inbound authority. Reject contradictory URI/Host origin facts as unavailable metadata; never guess HTTPS from forwarded headers. Generic converter callers without binding get unavailable origin. +- [x] Declare global wire order unavailable. Add a Content-Length-specific multiplicity limitation grounded in the pinned parser; preserve known same-name runtime values without claiming all original controls survived. +- [x] Extend service tests to show early hook response, continuation, extension method and router error behavior. Capability registries constructed before the hook are not ordinary consumer KV operations. +- [x] Add raw TCP cases to the existing dev-server listener harness: dot spellings, Cookie/Origin/control duplicates, identical/conflicting lengths, non-UTF-8 bytes and TE/CL combinations. Assert whether Hyper rejects or the hook sees the request. +- [x] Run `cargo test -p edgezero-adapter-axum --features axum` and native target clippy. +- [ ] Planned commit: `Expose Axum ingress fidelity without changing body behavior`. + +## P5: Capture Spin runtime parts and test the actual seam + +**Files:** Spin `src/request.rs` and `tests/contract.rs`. + +- [x] Add parts-conversion helper tests, or actual runtime tests where SDK incoming-body construction permits it, for runtime method/URI/header metadata. +- [x] Run host tests for host-covered helpers and the WASM contract/lib commands for runtime code. Existing core-request-only contract success is not converter coverage. +- [x] Capture runtime parts before rebuilding core Request. Preserve byte HeaderValues/method and source URI scheme/authority. Do not grant trust to spin-full-url/spin-client-addr solely because those headers exist. +- [x] Preserve existing buffering and store registry setup. Document earlier capability setup separately from ordinary app lifecycle work. +- [x] Verify extension `Method::Other` conversion, same-name values, unavailable global wire order, missing origin/facts, and metadata through actual dispatch. +- [x] Use P7's Spin invocation to establish URI/header behavior and spoofed special-header outcomes. If a synthetic shared-parts helper was used, explicitly identify which claims still need runtime evidence. +- [x] Run affected host tests and wasm32-wasip2 check/clippy/contract. Full WASM lib execution remains unavailable because plain Wasmtime lacks Spin-specific imports; actual Spin HTTP fixture covers the converter instead. +- [ ] Planned commit: `Expose Spin runtime ingress provenance and conversion tests`. + +## P6: Add safe Fastly capture and pre-captured metadata integration + +**Files:** Fastly `src/request.rs`, `src/lib.rs`, `tests/contract.rs`; dependency files only after P0's reviewed SDK pin. + +- [x] Checkpoint the external SDK accessor. If unavailable, implement honest unavailable/runtime-normalized metadata and finish byte-path regression work, but leave original-target/client-provenance acceptance pending. +- [ ] Add red target-cap/capture-failure/client-provenance tests before changing converters. Execution: bounded supplied snapshots, unavailable SDK target and actual HTTP client preservation are tested; raw acquisition failure and TLS coverage await the SDK accessor. Synthetic `FastlyRequest::new` already parses URLs and does not establish raw incoming preservation. +- [ ] Implement `capture_request_ingress(&FastlyRequest) -> RequestIngress` using the reviewed safe borrowed accessor before any URL accessor. It cannot consume/clone/reacquire the request. Bounded capture errors create unavailable metadata without raw error strings. +- [x] Add `into_core_request_with_ingress(req, ingress)`; existing `into_core_request(req)` captures once and delegates. A consumer that captures immediately after from_client can carry that exact snapshot through later conversion, avoiding recapture of mutated native state. +- [x] Add public `dispatch_with_registries_and_ingress(app, req, stores, env, ingress, extend)` with the existing return type/generic callback. The existing `dispatch_with_registries` captures and delegates; preserve per-id registries, EnvConfig/default-key selectors and required-store errors, rather than replacing it with legacy bare-handle defaults. Thread captured facts through FastlyService and registry/request-extensions dispatch with one internal conversion/store-wiring path. Add `FastlyService::with_request_ingress` only if required by that dispatch API. Ordinary runners capture before their first native URL access/callback; custom early-entry callers use the explicit snapshot path. +- [x] Ensure application scratch extension merging cannot accidentally replace adapter-captured ingress facts; define adapter snapshot insertion after generic scratch merge. Test collision, same snapshot retention and unchanged device/client extensions. Do not alter normal service/store selection. +- [x] Preserve existing byte-oriented header append path. Add all-values/high-byte/same-name-order tests, explicitly excluding unproved global wire order/original-name reconstruction. +- [x] Run Fastly contract and lib WASM/Viceroy suites, no-feature/CLI-feature and WASM clippy. Real ingress and native-shortcut evidence is recorded in P7. +- [ ] Planned commit: `Add safe early Fastly ingress capture and snapshot handoff`. + +## P7: Prove actual local transport behavior + +**Files:** proposed test fixture workspace/runtime configs and `scripts/test-request-fidelity.sh` and `tests/fixtures/request-fidelity-runner/`. + +- [x] Build a store-free generic fixture router with one early hook and an ordinary route/middleware counter. Test namespace is `/reserved`, not Trusted Server policy. New handlers use #[action]. +- [x] Expose only synthetic fixture verdicts: method equality, captured-target equality/availability, expected header-byte equality, per-field counts/fidelity and ordinary-handler-called. Use fixed fictional fixtures; do not build a reusable unrestricted header/cookie/URI echo endpoint. +- [x] Add Fastly entry fixture that captures before a generic native shortcut and carries the snapshot into conversion. Test reserved dot spellings remain visible when the SDK/runtime supports them. Raw target unavailable is an explicit unsupported verdict, not a passed bypass test. +- [x] Add Cloudflare Worker fixture selecting the cloudflare feature and matching worker-build/Wrangler tooling. Pin Wrangler as an exact npm dev dependency with package-lock. Resolve/pin worker-build as a Rust CLI compatible with the fixture’s locked worker version, installed with cargo --version/--locked; npm locking does not pin it. Use lock-matched wasm-bindgen tooling. Verify exact compatible tool versions at implementation; do not copy floating ^4.38.0 or invent a release. +- [x] Add a feature-gated #[http_service] entry in src/spin.rs, included by the fixture library, targeting wasm32-wasip2 as a cdylib HTTP component; do not build Spin as a main/bin WASI command. For package name request-fidelity, spin.toml references target/spin/wasm32-wasip2/release/request_fidelity.wasm, built with cargo build --manifest-path tests/fixtures/request-fidelity/Cargo.toml --target-dir tests/fixtures/request-fidelity/target/spin --target wasm32-wasip2 --features spin --lib --release --locked. Resolve that path relative to spin.toml. Fastly selects its required-feature binary; Cloudflare selects its feature-gated library via worker-build. Reuse existing runtime build conventions; new fixture dependency pins/lockfile must be reproducible. +- [x] The shell runner builds the host Rust helper and accepts `fastly`, `cloudflare`, `spin` or `all`, allocates an available loopback port, builds the selected fixture, starts the runtime, waits with a bounded readiness deadline, runs raw socket probes, and always stops its process group on failure/signal/success. No platform credentials, remote services or publisher requests. +- [x] Native Rust socket client replaces the original Python test helper and sends exact HTTP/1.1 bytes with Host example.com. Include extension methods, origin/absolute forms where supported, literal/encoded dot paths, repeated/encoded separators, non-UTF-8 Cookie octets, interleaved/case-varied repeated fields, duplicate controls and identical/conflicting length cases. +- [x] Record transport rejection versus runtime transformation versus EdgeZero conversion versus hook response. A browser Fetch/reqwest-normalized input cannot replace a raw request probe. Local Wrangler/workerd/Viceroy/Spin results cannot prove all production-edge behavior. +- [x] Test the Rust replacement’s cleanup after failed readiness/probe/build, parallel port isolation and no lingering process/artifact mutation of baseline demo config. +- [x] Run the Rust-backed `./scripts/test-request-fidelity.sh fastly`, `cloudflare` and `spin` with the required tools installed. Each supported verdict is asserted; unresolved guarantees fail the compliance claim or remain a specifically listed pending gate, not a blanket skip/pass. +- [ ] Planned commit after checks: `Add local runtime probes for inbound request fidelity`. + +## P8: Connect CI, document capabilities and hand off the reviewed revision + +**Files:** .github/workflows/test.yml, guide paths in file map, dependency/fixture locks if changed. + +- [x] Cloudflare converter regressions execute in the browser contract target; the incorrect scalar helper and its helper-only lib gate are removed. Spin adjustment: host helper tests, WASM contract/check/lint and actual Spin HTTP fixture cover the converter; its full WASM lib artifact requires unavailable Spin host interfaces and is not reported as passing. Fastly already has contract/lib execution; host workspace tests do not run gated WASM conversion code. +- [x] Provision browser/WebDriver for Cloudflare run_in_browser tests, matching wasm-bindgen-cli from Cargo.lock. Reuse current version-resolution workflow rather than independently pinning a drifting runner. +- [x] Add local fixture transport jobs with exact installed tool versions/locks and deterministic cleanup. Spin CLI availability must be explicit; Wasmtime contract execution alone is not Spin HTTP ingress. +- [x] Document core API example, hook result/error/state/finalizer semantics, buffering limitation, origin trust rules, per-field fidelity and every runtime limitation. Separate capability availability from consumer approval. +- [x] Build the final platform evidence matrix with source pin, exact fixture, observed outcome and status for each requirement. Record local versus production verification limits. +- [x] Run all Shared verification gates and confirm tests selected the new modules. Scope red/green at tasks; full gates at implementation handoff. +- [x] Independent reviewers check spec/plan/implementation alignment, correctness, minimum scope, supported target coverage and unresolved gates. No upstream/downstream compliance approval by inference. +- [ ] Handoff an immutable reviewed EdgeZero commit/tag only after release approval. Record it for Trusted Server to pin all EdgeZero dependencies consistently. Downstream changes stay on its existing branch and require its own acceptance checks. +- [ ] Planned commit: `Document and verify pre-dispatch ingress contracts across adapters`. + +## Shared verification + +Focused commands run from repository root with the relevant features/targets. Require nonzero selected tests and a passing intended red/green cycle. New code must not be committed before tests required by CLAUDE.md pass. + +```bash +cargo test -p edgezero-core ingress +cargo test -p edgezero-core pre_dispatch +cargo test -p edgezero-core +cargo test -p edgezero-adapter-axum --features axum +cargo test -p edgezero-adapter-spin +CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract +CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --lib +CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract +# Full Spin WASM lib needs Spin-specific host imports; use host helper tests +# plus the actual Spin HTTP fixture instead of claiming plain Wasmtime execution. +``` + +Fastly feature tests require the WASM/Viceroy runner; a host feature test may fail at linking and cannot substitute. Cloudflare contract uses a real headless browser/WebDriver and the lock-resolved wasm-bindgen-cli (currently 0.2.122). Existing matrix provisions Viceroy and Wasmtime; new local runtime fixtures may require additional CLI/tool installation. + +Full implementation handoff gates: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 +cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown +cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 +cargo clippy -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --all-targets -- -D warnings +cargo doc -p edgezero-core --no-deps --all-features +cargo test --locked --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets +cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check +cargo clippy --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets --all-features -- -D warnings +./scripts/test-request-fidelity.sh all +npm --prefix docs run lint +npm --prefix docs run format +npm --prefix docs run build +git diff --check +``` + +Expected: exit 0 for all available required environments, unchanged baseline behavior except documented Cloudflare conversion corrections, and no unexecuted suite reported as a pass. No runtime/CI pass is claimed by this planning document. + +## Spec-to-task acceptance map + +| Spec requirement | Tasks | Evidence | +| -------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------- | +| Generic hook and no-hook compatibility | P2 | Ordering, error/response/continuation/state/clone/non-Send regressions | +| Bounded confidential metadata and origin provenance | P1/P3–P6 | Target caps, borrowed getters, missing facts, no logged/serialized values | +| Correct runtime method/header conversion | P3–P6 | Actual converters with extension tokens and high bytes | +| Fastly safe early capture and consumer shortcut seam | P0/P6/P7 | Reviewed SDK accessor, client provenance, actual Viceroy wire paths | +| Honest multiplicity/normalization limits | P0/P1/P4/P7/P8 | Hyper/Fetch/runtime rejection/transformation matrix | +| Buffered-body and outer-finalizer limits preserved | P2–P6/P8 | Existing body/store/response contracts and accurate docs | +| All-adapter runtime evidence and target gates | P7/P8 | Native parser, WASM contract/lib and local runtime fixture results | +| Downstream F0 readiness distinguished from infrastructure completion | P0/P8 | Explicit immutable pin and separately approved consumer-contract resolution | + +## Independent document review + +Three independent read-only reviewers assessed both complete documents against main 98930917 and the approved consumer requirements. They proposed minimal approaches during investigation, reviewed the selected hook/metadata design, and reread the corrected documents. This document review completed before the user authorized implementation. + +| Scope | Spec result | Plan result | +| ------------------------------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- | +| Core router/API, Axum/Spin conversion, body/bootstrap limits and execution feasibility | Approved | Approved after fixture/origin corrections | +| Cloudflare method/ByteString/provenance, bounded public API and browser/runtime tooling | Approved after bounded-payload correction | Approved after matching API/tooling corrections | +| Fastly native ownership, SDK dependency, snapshot/registry integration and Viceroy wire evidence | Approved after header-fidelity clarification | Approved after matching clarification | + +Corrections incorporated: private bounded CompleteTarget payload and compile-fail boundary test; explicit origin provenance/Cloudflare origin construction; Spin HTTP-component library rather than command binary; distinct npm/Cargo tool locks; original-field versus runtime-copy fidelity semantics; and named metadata-aware registry dispatch preserving EnvConfig/store-selector behavior. + +The safe borrowed Fastly SDK accessor remains external work, and original-wire properties require runtime evidence or separately approved consumer-contract resolution. The implementation evidence below is separate from these initial document reviews; neither review nor green infrastructure checks imply completion of Trusted Server F0. + +## Executor handoff + +Use this instruction with EdgeZero's Codex after maintainer approval: + +> Work in EdgeZero on branch spec/pre-dispatch-request-fidelity. Read AGENTS.md and CLAUDE.md, then the corresponding spec and this plan. Implement P0–P8 using the approved API and task verification. Keep RouterService concrete and non-Send-compatible; keep the hook generic. Preserve runtime-visible facts without inventing raw wire guarantees. Resolve the external safe Fastly SDK accessor before claiming original-target support. Verify actual method/header/target conversion and run local wire fixtures. Treat missing runtime facts or test environments as explicit pending gates, never success. Do not change Trusted Server's spec, implement trace policy, patch Cargo caches, push or publish without separate authorization. Report focused/full checks, the final capability matrix, and the immutable dependency revision ready for consumer review. + +Only the spec and this matching plan are planning artifacts for this feature; independent review results are recorded here after corrections. + +## Implementation and verification record + +The fresh pre-implementation review used three independent scopes: core/Axum/Spin; Cloudflare/provenance; Fastly/SDK/native ownership. All three approved both the spec and plan before code work. Subsequent independent implementation reviews found no production correctness defects. Coverage and lint findings were corrected centrally: IPv6/ASCII-port validation, matched-route lifecycle testing, scratch/client extension assertions, transport-specific Content-Length fidelity, executable Spin helper placement, and process-group cleanup races. Final review also corrected pinned-browser selection in CI, exact FF cookie equality and mandatory runtime-observation comparison. + +### Implemented surfaces + +- Core: bounded confidential ingress API, validated origin provenance, field-specific fidelity overrides and sealed target payload; optional shared non-Send hook before method/path/state/introspection/context/middleware. +- Cloudflare: runtime method tokens, checked UTF-8 runtime HeaderValues, runtime URL/origin capture and real browser converter/service regressions. +- Axum: runtime URI/HeaderMap retention, private HTTP binding, consistent Host origin, Content-Length parser limitation and exact TCP converter/service probes. +- Spin: runtime-parts snapshot and byte/method conversion retained; host-executed URI metadata tests and actual SDK6 HTTP component fixture. +- Fastly: safe unavailable-target capture, exact supplied-snapshot retention, registry-aware dispatch, service builder and capture-before-callback runner path; no unsafe handles, URL-cloning workaround or SDK dependency substitution. +- Verification: isolated locked multi-runtime fixtures, fixed verdicts, pinned observations, bounded loopback readiness and process-group cleanup for builds and runtimes; browser/fixture CI gates and public API/capability documentation. + +### Evidence and limits + +| Surface | Executed evidence | Outcome | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Core | 484 unit tests, integration test and compile-fail bounded-payload doctest | Pass; existing ignored docs unchanged | +| Native workspace | Workspace/all-target test run | 1,440 passed, 0 failed, 1 existing ignored; 20 suites | +| Axum | 115 full tests including the cap regression | Pass; raw TCP includes duplicates, FF, dots, identical/conflicting CL, TE/CL, continuation/error | +| Cloudflare | 11 browser contract tests including non-HTTP origin; explicit Chrome binary capabilities; removed incorrect scalar helper/lib-only test | Pass; converter and service hooks execute | +| Fastly | Viceroy 0.17.0 contract 6 and WASM library 91 | Pass; byte/header/snapshot/collision/client/proxy regressions execute | +| Spin | Host 31 and Wasmtime 44.0.1 contract 12 with async/P3/HTTP flags | Pass; host contract's zero selected tests do not count as runtime evidence | +| CLI/audits/scaffold | Fastly CLI-feature 287; nested AppConfig checker 18; placeholder/legacy/nested audits; generated project build test | Pass; generated native/WASM adapters compile | +| Excluded demo | Locked workspace 33 tests, formatting and strict all-feature clippy | Pass; six host adapter targets select zero tests | +| Build/lint/docs | Root/adapter/fixture formatting; workspace, adapter and all three fixture target clippy; feature checks, core rustdoc; docs ESLint/Prettier/VitePress build | Pass; three existing warnings in untouched rustdoc files remain | + +Spin plan adjustment: the new pure helper/tests live in `src/context.rs`, which already has a host-safe test gate. Plain Wasmtime cannot supply `spin:key-value/key-value@3.0.0` to the full WASM library test artifact. That artifact is not reported as passing. Host helper execution, existing WASM contracts with explicit flags, target lint/check and the actual Spin HTTP component establish the implemented seam without inventing a Spin-aware unit runner. CI retains this distinction. + +Local fixture pins: Fastly 0.12.1/Viceroy 0.17.0; worker 0.8.3/wasm-bindgen 0.2.122/worker-build 0.8.3/Wrangler 4.83.0/Miniflare 4.20260415.0/workerd 1.20260415.1; Spin SDK 6.0.0/Spin 4.0.0; Rust 1.95.0/Node 24.12.0. Full fixed observations are recorded in `tests/fixtures/request-fidelity/observations.json`; they are regression evidence, not compliance approval. All 47 cases were executed with the native Rust client on the three pinned local runtimes. Failure probes covered a failed build, interrupted build with child process, forced readiness failure and forced probe failure; parallel Fastly runs used distinct ports and left no live owned processes. CI is configured with the locally verified commands; remote GitHub Actions execution remains unrun. + +### Remaining upstream and consumer gates + +1. Fastly's safe borrowed raw target accessor is absent. Capture reports NotExposed. Actual Viceroy ingress demonstrates `/reserved/../native` can reach a preceding normalized `/native` shortcut. The hook cannot repair earlier native routing. A reviewed SDK revision and fresh raw-target/client/TLS proof are still required. +2. Local workerd's fixed KJ method parser returns 501 for extension methods before Worker execution. Fetch also normalizes literal/encoded dot paths and coalesces Cookie/Origin/control fields. Fresh review corrected the adapter scalar-to-byte bug: valid UTF-8 now copies without casting. Raw FF already appears as U+FFFD in Web Headers before Rust and becomes runtime UTF-8 EF BF BD in core; it cannot be recovered as original FF. Common octet/multiplicity fidelity is Unknown, since the observed transforming path does not prove every particular field changed. Absolute-form and duplicate CL requests also return 500 without a hook verdict. Browser-created method/ByteString success does not establish original wire support. +3. Spin 4.0 requires UTF-8 while preparing incoming headers and returns 500 for FF before the component runs. This requires an upstream runtime fix. Valid runtime values continue through EdgeZero's byte path. +4. General original-field octets/multiplicity/order are not universally proved. Metadata keeps unknown axes conservative and global order unavailable; local fixtures do not prove production edge behavior. Consumer-required original paths/fields need an explicitly approved supported contract where runtime recovery is impossible. + +Pinned primary source: [Spin header preparation](https://github.com/spinframework/spin/blob/v4.0.0/crates/trigger-http/src/headers.rs#L96-L106); [workerd KJ dependency](https://github.com/cloudflare/workerd/blob/v1.20260415.1/build/deps/gen/deps.MODULE.bazel#L27-L35); [KJ method rejection](https://github.com/capnproto/capnproto/blob/d0e9605b288ac4d8b86ee3201a23c43c017a6b61/c%2B%2B/src/kj/compat/http.c%2B%2B#L961-L999); [workerd target rewriting](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/server/server.c%2B%2B#L451-L471). + +The toolkit implementation can be reviewed independently of these gates. Trusted Server F0 remains unresolved; this branch does not change or weaken its approved spec. Planned commits and immutable consumer dependency/release handoff remain pending; no commit, push or publication is performed by this execution record. + +### Earlier independent implementation review + +| Reviewer scope | Final result | +| ------------------------------------------------------------------------------------- | --------------------------------------- | +| Core bounded API/router and Fastly native ownership/registry/snapshot paths | No remaining findings | +| Cloudflare/Axum/Spin conversion, public API docs and adapter CI feasibility | No remaining findings | +| Locked runtime fixtures, exact observations, process cleanup and CI browser selection | No remaining findings after corrections | + +Final corrections were independently rereviewed. The exact FF cookie probe and mandatory full-profile comparison were rebuilt and executed across all three local runtimes, with strict fixture clippy passing on each target. The explicit Chrome binary capability configuration was executed through the real wasm-bindgen browser runner. No production behavior changed during this final review cycle. The results above do not close the listed external SDK/runtime, production provenance or downstream consumer gates. + +### Subsequent skeptical review — supersedes the earlier readiness assessment + +The user requested fresh independent reviewers without relying on the prior review conclusions. Architecture/core/adapters, consumer-contract alignment and runtime/CI/fixture scopes were reviewed independently. The optional router hook and bounded extension metadata remain the smallest appropriate architecture. No additional hook ordering, ownership, body/error propagation, process cleanup, pinning or CI-wiring defect was found. The Cloudflare correction and user-requested Rust tooling replacement are implemented and independently reviewed; the final executed checks below distinguish toolkit correctness from unresolved consumer requirements. + +- [x] **P1 Cloudflare UTF-8 conversion:** The browser ByteString assumption is incorrect for pinned workerd. Actual wire C3 A9 produces core E9; valid C4 80 returns 500 before the hook. Root independently reproduced both with the existing real local Worker fixture. Correct conversion to preserve Worker UTF-8 runtime strings, add exact real-workerd assertions for both cases, revise browser-only expectations and update observations only after those assertions pass. Keep invalid UTF-8 replacement as a distinct upstream capability limitation. The corrected baseline is collected only after exact semantic assertions pass and is compared in normal runs. All 11 browser contracts, all 47 real runtime probes and strict target clippy pass. The invalid FF case exposes runtime replacement rather than inventing original bytes. +- [x] **Origin provenance wording:** Clarify TransportBinding as trusted transport scheme plus a single validated inbound authority. Host is request authority, not operator ownership/approval. No configured host allowlist is required by the consumer spec. Public core documentation and this spec now agree with the Axum implementation. +- [x] **Spin runner documentation:** Public test commands now include the same async/P3/HTTP flags as verified CI. Wasmtime contracts and actual Spin incoming conversion evidence remain distinct. +- [x] **Framing coverage:** Actual TE+CL and standalone empty chunked requests retain one Transfer-Encoding: chunked field on each tested local runtime. The consumer can inspect it and must still implement its rejection semantics. +- [ ] **Consumer semantic coverage:** Add exact repeated reserved-cookie and real action/Fetch Metadata control probes in Trusted Server's integration harness. Strict complete-value comparison may reject coalesced controls safely; do not comma-split or require every metadata fidelity axis to say Preserved without checking the actual feature requirement. + +F0 is a consumer dependency/behavior gate, not a synonym for every header's perfect preservation. Global order across different header names is unnecessary. Cookie rules depend on exact reserved-name counts, bounds and non-UTF-8 detection, with order-independent outcomes. The path requirement concerns original pathname classification; unrelated query preservation is unnecessary. Avoid interpreting a 16 KiB whole-target metadata cap as proof that a short pathname was uninspectable. + +| Resolution work | Owner/location | Required proof | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Fix valid Worker UTF-8 conversion | EdgeZero Cloudflare converter/tests/fixture | C3 A9 and C4 80 retained through real Worker dispatch; no ordinary-request regression | +| Recover canonical Fastly origin separately | EdgeZero conversion or Trusted Server native-origin seam | After raw capture attempt and before mutation, validate HTTP(S), one Host, URL authority agreement; preserve native provenance and unavailable target status | +| Safe borrowed original-target accessor | Fastly Rust SDK | Bounded host URI access before Url parsing; no consume/clone/reacquire; preserved client/TLS metadata and actual reserved-dot-path tests | +| Early native shortcut exclusion | Trusted Server Fastly entry point | Raw reserved spellings cannot reach health/JA4 shortcuts; configured authentication and trace response hardening remain first | +| Hidden Cloudflare raw paths and pre-Worker method rejection | Cloudflare/runtime, or explicitly approved consumer amendment | Original reserved-path ambiguity and authenticated/hardened response guarantees, not only no cookie mutation | +| Invalid UTF-8 headers rejected before Spin invocation / replaced in Worker headers | Spin/Worker runtime, or explicitly approved consumer amendment | Original cookie uninspectability is available to the consumer or the transport-boundary contract explicitly changes | +| Coalesced Cookie semantics | EdgeZero/consumer exact transport tests, then runtime work or an explicitly approved amendment | Reserved-name counts, duplicate precedence, bounds and original non-UTF-8 observability; valid diagnostics cookie plus unrelated FF must never activate | +| Dependency integration | Reviewed EdgeZero immutable commit/release and Trusted Server Cargo.toml/Cargo.lock | Consistent immutable pin, target compilation and actual consumer integration tests | + +Preserving the approved consumer contract requires upstream capability work followed by real ingress verification. An alternative runtime-boundary contract must explicitly define normalized-path classification, transport-level rejection and unavailable-cookie reporting; it requires user approval because it changes the approved feature semantics. Disabling feature support on selected adapters also changes the approved all-four-adapter scope. This review does not silently choose either alternative. + +Pinned primary source for the newly identified encoding assumption: [workerd UTF-8 header strings](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/api/headers.c%2B%2B#L276-L292). The reproduction report is `/tmp/edgezero-fresh-review-encoding.json`; only fixed statuses and boolean verdicts are recorded. Production conversion now preserves Worker UTF-8 strings; exact real Worker verification and the Python-to-Rust tooling replacement are complete. + +### User language constraint + +The user rejects shipping Python. Replace the test orchestration/client with an isolated native Rust harness and a small shell entry point; use Node for existing Worker browser capability JSON and shell extraction for the controlled Spin CLI pin. Native-only Tokio cancellation does not enter core or WASM adapters. Retain mandatory observations comparison, exact raw TCP bytes, pinned builds, bounded deadlines, failure/interruption cleanup and concurrent port isolation. Independently review the replacement before reporting completion. + +### Final Rust replacement evidence + +The Python probe file is removed and no Python snippets remain in the new CI or shell entry point. The native-only helper uses the exact existing Tokio 1.52.3, serde_json 1.0.150, tempfile 3.27.0 and TOML 1.1.2 pins in its own locked test workspace. Its eight tests cover exact request bytes, HTTP chunk/length/EOF framing, header/body bounds, semantic rejection of the UTF-8 regressions and owned process cancellation/reaping. Native formatting and strict clippy pass; CI checks this separate workspace explicitly. + +A full locked-build run passes all 47 fixed raw TCP cases against the saved observations: Fastly 17, Cloudflare 15 and Spin 15. Valid UTF-8 C3 A9 and C4 80 remain exact on all three; both framing probes retain Transfer-Encoding: chunked. Workerd FF produces runtime replacement, explicitly unsupported as original preservation; Spin FF never reaches the component. Root also verified shell-bootstrap SIGTERM, Rust build SIGTERM, failed build descendants, failed readiness, failed baseline/probes and concurrent runs. Distinct concurrent ports close and no live owned descendants remain. + +The independent architecture reviewer reproduced an initial shell-bootstrap cancellation leak. Job control now gives bootstrap Cargo its own group, with signal/exit cleanup and reaping before entering the Rust supervisor. Root verified the exact shim reproduction. Real execution also corrected Spin manifest parsing and worker-build's bare-version output. These corrections leave no extra runtime dependency in core or the adapters. + +After the Cloudflare production change, workspace tests again pass 1,440 tests, workspace strict clippy and feature checks pass, and all 11 browser contracts pass. Subsequent reviewer rereads confirm the UTF-8/framing and CI-workspace checks are resolved. External SDK/runtime capability and Trusted Server integration/consumer acceptance gates above remain open. Remote GitHub Actions is configured but not executed locally; no commit or push is performed. + +## Independent Claude audit follow-up + +The external audit prompted three independent read-only scopes: runner/CI, adapters/provenance and consumer-spec alignment. Implement centrally after reproducing the concrete defects; retain the approved Trusted Server contract rather than accepting suggested runtime exceptions. + +- [x] **Blocked-read cancellation:** The reviewer reproduced SIGTERM leaving the native runner spinning at 91.6% CPU. `read_until` and `read_exact` retry `Interrupted`, so socket cancellation now returns a terminal `Other` error. A bounded silent-server regression covers header and exact-body reads and failed before the fix. SIGHUP joins SIGINT/SIGTERM on the cancellation-and-await path; the shell bootstrap explicitly handles HUP. Independent post-fix probes cover all six signal/read combinations, exit within 297–308 ms and leave no runtime/descendant alive. +- [x] **Fastly canonical origin:** Record original-target `NotExposed` before any URL access, then recover origin from a native client runtime URI and exactly one validated Host with authority/default-port agreement. Validate the full runtime authority, including user-info and port syntax. Origin uses `RuntimeUri`, never a guessed HTTP scheme from absent TLS. Three regression tests cover matching, missing/duplicate/conflicting Host and invalid runtime authority; the invalid-authority test failed before the final correction. An actual Viceroy probe changes the native URL and Host after capture and proves explicit-snapshot dispatch retains original origin and client metadata. SDK URL parsing may normalize paths and can panic; public docs now state that behavior without claiming raw-path support. +- [x] **Honest fidelity and caller assertions:** Cloudflare common octet/multiplicity metadata is Unknown; a potentially transforming runtime does not prove each field changed. Public docs agree. Metadata constructors and Fastly snapshot overloads state caller responsibility for provenance/fidelity; the useful snapshot APIs are not hidden. +- [x] **Exact cookie evidence:** Add fixed comma/semicolon join verdicts and compound invalid-FF versus original-valid-EF-BF-BD probes. Actual Worker repeated Cookies are comma-joined, and both compound inputs produce identical verdicts. This proves ambiguity, not an implemented Trusted Server activation defect. A replacement-character fallback would reject some original valid UTF-8 and cannot recover original reserved-name counts or byte bounds; it remains an unapproved consumer decision. +- [x] **CI coverage:** Add locked feature/target-matched clippy for the isolated WASM fixture workspace; install clippy/rustfmt in both test jobs. Independent reread confirms coverage of the Fastly enabled binary/library and Cloudflare/Spin libraries. Remote Actions execution remains pending. +- [x] **Existing consumer plan:** Update F0/F4 against `RequestIngress` and explicit-snapshot conversion, require hardened terminal responses rather than generic hook errors, remove stale ByteString guidance and record dependency-graph re-verification. F5 no longer demands transport-level 413 for identical duplicate zero lengths. No approved consumer spec, runtime-boundary exception, replacement-character rule or platform exclusion is changed. + +Final local evidence executes 54 exact wire cases: Fastly 20, Cloudflare 17 and Spin 17. Normal locked-build runs compare the complete checked-in baseline; Node uses 24.12.0. Fastly origin is available and consistent in every hook verdict, and the original-origin snapshot probe passes. The native harness has nine passing tests, the browser converter has eleven, and all 1,440 workspace tests pass. Strict workspace/isolated fixture lint and feature checks remain required. Original Fastly path access, Worker normalization/replacement/pre-invocation rejection and Spin invalid-UTF-8 rejection remain external consumer gates. No production deployment, commit, push or release is performed. + +## Approved consumer amendment and dependency handoff + +On 2026-10-05, the user accepted the independently reviewed runtime-boundary +amendment to the existing Trusted Server trace spec and single plan and +authorized implementation. This supersedes the historical original-path/ +original-wire consumer gates and unapproved-fallback statements above. +EdgeZero retains honest capability metadata and does not reconstruct erased +information. Trusted Server owns visible-path classification and conservative +Cookie ambiguity suppression under its amended contract. + +The pre-dispatch hook and converter implementation passed fresh required +workspace gates, adapter/browser/WASM contracts and 54 source-built local +runtime probes before the dependency handoff. Independent PR-readiness +review found one CI-shell assignment/export lint issue, corrected centrally; +the remaining two actionlint warnings reproduce against the baseline. + +A reviewed immutable dependency revision, consumer repinning and tests on +Trusted Server's resolved graph remain required. Do not claim consumer F0 or +the full trace feature complete from toolkit checks. Follow the repository +PR-creator workflow, including its linked-issue requirement, for publication. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md new file mode 100644 index 00000000..12cd7eb2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -0,0 +1,246 @@ +# Pre-dispatch interception and inbound request fidelity + +**Status:** Approved for implementation after independent review; capability evidence and remaining gates are recorded in the matching plan. +**Date:** 2026-10-05. +**Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Branch:** `spec/pre-dispatch-request-fidelity`. +**Implementation plan:** [Matching plan](../plans/2026-10-05-pre-dispatch-request-fidelity.md). + +## 1. Problem and outcome + +Applications need to intercept a reserved namespace independently of the registered method/path routes, before router-owned state, introspection, request context and middleware run. A consumer must be able to authenticate a reserved request and return its own complete response even when its method has no registered handler. + +At the planning baseline the router performs `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare reduces extension methods to GET and re-encodes header strings as UTF-8; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. + +Provide one optional generic pre-dispatch hook, preserve runtime-visible method/target/header data without additional avoidable loss, and expose explicit fidelity information. Preserve the concrete `RouterService` and existing routes when no hook is installed. This is toolkit infrastructure, not an implementation of Trusted Server tracing. + +## 2. Consumer requirements and references + +The motivating consumer is approved [Trusted Server PR #1107](https://github.com/IABTechLab/trusted-server/pull/1107), whose [design at the planning baseline](https://github.com/IABTechLab/trusted-server/blob/20f4a0cc0139eac842d1d6ff1410a1af6ee8eba8/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) defines: + +- Section 8: authentication and local handling across reserved spellings and methods before ordinary application lifecycle work. +- Section 9.2: bounded cookie inspection, duplicate detection and non-UTF-8 rejection. +- Sections 12 and 14: hardened local responses and actual adapter ordering/conversion tests. + +Local source: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md`. Local consumer plan: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md`, tasks F0/F4. + +Primary interoperability references: [Fetch header sorting/combining](https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine), [URL parsing](https://url.spec.whatwg.org/#url-parsing), and [Web IDL ByteString](https://webidl.spec.whatwg.org/#idl-ByteString). These define platform semantics, not proof of a particular deployed runtime. + +## 3. Scope and alternatives + +Recommended: an optional hook on `RouterBuilder` plus adapter-populated ingress metadata. It keeps manual `Hooks::routes()` and all existing adapter runners on the same concrete router and provides a single method-independent interception point. + +Alternatives considered: + +1. Ordinary middleware or standard-method route registration: smaller apparent edit, but it runs after lookup and cannot cover arbitrary extension methods or route-generated errors. Rejected. +2. Separate wrappers/entry-point bypasses per platform: can intercept native requests but duplicates policy and does not fit existing concrete `RouterService` seams consistently. Consumer-native shortcuts still require native integration; they do not replace the router hook. +3. Replace routing/body/bootstrap wholesale: unnecessary compatibility risk. Existing buffering is explicitly accepted by consumer section 8; no lazy-body or store-initialization redesign is included. + +Included: core hook, bounded ingress metadata, lossless runtime method/header conversion, adapter-specific target capture and fidelity descriptions, native/WASM/runtime contract tests, and docs. + +Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processing, auction evidence, telemetry, browser UI, generic hook chains, macro grammar additions, forwarding-header trust, transport deadlines/size protection, deployment, and unrelated proxy/response refactors. + +## 4. Verified baseline and limits + +| Surface | Current source | Consequence | +| ---------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Router | `crates/edgezero-core/src/router.rs`, `RouterInner::dispatch` | Lookup precedes state/introspection/context/middleware; no interception hook exists. | +| Bootstrap | `crates/edgezero-core/src/app.rs`; `crates/edgezero-macros/src/app.rs` | `Hooks::routes`, `App` and generated routers use `RouterService`; retain that type. | +| Fastly | `crates/edgezero-adapter-fastly/src/request.rs`, `into_core_request` | SDK URL accessor has already parsed with `url::Url`. Runtime header bytes and same-name values pass through byte-oriented construction. Body is buffered. | +| Cloudflare | `crates/edgezero-adapter-cloudflare/src/request.rs`, `into_core_request` | `req.method()` is a closed Workers enum; extension methods become GET. `req.url()` parses the runtime URL again. Header entries become Rust strings. Body is buffered. | +| Axum | `crates/edgezero-adapter-axum/src/request.rs` | `Request::from_parts` preserves the incoming `http::Uri` and `HeaderMap`. Hyper may already reject/fold fields; JSON bodies are buffered. | +| Spin | `crates/edgezero-adapter-spin/src/request.rs` | SDK request parts contain runtime URI and byte-oriented headers. Original wire interleaving is already unavailable in `HeaderMap`. Body is buffered. | +| Pins | `Cargo.lock` | This checkout pins worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122; verify again during execution. Do not substitute the consumer's worker 0.8.5 pin. | + +Fastly SDK `RequestHandle::get_url` calls `uri_get` then `Url::parse`. Its request handle getter is private; the public consuming handle round trip rebuilds the request without original client provenance, changing IP/TLS/original-header accessors. It is not an acceptable raw-target workaround. A safe borrowed SDK accessor is an external prerequisite for an original-target claim. + +Fetch `Headers` may combine/sort fields. Pinned workerd uses UTF-8 header strings, unlike browser ByteString construction; copying the runtime string cannot recover invalid UTF-8 bytes replaced earlier or missing field boundaries. Hyper's HTTP/1 parser can fold identical repeated Content-Length and reject conflicting lengths before application dispatch. Therefore `HeaderMap` fidelity is not a blanket wire-multiplicity guarantee. + +Existing Fastly/Cloudflare/Spin buffering and Axum JSON buffering occur before the hook. The hook guarantees ordering relative to the router/application, not relative to network receipt, SDK parsing, body buffering, logger setup or capability-handle construction. Consumer section 8 explicitly accepts pre-buffering and offers no transport deadline. Do not claim that the hook removes those limits. + +## 5. Core pre-dispatch API + +Add `PreDispatchHook` and `BoxPreDispatchHook` in `crates/edgezero-core/src/router.rs`: + +```rust +#[async_trait::async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + async fn handle( + &self, + request: &mut Request, + ) -> Result, EdgeError>; +} + +pub type BoxPreDispatchHook = Arc; +``` + +`Request`/`Response` are existing `edgezero_core::http` aliases; the hook future has no Send requirement. Use the existing async-trait dependency. New public items receive the repository's normal documentation. + +Add consuming builder method `RouterBuilder::pre_dispatch_hook(hook: BoxPreDispatchHook) -> Self`. Store one `Option` in builder/inner. Default is None. A repeated builder call replaces the previous hook; this is one hook, not a new middleware chain. `RouterService` clones share the same Arc-backed instance. + +The first operation in `RouterInner::dispatch` becomes: + +```rust +if let Some(hook) = &self.pre_dispatch_hook { + if let Some(response) = hook.handle(&mut request).await? { + return Ok(response); + } +} +``` + +Only after continuation may the router clone the method/path, call `find_route`, insert introspection/state, build `RequestContext` or run middleware. + +### Result contract + +- `Ok(Some(response))`: terminal core response returned unchanged. No route matching, state/introspection injection, `RequestContext`, route middleware or handler follows. +- `Ok(None)`: continue using the same mutated request, including method, URI, headers, extensions and remaining body. +- `Err(error)`: direct `Service::call` propagates the existing `EdgeError`; existing `RouterService::oneshot` renders it through `IntoResponse`. Neither dispatches fallback routes or silently continues. Return `Some(Response)` when exact consumer-specific error headers/body are required. +- No hook: retain all current routing/status/middleware behavior. +- Request cancellation: follow the existing future/drop behavior; introduce no tasks, timers or background work. + +The hook has no implicit router-state access because it precedes injection. Consumers capture their own `Arc` when building the hook. They own authentication, method validation, complete local response headers and any diagnostic policy. Outer adapter/consumer finalizers still exist; a terminal core response is not a promise to bypass arbitrary external finalization. + +Manual builder registration is sufficient; no new `Hooks` method or `app!` argument is required. + +## 6. Ingress metadata contract + +Create `crates/edgezero-core/src/request.rs` and export `pub mod request`. This module defines an adapter-populated `RequestIngress` request extension, not a second request implementation. + +Use private fields, Clone, and borrowed accessors. Do not derive value-bearing Debug, Serialize or Display for target/origin/metadata. These values can contain credentials or query strings. Never log them, put them in error messages, serialize them into responses, or clone all header values into a second buffer. + +### Public data model + +- `CapturedTarget::Complete(CompleteTarget)`, or `Unavailable(TargetUnavailable)`. The public CompleteTarget payload has private value/source/fidelity fields and borrowed getters; only the bounded capture constructor can create it. Do not expose constructible enum fields that bypass the cap. +- `TargetSource`: `TransportRequestTarget`, `RuntimeUrl`, `RuntimePathAndQuery`. Source and fidelity are independent: a runtime URL is not automatically the original request target. +- `Preservation`: `Preserved`, `Transformed`, `Unknown`, `Unavailable`. +- `TargetUnavailable`: `NotExposed`, `TooLarge`, `ReadFailed`. +- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source; TransportBinding records a trusted transport scheme combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. +- `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from trusted inbound/runtime request information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. +- `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. +- `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. + +Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. The plan owns the exact minimum constructor/accessor set. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. + +HeaderFidelity axes describe original received HTTP field-value octets, field multiplicity and ordering compared with the resulting core headers, not merely a successful runtime-to-core copy. Octets mean field-value bytes, not header-name case, delimiters, HTTP framing or complete request serialization. Trimming/combining or parser/runtime loss must be reflected as Transformed, Unknown or Unavailable; Preserved requires pinned runtime/parser evidence and actual transport tests. Document and test runtime-to-core copying separately when it is all that can be established. + +Capture target strings only up to 16 KiB UTF-8 bytes. A larger or failed target capture stores Unavailable without a partial string. Raw octets that cannot be represented by the verified platform string API are unavailable, not replacement-decoded. This toolkit bound is a metadata allocation bound, not a server URI acceptance limit. + +Populate metadata before EdgeZero's own URL parse/normalization where possible. It preserves what the runtime actually supplies. Report stronger wire guarantees only with direct transport evidence. Missing metadata means unknown capability; consumers cannot infer full preservation from its absence. + +Do not turn optional target-capture failure into an unrelated new ordinary-request rejection. Retain the current conversion/error behavior for the primary core URI; record optional metadata failure separately. Invalid original method or invalid runtime HeaderValues are conversion errors, never coerced values. + +## 7. Adapter requirements + +### 7.1 Fastly + +- Preserve `req.get_method()` and existing byte-oriented repeated header conversion. +- Add a reusable `capture_request_ingress(&fastly::Request) -> RequestIngress` helper in the Fastly request module. A consumer calls it before native health/debug shortcuts or mutations. Record the raw-target capture result before reading the SDK URL. With the current missing accessor, retain `NotExposed`, then recover canonical origin separately from the runtime URL plus exactly one valid, consistent Host on a native client request. URL normalization is acceptable for origin acquisition only; it never proves original pathname preservation. Use `RuntimeUri` provenance and do not infer HTTP from missing TLS metadata. Supplied snapshots remain caller assertions and are retained through subsequent mutation. +- With current SDK, mark original target unavailable or runtime-normalized/unknown; do not call `get_url_str` “raw”. +- Obtain a reviewed safe SDK accessor that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance. Pin the resolved compatible SDK revision/version; do not fabricate one or add a local Cargo-cache patch. +- Do not use consuming handle round trips, clone_without_body, raw pointers or a new unsafe fastly-sys wrapper to bypass the SDK gate. +- After the accessor exists, parse scheme/authority without normalizing the captured path. Prove encoded separators/dot segments/repeated separators survive in metadata and that client IP/TLS accessors retain behavior. +- Runtime header bytes and same-name order must be tested. Original-header names/count alone do not prove value-field multiplicity; do not reconstruct fields from mismatched/FIFO guesses. +- Add `into_core_request_with_ingress` and a public `dispatch_with_registries_and_ingress` overload that retains the existing App/StoresMetadata/EnvConfig/extension-callback semantics. Add a metadata-aware conversion path for an already captured snapshot: retain that same snapshot through later native mutation/dispatch. Ordinary converters capture once and delegate. Insert adapter-captured ingress after generic scratch extension merging so it cannot be accidentally overwritten. +- Existing request-extensions callbacks can carry native facts. They are not the new router policy hook and cannot return local responses. + +### 7.2 Cloudflare + +Use `req.inner().method()` and parse with core `Method::from_bytes`. Preserve any valid extension token exposed by the runtime; malformed values produce a bounded conversion error. Do not pass through Workers' closed method enum for inbound conversion. + +Capture `req.inner().url()` before `req.url()`/`url::Url` parsing, then preserve it separately with runtime-visible provenance. This does not recover a wire target normalized by the Fetch runtime. + +For each `Headers.entries()` value, preserve the pinned Worker runtime's UTF-8 string representation with `HeaderValue::from_bytes(value.as_bytes())`. Header names retain validated construction; invalid HeaderValues produce bounded conversion errors rather than silent omission. Pinned workerd represents headers as UTF-8, which differs from browser ByteString expectations. Do not cast Unicode scalars to single bytes: that changes valid wire C3 A9 to E9 and rejects valid C4 80. Browser-created high-byte strings are not evidence of original Worker wire bytes. + +Real workerd probes must assert exact valid UTF-8 preservation for C3 A9 and C4 80. A raw invalid FF is already represented by U+FFFD before Rust; its runtime UTF-8 copy EF BF BD is not the original FF. Expose conservative transformed fidelity and keep this consumer capability unresolved rather than invent lost bytes or add an ordinary-request rejection for a valid runtime string. + +Web Headers has already normalized/combined entries: mark original wire multiplicity/global order unavailable or transformed according to verified behavior. Per-name value order and octet provenance must also be stated precisely. Never comma-split Origin/Cookie/control values to invent original fields. + +Keep existing body buffering and Env/Context/proxy wiring intact. Distinguish browser Fetch construction constraints from actual incoming Workers HTTP behavior in tests. + +### 7.3 Axum + +Capture `parts.uri` before any added URL parsing and preserve `parts.headers` through `from_parts`. Source is runtime path/query or URI, not an original TCP request line. Validate inbound scheme/authority from trustworthy runtime binding; an origin-form URI without that information has unavailable origin rather than a guessed scheme. + +Audit Hyper parser changes and report per-field limitations. Identical repeated Content-Length may be folded; conflicting lengths may be rejected before hook invocation. Such rejection is transport evidence, not a hook-built local response. + +Keep current JSON buffering and streaming non-JSON behavior. No new Tokio dependency in core; existing Axum test infrastructure may use its existing Tokio runtime. + +### 7.4 Spin + +Capture `parts.uri` and byte headers before conversion. Prefer runtime URI scheme/authority supplied by the SDK/WIT; do not accept `spin-full-url` or another ordinary header as trusted without proving runtime injection and spoofing protection. + +Preserve runtime method tokens and existing byte-valued header construction. The SDK has already built a HeaderMap, so global wire order cannot be promised. Record same-name multiplicity/order only at the level actually established by a transport fixture. + +Keep current body buffering, store registries and runtime response conversion. Synthetic core-request contract tests alone do not prove SDK request conversion; provide an actual Spin invocation where needed. + +## 8. Capability matrix and consumer integration gate + +Initial investigation, not a verified release matrix: + +| Property | Fastly | Cloudflare | Axum | Spin | +| ---------------------------------- | ------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | ---------------------------- | +| Arbitrary runtime method | Byte-oriented existing method; verify | Fix closed enum loss | Existing method parts; verify | SDK Other/from_bytes; verify | +| Target before EdgeZero parsing | SDK raw accessor dependency | Runtime URL exposed; wire normalization may precede it | Runtime URI parts | SDK runtime URI parts | +| Header octets before EdgeZero loss | Existing byte path | Copy Worker UTF-8 runtime strings | Existing byte HeaderMap | Existing byte HeaderMap | +| Original field multiplicity | Runtime proof required | Fetch coalescing can make it unavailable | Per-field parser limits, especially Content-Length | Runtime/SDK proof required | +| Original global wire order | Not implied by grouped iteration | Unavailable after sorted/combined entries | Not retained by HeaderMap | Not retained by HeaderMap | + +The release review must replace each required unknown with Preserved, Transformed, Unavailable or a clearly pending environment test backed by evidence. Unknown is never success. + +Two completion claims are distinct: + +1. **EdgeZero infrastructure complete:** hook/API, concrete conversion fixes, metadata that honestly describes remaining limits, tests and docs pass. +2. **Trusted Server F0 satisfied across all platforms:** consumer-required path/cookie/control properties are proved, or the consumer spec receives explicit approval for a supported runtime contract. EdgeZero cannot grant that approval. + +If a runtime irreversibly hides required path spellings or original duplicate control fields, record the exact limitation and a proposed consumer resolution. Do not silently weaken the approved consumer spec, advertise universal original-wire preservation, disable a consumer platform without approval or report F0 complete solely because a hook exists. + +Trusted Server owns early Fastly native shortcut exclusion, classification, authentication, lifecycle gates and terminal response finalization. The ingress helper must be callable before those shortcuts; changing Trusted Server's `main.rs` is outside this EdgeZero branch. + +## 9. Tests and acceptance + +1. No-hook routing and existing 404/405/middleware/state behavior remain identical. +2. Hook sees arbitrary methods/paths before any router lookup result; terminal success/error runs zero state/context/middleware/handler effects. +3. Continued mutations are observed by actual route lookup and handler; consumed/replaced body behavior is explicit. +4. Hook sharing across clones and a non-Send future compile/run without introducing Tokio. +5. Metadata captures at/over target cap, omits partial values, rejects spoofed origin sources and exposes conservative missing capability. +6. Cloudflare runtime extension methods and UTF-8 strings survive the real converter, not a synthetic core request. +7. Actual runtime probes distinguish platform rejection, normalization, coalescing, EdgeZero conversion and hook response. +8. Fastly raw-target/client-provenance claims depend on the reviewed SDK accessor and real Viceroy ingress evidence. +9. Existing route/response/header/store contracts and all supported target checks pass. +10. Docs include API usage, buffering/finalization limits, per-platform capability matrix and consumer handoff evidence. + +Use fictional/example.com fixtures only. Never log actual headers, cookies, credentials, raw target strings or exception payloads in new diagnostic code. + +## 10. Review and implementation status + +This spec and its matching plan received independent review before the user authorized implementation. The matching plan records implemented surfaces, executed evidence and remaining gates. These documents are not a published API or proof of runtime preservation. The Fastly SDK accessor and any consumer-contract resolution remain explicit external gates. + +The plan records independent review results after reviewer findings are resolved. Neither document authorizes publishing, deployment or downstream spec changes. + +## 11. Fresh review correction + +A subsequent independent review disproved the unconditional browser ByteString assumption in the Cloudflare design above. Pinned workerd exposes header strings as UTF-8; the initial scalar-to-byte converter changed valid wire C3 A9 to E9 and rejected valid C4 80 before the hook. Conversion now copies Worker UTF-8 runtime strings; the matching plan records real Worker verification before release. Invalid UTF-8 replacement remains an independent upstream limitation. This correction does not authorize changing the approved Trusted Server contract. The matching plan records the reproduction, corrective work and F0 resolution choices. + +## 12. Approved consumer runtime-boundary amendment + +On 2026-10-05, the user approved the independently reviewed amendment to the +existing Trusted Server trace spec and single plan, then authorized its +implementation. The original consumer guarantees described above are the +historical baseline; original-target recovery and original-wire reconstruction +are no longer prerequisites for that consumer v1. + +The amended consumer classifies its application-visible URI pathname, keeps +literal-path authentication, and distinguishes runtime rejection, adapter +bootstrap/conversion failure and successful application dispatch. It rejects +still-visible reserved aliases and validates complete same-origin controls. +Runtime-visible Cookie fields are bounded before sanitation. Unproved octets +with U+FFFD, or unproved multiplicity with a literal comma, yield +`unavailable/runtime_header_ambiguous` and suppress trace capture; missing or +Unknown metadata alone permits readable marker-free sessions. The consumer +owns that policy; EdgeZero metadata does not impose it on other applications. + +The SDK/runtime limits remain documented capabilities. Consumer F0 now +requires a reviewed immutable EdgeZero revision and verification against the +consumer's resolved dependency graph. Successful normal browser workflows on +all four adapters, early native ordering and terminal finalization remain +consumer acceptance work. Toolkit tests alone do not complete those gates. diff --git a/scripts/test-request-fidelity.sh b/scripts/test-request-fidelity.sh new file mode 100755 index 00000000..8bdd2c46 --- /dev/null +++ b/scripts/test-request-fidelity.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +host_triple="$(rustc -vV | awk '$1 == "host:" { print $2 }')" +test -n "$host_triple" + +# Job control gives the bootstrap build its own process group on Linux and macOS. +set -m +bootstrap_pid="" +cleanup_bootstrap() { + if [[ -n "$bootstrap_pid" ]]; then + kill -TERM -- "-$bootstrap_pid" 2>/dev/null || true + kill -KILL -- "-$bootstrap_pid" 2>/dev/null || true + wait "$bootstrap_pid" 2>/dev/null || true + fi +} +trap cleanup_bootstrap EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +cargo build --manifest-path "$repo_root/tests/fixtures/request-fidelity-runner/Cargo.toml" \ + --locked --target "$host_triple" --target-dir "$repo_root/target/request-fidelity-runner" --quiet & +bootstrap_pid=$! +wait "$bootstrap_pid" +cleanup_bootstrap +bootstrap_pid="" +trap - EXIT INT TERM HUP +set +m +exec "$repo_root/target/request-fidelity-runner/$host_triple/debug/request-fidelity-runner" \ + "$@" --repo-root "$repo_root" diff --git a/tests/fixtures/request-fidelity-runner/Cargo.lock b/tests/fixtures/request-fidelity-runner/Cargo.lock new file mode 100644 index 00000000..4d105aa3 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/Cargo.lock @@ -0,0 +1,346 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "request-fidelity-runner" +version = "0.1.0" +dependencies = [ + "serde_json", + "tempfile", + "tokio", + "toml", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/request-fidelity-runner/Cargo.toml b/tests/fixtures/request-fidelity-runner/Cargo.toml new file mode 100644 index 00000000..90899968 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "request-fidelity-runner" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +serde_json = "=1.0.150" +tempfile = "=3.27.0" +tokio = { version = "=1.52.3", features = ["macros", "rt-multi-thread", "signal"] } +toml = "=1.1.2" + +[lints.rust] +unsafe_code = "deny" diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs new file mode 100644 index 00000000..59e744b2 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -0,0 +1,1175 @@ +//! Fixed, loopback-only HTTP ingress probes for the three WASM adapters. + +use serde_json::{Value, json}; +use std::env; +use std::fs::{self, File}; +use std::io::Seek as _; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; +use tokio::signal::unix::{SignalKind, signal}; + +const RESPONSE_LIMIT: usize = 1024 * 1024; +const HEADER_LIMIT: usize = 64 * 1024; +const POLL_INTERVAL: Duration = Duration::from_millis(100); +type Cancelled = Arc; + +struct Options { + platforms: Vec<&'static str>, + skip_build: bool, + readiness_timeout: Duration, + record_observations: bool, + root: PathBuf, +} + +impl Options { + fn parse() -> io::Result { + let mut args = env::args().skip(1); + let first = args.next(); + let (root, platform) = if first.as_deref() == Some("--repo-root") { + let root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + (root, args.next()) + } else { + ( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize()?, + first, + ) + }; + let platforms = match platform.as_deref() { + Some("all") => vec!["fastly", "cloudflare", "spin"], + Some("fastly") => vec!["fastly"], + Some("cloudflare") => vec!["cloudflare"], + Some("spin") => vec!["spin"], + _ => { + return Err(io::Error::other( + "usage: request-fidelity-runner [--skip-build] [--readiness-timeout SECONDS] [--record-observations]", + )); + } + }; + let mut options = Self { + platforms, + skip_build: false, + readiness_timeout: Duration::from_secs(40), + record_observations: false, + root, + }; + while let Some(argument) = args.next() { + match argument.as_str() { + "--repo-root" => { + options.root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + } + "--skip-build" => options.skip_build = true, + "--record-observations" => options.record_observations = true, + "--readiness-timeout" => { + let seconds = args + .next() + .ok_or_else(|| io::Error::other("missing readiness timeout"))? + .parse::() + .map_err(io::Error::other)?; + options.readiness_timeout = + Duration::try_from_secs_f64(seconds).map_err(io::Error::other)?; + if options.readiness_timeout.is_zero() { + return Err(io::Error::other("readiness timeout must be positive")); + } + } + _ => return Err(io::Error::other(format!("unknown argument: {argument}"))), + } + } + Ok(options) + } +} + +#[tokio::main] +async fn main() -> ExitCode { + match execute().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let _result = writeln!(io::stderr(), "request fidelity: {error}"); + ExitCode::FAILURE + } + } +} + +async fn execute() -> io::Result<()> { + let options = Options::parse()?; + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; + let mut worker = tokio::task::spawn_blocking(move || run(options, &worker_cancelled)); + tokio::select! { + result = &mut worker => result.map_err(io::Error::other)?, + _ = interrupt.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGINT: owned children stopped")) + } + _ = terminate.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGTERM: owned children stopped")) + } + _ = hangup.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGHUP: owned children stopped")) + } + } +} + +fn check_cancelled(cancelled: &Cancelled) -> io::Result<()> { + if cancelled.load(Ordering::SeqCst) { + Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")) + } else { + Ok(()) + } +} + +struct OwnedProcess { + child: Child, + group: u32, +} + +impl OwnedProcess { + fn spawn(command: &mut Command) -> io::Result { + command.process_group(0); + let child = command.spawn()?; + let group = child.id(); + Ok(Self { child, group }) + } + + fn wait(&mut self, cancelled: &Cancelled) -> io::Result<()> { + loop { + check_cancelled(cancelled)?; + if let Some(status) = self.child.try_wait()? { + return if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "fixture command exits with {status}" + ))) + }; + } + thread::sleep(POLL_INTERVAL); + } + } +} + +fn signal_group(group: u32, name: &str) { + let _result = Command::new("/bin/kill") + .args([name, "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + // Signal the owned group even if its direct wrapper already exited. + signal_group(self.group, "-TERM"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if self.child.try_wait().is_ok_and(|status| status.is_some()) + || Instant::now() >= deadline + { + break; + } + thread::sleep(POLL_INTERVAL); + } + signal_group(self.group, "-KILL"); + let _result = self.child.wait(); + } +} + +fn command(fixture: &Path, program: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(program); + command + .args(arguments) + .current_dir(fixture) + .env("WRANGLER_SEND_METRICS", "false"); + command +} + +fn run_command( + fixture: &Path, + program: &str, + arguments: &[&str], + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + check_cancelled(cancelled)?; + let mut command = command(fixture, program, arguments); + // A file avoids pipe backpressure while waiting interruptibly for build tools. + let mut output = tempfile::tempfile()?; + command.stdout(output.try_clone()?); + command.stderr(Stdio::inherit()); + let result = OwnedProcess::spawn(&mut command)?.wait(cancelled); + output.rewind()?; + if !capture { + io::copy(&mut output, &mut io::stderr())?; + } + result?; + if capture { + let mut value = String::new(); + output.read_to_string(&mut value)?; + Ok(value) + } else { + Ok(String::new()) + } +} + +fn verify_version( + fixture: &Path, + program: &str, + expected: &str, + cancelled: &Cancelled, +) -> io::Result<()> { + let output = run_command(fixture, program, &["--version"], cancelled, true)?; + let actual = if program == "worker-build" { + output.trim() + } else { + output.split_whitespace().nth(1).unwrap_or("") + }; + if actual != expected { + return Err(io::Error::other(format!( + "{program} requires version {expected}" + ))); + } + Ok(()) +} + +fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { + run_command( + fixture, + "cargo", + &["metadata", "--locked", "--format-version", "1", "--no-deps"], + cancelled, + true, + )?; + if platform == "cloudflare" { + verify_version(fixture, "worker-build", "0.8.3", cancelled)?; + run_command(fixture, "npm", &["ci"], cancelled, false)?; + let lock = fs::read(fixture.join("Cargo.lock"))?; + run_command( + fixture, + "worker-build", + &[ + ".", + "--release", + "--no-opt", + "--out-dir", + "build/cloudflare", + "--", + "--features", + "cloudflare", + "--locked", + "--target-dir", + "target/cloudflare", + ], + cancelled, + false, + )?; + if fs::read(fixture.join("Cargo.lock"))? != lock { + return Err(io::Error::other( + "worker-build changes the locked fixture dependencies", + )); + } + } else { + let mut arguments = vec![ + "build", + "--locked", + "--release", + "--features", + platform, + "--target-dir", + ]; + arguments.extend(if platform == "fastly" { + [ + "target/fastly", + "--target", + "wasm32-wasip1", + "--bin", + "request-fidelity-fastly", + ] + .as_slice() + } else { + ["target/spin", "--target", "wasm32-wasip2", "--lib"].as_slice() + }); + run_command(fixture, "cargo", &arguments, cancelled, false)?; + } + Ok(()) +} + +fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { + let fixture = options.root.join("tests/fixtures/request-fidelity"); + let mut observations = json!({}); + for platform in &options.platforms { + check_cancelled(cancelled)?; + if *platform == "spin" { + let manifest: toml::Value = + toml::from_str(&fs::read_to_string(fixture.join("Cargo.toml"))?) + .map_err(io::Error::other)?; + let pin = manifest["package"]["metadata"]["request-fidelity"]["spin-cli"] + .as_str() + .ok_or_else(|| io::Error::other("missing Spin runtime pin"))?; + verify_version(&fixture, "spin", pin, cancelled)?; + } else if *platform == "fastly" { + let versions = fs::read_to_string(options.root.join(".tool-versions"))?; + let pin = versions + .lines() + .find_map(|line| line.strip_prefix("viceroy ")) + .ok_or_else(|| io::Error::other("missing Viceroy pin"))?; + verify_version(&fixture, "viceroy", pin.trim(), cancelled)?; + } + if !options.skip_build { + build(platform, &fixture, cancelled)?; + } + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + writeln!(io::stderr(), "{platform} loopback fixture uses port {port}")?; + let address = format!("127.0.0.1:{port}"); + let port_string = port.to_string(); + let scratch_string = scratch.path().to_string_lossy(); + let log_dir = scratch.path().join("logs"); + let log_dir_string = log_dir.to_string_lossy(); + let mut runtime = match *platform { + "fastly" => command( + &fixture, + "viceroy", + &[ + "serve", + "-C", + "fastly.toml", + "--addr", + &address, + "target/fastly/wasm32-wasip1/release/request-fidelity-fastly.wasm", + ], + ), + "spin" => command( + &fixture, + "spin", + &[ + "up", + "--from", + "spin.toml", + "--listen", + &address, + "--state-dir", + &scratch_string, + "--log-dir", + &log_dir_string, + ], + ), + _ => command( + &fixture, + "node_modules/.bin/wrangler", + &[ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + &port_string, + ], + ), + }; + let log_path = scratch.path().join("runtime.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + options.readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() { + return Err(io::Error::other("runtime exits before readiness")); + } + if Instant::now() >= deadline { + return Err(io::Error::other("runtime readiness deadline exceeded")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let observed = probes(platform, port, cancelled)?; + if !options.record_observations { + let expected: Value = + serde_json::from_slice(&fs::read(fixture.join("observations.json"))?) + .map_err(io::Error::other)?; + if expected.get(*platform) != Some(&observed) { + return Err(io::Error::other(format!( + "{platform}: full runtime observations changed; inspect before updating baseline" + ))); + } + } + Ok(observed) + })(); + drop(process); + match result { + Ok(observed) => observations[*platform] = observed, + Err(error) => { + if let Ok(log) = fs::read_to_string(&log_path) { + writeln!(io::stderr(), "{log}")?; + } + return Err(error); + } + } + } + serde_json::to_writer_pretty(io::stdout(), &observations).map_err(io::Error::other)?; + writeln!(io::stdout()) +} + +fn request_packet(case: &Case) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(case.method); + packet.push(b' '); + packet.extend_from_slice(case.target); + packet.extend_from_slice(b" HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: "); + packet.extend_from_slice(case.name.as_bytes()); + packet.extend_from_slice(b"\r\n"); + for (name, value) in &case.headers { + packet.extend_from_slice(name); + packet.extend_from_slice(b": "); + packet.extend_from_slice(value); + packet.extend_from_slice(b"\r\n"); + } + packet.extend_from_slice(b"\r\n"); + packet.extend_from_slice(case.body); + packet +} + +struct TimedSocket<'a> { + socket: TcpStream, + cancelled: &'a Cancelled, + deadline: Instant, +} + +impl Read for TimedSocket<'_> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + loop { + // Buffered/exact reads retry Interrupted, so cancellation must be terminal. + if self.cancelled.load(Ordering::SeqCst) { + return Err(io::Error::other("cancelled")); + } + if Instant::now() >= self.deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP response deadline exceeded", + )); + } + match self.socket.read(bytes) { + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + result => return result, + } + } + } +} + +fn exchange( + port: u16, + case: &Case, + cancelled: &Cancelled, + deadline: Instant, +) -> io::Result<(u16, Value)> { + check_cancelled(cancelled)?; + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let timeout = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(5)); + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP connection deadline exceeded", + )); + } + let mut socket = TcpStream::connect_timeout(&address, timeout)?; + socket.set_write_timeout(Some(timeout))?; + socket.set_read_timeout(Some(POLL_INTERVAL))?; + socket.write_all(&request_packet(case))?; + let mut reader = BufReader::new(TimedSocket { + socket, + cancelled, + deadline, + }); + let (status, payload) = read_response(&mut reader)?; + Ok(( + status, + serde_json::from_slice(&payload).unwrap_or(Value::Null), + )) +} + +fn http_line(reader: &mut impl BufRead, remaining: &mut usize) -> io::Result { + let mut bytes = Vec::new(); + reader + .take(u64::try_from(*remaining).map_err(io::Error::other)?) + .read_until(b'\n', &mut bytes)?; + *remaining -= bytes.len(); + if !bytes.ends_with(b"\r\n") { + return Err(io::Error::other("missing CRLF or oversized HTTP header")); + } + bytes.truncate(bytes.len() - 2); + String::from_utf8(bytes).map_err(io::Error::other) +} + +fn read_response(reader: &mut impl BufRead) -> io::Result<(u16, Vec)> { + let mut remaining = HEADER_LIMIT; + loop { + let status_line = http_line(reader, &mut remaining)?; + let mut fields = status_line.split_whitespace(); + if !matches!(fields.next(), Some("HTTP/1.1" | "HTTP/1.0")) { + return Err(io::Error::other("invalid HTTP response status line")); + } + let status = fields + .next() + .ok_or_else(|| io::Error::other("missing response status"))? + .parse::() + .map_err(io::Error::other)?; + let mut length = None; + let mut chunked = false; + loop { + let line = http_line(reader, &mut remaining)?; + if line.is_empty() { + break; + } + let (name, value) = line + .split_once(':') + .ok_or_else(|| io::Error::other("invalid HTTP response header"))?; + if name.eq_ignore_ascii_case("content-length") { + let next = value.trim().parse::().map_err(io::Error::other)?; + if length.is_some_and(|previous| previous != next) { + return Err(io::Error::other("conflicting response lengths")); + } + length = Some(next); + } + if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value + .split(',') + .any(|item| item.trim().eq_ignore_ascii_case("chunked")); + } + } + if (100..200).contains(&status) { + continue; + } + let mut body = Vec::new(); + if chunked { + loop { + let line = http_line(reader, &mut remaining)?; + let size = usize::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16) + .map_err(io::Error::other)?; + if size == 0 { + while !http_line(reader, &mut remaining)?.is_empty() {} + break; + } + append_body(reader, &mut body, size)?; + let mut delimiter = [0; 2]; + reader.read_exact(&mut delimiter)?; + if delimiter != *b"\r\n" { + return Err(io::Error::other("invalid chunk delimiter")); + } + } + } else if let Some(length) = length { + append_body(reader, &mut body, length)?; + } else { + reader + .take(u64::try_from(RESPONSE_LIMIT + 1).map_err(io::Error::other)?) + .read_to_end(&mut body)?; + if body.len() > RESPONSE_LIMIT { + return Err(io::Error::other("oversized HTTP response body")); + } + } + return Ok((status, body)); + } +} + +fn append_body(reader: &mut impl Read, body: &mut Vec, length: usize) -> io::Result<()> { + let end = body + .len() + .checked_add(length) + .filter(|end| *end <= RESPONSE_LIMIT) + .ok_or_else(|| io::Error::other("oversized HTTP response body"))?; + let start = body.len(); + body.resize(end, 0); + reader.read_exact(&mut body[start..]) +} + +fn ready_case() -> Case { + Case { + name: "ready", + method: b"GET", + target: b"/reserved", + headers: Vec::new(), + body: b"", + } +} + +fn cases() -> Vec { + let mut cases: Vec<_> = [ + "extension", + "dot", + "encoded-dot", + "slashes", + "absolute", + "high-byte", + "utf8", + "unicode", + "repeat", + "compound-invalid", + "compound-valid", + "spoof", + "length-identical", + "length-conflict", + "te-cl", + "chunked", + "continue", + ] + .into_iter() + .map(|name| Case { + name, + ..ready_case() + }) + .collect(); + for case in &mut cases { + match case.name { + "extension" => case.method = b"EXAMPLE-METHOD", + "dot" => case.target = b"/reserved/../ordinary", + "encoded-dot" => case.target = b"/reserved/%2e%2e/ordinary", + "slashes" => case.target = b"/reserved//%2F?q=a%2Fb", + "absolute" => case.target = b"http://example.com/reserved?fixed=1", + "high-byte" => case.headers = vec![(b"Cookie", b"a=\xff")], + "utf8" => case.headers = vec![(b"Cookie", b"a=\xc3\xa9")], + "unicode" => case.headers = vec![(b"Cookie", b"a=\xc4\x80")], + "repeat" => { + case.headers = vec![ + (b"Cookie", b"a=1"), + (b"Origin", b"https://example.com"), + (b"X-Control", b"first"), + (b"COOKIE", b"b=2"), + (b"Origin", b"https://other.example.com"), + (b"x-control", b"second"), + ] + } + "compound-invalid" | "compound-valid" => { + case.headers = vec![ + (b"Cookie", b"diagnostics=1"), + ( + b"Cookie", + if case.name == "compound-invalid" { + b"a=\xff" + } else { + b"a=\xef\xbf\xbd" + }, + ), + ]; + } + "spoof" => { + case.headers = vec![ + (b"Origin", b"https://spoof.example.com"), + (b"Forwarded", b"proto=https;host=spoof.example.com"), + (b"spin-full-url", b"https://spoof.example.com/reserved"), + (b"spin-client-addr", b"192.0.2.1:1"), + ] + } + "length-identical" | "length-conflict" => { + case.method = b"POST"; + case.headers = vec![ + (b"Content-Length", b"0"), + ( + b"Content-Length", + if case.name == "length-identical" { + b"0" + } else { + b"1" + }, + ), + ]; + } + "te-cl" | "chunked" => { + case.method = b"POST"; + case.headers = vec![(b"Transfer-Encoding", b"chunked")]; + if case.name == "te-cl" { + case.headers.push((b"Content-Length", b"0")); + } + case.body = b"0\r\n\r\n"; + } + "continue" => { + case.method = b"POST"; + case.target = b"/missing"; + } + _ => return Vec::new(), + } + } + cases +} + +fn require(condition: bool, platform: &str, case: &str, message: &str) -> io::Result<()> { + if condition { + Ok(()) + } else { + Err(io::Error::other(format!("{platform}/{case}: {message}"))) + } +} + +fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) -> io::Result<()> { + if status == 200 { + if case == "continue" { + return require( + *verdict == json!({"ordinary": true}), + platform, + case, + "continuation must run ordinary handler", + ); + } + require( + verdict["hook"] == true && verdict["ordinary"] == false, + platform, + case, + "hook must intercept before ordinary lifecycle", + )?; + if platform == "fastly" { + require( + verdict["native_client_preserved"] == true, + platform, + case, + "native provenance lost", + )?; + } + require( + verdict["global_order_unavailable"] == true + && verdict["spoofed_origin_ignored"] == true + && verdict["origin_available"] == true + && verdict["origin_matches_expected"] == true, + platform, + case, + "unavailable order and origin provenance must remain explicit", + )?; + require( + verdict["target_available"] == (platform != "fastly"), + platform, + case, + "unexpected target availability", + )?; + if platform != "fastly" { + require( + verdict["target_matches_runtime"] == true, + platform, + case, + "target must match runtime", + )?; + } + match case { + "extension" => require( + verdict["extension_method"] == true, + platform, + case, + "extension token lost", + )?, + "high-byte" if platform == "cloudflare" => { + require( + verdict["cookie_replacement"] == true && verdict["cookie_octets"] == false, + platform, + case, + "expected documented UTF-8 replacement, not original FF preservation", + )?; + writeln!( + io::stderr(), + "UNSUPPORTED local workerd replaces invalid UTF-8 FF before Rust; hook sees UTF-8 replacement bytes." + )?; + } + "high-byte" => require( + verdict["cookie_octets"] == true, + platform, + case, + "original cookie FF lost", + )?, + "utf8" => require( + verdict["cookie_utf8"] == true && verdict["cookie_latin1"] == false, + platform, + case, + "valid UTF-8 C3 A9 must remain exact", + )?, + "unicode" => require( + verdict["cookie_unicode"] == true, + platform, + case, + "valid UTF-8 C4 80 must remain exact", + )?, + "repeat" if platform != "cloudflare" => require( + verdict["cookie_count"] == 2 + && verdict["same_name_order"] == true + && verdict["origin_count"] == 2 + && verdict["control_count"] == 2, + platform, + case, + "ordinary repeated fields or same-name order lost", + )?, + "repeat" => require( + verdict["cookie_count"] == 1 + && verdict["cookie_repeat_comma_joined"] == true + && verdict["cookie_repeat_semicolon_joined"] == false, + platform, + case, + "must establish exact comma joining rather than infer cookie semantics from counts", + )?, + "compound-invalid" | "compound-valid" if platform == "cloudflare" => require( + verdict["cookie_count"] == 1 && verdict["cookie_compound_comma_joined"] == true, + platform, + case, + "invalid FF and original UTF-8 replacement must have the same documented runtime representation", + )?, + "compound-invalid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_original"] == true, + platform, + case, + "must retain valid-looking diagnostics alongside unrelated original FF", + )?, + "compound-valid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_runtime_utf8"] == true, + platform, + case, + "must retain the client's original valid UTF-8 replacement bytes", + )?, + "te-cl" | "chunked" => { + let count = verdict["transfer_encoding_count"].as_u64(); + let chunked = verdict["transfer_encoding_chunked"].as_bool(); + require( + matches!(count, Some(0 | 1)) && chunked == count.map(|count| count == 1), + platform, + case, + "must record whether chunked Transfer-Encoding survives conversion", + )?; + } + _ => {} + } + return Ok(()); + } + match (platform, case, status) { + ("cloudflare", "length-identical" | "length-conflict", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler duplicate Content-Length ingress (500); no hook verdict." + )?, + ("cloudflare", "absolute", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler absolute-form ingress (500); no hook verdict." + )?, + ("cloudflare", "extension", 501) => writeln!( + io::stderr(), + "PENDING local workerd rejects extension methods before Worker invocation (501)." + )?, + ("spin", "high-byte" | "compound-invalid", 500) => writeln!( + io::stderr(), + "PENDING Spin 4.0 rejects non-UTF-8 header bytes before component invocation (500)." + )?, + (_, "length-identical" | "length-conflict" | "te-cl" | "absolute", 400) => {} + _ => { + return Err(io::Error::other(format!( + "{platform}/{case}: unexpected HTTP status {status}" + ))); + } + } + require( + verdict.is_null(), + platform, + case, + "transport rejection must have no hook verdict", + ) +} + +fn probes(platform: &str, port: u16, cancelled: &Cancelled) -> io::Result { + let mut observed = json!({}); + let cases = cases(); + require( + cases.len() == 17, + platform, + "fixture", + "must execute all 17 fixed ingress cases", + )?; + for case in cases { + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + verify_observation(platform, case.name, status, &verdict)?; + observed[case.name] = json!({ "status": status, "verdict": verdict }); + } + if platform == "fastly" { + let case = Case { + name: "snapshot-origin", + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + verify_observation(platform, case.name, status, &verdict)?; + observed[case.name] = json!({"status": status, "verdict": verdict}); + for (name, target) in [ + ("native", b"/native".as_slice()), + ("native-dot", b"/reserved/../native".as_slice()), + ] { + let case = Case { + name, + target, + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + require( + status == 200 && verdict == json!({"native": true, "raw_target_supported": false}), + platform, + name, + "native shortcut must report unavailable raw-target support", + )?; + observed[name] = json!({"status": status, "verdict": verdict}); + } + writeln!( + io::stderr(), + "PENDING Fastly original-target and native-shortcut exclusion: safe SDK accessor required." + )?; + } + Ok(observed) +} + +struct Case { + name: &'static str, + method: &'static [u8], + target: &'static [u8], + headers: Vec<(&'static [u8], &'static [u8])>, + body: &'static [u8], +} + +#[cfg(test)] +mod tests { + use super::{ + Case, HEADER_LIMIT, OwnedProcess, TimedSocket, cases, read_response, request_packet, + verify_observation, + }; + use serde_json::json; + use std::io::{self, BufRead as _, BufReader, Cursor, Write as _}; + use std::net::{TcpListener, TcpStream}; + use std::process::{Command, Stdio}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn cancelled_response_reads_do_not_retry_until_deadline() { + for prefix in [ + b"".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n".as_slice(), + ] { + let listener = + TcpListener::bind("127.0.0.1:0").expect("should bind a silent loopback server"); + let socket = TcpStream::connect(listener.local_addr().expect("should get port")) + .expect("should connect to silent server"); + let (mut server, _) = listener.accept().expect("should accept test client"); + server + .write_all(prefix) + .expect("should write partial response"); + socket + .set_read_timeout(Some(Duration::from_millis(10))) + .expect("should bound individual reads"); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut reader = BufReader::new(TimedSocket { + socket, + cancelled: &cancelled, + deadline: Instant::now() + Duration::from_millis(150), + }); + if !prefix.is_empty() { + let _buffer = reader.fill_buf().expect("should buffer response headers"); + } + cancelled.store(true, Ordering::SeqCst); + // Bound the old retry loop as well, so a regression fails rather than hangs. + let fallback_cancelled = Arc::clone(&cancelled); + let fallback = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + fallback_cancelled.store(false, Ordering::SeqCst); + }); + let error = read_response(&mut reader) + .expect_err("should cancel both header and exact-body reads"); + fallback.join().expect("should join retry-loop fallback"); + assert_eq!( + error.kind(), + io::ErrorKind::Other, + "should return a terminal cancellation error instead of a retryable interrupt" + ); + assert_eq!( + error.to_string(), + "cancelled", + "should preserve cancellation reason" + ); + } + } + + #[test] + fn fixed_registry_contains_unicode_and_standalone_chunked_probes() { + let cases = cases(); + assert_eq!(cases.len(), 17, "should include every fixed ingress case"); + let unicode = cases + .iter() + .find(|case| case.name == "unicode") + .expect("should include unicode case"); + assert_eq!( + unicode.headers, + [(b"Cookie".as_slice(), b"a=\xc4\x80".as_slice())], + "should send exact UTF-8 C4 80" + ); + let chunked = cases + .iter() + .find(|case| case.name == "chunked") + .expect("should include chunked case"); + assert_eq!( + chunked.headers, + [(b"Transfer-Encoding".as_slice(), b"chunked".as_slice())], + "should probe Transfer-Encoding without Content-Length" + ); + assert_eq!( + chunked.body, b"0\r\n\r\n", + "should terminate the empty chunked body" + ); + } + + #[test] + fn observation_verification_rejects_utf8_corruption_even_during_collection() { + let verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); + let _error = verify_observation("cloudflare", "utf8", 200, &verdict) + .expect_err("should reject the former C3 A9 to E9 defect"); + let _error = verify_observation("cloudflare", "unicode", 500, &serde_json::Value::Null) + .expect_err("should reject the former C4 80 conversion error"); + } + + #[test] + fn cancellation_stops_and_reaps_the_owned_process_group() { + let cancelled = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("/bin/sleep"); + command + .arg("30") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut process = OwnedProcess::spawn(&mut command).expect("should spawn owned group"); + let group = process.group; + let worker_cancelled = Arc::clone(&cancelled); + let stopper = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + worker_cancelled.store(true, Ordering::SeqCst); + }); + let start = Instant::now(); + let error = process + .wait(&cancelled) + .expect_err("should stop waiting on interruption"); + assert_eq!( + error.kind(), + io::ErrorKind::Interrupted, + "should preserve interruption reason" + ); + drop(process); + stopper.join().expect("should join cancellation trigger"); + assert!( + start.elapsed() < Duration::from_secs(6), + "should bound cleanup even when interrupted" + ); + let status = Command::new("/bin/kill") + .args(["-0", "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("should inspect owned group"); + assert!( + !status.success(), + "should leave no live owned process group" + ); + } + + #[test] + fn packet_preserves_case_order_high_bytes_and_chunk_body() { + let case = Case { + name: "fixed", + method: b"EXAMPLE-METHOD", + target: b"/reserved//%2F?q=a%2Fb", + headers: vec![(b"Cookie", b"a=\xff"), (b"COOKIE", b"b=2")], + body: b"0\r\n\r\n", + }; + assert_eq!( + request_packet(&case), + b"EXAMPLE-METHOD /reserved//%2F?q=a%2Fb HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: fixed\r\nCookie: a=\xff\r\nCOOKIE: b=2\r\n\r\n0\r\n\r\n", + "should preserve the exact raw request bytes" + ); + } + + #[test] + fn response_decodes_chunks_extensions_and_trailers() { + let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4;fixed=yes\r\n{\"a\"\r\n3\r\n:1}\r\n0\r\nX-Fixture: fixed\r\n\r\n"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse chunks"); + assert_eq!(status, 200, "should preserve status"); + assert_eq!(body, b"{\"a\":1}", "should decode the chunk framing"); + } + + #[test] + fn response_handles_interim_status_and_content_length() { + let raw = + b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 418 Fixed\r\nContent-Length: 2\r\n\r\n{}extra"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse response"); + assert_eq!(status, 418, "should use final response status"); + assert_eq!(body, b"{}", "should read exactly Content-Length"); + } + + #[test] + fn response_rejects_truncation_and_oversized_body() { + for raw in [ + b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n{}".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}XX".as_slice(), + ] { + let _error = read_response(&mut Cursor::new(raw)) + .expect_err("should reject incomplete or oversized responses"); + } + } + + #[test] + fn response_reads_eof_framing_and_rejects_excessive_headers() { + let (status, body) = read_response(&mut Cursor::new(b"HTTP/1.0 200 OK\r\n\r\n{}")) + .expect("should parse EOF-framed response"); + assert_eq!(status, 200, "should retain status"); + assert_eq!(body, b"{}", "should read the EOF-framed body"); + let oversized = format!( + "HTTP/1.1 200 OK\r\nX-Fixture: {}\r\n\r\n", + "a".repeat(HEADER_LIMIT) + ); + let _error = read_response(&mut Cursor::new(oversized)) + .expect_err("should enforce a total header budget"); + } +} diff --git a/tests/fixtures/request-fidelity/.gitignore b/tests/fixtures/request-fidelity/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/tests/fixtures/request-fidelity/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/tests/fixtures/request-fidelity/Cargo.lock b/tests/fixtures/request-fidelity/Cargo.lock new file mode 100644 index 00000000..0daa5c89 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.lock @@ -0,0 +1,2097 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + +[[package]] +name = "edgezero-adapter-fastly" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "brotli", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit 0.9.2", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.10.9", + "thiserror 2.0.20", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.3", + "toml", + "validator", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "lazy_static", + "mime", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "sha2 0.9.9", + "smallvec", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "fastly-macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "log-fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +dependencies = [ + "fastly", + "log", + "regex", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "request-fidelity" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "fastly", + "serde_json", + "spin-sdk", + "wasm-bindgen", + "worker", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin-macro" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.20", + "wasip3", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.20", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worker" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml new file mode 100644 index 00000000..3240c8df --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "request-fidelity" +version = "0.1.0" +edition = "2024" +publish = false + +[package.metadata.request-fidelity] +spin-cli = "4.0.0" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "request-fidelity-fastly" +path = "src/bin/fastly.rs" +required-features = ["fastly"] + +[features] +default = [] +fastly = ["dep:edgezero-adapter-fastly", "dep:fastly"] +cloudflare = ["dep:edgezero-adapter-cloudflare", "dep:worker", "dep:wasm-bindgen"] +spin = ["dep:edgezero-adapter-spin", "dep:spin-sdk", "dep:anyhow"] + +[dependencies] +async-trait = "=0.1.89" +edgezero-core = { path = "../../../crates/edgezero-core" } +edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"], optional = true } +edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"], optional = true } +edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"], optional = true } +fastly = { version = "=0.12.1", optional = true } +worker = { version = "=0.8.3", default-features = false, features = ["http"], optional = true } +wasm-bindgen = { version = "=0.2.122", optional = true } +spin-sdk = { version = "=6.0.0", default-features = false, features = ["http"], optional = true } +anyhow = { version = "=1.0.103", optional = true } +serde_json = "=1.0.150" diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md new file mode 100644 index 00000000..cc8d60ef --- /dev/null +++ b/tests/fixtures/request-fidelity/README.md @@ -0,0 +1,52 @@ +# Request ingress runtime fixtures + +Run from the repository root with Rust 1.95.0 and Node 24.12.0: + +```sh +./scripts/test-request-fidelity.sh all +./scripts/test-request-fidelity.sh fastly --skip-build +``` + +`--skip-build` intentionally reuses existing fixture artifacts; it does not +prove source freshness. Use the default locked build for review evidence. + +The isolated Cargo workspace and npm lock pin fixture dependencies. Install +Viceroy 0.17.0, Spin 4.0.0, worker-build 0.8.3 and wasm-bindgen CLI 0.2.122. +Wrangler 4.83.0 and its local workerd are installed by `npm ci`. Each runtime +uses its own target directory, an ephemeral loopback port, bounded readiness +and an owned process group that is cleaned up on success, failure or interruption. +No platform credentials or deployment are required. + +The runner sends 54 fixed raw HTTP cases across Fastly, Cloudflare and Spin. +The client and process orchestration use an isolated native Rust harness with exact existing Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. +`observations.json` records the pinned local runtimes' observed outcomes, and +subsequent runs compare them exactly. Inspect changes before updating it. +A passing regression run includes explicitly reported unsupported cases; it +does not establish consumer compliance or production wire preservation. +Axum's real TCP ingress cases live alongside its request converter tests. + +Known gates: + +- Fastly 0.12.1 lacks a safe borrowed original-target getter. Ingress reports + `NotExposed`; a preceding native path shortcut can see a normalized target. + Its canonical origin is recovered separately from the client runtime URI + and one validated consistent Host, before native mutation. + A snapshot probe changes the native URL/Host after capture and proves the + original origin remains available through explicit-snapshot dispatch. +- Local workerd rejects extension methods before Worker invocation, normalizes + dot segments and coalesces repeated fields. Raw FF becomes U+FFFD before Rust + and is copied as runtime UTF-8 EF BF BD. Common header fidelity remains + `Unknown`, since a transforming runtime does not prove any particular field + changed. Valid wire UTF-8 C3 A9 and C4 80 are asserted byte-for-byte. + Repeated Cookie values are checked for exact comma joining versus semicolon + joining. Compound probes compare a valid-looking diagnostics value plus + unrelated invalid FF against original valid EF BF BD: their Worker verdicts + are identical. They do not implement or prove Trusted Server cookie parsing. + Absolute-form and duplicate Content-Length requests return local 500 errors. +- Spin 4.0.0 rejects non-UTF-8 header values before component invocation. +- Global original field order is unavailable on every adapter. Local evidence + cannot certify production runtime or TLS/client provenance guarantees. + +The single [spec](../../../docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md) +and [plan](../../../docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md) +record the complete contract, verification and remaining upstream/consumer gates. diff --git a/tests/fixtures/request-fidelity/fastly.toml b/tests/fixtures/request-fidelity/fastly.toml new file mode 100644 index 00000000..13b31829 --- /dev/null +++ b/tests/fixtures/request-fidelity/fastly.toml @@ -0,0 +1,4 @@ +manifest_version = 3 +name = "request-fidelity" +language = "rust" +[local_server] diff --git a/tests/fixtures/request-fidelity/observations.json b/tests/fixtures/request-fidelity/observations.json new file mode 100644 index 00000000..a443ef10 --- /dev/null +++ b/tests/fixtures/request-fidelity/observations.json @@ -0,0 +1,1425 @@ +{ + "cloudflare": { + "absolute": { + "status": 500, + "verdict": null + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": true, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": true, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": false, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": false, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 501, + "verdict": null + }, + "high-byte": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": true, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "length-conflict": { + "status": 500, + "verdict": null + }, + "length-identical": { + "status": 500, + "verdict": null + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 1, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": true, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + }, + "fastly": { + "absolute": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": true, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": true, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "native": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "native-dot": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "snapshot-origin": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + }, + "spin": { + "absolute": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 500, + "verdict": null + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 500, + "verdict": null + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + } +} diff --git a/tests/fixtures/request-fidelity/package-lock.json b/tests/fixtures/request-fidelity/package-lock.json new file mode 100644 index 00000000..f8cc7069 --- /dev/null +++ b/tests/fixtures/request-fidelity/package-lock.json @@ -0,0 +1,1503 @@ +{ + "name": "request-fidelity-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "request-fidelity-fixture", + "devDependencies": { + "wrangler": "4.83.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260415.1.tgz", + "integrity": "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260415.1.tgz", + "integrity": "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260415.1.tgz", + "integrity": "sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260415.1.tgz", + "integrity": "sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260415.1.tgz", + "integrity": "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260415.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260415.0.tgz", + "integrity": "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260415.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260415.1.tgz", + "integrity": "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260415.1", + "@cloudflare/workerd-darwin-arm64": "1.20260415.1", + "@cloudflare/workerd-linux-64": "1.20260415.1", + "@cloudflare/workerd-linux-arm64": "1.20260415.1", + "@cloudflare/workerd-windows-64": "1.20260415.1" + } + }, + "node_modules/wrangler": { + "version": "4.83.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.83.0.tgz", + "integrity": "sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260415.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260415.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260415.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/tests/fixtures/request-fidelity/package.json b/tests/fixtures/request-fidelity/package.json new file mode 100644 index 00000000..a944fcdf --- /dev/null +++ b/tests/fixtures/request-fidelity/package.json @@ -0,0 +1,7 @@ +{ + "name": "request-fidelity-fixture", + "private": true, + "devDependencies": { + "wrangler": "4.83.0" + } +} diff --git a/tests/fixtures/request-fidelity/spin.toml b/tests/fixtures/request-fidelity/spin.toml new file mode 100644 index 00000000..d06c2e53 --- /dev/null +++ b/tests/fixtures/request-fidelity/spin.toml @@ -0,0 +1,10 @@ +spin_manifest_version = 2 +[application] +name = "request-fidelity" +version = "0.1.0" +[[trigger.http]] +route = "/..." +component = "request-fidelity" +[component.request-fidelity] +source = "target/spin/wasm32-wasip2/release/request_fidelity.wasm" +allowed_outbound_hosts = [] diff --git a/tests/fixtures/request-fidelity/src/bin/fastly.rs b/tests/fixtures/request-fidelity/src/bin/fastly.rs new file mode 100644 index 00000000..cd2a2b4b --- /dev/null +++ b/tests/fixtures/request-fidelity/src/bin/fastly.rs @@ -0,0 +1,34 @@ +use edgezero_adapter_fastly::request::{ + capture_request_ingress, dispatch_with_registries_and_ingress, +}; +use edgezero_core::app::{Hooks, StoresMetadata}; +use edgezero_core::env_config::EnvConfig; +use fastly::{Error, Request, Response}; +use request_fidelity::{Fixture, NativeClient}; + +#[fastly::main] +fn main(mut req: Request) -> Result { + let ingress = capture_request_ingress(&req); + let client_ip = req.get_client_ip_addr(); + // This generic shortcut deliberately demonstrates the SDK normalization gate. + if req.get_path() == "/native" { + return Ok(Response::from_body( + r#"{"native":true,"raw_target_supported":false}"#, + )); + } + // The snapshot must keep the original origin through later native mutation. + if req.get_header_str("x-fixture") == Some("snapshot-origin") { + req.set_url("https://spoof.example.com/changed"); + req.set_header("host", "spoof.example.com"); + } + dispatch_with_registries_and_ingress( + &Fixture::build_app(), + req, + StoresMetadata::default(), + &EnvConfig::default(), + ingress, + |_req, extensions| { + extensions.insert(NativeClient(client_ip)); + }, + ) +} diff --git a/tests/fixtures/request-fidelity/src/cloudflare.rs b/tests/fixtures/request-fidelity/src/cloudflare.rs new file mode 100644 index 00000000..2a9e5f0b --- /dev/null +++ b/tests/fixtures/request-fidelity/src/cloudflare.rs @@ -0,0 +1,11 @@ +use crate::Fixture; +use edgezero_adapter_cloudflare::request::CloudflareService; +use edgezero_core::app::Hooks; +use worker::{Context, Env, Request, Response, Result}; + +#[worker::event(fetch)] +async fn main(req: Request, env: Env, ctx: Context) -> Result { + CloudflareService::new(&Fixture::build_app()) + .dispatch(req, env, ctx) + .await +} diff --git a/tests/fixtures/request-fidelity/src/lib.rs b/tests/fixtures/request-fidelity/src/lib.rs new file mode 100644 index 00000000..16687658 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/lib.rs @@ -0,0 +1,155 @@ +//! Fixed local ingress probes; no unrestricted request echo endpoint. + +#[cfg(feature = "cloudflare")] +mod cloudflare; +#[cfg(feature = "spin")] +mod spin; + +use async_trait::async_trait; +use edgezero_core::action; +use edgezero_core::app::Hooks; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Method, Request, Response, response_builder}; +use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress}; +use edgezero_core::router::{PreDispatchHook, RouterService}; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Store-free fixture entry hooks. +pub struct Fixture; + +/// The original native client fact carried through conversion. +#[cfg(feature = "fastly")] +#[derive(Clone)] +pub struct NativeClient(pub Option); + +impl Hooks for Fixture { + fn owns_logging() -> bool { + true + } + fn routes() -> RouterService { + let ordinary_calls = Arc::new(AtomicUsize::new(0)); + RouterService::builder() + .pre_dispatch_hook(Arc::new(Probe { + ordinary_calls: Arc::clone(&ordinary_calls), + })) + .middleware(CountOrdinary(ordinary_calls)) + .get("/ordinary", ordinary) + .build() + } +} + +struct CountOrdinary(Arc); +struct Probe { + ordinary_calls: Arc, +} + +#[async_trait(?Send)] +impl Middleware for CountOrdinary { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } +} + +#[action] +async fn ordinary() -> &'static str { + r#"{"ordinary":true}"# +} + +#[async_trait(?Send)] +impl PreDispatchHook for Probe { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + let case = request + .headers() + .get("x-fixture") + .and_then(|value| value.to_str().ok()) + .unwrap_or("ready"); + if case == "continue" { + *request.method_mut() = Method::GET; + *request.uri_mut() = "/ordinary".parse().map_err(EdgeError::internal)?; + return Ok(None); + } + let expected_target = match case { + "dot" => "/reserved/../ordinary", + "encoded-dot" => "/reserved/%2e%2e/ordinary", + "slashes" => "/reserved//%2F?q=a%2Fb", + "absolute" => "/reserved?fixed=1", + _ => "/reserved", + }; + let ingress = request + .extensions() + .get::() + .ok_or_else(|| EdgeError::bad_request("missing ingress fixture metadata"))?; + let (available, matches_original, matches_runtime) = match ingress.target() { + CapturedTarget::Complete(target) => { + let original = target.value() == expected_target + || target.value() == format!("http://example.com{expected_target}"); + (true, original, target.value() == *request.uri()) + } + CapturedTarget::Unavailable(_) => (false, false, false), + }; + let cookies: Vec<_> = request + .headers() + .get_all("cookie") + .iter() + .map(|value| value.as_bytes()) + .collect(); + let cookie_octets = cookies == [b"a=\xff".as_slice()]; + let same_name_order = cookies == [b"a=1".as_slice(), b"b=2".as_slice()]; + let cookie_name = "cookie".parse().map_err(EdgeError::internal)?; + let fidelity = ingress.header_fidelity(&cookie_name); + #[cfg(feature = "fastly")] + let native_client_preserved = + request + .extensions() + .get::() + .is_some_and(|native| { + native.0.is_some() + && edgezero_adapter_fastly::context::FastlyRequestContext::get(request) + .is_some_and(|context| context.client_ip == native.0) + }); + #[cfg(not(feature = "fastly"))] + let native_client_preserved = None::; + let verdict = json!({ + "hook": true, + "native_client_preserved": native_client_preserved, + "ordinary": self.ordinary_calls.load(Ordering::SeqCst) != 0, + "extension_method": request.method().as_str() == "EXAMPLE-METHOD", + "cookie_octets": cookie_octets, + "cookie_utf8": cookies == [b"a=\xc3\xa9".as_slice()], + "cookie_latin1": cookies == [b"a=\xe9".as_slice()], + "cookie_unicode": cookies == [b"a=\xc4\x80".as_slice()], + "cookie_replacement": cookies == [b"a=\xef\xbf\xbd".as_slice()], + "cookie_repeat_comma_joined": cookies == [b"a=1, b=2".as_slice()], + "cookie_repeat_semicolon_joined": cookies == [b"a=1; b=2".as_slice()], + "cookie_compound_original": cookies == [b"diagnostics=1".as_slice(), b"a=\xff".as_slice()], + "cookie_compound_runtime_utf8": cookies == [b"diagnostics=1".as_slice(), b"a=\xef\xbf\xbd".as_slice()], + "cookie_compound_comma_joined": cookies == [b"diagnostics=1, a=\xef\xbf\xbd".as_slice()], + "cookie_count": cookies.len(), + "same_name_order": same_name_order, + "origin_count": request.headers().get_all("origin").iter().count(), + "control_count": request.headers().get_all("x-control").iter().count(), + "content_length_count": request.headers().get_all("content-length").iter().count(), + "transfer_encoding_count": request.headers().get_all("transfer-encoding").iter().count(), + "transfer_encoding_chunked": request.headers().get("transfer-encoding").is_some_and(|value| value.as_bytes() == b"chunked"), + "target_available": available, + "target_matches_original": matches_original, + "target_matches_runtime": matches_runtime, + "origin_available": ingress.origin().is_some(), + "origin_matches_expected": ingress.origin().is_some_and(|origin| origin.scheme() == "http" && origin.authority() == "example.com"), + "spoofed_origin_ignored": ingress.origin().is_none_or(|origin| !origin.authority().contains("spoof")), + "global_order_unavailable": fidelity.global_field_order() == Preservation::Unavailable, + }); + Ok(Some( + response_builder() + .header("content-type", "application/json") + .body(Body::text(verdict.to_string())) + .map_err(EdgeError::internal)?, + )) + } +} diff --git a/tests/fixtures/request-fidelity/src/spin.rs b/tests/fixtures/request-fidelity/src/spin.rs new file mode 100644 index 00000000..7da12d16 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/spin.rs @@ -0,0 +1,13 @@ +use crate::Fixture; +use edgezero_adapter_spin::request::into_core_request; +use edgezero_adapter_spin::response::from_core_response; +use edgezero_core::app::Hooks; +use spin_sdk::http::{IntoResponse, Request}; +use spin_sdk::http_service; + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + let request = into_core_request(req).await?; + let response = Fixture::build_app().router().oneshot(request).await?; + Ok(from_core_response(response).await?) +} diff --git a/tests/fixtures/request-fidelity/wrangler.toml b/tests/fixtures/request-fidelity/wrangler.toml new file mode 100644 index 00000000..b133beff --- /dev/null +++ b/tests/fixtures/request-fidelity/wrangler.toml @@ -0,0 +1,6 @@ +name = "request-fidelity" +main = "build/cloudflare/worker/shim.mjs" +compatibility_date = "2026-04-01" +[dev] +ip = "127.0.0.1" +local_protocol = "http" From 75067d2c9a3cf865591665e88a736db4c8a13be0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 6 Oct 2026 12:44:25 +0530 Subject: [PATCH 02/11] Clarify ingress preservation per request --- .../edgezero-adapter-axum/src/dev_server.rs | 18 +++++--- crates/edgezero-adapter-axum/src/request.rs | 44 ++++++++++++++++--- .../src/request.rs | 2 +- .../tests/contract.rs | 3 +- docs/guide/adapters/axum.md | 5 +++ docs/guide/adapters/cloudflare.md | 4 ++ docs/guide/adapters/fastly.md | 5 ++- docs/guide/adapters/overview.md | 16 +++---- ...026-10-05-pre-dispatch-request-fidelity.md | 18 ++++++++ ...05-pre-dispatch-request-fidelity-design.md | 9 ++++ 10 files changed, 103 insertions(+), 21 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 6207bec0..acb96915 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -754,7 +754,7 @@ mod integration_tests { use edgezero_core::body::Body; use edgezero_core::http::{HeaderValue, Method, Request, Response, response_builder}; use edgezero_core::middleware::{Middleware, Next}; - use edgezero_core::request::{CapturedTarget, RequestIngress}; + use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress}; use edgezero_core::router::PreDispatchHook; use std::io::{Read as _, Write as _}; use std::net::TcpStream; @@ -821,10 +821,18 @@ mod integration_tests { .collect(); assert_eq!(cookies, [b"a=\xff".as_slice(), b"b=2".as_slice()]); } - "length" => assert_eq!( - request.headers().get_all("content-length").iter().count(), - 1 - ), + "length" => { + assert_eq!( + request.headers().get_all("content-length").iter().count(), + 1 + ); + assert_eq!( + ingress + .header_fidelity(&"content-length".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } "te-cl" => assert!(!request.headers().contains_key("content-length")), "continue" => { *request.method_mut() = Method::GET; diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index aca848ca..b096101c 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -95,11 +95,7 @@ fn capture_request_ingress( ); let content_length = HeaderFidelity::new( Preservation::Unknown, - if transport.is_some() { - Preservation::Transformed - } else { - Preservation::Unknown - }, + Preservation::Unknown, Preservation::Unknown, Preservation::Unavailable, ); @@ -344,6 +340,44 @@ mod ingress_tests { ); } + #[tokio::test] + async fn ingress_content_length_fidelity_is_unknown_with_or_without_transport() { + for bound in [false, true] { + for count in 0..=2 { + let mut request = request_builder() + .uri("/reserved") + .header("host", "example.com") + .body(AxumBody::empty()) + .expect("should build request"); + for _ in 0..count { + request + .headers_mut() + .append("content-length", HeaderValue::from_static("0")); + } + if bound { + request.extensions_mut().insert(HttpTransport); + } + let core = into_core_request(request) + .await + .expect("should convert request"); + assert_eq!( + core.headers().get_all("content-length").iter().count(), + count + ); + let ingress = core + .extensions() + .get::() + .expect("should insert metadata"); + assert_eq!( + ingress + .header_fidelity(&"content-length".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } + } + } + #[tokio::test] async fn ingress_bound_does_not_change_uri_body_or_extensions() { let path = format!("/{}", "a".repeat(MAX_TARGET_BYTES)); diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 509109fb..8037cebf 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -253,7 +253,7 @@ pub async fn into_core_request( let target = CapturedTarget::capture( &runtime_url, TargetSource::RuntimeUrl, - Preservation::Transformed, + Preservation::Unknown, ); let uri: Uri = runtime_url .parse() diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 6f03ce10..f2e65d6c 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -113,6 +113,7 @@ mod tests { async fn ingress_metadata_tracks_runtime_url_origin_and_coalescing() { let oversized = format!("https://example.com/{}", "a".repeat(MAX_TARGET_BYTES)); for url in [ + "https://example.com/reserved?q=a%2Fb", "https://example.com/reserved/../ordinary?q=a%2Fb", "https://example.com/reserved/%2e%2e/ordinary", "https://example.com/reserved//%2F", @@ -143,7 +144,7 @@ mod tests { match ingress.target() { CapturedTarget::Complete(target) => { assert_eq!(target.value(), exposed); - assert_eq!(target.fidelity(), Preservation::Transformed); + assert_eq!(target.fidelity(), Preservation::Unknown); } CapturedTarget::Unavailable(reason) => { assert_eq!(*reason, TargetUnavailable::TooLarge); diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index c2f93cfd..7e803ce4 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -292,3 +292,8 @@ duplicates and FF values. Hyper folds identical Content-Length values and rejects conflicting lengths before the hook. The established server path therefore records a Content-Length-specific multiplicity limitation. Global wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). + +Content-Length field multiplicity is `Unknown`: an HTTP binding does not reveal +whether the parser folded original fields. This differs from the independently +tested capability that identical repeated lengths fold and conflicting lengths +are rejected before dispatch. diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 4b593e67..567e9c60 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -331,3 +331,7 @@ hook dispatch. Locked local workerd transport tests additionally distinguish pre-Worker rejections: its HTTP parser returns 501 for extension methods. Real Worker tests assert valid UTF-8 values C3 A9 and C4 80 survive conversion. Local wire FF becomes U+FFFD in Web Headers before Rust, so its runtime UTF-8 copy EF BF BD cannot prove original bytes. Common octet and multiplicity fidelity is `Unknown`: the observed transformations do not prove every particular field changed. Local absolute-form and duplicate Content-Length requests return 500 without a hook verdict. These transport/runtime limits remain separate from runtime-to-core conversion correctness. See [the capability matrix](./overview#inbound-request-fidelity). + +Captured runtime-target preservation is `Unknown`. Fetch URL normalization is +a known runtime capability, but the original request target is unavailable, so +a transformation cannot be established for each individual incoming request. diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 1aab5097..77782faf 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -384,7 +384,10 @@ replace it. Ordinary runners and existing converters capture once and delegate. Supplied metadata and fidelity are caller assertions; syntax validation does not independently establish their provenance. -Viceroy probes retain tested repeated fields and FF header octets. They also +Viceroy probes retain tested ordinary repeated fields and FF header octets. +Identical repeated Content-Length fields fold into one; conflicting lengths are +rejected with 400 before application dispatch. These parser observations do not +establish preservation for every incoming request. The probes also demonstrate that a normalized native shortcut can receive `/reserved/../native` as `/native`. The router hook cannot intercept a shortcut taken earlier. Original-target support and safe shortcut exclusion remain diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index e415254c..ec29018b 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -192,14 +192,14 @@ Local evidence uses locked SDKs and fixed raw HTTP/1.1 fixtures. These results describe the tested local runtimes; they do not establish production-edge behavior. -| Surface | Fastly 0.12.1 / Viceroy 0.17.0 | worker 0.8.3 / workerd 1.20260415.1 | Axum / Hyper 1.10.1 | Spin SDK 6.0.0 / Spin 4.0.0 | -| ----------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | -| Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | -| Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | -| High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | -| Repeated control fields | Tested same-name fields retained | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | -| Global wire order | Unavailable | Unavailable | Unavailable | Unavailable | +| Surface | Fastly 0.12.1 / Viceroy 0.17.0 | worker 0.8.3 / workerd 1.20260415.1 | Axum / Hyper 1.10.1 | Spin SDK 6.0.0 / Spin 4.0.0 | +| ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | +| Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | +| Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | +| High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | +| Repeated control fields | Tested ordinary duplicates retained; identical Content-Length folded | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | +| Global wire order | Unavailable | Unavailable | Unavailable | Unavailable | Run `./scripts/test-request-fidelity.sh all` for local transport probes. They assert pinned observations, including unsupported outcomes; a green regression diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index 0f3ffe01..27accb00 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -388,3 +388,21 @@ A reviewed immutable dependency revision, consumer repinning and tests on Trusted Server's resolved graph remain required. Do not claim consumer F0 or the full trace feature complete from toolkit checks. Follow the repository PR-creator workflow, including its linked-issue requirement, for publication. + +### Approved external-review corrections — 2026-10-06 + +- [x] Prove canonical Cloudflare URLs and Axum absent/single/repeated visible + Content-Length fields do not claim per-request `Transformed` merely because + the runtime can normalize or fold them; use `Unknown`. +- [x] Retain actual parser folding and conversion behavior; rerun real browser + contract tests, the native TCP hook and the fixed raw observation matrix. +- [x] Document Fastly identical Content-Length folding and conflicting-length + rejection. Verify the required full gates and independent review, then update + the existing PR and Trusted Server consumer pin. No Python is introduced. + +Verification: all five required workspace gates and docs lint/format/build pass. +Native Axum contract tests pass 116/0, real-browser Cloudflare 11/0, Fastly +Viceroy 6/0 and Spin Wasmtime 12/0. The rebuilt raw matrix retains all 54 +committed observations (20 Fastly, 17 Cloudflare, 17 Spin). Independent review +approved the metadata correction without changing request conversion or claims +about original wire bytes. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index 12cd7eb2..79c851f6 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -244,3 +244,12 @@ requires a reviewed immutable EdgeZero revision and verification against the consumer's resolved dependency graph. Successful normal browser workflows on all four adapters, early native ordering and terminal finalization remain consumer acceptance work. Toolkit tests alone do not complete those gates. + +### Per-observation preservation clarification — 2026-10-06 + +`Transformed` means a change is established for the specific observation. A +runtime capability to normalize URLs or fold Content-Length fields does not +prove that every request changed. Cloudflare runtime targets and Axum +Content-Length multiplicity therefore use `Unknown` when original equality or +change cannot be established. Conversion behavior and recorded capability +observations remain unchanged. From 4f221d257e3224c946326b949bb966ac822ae5a3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 6 Oct 2026 14:35:22 +0530 Subject: [PATCH 03/11] Clarify authority validation and fidelity evidence --- docs/guide/adapters/axum.md | 10 +++++++--- .../2026-10-05-pre-dispatch-request-fidelity.md | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 7e803ce4..070f433a 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -289,11 +289,15 @@ the marker leaves origin unavailable. Raw TCP tests retain extension methods, tested path spelling, same-name duplicates and FF values. Hyper folds identical Content-Length values and -rejects conflicting lengths before the hook. The established server path -therefore records a Content-Length-specific multiplicity limitation. Global -wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). +rejects conflicting lengths before the hook. The established server path reports common field multiplicity as `Unknown`; +the Content-Length override retains that same conservative value rather than +adding a field-specific verdict. Global wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). Content-Length field multiplicity is `Unknown`: an HTTP binding does not reveal whether the parser folded original fields. This differs from the independently tested capability that identical repeated lengths fold and conflicting lengths are rejected before dispatch. + +Origin validation checks HTTP(S) URI authority syntax, including registered +names and trailing-dot hosts. It does not establish DNS resolution or ownership. +Consumers that require DNS-label syntax must apply that additional policy. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index 27accb00..7583264b 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -406,3 +406,17 @@ Viceroy 6/0 and Spin Wasmtime 12/0. The rebuilt raw matrix retains all 54 committed observations (20 Fastly, 17 Cloudflare, 17 Spin). Independent review approved the metadata correction without changing request conversion or claims about original wire bytes. + +## Latest independent review follow-up — 2026-10-06 + +The earlier 47-case records above describe earlier execution checkpoints. The +final baseline and dependency-handoff evidence contain 54 cases: Fastly 20, +Cloudflare 17 and Spin 17. Those final counts supersede earlier probe totals. + +Clarify the Axum guide: common field multiplicity remains `Unknown`, including +the Content-Length override. `InboundOrigin::parse` validates URI authorities, +not DNS-label-only names. Commas, asterisks and semicolons are permitted +registered-name characters under [RFC 3986 section 3.2.2](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2), +and a trailing dot is valid. The proposed DNS-only rejection would narrow the +approved contract and reject valid authorities, so no parser change is made. +These clarifications do not change the consumer dependency API or runtime code. From 08c00e823c6b0d75b2a974d9130ce2a8a36a7ce3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 20:00:35 +0530 Subject: [PATCH 04/11] Fix Cloudflare primary URI conversion compatibility --- .../src/request.rs | 6 +- .../request-fidelity-runner/src/main.rs | 107 +++++++++++++++++- tests/fixtures/request-fidelity/README.md | 7 +- .../request-fidelity/observations.json | 16 +++ .../request-fidelity/src/cloudflare.rs | 52 ++++++++- tests/fixtures/request-fidelity/workerd.capnp | 13 +++ 6 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/request-fidelity/workerd.capnp diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 8037cebf..6bee0ff4 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -255,7 +255,11 @@ pub async fn into_core_request( TargetSource::RuntimeUrl, Preservation::Unknown, ); - let uri: Uri = runtime_url + let url = req + .url() + .map_err(|_error| EdgeError::bad_request("invalid runtime URL"))?; + let uri: Uri = url + .as_str() .parse() .map_err(|_error| EdgeError::bad_request("invalid runtime URI"))?; let origin = uri diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index 59e744b2..7fab6c10 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -413,7 +413,15 @@ fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { } thread::sleep(POLL_INTERVAL); } - let observed = probes(platform, port, cancelled)?; + let mut observed = probes(platform, port, cancelled)?; + if *platform == "cloudflare" { + let routing = + cloudflare_routing_probes(&fixture, cancelled, options.readiness_timeout)?; + observed + .as_object_mut() + .expect("probe results are objects") + .extend(routing); + } if !options.record_observations { let expected: Value = serde_json::from_slice(&fs::read(fixture.join("observations.json"))?) @@ -900,6 +908,86 @@ fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) ) } +fn cloudflare_routing_probes( + fixture: &Path, + cancelled: &Cancelled, + readiness_timeout: Duration, +) -> io::Result> { + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + let address = format!("http=127.0.0.1:{port}"); + let mut runtime = command( + fixture, + "node_modules/.bin/workerd", + &["serve", "workerd.capnp", "--socket-addr", &address], + ); + let log_path = scratch.path().join("workerd.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() || Instant::now() >= deadline { + return Err(io::Error::other("direct workerd readiness failed")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let mut observed = serde_json::Map::new(); + let mut failures = Vec::new(); + for (name, target) in [ + ("route-unicode", b"/caf\xc3\xa9".as_slice()), + ("route-backslash", b"/a\\b".as_slice()), + ] { + let case = Case { + name, + target, + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + if status != 200 + || verdict + != json!({ + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true, + }) + { + failures.push(format!("{name}: HTTP {status}, verdict {verdict}")); + } + observed.insert( + name.to_owned(), + json!({"status": status, "verdict": verdict}), + ); + } + require( + failures.is_empty(), + "cloudflare", + "no-hook routing", + &failures.join("; "), + )?; + Ok(observed) + })(); + drop(process); + if result.is_err() { + writeln!(io::stderr(), "{}", fs::read_to_string(log_path)?)?; + } + result +} + fn probes(platform: &str, port: u16, cancelled: &Cancelled) -> io::Result { let mut observed = json!({}); let cases = cases(); @@ -974,18 +1062,31 @@ struct Case { #[cfg(test)] mod tests { use super::{ - Case, HEADER_LIMIT, OwnedProcess, TimedSocket, cases, read_response, request_packet, - verify_observation, + Case, HEADER_LIMIT, OwnedProcess, TimedSocket, cases, cloudflare_routing_probes, + read_response, request_packet, verify_observation, }; use serde_json::json; use std::io::{self, BufRead as _, BufReader, Cursor, Write as _}; use std::net::{TcpListener, TcpStream}; + use std::path::Path; use std::process::{Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::{Duration, Instant}; + #[test] + #[ignore = "requires a freshly built Cloudflare fixture and pinned workerd"] + fn cloudflare_primary_uri_preserves_no_hook_routing() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runner has a fixture parent") + .join("request-fidelity"); + let cancelled = Arc::new(AtomicBool::new(false)); + cloudflare_routing_probes(&fixture, &cancelled, Duration::from_secs(60)) + .expect("primary URI normalization and raw ingress capture must remain independent"); + } + #[test] fn cancelled_response_reads_do_not_retry_until_deadline() { for prefix in [ diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index cc8d60ef..a153c23a 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -17,7 +17,7 @@ uses its own target directory, an ephemeral loopback port, bounded readiness and an owned process group that is cleaned up on success, failure or interruption. No platform credentials or deployment are required. -The runner sends 54 fixed raw HTTP cases across Fastly, Cloudflare and Spin. +The runner sends 56 fixed raw HTTP cases across Fastly, Cloudflare and Spin. The client and process orchestration use an isolated native Rust harness with exact existing Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. `observations.json` records the pinned local runtimes' observed outcomes, and subsequent runs compare them exactly. Inspect changes before updating it. @@ -27,6 +27,11 @@ Axum's real TCP ingress cases live alongside its request converter tests. Known gates: +- Two Cloudflare routing regressions send raw UTF-8 `/café` and `/a\b` + directly to the pinned workerd, bypassing Wrangler's development proxy, + which normalizes these targets before Worker invocation. A no-hook + application must match `/caf%C3%A9` and `/a/b` while ingress metadata + independently retains the raw runtime URL with unknown wire preservation. - Fastly 0.12.1 lacks a safe borrowed original-target getter. Ingress reports `NotExposed`; a preceding native path shortcut can see a normalized target. Its canonical origin is recovered separately from the client runtime URI diff --git a/tests/fixtures/request-fidelity/observations.json b/tests/fixtures/request-fidelity/observations.json index a443ef10..1ad21841 100644 --- a/tests/fixtures/request-fidelity/observations.json +++ b/tests/fixtures/request-fidelity/observations.json @@ -253,6 +253,22 @@ "transfer_encoding_count": 0 } }, + "route-backslash": { + "status": 200, + "verdict": { + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true + } + }, + "route-unicode": { + "status": 200, + "verdict": { + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true + } + }, "slashes": { "status": 200, "verdict": { diff --git a/tests/fixtures/request-fidelity/src/cloudflare.rs b/tests/fixtures/request-fidelity/src/cloudflare.rs index 2a9e5f0b..4d830a87 100644 --- a/tests/fixtures/request-fidelity/src/cloudflare.rs +++ b/tests/fixtures/request-fidelity/src/cloudflare.rs @@ -1,11 +1,55 @@ use crate::Fixture; use edgezero_adapter_cloudflare::request::CloudflareService; -use edgezero_core::app::Hooks; +use edgezero_core::action; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::context::RequestContext; +use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress, TargetSource}; +use edgezero_core::router::RouterService; +use serde_json::json; use worker::{Context, Env, Request, Response, Result}; #[worker::event(fetch)] async fn main(req: Request, env: Env, ctx: Context) -> Result { - CloudflareService::new(&Fixture::build_app()) - .dispatch(req, env, ctx) - .await + let app = if matches!( + req.headers().get("x-fixture")?.as_deref(), + Some("route-unicode" | "route-backslash") + ) { + App::new( + RouterService::builder() + .get("/caf%C3%A9", normalized_route) + .get("/a/b", normalized_route) + .build(), + ) + } else { + Fixture::build_app() + }; + CloudflareService::new(&app).dispatch(req, env, ctx).await +} + +#[action] +async fn normalized_route(ctx: RequestContext) -> String { + let (raw_target, normalized_uri) = match ctx + .request() + .headers() + .get("x-fixture") + .and_then(|value| value.to_str().ok()) + { + Some("route-unicode") => ("http://example.com/café", "http://example.com/caf%C3%A9"), + Some("route-backslash") => ("http://example.com/a\\b", "http://example.com/a/b"), + _ => ("", ""), + }; + let target = ctx + .request() + .extensions() + .get::() + .map(RequestIngress::target); + json!({ + "ordinary": true, + "primary_uri_normalized": *ctx.request().uri() == normalized_uri, + "raw_target_preserved": matches!(target, Some(CapturedTarget::Complete(target)) + if target.value() == raw_target + && target.source() == TargetSource::RuntimeUrl + && target.fidelity() == Preservation::Unknown), + }) + .to_string() } diff --git a/tests/fixtures/request-fidelity/workerd.capnp b/tests/fixtures/request-fidelity/workerd.capnp new file mode 100644 index 00000000..a8a3eb52 --- /dev/null +++ b/tests/fixtures/request-fidelity/workerd.capnp @@ -0,0 +1,13 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Direct ingress avoids Wrangler's development proxy URL normalization. +const config :Workerd.Config = ( + services = [(name = "fixture", worker = ( + compatibilityDate = "2026-04-01", + modules = [ + (name = "index.js", esModule = embed "build/cloudflare/index.js"), + (name = "index_bg.wasm", wasm = embed "build/cloudflare/index_bg.wasm") + ] + ))], + sockets = [(name = "http", address = "127.0.0.1:8080", http = (), service = "fixture")] +); From 02ab00d4f6bba2ae33604b470b6728dd452273bc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 15:29:46 +0530 Subject: [PATCH 05/11] Address request ingress fidelity review findings --- .github/workflows/test.yml | 12 ++- crates/edgezero-adapter-axum/src/request.rs | 19 +---- .../src/request.rs | 7 +- crates/edgezero-adapter-fastly/src/request.rs | 20 ++--- crates/edgezero-adapter-spin/src/context.rs | 77 ------------------- crates/edgezero-adapter-spin/src/lib.rs | 3 + crates/edgezero-adapter-spin/src/request.rs | 3 +- .../src/request/ingress.rs | 70 +++++++++++++++++ crates/edgezero-core/src/request.rs | 14 +++- docs/guide/adapters/axum.md | 24 +++--- docs/guide/adapters/cloudflare.md | 19 +++-- docs/guide/adapters/fastly.md | 10 +-- docs/guide/adapters/overview.md | 6 +- docs/guide/adapters/spin.md | 18 ++--- docs/guide/middleware.md | 10 +-- docs/guide/routing.md | 10 +-- ...026-10-05-pre-dispatch-request-fidelity.md | 23 +++++- ...05-pre-dispatch-request-fidelity-design.md | 2 - 18 files changed, 183 insertions(+), 164 deletions(-) create mode 100644 crates/edgezero-adapter-spin/src/request/ingress.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 293944ec..8ec3c955 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -162,7 +162,7 @@ jobs: - adapter: spin target: wasm32-wasip2 runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER - runner_value: wasmtime run -W component-model-async=y -S p3=y,http=y + runner_value: wasmtime run steps: - uses: actions/checkout@v6 with: @@ -331,7 +331,11 @@ jobs: # The real Spin HTTP fixture below executes the converter instead. - name: Install lock-matched worker-build if: matrix.adapter == 'cloudflare' - run: cargo install worker-build --version 0.8.3 --locked --force + shell: bash + run: | + if [[ "$(worker-build --version 2>/dev/null || true)" != "0.8.3" ]]; then + cargo install worker-build --version 0.8.3 --locked --force + fi - name: Install pinned Spin HTTP runtime if: matrix.adapter == 'spin' @@ -341,6 +345,10 @@ jobs: install_dir="$RUNNER_TEMP/spin" mkdir -p "$install_dir" curl -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" + # GitHub's published digest for the v4.0.0 linux-amd64 release asset. + # Keep the version and digest paired when updating the fixture pin. + test "$version" = "4.0.0" + echo "e705c9bfd9484a9175f392a116856680862a40f31d1a85618bed331f34ffccfa $RUNNER_TEMP/spin.tar.gz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir" echo "$install_dir" >> "$GITHUB_PATH" "$install_dir/spin" --version diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index b096101c..e54dbff6 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -5,7 +5,7 @@ use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::{CONTENT_LENGTH, CONTENT_TYPE, HOST}; +use edgezero_core::http::header::{CONTENT_TYPE, HOST}; use edgezero_core::http::{HeaderMap, HeaderValue, Uri}; use edgezero_core::proxy::ProxyHandle; use edgezero_core::request::{ @@ -74,7 +74,7 @@ pub async fn into_core_request(request: Request) -> Result RequestIngress { RequestIngress::new( target, origin, - HeaderFidelity::new( - Preservation::Unknown, - Preservation::Unknown, - Preservation::Unknown, - Preservation::Unavailable, - ), + HeaderFidelity::RUNTIME_HEADER_MAP, Vec::new(), ) .expect("should use unique header overrides") @@ -627,7 +621,7 @@ fn origin_from_runtime_uri<'header>( return None; } let scheme = uri.scheme_str()?; - let _runtime_origin = + let runtime_origin = InboundOrigin::parse(scheme, uri.authority()?.as_str(), OriginSource::RuntimeUri).ok()?; let origin = InboundOrigin::parse(scheme, host, OriginSource::RuntimeUri).ok()?; let host_uri = Uri::builder() @@ -642,7 +636,7 @@ fn origin_from_runtime_uri<'header>( { return None; } - Some(origin) + Some(runtime_origin) } /// Convert a native request, capturing ingress before native mutation. @@ -1039,8 +1033,10 @@ mod ingress_tests { ); assert_eq!( origin.authority(), - host, - "should retain validated authority" + uri.authority() + .expect("should have runtime authority") + .as_str(), + "should retain runtime authority rather than Host spelling" ); assert_eq!( origin.source(), diff --git a/crates/edgezero-adapter-spin/src/context.rs b/crates/edgezero-adapter-spin/src/context.rs index 88b70e39..4061d471 100644 --- a/crates/edgezero-adapter-spin/src/context.rs +++ b/crates/edgezero-adapter-spin/src/context.rs @@ -4,13 +4,6 @@ use std::net::SocketAddr; use edgezero_core::http::Request; -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -use edgezero_core::http::Uri; -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -use edgezero_core::request::{ - CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, - TargetSource, -}; /// Platform-specific request context for Spin. /// /// Spin exposes client information via special headers @@ -39,34 +32,6 @@ impl SpinRequestContext { } } -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -#[expect( - clippy::expect_used, - reason = "the empty override list cannot contain duplicate names" -)] -pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { - let origin = uri - .scheme_str() - .zip(uri.authority()) - .and_then(|(scheme, authority)| { - InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() - }); - let source = if uri.scheme().is_some() { - TargetSource::RuntimeUrl - } else { - TargetSource::RuntimePathAndQuery - }; - let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); - let headers = HeaderFidelity::new( - Preservation::Unknown, - Preservation::Unknown, - Preservation::Unknown, - Preservation::Unavailable, - ); - RequestIngress::new(target, origin, headers, Vec::new()) - .expect("should use unique header overrides") -} - /// Parse an IP address from a `host:port` string. /// /// Falls back to parsing the raw value as a bare IP (no port) and also @@ -151,45 +116,3 @@ mod tests { assert_eq!(ip, IpAddr::from_str("::1").unwrap()); } } -#[cfg(test)] -mod ingress_tests { - use super::capture_request_ingress; - use edgezero_core::http::Uri; - use edgezero_core::request::{CapturedTarget, OriginSource, Preservation}; - - #[test] - fn ingress_captures_runtime_uri_and_origin_only() { - let uri: Uri = "https://example.com/reserved/%2e//%2F?q=a%2Fb" - .parse() - .expect("should parse URI"); - let ingress = capture_request_ingress(&uri); - let CapturedTarget::Complete(target) = ingress.target() else { - panic!("should capture target") - }; - assert_eq!(target.value(), uri.to_string()); - let origin = ingress.origin().expect("should capture runtime origin"); - assert_eq!(origin.scheme(), "https"); - assert_eq!(origin.authority(), "example.com"); - assert_eq!(origin.source(), OriginSource::RuntimeUri); - assert_eq!( - ingress - .header_fidelity(&"cookie".parse().expect("should parse name")) - .global_field_order(), - Preservation::Unavailable - ); - assert!( - capture_request_ingress(&"/reserved".parse().expect("should parse path")) - .origin() - .is_none() - ); - assert!( - capture_request_ingress( - &"ftp://example.com/reserved" - .parse() - .expect("should parse URI") - ) - .origin() - .is_none() - ); - } -} diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 266ccca7..b1aea833 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -7,6 +7,9 @@ pub mod cli; pub mod config_store; pub mod context; mod decompress; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[path = "request/ingress.rs"] +mod ingress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub mod key_value_store; // `kv_pagination` is the pure paging logic for `SpinKvStore::list_keys_page`. diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 9c47e22f..9c08e1e3 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -5,7 +5,8 @@ use anyhow::Context as _; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; -use crate::context::{SpinRequestContext, capture_request_ingress, parse_client_addr}; +use crate::context::{SpinRequestContext, parse_client_addr}; +use crate::ingress::capture_request_ingress; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; diff --git a/crates/edgezero-adapter-spin/src/request/ingress.rs b/crates/edgezero-adapter-spin/src/request/ingress.rs new file mode 100644 index 00000000..82460505 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/request/ingress.rs @@ -0,0 +1,70 @@ +use edgezero_core::http::Uri; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; + +#[expect( + clippy::expect_used, + reason = "the empty override list cannot contain duplicate names" +)] +pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let headers = HeaderFidelity::RUNTIME_HEADER_MAP; + RequestIngress::new(target, origin, headers, Vec::new()) + .expect("should use unique header overrides") +} + +#[cfg(test)] +mod ingress_tests { + use super::capture_request_ingress; + use edgezero_core::http::Uri; + use edgezero_core::request::{CapturedTarget, OriginSource, Preservation}; + + #[test] + fn ingress_captures_runtime_uri_and_origin_only() { + let uri: Uri = "https://example.com/reserved/%2e//%2F?q=a%2Fb" + .parse() + .expect("should parse URI"); + let ingress = capture_request_ingress(&uri); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture target") + }; + assert_eq!(target.value(), uri.to_string()); + let origin = ingress.origin().expect("should capture runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!( + ingress + .header_fidelity(&"cookie".parse().expect("should parse name")) + .global_field_order(), + Preservation::Unavailable + ); + assert!( + capture_request_ingress(&"/reserved".parse().expect("should parse path")) + .origin() + .is_none() + ); + assert!( + capture_request_ingress( + &"ftp://example.com/reserved" + .parse() + .expect("should parse URI") + ) + .origin() + .is_none() + ); + } +} diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs index 8872aef4..e390043b 100644 --- a/crates/edgezero-core/src/request.rs +++ b/crates/edgezero-core/src/request.rs @@ -93,6 +93,15 @@ pub struct HeaderFidelity { } impl HeaderFidelity { + /// Conservative baseline for copied runtime headers: original octets, + /// multiplicity and same-name order are unknown; global order is unavailable. + pub const RUNTIME_HEADER_MAP: Self = Self::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + /// Return original field-count preservation for this header. #[inline] #[must_use] @@ -151,7 +160,10 @@ pub struct InboundOrigin { } impl InboundOrigin { - /// Borrow the validated authority, including any explicit port. + /// Borrow the validated authority in its source-provided spelling. + /// + /// Host case and explicit default ports are retained when the source exposes + /// them. Consumers must normalize authorities before comparing origins. #[inline] #[must_use] pub fn authority(&self) -> &str { diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 0e26a89c..fff6da6d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -284,12 +284,6 @@ extensions, and body. Restarting the server creates a fresh app. Native Axum measurements are a useful ownership reference but do not establish WASM runtime performance or resource lifetimes. -## Next Steps - -- Deploy to [Fastly Compute](/guide/adapters/fastly) for production -- Deploy to [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative -- Explore [Configuration](/guide/configuration) for manifest options - ## Ingress provenance Conversion captures runtime URI parts and retains the original HeaderMap and @@ -301,15 +295,17 @@ the marker leaves origin unavailable. Raw TCP tests retain extension methods, tested path spelling, same-name duplicates and FF values. Hyper folds identical Content-Length values and -rejects conflicting lengths before the hook. The established server path reports common field multiplicity as `Unknown`; -the Content-Length override retains that same conservative value rather than -adding a field-specific verdict. Global wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). - -Content-Length field multiplicity is `Unknown`: an HTTP binding does not reveal -whether the parser folded original fields. This differs from the independently -tested capability that identical repeated lengths fold and conflicting lengths -are rejected before dispatch. +rejects conflicting lengths before the hook. Common field multiplicity remains +`Unknown`, including Content-Length: the runtime binding does not reveal whether +the parser folded original fields. Global wire order remains unavailable. +See [the capability matrix](./overview#inbound-request-fidelity). Origin validation checks HTTP(S) URI authority syntax, including registered names and trailing-dot hosts. It does not establish DNS resolution or ownership. Consumers that require DNS-label syntax must apply that additional policy. + +## Next Steps + +- Deploy to [Fastly Compute](/guide/adapters/fastly) for production +- Deploy to [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative +- Explore [Configuration](/guide/configuration) for manifest options diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 28f55d48..7727ea61 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -348,11 +348,6 @@ in flight in the same instance. A serialized test or two separate instances does not establish this property. Restore the existing `run_app` entry point to remove explicit retention. The generated default remains unchanged. -## Next Steps - -- Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative -- Explore the [Axum adapter](/guide/adapters/axum) for local development - ## Runtime request fidelity Inbound conversion reads `req.inner().method()` rather than the closed Workers @@ -373,3 +368,17 @@ See [the capability matrix](./overview#inbound-request-fidelity). Captured runtime-target preservation is `Unknown`. Fetch URL normalization is a known runtime capability, but the original request target is unavailable, so a transformation cannot be established for each individual incoming request. + +### Conversion compatibility + +Unknown method tokens are retained rather than coerced to GET, so they no longer +match a GET route. Invalid runtime header values return 400 during conversion. +Method, URL, URI and header conversion errors use bounded messages without parse +details. Primary routing still uses the existing SDK URL conversion, including +Unicode percent encoding and backslash normalization; ingress target metadata is +captured separately before that conversion. + +## Next Steps + +- Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative +- Explore the [Axum adapter](/guide/adapters/axum) for local development diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 09563100..ef9f0399 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -601,11 +601,6 @@ resource accounting, or performance. Named endpoint delivery must be verified separately from echoed stdout. Roll back by restoring the original single-request entry point and removing retained state, not merely setting a request limit of one. -## Next Steps - -- Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target -- Explore [Configuration](/guide/configuration) for manifest details - ## Early ingress snapshots Call `request::capture_request_ingress(&req)` immediately after acquiring the @@ -639,3 +634,8 @@ demonstrate that a normalized native shortcut can receive taken earlier. Original-target support and safe shortcut exclusion remain blocked on the SDK accessor; client/TLS preservation must be proved on the appropriate ingress. See [the capability matrix](./overview#inbound-request-fidelity). + +## Next Steps + +- Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target +- Explore [Configuration](/guide/configuration) for manifest details diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 178cb373..7498ff29 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -110,7 +110,7 @@ Spin's adapter targets `wasm32-wasip2` and its contract suite runs under Wasmtim ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` @@ -174,10 +174,12 @@ establishes provenance. Runtime URI facts use `RuntimeUri`; Axum's plain HTTP listener uses `TransportBinding` plus one consistent Host. Origin-form Axum conversion without that binding has no trusted origin. Ordinary Origin, Forwarded and Spin special headers do not establish ingress origin. +Authorities retain source-provided spelling, including host case and explicit +default ports when exposed. Consumers must normalize before comparing origins. Fastly records target unavailability before reading its SDK runtime URL for origin, and requires a client request with one consistent validated Host. -This recovers canonical origin without claiming an original path. +This recovers the validated runtime origin without claiming an original path. `HeaderFidelity` has separate octet, field-multiplicity, same-name-order and global-order axes, with per-name overrides. They concern original received diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index aaaa8d77..5e0e3954 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -306,7 +306,7 @@ Run contract tests for the Spin adapter: ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` @@ -353,13 +353,6 @@ request/response bodies and pending operations invocation-local. Existing response buffering and stream collection limits still apply. Restore `run_app` and remove the cache to roll back; default generated entry points are unchanged. -## Next Steps - -- [Migration guide](/guide/manifest-store-migration) — moving from the - pre-rewrite store schema -- [Adapters overview](/guide/adapters/overview) — cross-adapter contracts -- [Configuration](/guide/configuration) — full manifest reference - ## Runtime ingress metadata Conversion captures SDK URI parts before rebuilding the core request. Origin @@ -376,7 +369,14 @@ restored inside EdgeZero. Same-name runtime values are copied byte-for-byte, while original-wire fidelity remains conservative and global order unavailable. Pure metadata tests execute on the host. The WASM contract suite runs with -Wasmtime's explicit async/P3/HTTP flags. The full WASM library test artifact +`wasmtime run`. The full WASM library test artifact also imports Spin-specific host interfaces, so plain Wasmtime cannot execute that artifact. The real Spin HTTP fixture supplies runtime conversion evidence. See [the capability matrix](./overview#inbound-request-fidelity). + +## Next Steps + +- [Migration guide](/guide/manifest-store-migration) — moving from the + pre-rewrite store schema +- [Adapters overview](/guide/adapters/overview) — cross-adapter contracts +- [Configuration](/guide/configuration) — full manifest reference diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index 2ef14a2a..1ee9aad0 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -228,11 +228,6 @@ EdgeZero provides these middleware out of the box: If you already hold an `Arc` (`BoxMiddleware`), register it with `.middleware_arc(...)` instead of `.middleware(...)`. -## Next Steps - -- Learn about [Streaming](/guide/streaming) for progressive responses -- Explore [Proxying](/guide/proxying) for upstream forwarding - ## Work before route lookup Ordinary middleware runs after route selection and router-owned extension setup. @@ -240,3 +235,8 @@ For method-independent interception before 404/405, use the [pre-dispatch hook](./routing#intercepting-before-dispatch). A terminal hook response or error bypasses ordinary middleware and handlers. A continuing hook passes its request mutations into the existing middleware chain. + +## Next Steps + +- Learn about [Streaming](/guide/streaming) for progressive responses +- Explore [Proxying](/guide/proxying) for upstream forwarding diff --git a/docs/guide/routing.md b/docs/guide/routing.md index d1e45faf..f7a22bf9 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -182,11 +182,6 @@ surfaces before any traffic, but the Fastly, Cloudflare, and Spin entry points c per request, so a conflicting route deploys successfully and then panics on the first request. Run the app under the Axum dev server before deploying to an edge target. -## Next Steps - -- Learn about [Handlers & Extractors](/guide/handlers) for processing requests -- Explore [Middleware](/guide/middleware) for cross-cutting concerns - ## Intercepting before dispatch Use an optional `PreDispatchHook` when handling a request must precede method @@ -239,3 +234,8 @@ router boundary; the hook does not replace those layers. Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) before classifying a request using its target or duplicate headers. + +## Next Steps + +- Learn about [Handlers & Extractors](/guide/handlers) for processing requests +- Explore [Middleware](/guide/middleware) for cross-cutting concerns diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index 7583264b..e6dea743 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -410,13 +410,30 @@ about original wire bytes. ## Latest independent review follow-up — 2026-10-06 The earlier 47-case records above describe earlier execution checkpoints. The -final baseline and dependency-handoff evidence contain 54 cases: Fastly 20, -Cloudflare 17 and Spin 17. Those final counts supersede earlier probe totals. +final checked-in baseline contains 56 cases: Fastly 20, Cloudflare 19 and +Spin 17, including the two direct-workerd no-hook routing regressions +(`route-unicode` and `route-backslash`). The 54-case execution records above +are historical checkpoints; this baseline count does not claim a fresh +56-probe runtime execution. Clarify the Axum guide: common field multiplicity remains `Unknown`, including -the Content-Length override. `InboundOrigin::parse` validates URI authorities, +Content-Length. `InboundOrigin::parse` validates URI authorities, not DNS-label-only names. Commas, asterisks and semicolons are permitted registered-name characters under [RFC 3986 section 3.2.2](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2), and a trailing dot is valid. The proposed DNS-only rejection would narrow the approved contract and reject valid authorities, so no parser change is made. These clarifications do not change the consumer dependency API or runtime code. + +## PR review follow-up — 2026-10-15 + +The adapters share the conservative `HeaderFidelity::RUNTIME_HEADER_MAP` baseline. +Axum uses the common value for Content-Length without a redundant override. +Fastly retains the validated runtime URI authority after checking Host consistency; +authority spelling is not canonicalized by the shared API. Spin's pure ingress +helper and existing host tests now live in `src/request/ingress.rs`, compiled +independently of its WASM-only request conversion module. + +The public Spin contract command and CI runner return to `wasmtime run`, matching +main's unchanged contract suite and dependency graph. Earlier explicit-flag runs +above remain historical evidence, not a requirement introduced by ingress metadata. +The full WASM library artifact still requires Spin-specific host interfaces. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index 79c851f6..ce0d0fa6 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -22,8 +22,6 @@ The motivating consumer is approved [Trusted Server PR #1107](https://github.com - Section 9.2: bounded cookie inspection, duplicate detection and non-UTF-8 rejection. - Sections 12 and 14: hardened local responses and actual adapter ordering/conversion tests. -Local source: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md`. Local consumer plan: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md`, tasks F0/F4. - Primary interoperability references: [Fetch header sorting/combining](https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine), [URL parsing](https://url.spec.whatwg.org/#url-parsing), and [Web IDL ByteString](https://webidl.spec.whatwg.org/#idl-ByteString). These define platform semantics, not proof of a particular deployed runtime. ## 3. Scope and alternatives From d6e3f4cc26b172426a0ef6a65263418653753ac3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 19 Oct 2026 12:24:29 +0530 Subject: [PATCH 06/11] fix: address request ingress fidelity review findings --- .github/workflows/test.yml | 23 +- .../edgezero-adapter-axum/src/dev_server.rs | 2 +- crates/edgezero-adapter-axum/src/request.rs | 20 +- .../src/request.rs | 9 +- .../tests/contract.rs | 2 +- crates/edgezero-adapter-fastly/src/lib.rs | 3 +- crates/edgezero-adapter-fastly/src/request.rs | 43 +- .../src/{request => }/ingress.rs | 7 +- crates/edgezero-adapter-spin/src/lib.rs | 1 - crates/edgezero-core/src/request.rs | 203 ++++-- crates/edgezero-core/src/router.rs | 62 +- docs/guide/adapters/cloudflare.md | 5 +- docs/guide/adapters/fastly.md | 9 +- docs/guide/adapters/overview.md | 39 +- docs/guide/adapters/spin.md | 9 +- docs/guide/routing.md | 23 +- ...026-10-05-pre-dispatch-request-fidelity.md | 554 ++++------------ ...05-pre-dispatch-request-fidelity-design.md | 100 ++- scripts/check_shared_dep_pins.sh | 4 +- .../request-fidelity-runner/src/main.rs | 85 ++- tests/fixtures/request-fidelity/Cargo.lock | 605 ++++++++++-------- tests/fixtures/request-fidelity/Cargo.toml | 10 +- tests/fixtures/request-fidelity/README.md | 29 +- .../request-fidelity/observations.json | 40 +- tests/fixtures/request-fidelity/src/lib.rs | 2 +- 25 files changed, 912 insertions(+), 977 deletions(-) rename crates/edgezero-adapter-spin/src/{request => }/ingress.rs (90%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93f03279..84824055 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -217,7 +217,7 @@ jobs: if: matrix.adapter == 'cloudflare' uses: actions/setup-node@v6 with: - node-version: 24.12.0 + node-version-file: .tool-versions cache: npm cache-dependency-path: tests/fixtures/request-fidelity/package-lock.json @@ -333,15 +333,12 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib - # Spin's metadata helper runs on the host; its full lib test artifact - # imports Spin-specific interfaces unavailable in plain Wasmtime. - # The real Spin HTTP fixture below executes the converter instead. - name: Install lock-matched worker-build if: matrix.adapter == 'cloudflare' shell: bash run: | - if [[ "$(worker-build --version 2>/dev/null || true)" != "0.8.3" ]]; then - cargo install worker-build --version 0.8.3 --locked --force + if [[ "$(worker-build --version 2>/dev/null || true)" != "0.8.5" ]]; then + cargo install worker-build --version 0.8.5 --locked --force fi - name: Install pinned Spin HTTP runtime @@ -349,13 +346,13 @@ jobs: shell: bash run: | version="$(awk '$1 == "spin-cli" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + test "$version" = "4.1.0" install_dir="$RUNNER_TEMP/spin" mkdir -p "$install_dir" - curl -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" - # GitHub's published digest for the v4.0.0 linux-amd64 release asset. + curl --retry 3 -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" + # GitHub's published digest for the v4.1.0 linux-amd64 release asset. # Keep the version and digest paired when updating the fixture pin. - test "$version" = "4.0.0" - echo "e705c9bfd9484a9175f392a116856680862a40f31d1a85618bed331f34ffccfa $RUNNER_TEMP/spin.tar.gz" | sha256sum --check --strict + echo "aeeb892f94cf861f5302546faa0bdaa82cc4be30539621299746a6dc011f3b5a $RUNNER_TEMP/spin.tar.gz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir" echo "$install_dir" >> "$GITHUB_PATH" "$install_dir/spin" --version @@ -373,7 +370,13 @@ jobs: shell: bash run: | if [ "${{ matrix.adapter }}" = "cloudflare" ]; then + version="$(awk '$1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next } in_pkg && $1 == "version" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.lock)" + test -n "$version" WASM_BINDGEN_BIN="$(command -v wasm-bindgen)" + if [[ "$("$WASM_BINDGEN_BIN" --version)" != "wasm-bindgen $version" ]]; then + cargo install wasm-bindgen-cli --version "$version" --locked --root "$RUNNER_TEMP/fixture-wasm-bindgen" + WASM_BINDGEN_BIN="$RUNNER_TEMP/fixture-wasm-bindgen/bin/wasm-bindgen" + fi export WASM_BINDGEN_BIN fi ./scripts/test-request-fidelity.sh ${{ matrix.adapter }} diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 72884696..b72986d4 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -884,7 +884,7 @@ mod integration_tests { .middleware(IngressMiddleware { calls: Arc::clone(&middleware_calls), }) - .pre_dispatch_hook(Arc::new(IngressHook { + .pre_dispatch_hook_arc(Arc::new(IngressHook { calls: Arc::clone(&hook_calls), })) .get("/ordinary", ingress_ordinary) diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index e54dbff6..ddaaa16a 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -72,10 +72,6 @@ pub async fn into_core_request(request: Request) -> Result().ok()?; - let parsed_uri = format!("http://{}/", supplied.authority()) - .parse::() - .ok()?; - if !parsed_host.host()?.eq_ignore_ascii_case(parsed_uri.host()?) - || parsed_host.port_u16().unwrap_or(80) != parsed_uri.port_u16().unwrap_or(80) - { + if !origin.same_authority(&supplied) { return None; } } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 13f4b161..04990669 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -244,7 +244,8 @@ pub(crate) struct RegistryInputs<'env> { /// Convert a Cloudflare Worker request into an `EdgeZero` core request. /// /// # Errors -/// Returns [`EdgeError::bad_request`] if the URL or URI cannot be parsed, +/// Returns [`EdgeError::bad_request`] for invalid runtime methods, header values, +/// URLs or URIs, /// and [`EdgeError::internal`] if the body cannot be read or the core /// request cannot be built. #[inline] @@ -261,8 +262,8 @@ pub async fn into_core_request( TargetSource::RuntimeUrl, Preservation::Unknown, ); - let url = req - .url() + let url: worker::Url = runtime_url + .parse() .map_err(|_error| EdgeError::bad_request("invalid runtime URL"))?; let uri: Uri = url .as_str() @@ -275,7 +276,7 @@ pub async fn into_core_request( InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() }); let fidelity = HeaderFidelity::RUNTIME_HEADER_MAP; - let ingress = RequestIngress::new(target, origin, fidelity, Vec::new())?; + let ingress = RequestIngress::new(target, origin, fidelity); let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 5b9629ad..6558f4a7 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -196,7 +196,7 @@ mod tests { for continue_routing in [false, true] { let app = App::new( RouterService::builder() - .pre_dispatch_hook(Arc::new(IngressHook { continue_routing })) + .pre_dispatch_hook_arc(Arc::new(IngressHook { continue_routing })) .get("/uri", capture_uri_for_ingress) .build(), ); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index a44a55e9..ba375193 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -154,7 +154,6 @@ where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions), { - let ingress = request::capture_request_ingress(&req); let stores = A::stores(); let logging = FastlyLogging::from(A::logging_for("fastly")); if logging.use_fastly_logger && !A::owns_logging() { @@ -162,7 +161,7 @@ where init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries_and_ingress(&app, req, stores, ingress, extend) + request::dispatch_with_registries(&app, req, stores, extend) } /// Dispatch with a config store wired explicitly. Its name and default key are diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 218e3628..9d86b15e 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -583,21 +583,20 @@ fn build_secret_registry(secret_meta: Option) -> Option RequestIngress { @@ -609,13 +608,7 @@ pub fn capture_request_ingress(req: &FastlyRequest) -> RequestIngress { } else { None }; - RequestIngress::new( - target, - origin, - HeaderFidelity::RUNTIME_HEADER_MAP, - Vec::new(), - ) - .expect("should use unique header overrides") + RequestIngress::new(target, origin, HeaderFidelity::default()) } fn origin_from_runtime_uri<'header>( @@ -630,16 +623,7 @@ fn origin_from_runtime_uri<'header>( let runtime_origin = InboundOrigin::parse(scheme, uri.authority()?.as_str(), OriginSource::RuntimeUri).ok()?; let origin = InboundOrigin::parse(scheme, host, OriginSource::RuntimeUri).ok()?; - let host_uri = Uri::builder() - .scheme(origin.scheme()) - .authority(origin.authority()) - .path_and_query("/") - .build() - .ok()?; - let default_port = if origin.scheme() == "https" { 443 } else { 80 }; - if !uri.host()?.eq_ignore_ascii_case(host_uri.host()?) - || uri.port_u16().unwrap_or(default_port) != host_uri.port_u16().unwrap_or(default_port) - { + if !runtime_origin.same_authority(&origin) { return None; } Some(runtime_origin) @@ -976,6 +960,7 @@ mod ingress_tests { use edgezero_core::app::{App, StoresMetadata}; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; + use edgezero_core::http::header::HOST; use edgezero_core::http::{HeaderValue, Request, Response, Uri, response_builder}; use edgezero_core::proxy::ProxyHandle; use edgezero_core::request::{ @@ -1099,15 +1084,17 @@ mod ingress_tests { ), None, HeaderFidelity::default(), - Vec::new(), ) - .expect("should build snapshot") } #[test] fn ingress_current_sdk_reports_missing_target_and_no_synthetic_origin() { let native = FastlyRequest::get("https://example.com/reserved"); let ingress = capture_request_ingress(&native); + assert_eq!( + ingress.header_fidelity(&HOST).global_field_order(), + Preservation::Unknown + ); assert!(matches!( ingress.target(), CapturedTarget::Unavailable(TargetUnavailable::NotExposed) @@ -1153,7 +1140,7 @@ mod ingress_tests { fn ingress_registry_dispatch_wins_scratch_collision() { let app = App::new( RouterService::builder() - .pre_dispatch_hook(Arc::new(VerifySnapshot { + .pre_dispatch_hook_arc(Arc::new(VerifySnapshot { scratch_expected: true, })) .build(), @@ -1172,7 +1159,7 @@ mod ingress_tests { assert_eq!(response.get_status().as_u16(), 204); let service_app = App::new( RouterService::builder() - .pre_dispatch_hook(Arc::new(VerifySnapshot { + .pre_dispatch_hook_arc(Arc::new(VerifySnapshot { scratch_expected: false, })) .build(), diff --git a/crates/edgezero-adapter-spin/src/request/ingress.rs b/crates/edgezero-adapter-spin/src/ingress.rs similarity index 90% rename from crates/edgezero-adapter-spin/src/request/ingress.rs rename to crates/edgezero-adapter-spin/src/ingress.rs index 82460505..c335e816 100644 --- a/crates/edgezero-adapter-spin/src/request/ingress.rs +++ b/crates/edgezero-adapter-spin/src/ingress.rs @@ -4,10 +4,6 @@ use edgezero_core::request::{ TargetSource, }; -#[expect( - clippy::expect_used, - reason = "the empty override list cannot contain duplicate names" -)] pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { let origin = uri .scheme_str() @@ -22,8 +18,7 @@ pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { }; let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); let headers = HeaderFidelity::RUNTIME_HEADER_MAP; - RequestIngress::new(target, origin, headers, Vec::new()) - .expect("should use unique header overrides") + RequestIngress::new(target, origin, headers) } #[cfg(test)] diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index 2930bc19..e67817b5 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -8,7 +8,6 @@ pub mod config_store; pub mod context; mod decompress; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -#[path = "request/ingress.rs"] mod ingress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub mod key_value_store; diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs index e390043b..fd2879e0 100644 --- a/crates/edgezero-core/src/request.rs +++ b/crates/edgezero-core/src/request.rs @@ -1,18 +1,25 @@ //! Confidential adapter-owned ingress facts and explicit preservation limits. //! //! Metadata describes what a runtime exposes; it cannot recover information -//! erased before conversion. It is intentionally neither serializable nor -//! printable because captured targets and authorities can contain private data. +//! erased before conversion. Captured values are not serialized, and Debug +//! implementations omit targets and authorities because they can contain private data. +use std::fmt; +use std::io::Error as IoError; use std::net::Ipv6Addr; use std::sync::Arc; +use http::uri::Authority; + use crate::error::EdgeError; -use crate::http::{HeaderName, Uri}; +use crate::http::HeaderName; /// Maximum complete target metadata, measured in UTF-8 bytes. pub const MAX_TARGET_BYTES: usize = 16 * 1024; +/// Maximum inbound authority metadata, measured in UTF-8 bytes. +pub const MAX_AUTHORITY_BYTES: usize = 1024; + /// A complete bounded target or an explicit acquisition failure. #[derive(Clone)] pub enum CapturedTarget { @@ -84,6 +91,8 @@ impl CompleteTarget { /// /// These axes compare original HTTP field values with core headers; successful /// runtime-to-core copying alone does not establish original-wire preservation. +/// `Default` leaves all axes unknown; `RUNTIME_HEADER_MAP` additionally states +/// that the converted map cannot supply global field order. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct HeaderFidelity { field_multiplicity: Preservation, @@ -154,7 +163,7 @@ impl HeaderFidelity { /// must establish provenance before calling the constructor. #[derive(Clone)] pub struct InboundOrigin { - authority: Arc, + authority: Authority, scheme: &'static str, source: OriginSource, } @@ -163,11 +172,13 @@ impl InboundOrigin { /// Borrow the validated authority in its source-provided spelling. /// /// Host case and explicit default ports are retained when the source exposes - /// them. Consumers must normalize authorities before comparing origins. + /// them, including leading zeros in explicit ports. Port zero is accepted + /// syntactically; this does not make it a usable destination. Consumers must + /// normalize authorities before comparing origins. #[inline] #[must_use] pub fn authority(&self) -> &str { - &self.authority + self.authority.as_str() } /// Validate a bounded HTTP(S) scheme and authority. @@ -185,22 +196,16 @@ impl InboundOrigin { return Err(EdgeError::bad_request("invalid ingress origin")); }; if authority.is_empty() - || authority.len() > 1024 + || authority.len() > MAX_AUTHORITY_BYTES || authority .chars() .any(|character| matches!(character, '@' | '/' | '?' | '#' | '\\')) { return Err(EdgeError::bad_request("invalid ingress origin")); } - let uri = Uri::builder() - .scheme(normalized_scheme) - .authority(authority) - .path_and_query("/") - .build() + let parsed: Authority = authority + .parse() .map_err(|_error| EdgeError::bad_request("invalid ingress origin"))?; - let parsed = uri - .authority() - .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; let host = parsed.host(); if host.chars().any(|character| matches!(character, '[' | ']')) && host @@ -224,12 +229,30 @@ impl InboundOrigin { return Err(EdgeError::bad_request("invalid ingress origin")); } Ok(Self { - authority: Arc::from(authority), + authority: parsed, scheme: normalized_scheme, source, }) } + /// Compare hosts case-insensitively and ports using each scheme's default. + /// + /// This compares authorities, not full origins: the schemes may differ. + #[inline] + #[must_use] + pub fn same_authority(&self, other: &Self) -> bool { + let port = |origin: &Self| { + origin + .authority + .port_u16() + .unwrap_or(if origin.scheme == "https" { 443 } else { 80 }) + }; + self.authority + .host() + .eq_ignore_ascii_case(other.authority.host()) + && port(self) == port(other) + } + /// Return the normalized HTTP(S) scheme. #[inline] #[must_use] @@ -249,6 +272,10 @@ impl InboundOrigin { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OriginSource { /// Scheme and authority supplied by the runtime request URI. + /// + /// Runtimes typically derive authority from the client's Host or :authority. + /// It identifies the requested origin; it does not establish that an operator + /// approves or owns that host. RuntimeUri, /// A trusted transport scheme combined with one validated inbound request authority. /// @@ -294,34 +321,23 @@ impl RequestIngress { .unwrap_or(self.common_header_fidelity) } - /// Construct metadata with unambiguous per-header fidelity overrides. - /// - /// Sources and preservation guarantees are caller assertions. Validation - /// checks override names, not the provenance or fidelity of captured facts. - /// - /// # Errors + /// Construct metadata without header-specific overrides. /// - /// Returns a bounded validation error for duplicate override names. + /// Sources and preservation guarantees are caller assertions, not validated + /// provenance or fidelity of captured facts. #[inline] + #[must_use] pub fn new( target: CapturedTarget, origin: Option, common_header_fidelity: HeaderFidelity, - mut overrides: Vec<(HeaderName, HeaderFidelity)>, - ) -> Result { - overrides.sort_unstable_by(|left, right| left.0.as_str().cmp(right.0.as_str())); - if overrides.windows(2).any(|pair| { - let [left, right] = pair else { return false }; - left.0 == right.0 - }) { - return Err(EdgeError::bad_request("duplicate ingress header override")); - } - Ok(Self { + ) -> Self { + Self { common_header_fidelity, origin, - overrides, + overrides: Vec::new(), target, - }) + } } /// Borrow the validated origin, when its trusted source is available. @@ -337,6 +353,65 @@ impl RequestIngress { pub const fn target(&self) -> &CapturedTarget { &self.target } + /// Set unambiguous per-header fidelity overrides. + /// + /// # Errors + /// Returns an internal error for duplicate override names, a caller mistake. + #[inline] + pub fn with_header_overrides( + mut self, + mut overrides: Vec<(HeaderName, HeaderFidelity)>, + ) -> Result { + overrides.sort_unstable_by(|left, right| left.0.as_str().cmp(right.0.as_str())); + if overrides + .windows(2) + .any(|pair| matches!(pair, [left, right] if left.0 == right.0)) + { + return Err(EdgeError::internal(IoError::other( + "duplicate ingress header override", + ))); + } + self.overrides = overrides; + Ok(self) + } +} + +impl fmt::Debug for CapturedTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Complete(target) => f.debug_tuple("Complete").field(target).finish(), + Self::Unavailable(reason) => f.debug_tuple("Unavailable").field(reason).finish(), + } + } +} + +impl fmt::Debug for CompleteTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CompleteTarget") + .field("source", &self.source) + .field("fidelity", &self.fidelity) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for InboundOrigin { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InboundOrigin") + .field("source", &self.source) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for RequestIngress { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RequestIngress") + .field("common_header_fidelity", &self.common_header_fidelity) + .finish_non_exhaustive() + } } /// Source of captured target information, independent of fidelity. @@ -363,6 +438,7 @@ pub enum TargetUnavailable { #[cfg(test)] mod tests { + use crate::http::StatusCode; use crate::http::header::{CONTENT_LENGTH, COOKIE}; use crate::request::{ CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, @@ -391,8 +467,8 @@ mod tests { CapturedTarget::Unavailable(TargetUnavailable::NotExposed), None, common, - vec![(CONTENT_LENGTH, folded)], ) + .with_header_overrides(vec![(CONTENT_LENGTH, folded)]) .expect("should accept distinct header overrides"); assert_eq!(metadata.header_fidelity(&CONTENT_LENGTH), folded); assert_eq!(metadata.header_fidelity(&COOKIE), common); @@ -453,15 +529,56 @@ mod tests { CapturedTarget::Unavailable(TargetUnavailable::ReadFailed), None, HeaderFidelity::default(), - vec![ - (COOKIE, HeaderFidelity::default()), - (COOKIE, HeaderFidelity::default()), - ], + ) + .with_header_overrides(vec![ + (COOKIE, HeaderFidelity::default()), + (COOKIE, HeaderFidelity::default()), + ]); + assert_eq!( + result + .expect_err("should reject ambiguous overrides") + .status(), + StatusCode::INTERNAL_SERVER_ERROR ); - assert!( - result.err().is_some(), - "should reject ambiguous override selection" + } + + #[test] + fn ingress_debug_never_prints_captured_values() { + let target = CapturedTarget::capture( + "/private-target?secret=1", + TargetSource::RuntimeUrl, + Preservation::Unknown, ); + let CapturedTarget::Complete(complete) = &target else { + panic!("should capture target") + }; + let origin = + InboundOrigin::parse("https", "private-host.example", OriginSource::RuntimeUri) + .expect("should parse origin"); + let values_output = format!("{target:?} {complete:?} {origin:?}"); + let ingress = RequestIngress::new(target, Some(origin), HeaderFidelity::default()); + let output = format!("{values_output} {ingress:?}"); + assert!(!output.contains("private-target")); + assert!(!output.contains("secret=1")); + assert!(!output.contains("private-host")); + } + + #[test] + fn ingress_authorities_compare_host_and_effective_port() { + for (scheme, left, right, same) in [ + ("http", "Example.COM", "example.com:00080", true), + ("https", "Example.COM", "example.com:443", true), + ("https", "example.com", "example.com:80", false), + ("http", "[::1]", "[::1]:80", true), + ("http", "example.com", "other.example", false), + ("http", "example.com:0", "example.com:80", false), + ] { + let left_origin = InboundOrigin::parse(scheme, left, OriginSource::RuntimeUri) + .expect("should parse origin"); + let right_origin = InboundOrigin::parse(scheme, right, OriginSource::TransportBinding) + .expect("should parse origin"); + assert_eq!(left_origin.same_authority(&right_origin), same); + } } #[test] diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index a6f21326..37af34c3 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -199,12 +199,31 @@ impl RouterBuilder { /// Install one hook before route lookup, state injection and middleware. /// - /// Repeated registration replaces the previous hook. Router clones share - /// the same instance. None of the normal route lifecycle runs after a - /// terminal response; outer adapter/application finalizers may still run. + /// Router clones share the same instance. None of the normal route lifecycle + /// runs after a terminal response; outer adapter/application finalizers may run. + /// + /// # Panics + /// Panics if a hook is already registered. + #[must_use] + #[inline] + pub fn pre_dispatch_hook(self, hook: H) -> Self + where + H: PreDispatchHook, + { + self.pre_dispatch_hook_arc(Arc::new(hook)) + } + + /// Install a shared hook with the same guarantees as [`Self::pre_dispatch_hook`]. + /// + /// # Panics + /// Panics if a hook is already registered. #[must_use] #[inline] - pub fn pre_dispatch_hook(mut self, hook: BoxPreDispatchHook) -> Self { + pub fn pre_dispatch_hook_arc(mut self, hook: BoxPreDispatchHook) -> Self { + assert!( + self.pre_dispatch_hook.is_none(), + "pre-dispatch hook already registered" + ); self.pre_dispatch_hook = Some(hook); self } @@ -1294,7 +1313,7 @@ mod pre_dispatch_tests { .with_state(CountedState { clones: Arc::clone(&clones), }) - .pre_dispatch_hook(Arc::new(RewriteHook)) + .pre_dispatch_hook(RewriteHook) .post("/after", after_handler) .build(); let response = block_on(router.oneshot(input(Method::GET, "/before", Body::empty()))) @@ -1311,7 +1330,7 @@ mod pre_dispatch_tests { .with_state(CountedState { clones: Arc::clone(&clones), }) - .pre_dispatch_hook(Arc::new(TerminalHook { + .pre_dispatch_hook_arc(Arc::new(TerminalHook { calls: Arc::clone(&calls), fail: true, })) @@ -1335,7 +1354,7 @@ mod pre_dispatch_tests { fn pre_dispatch_hook_is_shared_across_router_clones() { let calls = Arc::new(AtomicUsize::new(0)); let router = RouterService::builder() - .pre_dispatch_hook(Arc::new(TerminalHook { + .pre_dispatch_hook_arc(Arc::new(TerminalHook { calls: Arc::clone(&calls), fail: false, })) @@ -1351,7 +1370,7 @@ mod pre_dispatch_tests { #[test] fn pre_dispatch_inspects_empty_stream_without_send_bound() { let router = RouterService::builder() - .pre_dispatch_hook(Arc::new(InspectHook)) + .pre_dispatch_hook_arc(Arc::new(InspectHook)) .build(); let chunks = stream::iter([Ok::(Bytes::new()), Ok(Bytes::new())]); let response = @@ -1361,30 +1380,17 @@ mod pre_dispatch_tests { } #[test] - fn pre_dispatch_registration_replaces_previous_hook() { - let first = Arc::new(AtomicUsize::new(0)); - let second = Arc::new(AtomicUsize::new(0)); - let router = RouterService::builder() - .pre_dispatch_hook(Arc::new(TerminalHook { - calls: Arc::clone(&first), - fail: true, - })) - .pre_dispatch_hook(Arc::new(TerminalHook { - calls: Arc::clone(&second), - fail: false, - })) - .build(); - let response = block_on(router.oneshot(input(Method::GET, "/unknown", Body::empty()))) - .expect("should run replacement hook only"); - assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); - assert_eq!(first.load(Ordering::SeqCst), 0); - assert_eq!(second.load(Ordering::SeqCst), 1); + #[should_panic(expected = "pre-dispatch hook already registered")] + fn pre_dispatch_registration_rejects_duplicate_hook() { + let _builder = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(InspectHook)) + .pre_dispatch_hook(RewriteHook); } #[test] fn pre_dispatch_rejects_nonempty_or_failed_streams() { let router = RouterService::builder() - .pre_dispatch_hook(Arc::new(InspectHook)) + .pre_dispatch_hook_arc(Arc::new(InspectHook)) .build(); let chunks = stream::iter([Ok::(Bytes::from_static(b"x"))]); let response = @@ -1411,7 +1417,7 @@ mod pre_dispatch_tests { .middleware(CountingMiddleware { calls: Arc::clone(&middleware_calls), }) - .pre_dispatch_hook(Arc::new(TerminalHook { + .pre_dispatch_hook_arc(Arc::new(TerminalHook { calls: Arc::clone(&calls), fail: false, })) diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 7727ea61..c31cf367 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -372,7 +372,10 @@ a transformation cannot be established for each individual incoming request. ### Conversion compatibility Unknown method tokens are retained rather than coerced to GET, so they no longer -match a GET route. Invalid runtime header values return 400 during conversion. +match a GET route. Invalid runtime header values fail conversion with a bounded +`EdgeError::bad_request`. Direct converter callers receive that error; +`run_app`, `dispatch_app` and `CloudflareService` surface it as `worker::Error`, +which the standard fetch wrapper returns as HTTP 500. Method, URL, URI and header conversion errors use bounded messages without parse details. Primary routing still uses the existing SDK URL conversion, including Unicode percent encoding and backslash normalization; ingress target metadata is diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index ab6f1a98..6e9236f4 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -605,7 +605,7 @@ entry point and removing retained state, not merely setting a request limit of o Call `request::capture_request_ingress(&req)` immediately after acquiring the native request and before URL-based shortcuts or native mutation. It borrows -the same request without round-tripping handles. With Fastly SDK 0.12.1, +the same request without round-tripping handles. With Fastly SDK 0.13.1, safe raw-target acquisition is unavailable: the snapshot records `NotExposed` first, then uses the SDK runtime URL only to recover the canonical origin. This URL access can normalize the path; it never establishes original-target @@ -635,6 +635,13 @@ taken earlier. Original-target support and safe shortcut exclusion remain blocked on the SDK accessor; client/TLS preservation must be proved on the appropriate ingress. See [the capability matrix](./overview#inbound-request-fidelity). +Fastly exposes `get_original_header_names()` and `get_original_header_count()`. +These provide original-name ordering and a total count, but cannot associate +original fields with converted values. Local Viceroy can lowercase and deduplicate +names, so global order is `Unknown`, pending production verification. Aggregate +count equality cannot prove per-header multiplicity; no per-header guarantees +are inferred from it. + ## Next Steps - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 7498ff29..af367c1c 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -158,7 +158,7 @@ an unsupported error for those. ## Inbound request fidelity Adapters insert `edgezero_core::request::RequestIngress` into request extensions. -It records runtime-visible target and trusted origin facts separately from the +It records runtime-visible target and adapter-supplied origin facts separately from the primary URI and headers. Missing metadata means unknown capability. `CapturedTarget::Complete` holds the whole captured string, its acquisition @@ -172,10 +172,14 @@ serialization implementation. Do not log or serialize captured values. including IPv6 and port syntax. Parsing validates syntax; the adapter still establishes provenance. Runtime URI facts use `RuntimeUri`; Axum's plain HTTP listener uses `TransportBinding` plus one consistent Host. Origin-form Axum -conversion without that binding has no trusted origin. Ordinary Origin, +conversion without that binding has no established origin. Ordinary Origin, Forwarded and Spin special headers do not establish ingress origin. Authorities retain source-provided spelling, including host case and explicit -default ports when exposed. Consumers must normalize before comparing origins. +default ports and leading-zero ports when exposed. Syntax validation accepts +port zero without establishing a usable destination. Runtime authority usually +reflects the client's Host or :authority; adapter provenance does not establish +operator approval, ownership or an allowlist. Compare configured origins after +normalizing host case and effective ports. Fastly records target unavailability before reading its SDK runtime URL for origin, and requires a client request with one consistent validated Host. @@ -185,23 +189,28 @@ This recovers the validated runtime origin without claiming an original path. global-order axes, with per-name overrides. They concern original received fields, excluding HTTP framing and header-name spelling. Faithful copying from a runtime HeaderMap does not establish original-wire preservation. Unproved -axes remain `Unknown`; global field order is `Unavailable` on all four adapters. -Cloudflare's common octet and multiplicity guarantees remain `Unknown`: a -runtime capable of replacement/coalescing does not prove a particular field -changed. The observed transformations below remain capability limitations. - -Local evidence uses locked SDKs and fixed raw HTTP/1.1 fixtures. These results -describe the tested local runtimes; they do not establish production-edge -behavior. - -| Surface | Fastly 0.12.1 / Viceroy 0.17.0 | worker 0.8.3 / workerd 1.20260415.1 | Axum / Hyper 1.10.1 | Spin SDK 6.0.0 / Spin 4.0.0 | +axes remain `Unknown`. Axum, Cloudflare and Spin report +`HeaderFidelity::RUNTIME_HEADER_MAP`: octets, multiplicity and same-name order +are `Unknown`, and global order is `Unavailable`. Fastly reports all four axes +as `Unknown`, with no per-name overrides. Its original-header names/count APIs +expose partial evidence, but do not associate original fields with converted +values. Aggregate count equality cannot prove per-name multiplicity, and full +global order is not established. Production verification remains pending. +The matrix below shows observed runtime behavior, not `RequestIngress` guarantees. + +Local evidence below uses the listed SDK/runtime versions and freshly built +raw HTTP/1.1 fixtures. All 56 recorded cases were rerun with the refreshed pins. +These results describe the tested local runtimes; they do not establish +production-edge behavior. + +| Surface | Fastly 0.13.1 / Viceroy 0.21.0 | worker 0.8.5 / workerd 1.20260415.1 | Axum / Hyper 1.11.1 | Spin SDK 7.0.0 / Spin 4.1.0 | | ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | | Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | | Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | | Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | | High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | | Repeated control fields | Tested ordinary duplicates retained; identical Content-Length folded | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | -| Global wire order | Unavailable | Unavailable | Unavailable | Unavailable | +| Global wire order | Not established | Unavailable | Unavailable | Unavailable | Run `./scripts/test-request-fidelity.sh all` for local transport probes. They assert pinned observations, including unsupported outcomes; a green regression @@ -209,7 +218,7 @@ run does not turn an unsupported capability into a preservation guarantee. Fastly raw-target support requires a safe borrowed SDK accessor and new ingress evidence. Consumers that require original dot spellings or duplicate fields must separately resolve Fetch normalization/coalescing and platform rejection -limits. EdgeZero does not approve a weaker consumer contract. +limits. Consumer authentication and cookie policy remain application responsibilities. The [pre-dispatch hook](../routing#intercepting-before-dispatch) runs after adapter conversion. Fastly, Cloudflare and Spin still buffer bodies; Axum still diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 4391e1ab..578115c5 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -366,15 +366,18 @@ and remove the cache to roll back; default generated entry points are unchanged. Conversion captures SDK URI parts before rebuilding the core request. Origin comes from HTTP(S) URI scheme/authority, with runtime provenance. `spin-full-url`, `spin-client-addr`, Origin and forwarding headers do not become -trusted ingress-origin facts. Existing context fields and store/body behavior +adapter-supplied ingress-origin facts. Runtime URI authority still identifies the client-requested host; it does not establish operator approval or ownership. Existing context fields and store/body behavior remain unchanged. -The Spin 4.0 HTTP component fixture verifies methods, tested target spelling, -duplicates and hook ordering through the actual converter. Spin 4.0's request +The Spin 4.1 HTTP component fixture verifies methods, tested target spelling, +duplicates and hook ordering through the actual converter. Spin 4.1's request preparation requires UTF-8 header values; a Cookie containing FF returns 500 before the component starts. This needs an upstream runtime fix and cannot be restored inside EdgeZero. Same-name runtime values are copied byte-for-byte, while original-wire fidelity remains conservative and global order unavailable. +Spin 4.1 consumes chunked framing and removes `Transfer-Encoding` before +component invocation: the `chunked` and `te-cl` probes expose no such field to +the hook. The component cannot reconstruct that transport framing. Pure metadata tests execute on the host. The WASM contract suite runs with `wasmtime run`. The full WASM library test artifact diff --git a/docs/guide/routing.md b/docs/guide/routing.md index f7a22bf9..b94e2abd 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -190,7 +190,6 @@ the converted request before router state, introspection, request context, middleware or handlers. Its future can be non-`Send`. ```rust -use std::sync::Arc; use async_trait::async_trait; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; @@ -215,7 +214,7 @@ impl PreDispatchHook for ExampleHook { } let router = RouterService::builder() - .pre_dispatch_hook(Arc::new(ExampleHook)) + .pre_dispatch_hook(ExampleHook) .build(); ``` @@ -227,11 +226,29 @@ headers matter. `Err(error)` stops dispatch; direct `Service::call` propagates i while `RouterService::oneshot` renders it through the existing error response conversion. -Repeated registration replaces the hook. Router clones share its `Arc`. +Register a value with `pre_dispatch_hook`, or an existing shared hook with +`pre_dispatch_hook_arc`. A second registration panics during router construction. +Router clones share the same hook instance. Without a hook, existing routing is unchanged. Adapter buffering, capability bootstrap and outer response finalizers can still run before or after this router boundary; the hook does not replace those layers. +The hook value must be `Send + Sync + 'static`; it cannot hold `Rc` state, +although its returned future may be non-`Send`. Adapter extensions such as +`RequestIngress`, platform context and store registries are available. Router +state, introspection and `RequestContext` have not been injected yet: + +```rust +let ingress = request.extensions() + .get::(); +``` + +If the hook consumes the body and returns `Ok(None)`, the handler receives only +what remains unless the hook replaces the body. Generated `app!` applications +cannot install a hook through the macro or modify the built router. Use a +hand-written `Hooks::routes()` returning a configured `RouterService`, as in +`tests/fixtures/request-fidelity/src/lib.rs`. + Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) before classifying a request using its target or duplicate headers. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index e6dea743..2e722593 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -1,439 +1,127 @@ # Pre-dispatch interception and inbound request fidelity implementation plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use @superpowers:subagent-driven-development or @superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. +**Goal:** Method-independent interception before router dispatch, with bounded, +confidential metadata that accurately describes runtime-visible request facts. -**Goal:** Add generic method-independent interception before router dispatch and preserve/report inbound request information honestly across all four adapters. +**Status:** Core feature implemented. This document supersedes earlier execution +notes, signatures, case counts and runtime flags; it is a maintenance and +verification checklist, not a chronological session log. -**Architecture:** One optional Arc-backed non-Send pre-dispatch hook on the existing RouterService. Adapter-owned bounded RequestIngress metadata preserves runtime-visible target/origin information and declares header fidelity without duplicating sensitive headers. Narrow converter fixes and actual local runtime fixtures prove each supported claim. - -**Tech Stack:** Rust 1.95.0, edition 2024, existing async-trait/futures/http facade, Fastly 0.12.1, worker 0.8.3, Axum/Hyper, spin-sdk 6.0.0, Viceroy 0.17.0, wasm-bindgen-test, Wasmtime and VitePress. - -**Source:** [Corresponding spec](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). -**Baseline:** `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Contract:** [Design](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). **Branch:** `spec/pre-dispatch-request-fidelity`. -**Status:** Implementation authorized after independent review. Toolkit work is implemented; verification and external consumer gates are recorded below. - ---- - -## Execution boundary and dependency order - -The user authorized implementation after independent review with no material findings. Work remains on this EdgeZero branch. The downstream approved spec is unchanged; no push, publication or release approval is implied. - -Read AGENTS.md and CLAUDE.md before executing. Keep the feature generic. Do not add trace namespace checks, cookie names, report schemas, authentication policy or consumer finalization into EdgeZero. New handlers use `#[action]` and HTTP aliases come from `edgezero_core::http`. Preserve non-Send futures and avoid new runtime dependencies in core. Apply strict alphabetical/lint/public-documentation conventions at changed sites only. - -Order: P0 capability decisions → P1 ingress contract and P2 hook (independent core work) → P3 Cloudflare / P4 Axum / P5 Spin (independent implementations with central integration) → P6 Fastly after the SDK prerequisite → P7 real ingress fixtures → P8 CI/docs/release evidence. - -Pure core/Cloudflare/Axum/Spin work can proceed while the Fastly SDK getter is unavailable. That is progress on infrastructure, not completed downstream F0. Record every environment prerequisite explicitly; zero selected tests, host-only WASM checks, parser rejection and unavailable runtimes are not passing ingress evidence. - -Default no-hook behavior must remain identical. Cloudflare method/header conversion fixes are intentional correctness changes even without a hook: update existing tests that codify information loss and document the behavior change. - -## File map - -| Action | Exact path | Responsibility | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Modify | `crates/edgezero-core/src/router.rs` | Hook trait/alias, optional builder/inner field, dispatch ordering and colocated tests | -| Create | `crates/edgezero-core/src/request.rs` | Bounded RequestIngress types/constructors/accessors and colocated tests | -| Modify | `crates/edgezero-core/src/lib.rs` | Export request module | -| Modify | `crates/edgezero-adapter-cloudflare/src/request.rs` | Runtime method/URL capture and Worker UTF-8 header conversion | -| Modify | `crates/edgezero-adapter-cloudflare/tests/contract.rs` | Executed browser converter regressions | -| Modify if required | `crates/edgezero-adapter-cloudflare/Cargo.toml` | Explicit test Web RequestInit features | -| Modify | `crates/edgezero-adapter-axum/src/request.rs`, `crates/edgezero-adapter-axum/src/service.rs`, `crates/edgezero-adapter-axum/src/dev_server.rs` | Runtime parts metadata, trusted transport binding, real native parser/hook tests | -| Modify | `crates/edgezero-adapter-spin/src/request.rs`, `crates/edgezero-adapter-spin/tests/contract.rs` | Runtime parts metadata and converter/dispatch regression seams | -| Modify | `crates/edgezero-adapter-fastly/src/request.rs`, `crates/edgezero-adapter-fastly/src/lib.rs`, `crates/edgezero-adapter-fastly/tests/contract.rs` | Early safe capture, pre-captured metadata path, runners and regression coverage | -| Modify only after approved SDK revision | `Cargo.toml`, `Cargo.lock` | Reviewed dependency pin; no imaginary API or version | -| Create | `tests/fixtures/request-fidelity/Cargo.toml`, `tests/fixtures/request-fidelity/Cargo.lock`, `tests/fixtures/request-fidelity/src/lib.rs` | Isolated test-only fixture workspace and shared store-free router | -| Create | `tests/fixtures/request-fidelity/src/bin/fastly.rs`, `tests/fixtures/request-fidelity/src/spin.rs`, `tests/fixtures/request-fidelity/src/cloudflare.rs` | Actual runtime entry points and generic early-shortcut fixture | -| Create | `tests/fixtures/request-fidelity/fastly.toml`, `tests/fixtures/request-fidelity/spin.toml`, `tests/fixtures/request-fidelity/wrangler.toml` | Loopback-only local runtime configuration | -| Create | `tests/fixtures/request-fidelity/package.json`, `tests/fixtures/request-fidelity/package-lock.json` | Exact verified Wrangler/tooling pins and scripts | -| Create | `scripts/test-request-fidelity.sh`, `tests/fixtures/request-fidelity-runner/{Cargo.toml,Cargo.lock,src/main.rs}` | Runtime startup/build/readiness/cleanup and raw socket probes | -| Modify | `.github/workflows/test.yml` | Executed WASM unit/contract and local runtime transport gates | -| Modify | `docs/guide/routing.md`, `docs/guide/middleware.md`, `docs/guide/adapters/overview.md`, `docs/guide/adapters/fastly.md`, `docs/guide/adapters/cloudflare.md`, `docs/guide/adapters/axum.md`, `docs/guide/adapters/spin.md` | Hook API, ordering limits, platform matrix and consumer handoff | - -The fixture is a test-only separate workspace (`[workspace]` in its manifest); do not add it to production workspace members or inherit root workspace dependencies incorrectly. Use explicit local path dependencies and optional platform features; each build selects only its runtime. Any needed fixture-path exclusions/package publication guards must be explicit. Root all-feature clippy must not compile a multi-runtime fixture accidentally. - -## P0: Establish capability decisions before promising downstream compliance - -**Files:** source investigation only, spec capability matrix; dependency files only after an approved pin exists. - -- [x] Reconfirm clean implementation baseline, `git rev-parse HEAD`, `git status --short --branch` and `cargo metadata --format-version 1 --no-deps`. Re-read consumer section 8's explicit pre-buffering allowance. -- [x] Verify pinned SDK/runtime sources from Cargo.lock. Record worker 0.8.3 versus the consumer's 0.8.5, wasm-bindgen 0.2.122, Fastly 0.12.1, Hyper 1.10.1 and Spin SDK 6.0.0. Do not rely on floating package documentation. -- [x] For Fastly, investigate a safe borrowed raw-runtime-URI accessor. Existing get_url/get_path normalize; into_handles/from_handles loses client provenance, and clone_without_body forces lazy URL parsing. Rule out those workarounds. A proposed accessor name is not an existing SDK API. -- [ ] Prepare the external SDK change requirements: bounded native URI acquisition before Url parsing; no request/body consumption or cloning; no second from_client acquisition; client IP/TLS/original-header provenance retained; errors bounded; no fabricated original-wire promise. Obtain a reviewed immutable compatible revision/version before P6's complete-target implementation. -- [x] Record per-field runtime limitations, including Cloudflare Headers combining and Hyper identical Content-Length folding. Decide what the generic metadata can truthfully expose. -- [x] Separate adapter conversion guarantees from original wire guarantees. Where consumer requirements are impossible with the runtime, record an explicit downstream design-resolution item; do not edit or approve that spec from this branch. -- [x] Select the recommended hook plus metadata design over middleware-only or per-adapter policy wrappers; record scope/CI impact. External SDK or contract gates cannot be replaced by synthetic fixture success. -- [x] Checkpoint: approved upstream design before code; unresolved external SDK work may remain pending while independent tasks proceed. - -## P1: Define bounded adapter-owned RequestIngress - -**Files:** create `crates/edgezero-core/src/request.rs`; modify `crates/edgezero-core/src/lib.rs`. - -- [x] Add red tests named `ingress_target_bound_is_atomic`, `ingress_fidelity_defaults_are_unknown`, `ingress_header_override_is_field_specific` and `ingress_origin_rejects_untrusted_or_invalid_input`. -- [x] Run `cargo test -p edgezero-core ingress`. Expected red for missing types/behavior, not an unrelated compile/setup failure; make the initial test harness compile before judging behavioral red. -- [x] Implement the spec's closed enums/types. Exact minimum API: `RequestIngress::new(target, origin, common_header_fidelity, overrides)`, `target()`, `origin()` and `header_fidelity(&HeaderName)`. The CompleteTarget payload is public with private fields and borrowed getters; only CapturedTarget::capture constructs it. Add a compile-fail doctest proving external callers cannot bypass its bound with a struct literal. Use an owned small override list/map and reject duplicate override keys at construction rather than ambiguous selection. No copied header values. -- [x] Add `CapturedTarget::capture(value, source, fidelity)` applying the 16 KiB complete-or-unavailable bound. Add `InboundOrigin::parse(scheme, authority, source)` with a 1 KiB authority bound, HTTP(S)-only scheme and no user-info/path/query/fragment. Include the spec’s OriginSource enum/borrowed source accessor. Parsing validates input; adapter callers must still prove its trusted source. Origin-form requests without transport binding remain unavailable. -- [x] Define header-fidelity axes against original received field values/multiplicity/order, excluding HTTP framing/name spelling. Runtime-to-core copying alone cannot set Preserved. Tests/docs separately report converter-copy guarantees versus pinned parser/runtime/transport evidence, including trimming/coalescing loss. -- [x] Define private fields/borrowed getters and Clone-only value-bearing metadata. Use existing Arc/HTTP facade types. No Serialize or secret-bearing Debug/Display/logging. -- [x] Test exact cap and one byte over, non-ASCII UTF-8 byte counting, read-failure/unavailable states, same-name versus global order, Content-Length override, missing extension and IPv6/default-port authority. Never store a prefix as a complete target. -- [x] Run `cargo test -p edgezero-core ingress`, `cargo test -p edgezero-core` and scoped clippy. Expected green; all tests selected. -- [ ] Planned commit after checks: `Add bounded inbound request metadata and fidelity contracts`. - -## P2: Add the optional pre-dispatch hook to the concrete router - -**Files:** `crates/edgezero-core/src/router.rs`. - -- [x] Add behavioral tests for terminal response on unknown route and `EXAMPLE-METHOD`; continuation that changes method/URI; hook error; no-hook equivalence; absent router state/introspection in hook; state-clone count; cloned-router shared hook; streamed-body inspection; and Rc retained across await. -- [x] Run `cargo test -p edgezero-core pre_dispatch`. Expected meaningful red for route-first ordering, then implement the trait/alias and builder method from spec section 5. -- [x] Add one optional Arc field to builder/inner, thread it through build/new and invoke it before method/path copies. Document repeated registration replacing the existing hook. -- [x] Test mutations of header/extensions/body in addition to method/URI. Continued dispatch uses the remaining mutated body exactly; the hook cannot consume a stream and silently restore it. -- [x] Distinguish error APIs: direct `Service::call` propagates EdgeError; existing `RouterService::oneshot` renders it through IntoResponse. Both stop before normal dispatch. A consumer requiring custom challenge/hardening returns `Some(Response)` with those exact headers. -- [x] Test a terminal custom status/body/Allow/WWW-Authenticate is preserved by core. Document that outer adapter/application response finalizers may still operate. -- [x] Verify every existing core router/introspection/state/middleware test, plus manual `Hooks::routes` build compatibility. Keep macros/App/runner return types unchanged. -- [x] Run `cargo test -p edgezero-core` and `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings`. -- [ ] Planned commit: `Add method-independent router pre-dispatch interception`. - -## P3: Correct Cloudflare runtime method and header conversion - -**Files:** `crates/edgezero-adapter-cloudflare/src/request.rs`, `crates/edgezero-adapter-cloudflare/tests/contract.rs`, optional test feature changes in its Cargo.toml. - -- [x] Add raw Web Request fixtures using `web_sys::RequestInit::set_method`, `Request::new_with_str_and_init` and `CfRequest::from(raw)`. Worker enum-only RequestInit cannot create the regression. Cover uppercase and lowercase extension tokens and existing supported methods. -- [x] Add runtime URL metadata equality tests independently of parsed core URI equality. Use dot/encoded-dot/encoded-separator/repeated-slash/query cases, comparing what Web Request actually exposes rather than requiring it to preserve the input string. -- [x] Add browser converter tests for ASCII/high-byte runtime strings. Assert UTF-8 string copying; browser ByteString semantics are not Worker wire evidence. The incorrect helper-only lib test is removed; actual workerd tests cover valid C3 A9/C4 80 and invalid FF transformation. -- [x] Run the browser contract command in Shared verification. Red/green covers extension-token preservation and the corrected UTF-8 runtime-string convention. -- [x] Replace inbound method enum conversion with `CoreMethod::from_bytes(req.inner().method().as_bytes())` and bounded bad-request errors. Capture `req.inner().url()` before additional parsing and insert RequestIngress after core request construction. -- [x] Populate InboundOrigin from the runtime URL’s HTTP(S) scheme and validated authority with RuntimeUri provenance, after target capture. Never use Origin or forwarding headers for this fact. Add invalid/non-HTTP scheme, user-info/authority bounds and spoofed Origin/forwarding-header tests; missing or untrustworthy facts stay unavailable. Runtime URL parsing for origin must not replace the captured target. -- [x] Construct HeaderValue with `HeaderValue::from_bytes(value.as_bytes())`, following pinned workerd's UTF-8 header convention. Reject invalid runtime HeaderValues with bounded errors; preserve append behavior and validated names. Do not single-byte-cast Unicode scalars, silently omit fields or comma-split combined values. -- [x] Verify exact real Worker wire C3 A9 and C4 80 preservation, and distinguish invalid FF replacement from original-byte availability. Update the baseline only after semantic assertions pass. -- [x] Locate all `into_core_method` uses. Replace/update the old unknown-method→GET test; remove the helper only if unused, retaining supported-method tests through actual inbound conversion. -- [x] Keep buffering, context/proxy/store paths unchanged. Add hook-through-CloudflareService coverage, then run contract tests plus target clippy/check. -- [ ] Planned commit: `Preserve Cloudflare runtime methods and header octets`. - -## P4: Preserve Axum parts and declare parser limitations - -**Files:** Axum `src/request.rs`, `src/service.rs`, `src/dev_server.rs`. - -- [x] Add `ingress` converter tests for method/URI/bytes/repeated same-name values/extensions, origin-form and absolute-form targets, and missing trusted origin. -- [x] Run `cargo test -p edgezero-adapter-axum --features axum ingress`. Expected red for absent metadata. Add metadata around existing parts/from_parts; retain JSON buffering and non-JSON streaming. -- [x] If the standard dev server supplies an HTTP transport binding, insert only that adapter-owned scheme fact before conversion and combine it with exactly one validated inbound authority. Reject contradictory URI/Host origin facts as unavailable metadata; never guess HTTPS from forwarded headers. Generic converter callers without binding get unavailable origin. -- [x] Declare global wire order unavailable. Add a Content-Length-specific multiplicity limitation grounded in the pinned parser; preserve known same-name runtime values without claiming all original controls survived. -- [x] Extend service tests to show early hook response, continuation, extension method and router error behavior. Capability registries constructed before the hook are not ordinary consumer KV operations. -- [x] Add raw TCP cases to the existing dev-server listener harness: dot spellings, Cookie/Origin/control duplicates, identical/conflicting lengths, non-UTF-8 bytes and TE/CL combinations. Assert whether Hyper rejects or the hook sees the request. -- [x] Run `cargo test -p edgezero-adapter-axum --features axum` and native target clippy. -- [ ] Planned commit: `Expose Axum ingress fidelity without changing body behavior`. - -## P5: Capture Spin runtime parts and test the actual seam - -**Files:** Spin `src/request.rs` and `tests/contract.rs`. - -- [x] Add parts-conversion helper tests, or actual runtime tests where SDK incoming-body construction permits it, for runtime method/URI/header metadata. -- [x] Run host tests for host-covered helpers and the WASM contract/lib commands for runtime code. Existing core-request-only contract success is not converter coverage. -- [x] Capture runtime parts before rebuilding core Request. Preserve byte HeaderValues/method and source URI scheme/authority. Do not grant trust to spin-full-url/spin-client-addr solely because those headers exist. -- [x] Preserve existing buffering and store registry setup. Document earlier capability setup separately from ordinary app lifecycle work. -- [x] Verify extension `Method::Other` conversion, same-name values, unavailable global wire order, missing origin/facts, and metadata through actual dispatch. -- [x] Use P7's Spin invocation to establish URI/header behavior and spoofed special-header outcomes. If a synthetic shared-parts helper was used, explicitly identify which claims still need runtime evidence. -- [x] Run affected host tests and wasm32-wasip2 check/clippy/contract. Full WASM lib execution remains unavailable because plain Wasmtime lacks Spin-specific imports; actual Spin HTTP fixture covers the converter instead. -- [ ] Planned commit: `Expose Spin runtime ingress provenance and conversion tests`. - -## P6: Add safe Fastly capture and pre-captured metadata integration - -**Files:** Fastly `src/request.rs`, `src/lib.rs`, `tests/contract.rs`; dependency files only after P0's reviewed SDK pin. - -- [x] Checkpoint the external SDK accessor. If unavailable, implement honest unavailable/runtime-normalized metadata and finish byte-path regression work, but leave original-target/client-provenance acceptance pending. -- [ ] Add red target-cap/capture-failure/client-provenance tests before changing converters. Execution: bounded supplied snapshots, unavailable SDK target and actual HTTP client preservation are tested; raw acquisition failure and TLS coverage await the SDK accessor. Synthetic `FastlyRequest::new` already parses URLs and does not establish raw incoming preservation. -- [ ] Implement `capture_request_ingress(&FastlyRequest) -> RequestIngress` using the reviewed safe borrowed accessor before any URL accessor. It cannot consume/clone/reacquire the request. Bounded capture errors create unavailable metadata without raw error strings. -- [x] Add `into_core_request_with_ingress(req, ingress)`; existing `into_core_request(req)` captures once and delegates. A consumer that captures immediately after from_client can carry that exact snapshot through later conversion, avoiding recapture of mutated native state. -- [x] Add public `dispatch_with_registries_and_ingress(app, req, stores, env, ingress, extend)` with the existing return type/generic callback. The existing `dispatch_with_registries` captures and delegates; preserve per-id registries, EnvConfig/default-key selectors and required-store errors, rather than replacing it with legacy bare-handle defaults. Thread captured facts through FastlyService and registry/request-extensions dispatch with one internal conversion/store-wiring path. Add `FastlyService::with_request_ingress` only if required by that dispatch API. Ordinary runners capture before their first native URL access/callback; custom early-entry callers use the explicit snapshot path. -- [x] Ensure application scratch extension merging cannot accidentally replace adapter-captured ingress facts; define adapter snapshot insertion after generic scratch merge. Test collision, same snapshot retention and unchanged device/client extensions. Do not alter normal service/store selection. -- [x] Preserve existing byte-oriented header append path. Add all-values/high-byte/same-name-order tests, explicitly excluding unproved global wire order/original-name reconstruction. -- [x] Run Fastly contract and lib WASM/Viceroy suites, no-feature/CLI-feature and WASM clippy. Real ingress and native-shortcut evidence is recorded in P7. -- [ ] Planned commit: `Add safe early Fastly ingress capture and snapshot handoff`. - -## P7: Prove actual local transport behavior - -**Files:** proposed test fixture workspace/runtime configs and `scripts/test-request-fidelity.sh` and `tests/fixtures/request-fidelity-runner/`. - -- [x] Build a store-free generic fixture router with one early hook and an ordinary route/middleware counter. Test namespace is `/reserved`, not Trusted Server policy. New handlers use #[action]. -- [x] Expose only synthetic fixture verdicts: method equality, captured-target equality/availability, expected header-byte equality, per-field counts/fidelity and ordinary-handler-called. Use fixed fictional fixtures; do not build a reusable unrestricted header/cookie/URI echo endpoint. -- [x] Add Fastly entry fixture that captures before a generic native shortcut and carries the snapshot into conversion. Test reserved dot spellings remain visible when the SDK/runtime supports them. Raw target unavailable is an explicit unsupported verdict, not a passed bypass test. -- [x] Add Cloudflare Worker fixture selecting the cloudflare feature and matching worker-build/Wrangler tooling. Pin Wrangler as an exact npm dev dependency with package-lock. Resolve/pin worker-build as a Rust CLI compatible with the fixture’s locked worker version, installed with cargo --version/--locked; npm locking does not pin it. Use lock-matched wasm-bindgen tooling. Verify exact compatible tool versions at implementation; do not copy floating ^4.38.0 or invent a release. -- [x] Add a feature-gated #[http_service] entry in src/spin.rs, included by the fixture library, targeting wasm32-wasip2 as a cdylib HTTP component; do not build Spin as a main/bin WASI command. For package name request-fidelity, spin.toml references target/spin/wasm32-wasip2/release/request_fidelity.wasm, built with cargo build --manifest-path tests/fixtures/request-fidelity/Cargo.toml --target-dir tests/fixtures/request-fidelity/target/spin --target wasm32-wasip2 --features spin --lib --release --locked. Resolve that path relative to spin.toml. Fastly selects its required-feature binary; Cloudflare selects its feature-gated library via worker-build. Reuse existing runtime build conventions; new fixture dependency pins/lockfile must be reproducible. -- [x] The shell runner builds the host Rust helper and accepts `fastly`, `cloudflare`, `spin` or `all`, allocates an available loopback port, builds the selected fixture, starts the runtime, waits with a bounded readiness deadline, runs raw socket probes, and always stops its process group on failure/signal/success. No platform credentials, remote services or publisher requests. -- [x] Native Rust socket client replaces the original Python test helper and sends exact HTTP/1.1 bytes with Host example.com. Include extension methods, origin/absolute forms where supported, literal/encoded dot paths, repeated/encoded separators, non-UTF-8 Cookie octets, interleaved/case-varied repeated fields, duplicate controls and identical/conflicting length cases. -- [x] Record transport rejection versus runtime transformation versus EdgeZero conversion versus hook response. A browser Fetch/reqwest-normalized input cannot replace a raw request probe. Local Wrangler/workerd/Viceroy/Spin results cannot prove all production-edge behavior. -- [x] Test the Rust replacement’s cleanup after failed readiness/probe/build, parallel port isolation and no lingering process/artifact mutation of baseline demo config. -- [x] Run the Rust-backed `./scripts/test-request-fidelity.sh fastly`, `cloudflare` and `spin` with the required tools installed. Each supported verdict is asserted; unresolved guarantees fail the compliance claim or remain a specifically listed pending gate, not a blanket skip/pass. -- [ ] Planned commit after checks: `Add local runtime probes for inbound request fidelity`. - -## P8: Connect CI, document capabilities and hand off the reviewed revision - -**Files:** .github/workflows/test.yml, guide paths in file map, dependency/fixture locks if changed. - -- [x] Cloudflare converter regressions execute in the browser contract target; the incorrect scalar helper and its helper-only lib gate are removed. Spin adjustment: host helper tests, WASM contract/check/lint and actual Spin HTTP fixture cover the converter; its full WASM lib artifact requires unavailable Spin host interfaces and is not reported as passing. Fastly already has contract/lib execution; host workspace tests do not run gated WASM conversion code. -- [x] Provision browser/WebDriver for Cloudflare run_in_browser tests, matching wasm-bindgen-cli from Cargo.lock. Reuse current version-resolution workflow rather than independently pinning a drifting runner. -- [x] Add local fixture transport jobs with exact installed tool versions/locks and deterministic cleanup. Spin CLI availability must be explicit; Wasmtime contract execution alone is not Spin HTTP ingress. -- [x] Document core API example, hook result/error/state/finalizer semantics, buffering limitation, origin trust rules, per-field fidelity and every runtime limitation. Separate capability availability from consumer approval. -- [x] Build the final platform evidence matrix with source pin, exact fixture, observed outcome and status for each requirement. Record local versus production verification limits. -- [x] Run all Shared verification gates and confirm tests selected the new modules. Scope red/green at tasks; full gates at implementation handoff. -- [x] Independent reviewers check spec/plan/implementation alignment, correctness, minimum scope, supported target coverage and unresolved gates. No upstream/downstream compliance approval by inference. -- [ ] Handoff an immutable reviewed EdgeZero commit/tag only after release approval. Record it for Trusted Server to pin all EdgeZero dependencies consistently. Downstream changes stay on its existing branch and require its own acceptance checks. -- [ ] Planned commit: `Document and verify pre-dispatch ingress contracts across adapters`. - -## Shared verification - -Focused commands run from repository root with the relevant features/targets. Require nonzero selected tests and a passing intended red/green cycle. New code must not be committed before tests required by CLAUDE.md pass. - -```bash -cargo test -p edgezero-core ingress -cargo test -p edgezero-core pre_dispatch -cargo test -p edgezero-core -cargo test -p edgezero-adapter-axum --features axum -cargo test -p edgezero-adapter-spin -CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract -CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --lib -CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract -# Full Spin WASM lib needs Spin-specific host imports; use host helper tests -# plus the actual Spin HTTP fixture instead of claiming plain Wasmtime execution. -``` - -Fastly feature tests require the WASM/Viceroy runner; a host feature test may fail at linking and cannot substitute. Cloudflare contract uses a real headless browser/WebDriver and the lock-resolved wasm-bindgen-cli (currently 0.2.122). Existing matrix provisions Viceroy and Wasmtime; new local runtime fixtures may require additional CLI/tool installation. - -Full implementation handoff gates: - -```bash -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo clippy -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings -cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings -cargo test --workspace --all-targets -cargo check --workspace --all-targets --features "fastly cloudflare spin" -cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 -cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown -cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 -cargo clippy -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --all-targets -- -D warnings -cargo clippy -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings -cargo clippy -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --all-targets -- -D warnings -cargo doc -p edgezero-core --no-deps --all-features -cargo test --locked --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets -cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check -cargo clippy --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets --all-features -- -D warnings -./scripts/test-request-fidelity.sh all -npm --prefix docs run lint -npm --prefix docs run format -npm --prefix docs run build -git diff --check -``` - -Expected: exit 0 for all available required environments, unchanged baseline behavior except documented Cloudflare conversion corrections, and no unexecuted suite reported as a pass. No runtime/CI pass is claimed by this planning document. - -## Spec-to-task acceptance map - -| Spec requirement | Tasks | Evidence | -| -------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------- | -| Generic hook and no-hook compatibility | P2 | Ordering, error/response/continuation/state/clone/non-Send regressions | -| Bounded confidential metadata and origin provenance | P1/P3–P6 | Target caps, borrowed getters, missing facts, no logged/serialized values | -| Correct runtime method/header conversion | P3–P6 | Actual converters with extension tokens and high bytes | -| Fastly safe early capture and consumer shortcut seam | P0/P6/P7 | Reviewed SDK accessor, client provenance, actual Viceroy wire paths | -| Honest multiplicity/normalization limits | P0/P1/P4/P7/P8 | Hyper/Fetch/runtime rejection/transformation matrix | -| Buffered-body and outer-finalizer limits preserved | P2–P6/P8 | Existing body/store/response contracts and accurate docs | -| All-adapter runtime evidence and target gates | P7/P8 | Native parser, WASM contract/lib and local runtime fixture results | -| Downstream F0 readiness distinguished from infrastructure completion | P0/P8 | Explicit immutable pin and separately approved consumer-contract resolution | - -## Independent document review - -Three independent read-only reviewers assessed both complete documents against main 98930917 and the approved consumer requirements. They proposed minimal approaches during investigation, reviewed the selected hook/metadata design, and reread the corrected documents. This document review completed before the user authorized implementation. - -| Scope | Spec result | Plan result | -| ------------------------------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- | -| Core router/API, Axum/Spin conversion, body/bootstrap limits and execution feasibility | Approved | Approved after fixture/origin corrections | -| Cloudflare method/ByteString/provenance, bounded public API and browser/runtime tooling | Approved after bounded-payload correction | Approved after matching API/tooling corrections | -| Fastly native ownership, SDK dependency, snapshot/registry integration and Viceroy wire evidence | Approved after header-fidelity clarification | Approved after matching clarification | - -Corrections incorporated: private bounded CompleteTarget payload and compile-fail boundary test; explicit origin provenance/Cloudflare origin construction; Spin HTTP-component library rather than command binary; distinct npm/Cargo tool locks; original-field versus runtime-copy fidelity semantics; and named metadata-aware registry dispatch preserving EnvConfig/store-selector behavior. - -The safe borrowed Fastly SDK accessor remains external work, and original-wire properties require runtime evidence or separately approved consumer-contract resolution. The implementation evidence below is separate from these initial document reviews; neither review nor green infrastructure checks imply completion of Trusted Server F0. - -## Executor handoff - -Use this instruction with EdgeZero's Codex after maintainer approval: - -> Work in EdgeZero on branch spec/pre-dispatch-request-fidelity. Read AGENTS.md and CLAUDE.md, then the corresponding spec and this plan. Implement P0–P8 using the approved API and task verification. Keep RouterService concrete and non-Send-compatible; keep the hook generic. Preserve runtime-visible facts without inventing raw wire guarantees. Resolve the external safe Fastly SDK accessor before claiming original-target support. Verify actual method/header/target conversion and run local wire fixtures. Treat missing runtime facts or test environments as explicit pending gates, never success. Do not change Trusted Server's spec, implement trace policy, patch Cargo caches, push or publish without separate authorization. Report focused/full checks, the final capability matrix, and the immutable dependency revision ready for consumer review. - -Only the spec and this matching plan are planning artifacts for this feature; independent review results are recorded here after corrections. - -## Implementation and verification record - -The fresh pre-implementation review used three independent scopes: core/Axum/Spin; Cloudflare/provenance; Fastly/SDK/native ownership. All three approved both the spec and plan before code work. Subsequent independent implementation reviews found no production correctness defects. Coverage and lint findings were corrected centrally: IPv6/ASCII-port validation, matched-route lifecycle testing, scratch/client extension assertions, transport-specific Content-Length fidelity, executable Spin helper placement, and process-group cleanup races. Final review also corrected pinned-browser selection in CI, exact FF cookie equality and mandatory runtime-observation comparison. - -### Implemented surfaces - -- Core: bounded confidential ingress API, validated origin provenance, field-specific fidelity overrides and sealed target payload; optional shared non-Send hook before method/path/state/introspection/context/middleware. -- Cloudflare: runtime method tokens, checked UTF-8 runtime HeaderValues, runtime URL/origin capture and real browser converter/service regressions. -- Axum: runtime URI/HeaderMap retention, private HTTP binding, consistent Host origin, Content-Length parser limitation and exact TCP converter/service probes. -- Spin: runtime-parts snapshot and byte/method conversion retained; host-executed URI metadata tests and actual SDK6 HTTP component fixture. -- Fastly: safe unavailable-target capture, exact supplied-snapshot retention, registry-aware dispatch, service builder and capture-before-callback runner path; no unsafe handles, URL-cloning workaround or SDK dependency substitution. -- Verification: isolated locked multi-runtime fixtures, fixed verdicts, pinned observations, bounded loopback readiness and process-group cleanup for builds and runtimes; browser/fixture CI gates and public API/capability documentation. - -### Evidence and limits - -| Surface | Executed evidence | Outcome | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Core | 484 unit tests, integration test and compile-fail bounded-payload doctest | Pass; existing ignored docs unchanged | -| Native workspace | Workspace/all-target test run | 1,440 passed, 0 failed, 1 existing ignored; 20 suites | -| Axum | 115 full tests including the cap regression | Pass; raw TCP includes duplicates, FF, dots, identical/conflicting CL, TE/CL, continuation/error | -| Cloudflare | 11 browser contract tests including non-HTTP origin; explicit Chrome binary capabilities; removed incorrect scalar helper/lib-only test | Pass; converter and service hooks execute | -| Fastly | Viceroy 0.17.0 contract 6 and WASM library 91 | Pass; byte/header/snapshot/collision/client/proxy regressions execute | -| Spin | Host 31 and Wasmtime 44.0.1 contract 12 with async/P3/HTTP flags | Pass; host contract's zero selected tests do not count as runtime evidence | -| CLI/audits/scaffold | Fastly CLI-feature 287; nested AppConfig checker 18; placeholder/legacy/nested audits; generated project build test | Pass; generated native/WASM adapters compile | -| Excluded demo | Locked workspace 33 tests, formatting and strict all-feature clippy | Pass; six host adapter targets select zero tests | -| Build/lint/docs | Root/adapter/fixture formatting; workspace, adapter and all three fixture target clippy; feature checks, core rustdoc; docs ESLint/Prettier/VitePress build | Pass; three existing warnings in untouched rustdoc files remain | - -Spin plan adjustment: the new pure helper/tests live in `src/context.rs`, which already has a host-safe test gate. Plain Wasmtime cannot supply `spin:key-value/key-value@3.0.0` to the full WASM library test artifact. That artifact is not reported as passing. Host helper execution, existing WASM contracts with explicit flags, target lint/check and the actual Spin HTTP component establish the implemented seam without inventing a Spin-aware unit runner. CI retains this distinction. - -Local fixture pins: Fastly 0.12.1/Viceroy 0.17.0; worker 0.8.3/wasm-bindgen 0.2.122/worker-build 0.8.3/Wrangler 4.83.0/Miniflare 4.20260415.0/workerd 1.20260415.1; Spin SDK 6.0.0/Spin 4.0.0; Rust 1.95.0/Node 24.12.0. Full fixed observations are recorded in `tests/fixtures/request-fidelity/observations.json`; they are regression evidence, not compliance approval. All 47 cases were executed with the native Rust client on the three pinned local runtimes. Failure probes covered a failed build, interrupted build with child process, forced readiness failure and forced probe failure; parallel Fastly runs used distinct ports and left no live owned processes. CI is configured with the locally verified commands; remote GitHub Actions execution remains unrun. - -### Remaining upstream and consumer gates - -1. Fastly's safe borrowed raw target accessor is absent. Capture reports NotExposed. Actual Viceroy ingress demonstrates `/reserved/../native` can reach a preceding normalized `/native` shortcut. The hook cannot repair earlier native routing. A reviewed SDK revision and fresh raw-target/client/TLS proof are still required. -2. Local workerd's fixed KJ method parser returns 501 for extension methods before Worker execution. Fetch also normalizes literal/encoded dot paths and coalesces Cookie/Origin/control fields. Fresh review corrected the adapter scalar-to-byte bug: valid UTF-8 now copies without casting. Raw FF already appears as U+FFFD in Web Headers before Rust and becomes runtime UTF-8 EF BF BD in core; it cannot be recovered as original FF. Common octet/multiplicity fidelity is Unknown, since the observed transforming path does not prove every particular field changed. Absolute-form and duplicate CL requests also return 500 without a hook verdict. Browser-created method/ByteString success does not establish original wire support. -3. Spin 4.0 requires UTF-8 while preparing incoming headers and returns 500 for FF before the component runs. This requires an upstream runtime fix. Valid runtime values continue through EdgeZero's byte path. -4. General original-field octets/multiplicity/order are not universally proved. Metadata keeps unknown axes conservative and global order unavailable; local fixtures do not prove production edge behavior. Consumer-required original paths/fields need an explicitly approved supported contract where runtime recovery is impossible. - -Pinned primary source: [Spin header preparation](https://github.com/spinframework/spin/blob/v4.0.0/crates/trigger-http/src/headers.rs#L96-L106); [workerd KJ dependency](https://github.com/cloudflare/workerd/blob/v1.20260415.1/build/deps/gen/deps.MODULE.bazel#L27-L35); [KJ method rejection](https://github.com/capnproto/capnproto/blob/d0e9605b288ac4d8b86ee3201a23c43c017a6b61/c%2B%2B/src/kj/compat/http.c%2B%2B#L961-L999); [workerd target rewriting](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/server/server.c%2B%2B#L451-L471). - -The toolkit implementation can be reviewed independently of these gates. Trusted Server F0 remains unresolved; this branch does not change or weaken its approved spec. Planned commits and immutable consumer dependency/release handoff remain pending; no commit, push or publication is performed by this execution record. - -### Earlier independent implementation review - -| Reviewer scope | Final result | -| ------------------------------------------------------------------------------------- | --------------------------------------- | -| Core bounded API/router and Fastly native ownership/registry/snapshot paths | No remaining findings | -| Cloudflare/Axum/Spin conversion, public API docs and adapter CI feasibility | No remaining findings | -| Locked runtime fixtures, exact observations, process cleanup and CI browser selection | No remaining findings after corrections | - -Final corrections were independently rereviewed. The exact FF cookie probe and mandatory full-profile comparison were rebuilt and executed across all three local runtimes, with strict fixture clippy passing on each target. The explicit Chrome binary capability configuration was executed through the real wasm-bindgen browser runner. No production behavior changed during this final review cycle. The results above do not close the listed external SDK/runtime, production provenance or downstream consumer gates. - -### Subsequent skeptical review — supersedes the earlier readiness assessment - -The user requested fresh independent reviewers without relying on the prior review conclusions. Architecture/core/adapters, consumer-contract alignment and runtime/CI/fixture scopes were reviewed independently. The optional router hook and bounded extension metadata remain the smallest appropriate architecture. No additional hook ordering, ownership, body/error propagation, process cleanup, pinning or CI-wiring defect was found. The Cloudflare correction and user-requested Rust tooling replacement are implemented and independently reviewed; the final executed checks below distinguish toolkit correctness from unresolved consumer requirements. - -- [x] **P1 Cloudflare UTF-8 conversion:** The browser ByteString assumption is incorrect for pinned workerd. Actual wire C3 A9 produces core E9; valid C4 80 returns 500 before the hook. Root independently reproduced both with the existing real local Worker fixture. Correct conversion to preserve Worker UTF-8 runtime strings, add exact real-workerd assertions for both cases, revise browser-only expectations and update observations only after those assertions pass. Keep invalid UTF-8 replacement as a distinct upstream capability limitation. The corrected baseline is collected only after exact semantic assertions pass and is compared in normal runs. All 11 browser contracts, all 47 real runtime probes and strict target clippy pass. The invalid FF case exposes runtime replacement rather than inventing original bytes. -- [x] **Origin provenance wording:** Clarify TransportBinding as trusted transport scheme plus a single validated inbound authority. Host is request authority, not operator ownership/approval. No configured host allowlist is required by the consumer spec. Public core documentation and this spec now agree with the Axum implementation. -- [x] **Spin runner documentation:** Public test commands now include the same async/P3/HTTP flags as verified CI. Wasmtime contracts and actual Spin incoming conversion evidence remain distinct. -- [x] **Framing coverage:** Actual TE+CL and standalone empty chunked requests retain one Transfer-Encoding: chunked field on each tested local runtime. The consumer can inspect it and must still implement its rejection semantics. -- [ ] **Consumer semantic coverage:** Add exact repeated reserved-cookie and real action/Fetch Metadata control probes in Trusted Server's integration harness. Strict complete-value comparison may reject coalesced controls safely; do not comma-split or require every metadata fidelity axis to say Preserved without checking the actual feature requirement. - -F0 is a consumer dependency/behavior gate, not a synonym for every header's perfect preservation. Global order across different header names is unnecessary. Cookie rules depend on exact reserved-name counts, bounds and non-UTF-8 detection, with order-independent outcomes. The path requirement concerns original pathname classification; unrelated query preservation is unnecessary. Avoid interpreting a 16 KiB whole-target metadata cap as proof that a short pathname was uninspectable. - -| Resolution work | Owner/location | Required proof | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Fix valid Worker UTF-8 conversion | EdgeZero Cloudflare converter/tests/fixture | C3 A9 and C4 80 retained through real Worker dispatch; no ordinary-request regression | -| Recover canonical Fastly origin separately | EdgeZero conversion or Trusted Server native-origin seam | After raw capture attempt and before mutation, validate HTTP(S), one Host, URL authority agreement; preserve native provenance and unavailable target status | -| Safe borrowed original-target accessor | Fastly Rust SDK | Bounded host URI access before Url parsing; no consume/clone/reacquire; preserved client/TLS metadata and actual reserved-dot-path tests | -| Early native shortcut exclusion | Trusted Server Fastly entry point | Raw reserved spellings cannot reach health/JA4 shortcuts; configured authentication and trace response hardening remain first | -| Hidden Cloudflare raw paths and pre-Worker method rejection | Cloudflare/runtime, or explicitly approved consumer amendment | Original reserved-path ambiguity and authenticated/hardened response guarantees, not only no cookie mutation | -| Invalid UTF-8 headers rejected before Spin invocation / replaced in Worker headers | Spin/Worker runtime, or explicitly approved consumer amendment | Original cookie uninspectability is available to the consumer or the transport-boundary contract explicitly changes | -| Coalesced Cookie semantics | EdgeZero/consumer exact transport tests, then runtime work or an explicitly approved amendment | Reserved-name counts, duplicate precedence, bounds and original non-UTF-8 observability; valid diagnostics cookie plus unrelated FF must never activate | -| Dependency integration | Reviewed EdgeZero immutable commit/release and Trusted Server Cargo.toml/Cargo.lock | Consistent immutable pin, target compilation and actual consumer integration tests | - -Preserving the approved consumer contract requires upstream capability work followed by real ingress verification. An alternative runtime-boundary contract must explicitly define normalized-path classification, transport-level rejection and unavailable-cookie reporting; it requires user approval because it changes the approved feature semantics. Disabling feature support on selected adapters also changes the approved all-four-adapter scope. This review does not silently choose either alternative. - -Pinned primary source for the newly identified encoding assumption: [workerd UTF-8 header strings](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/api/headers.c%2B%2B#L276-L292). The reproduction report is `/tmp/edgezero-fresh-review-encoding.json`; only fixed statuses and boolean verdicts are recorded. Production conversion now preserves Worker UTF-8 strings; exact real Worker verification and the Python-to-Rust tooling replacement are complete. - -### User language constraint - -The user rejects shipping Python. Replace the test orchestration/client with an isolated native Rust harness and a small shell entry point; use Node for existing Worker browser capability JSON and shell extraction for the controlled Spin CLI pin. Native-only Tokio cancellation does not enter core or WASM adapters. Retain mandatory observations comparison, exact raw TCP bytes, pinned builds, bounded deadlines, failure/interruption cleanup and concurrent port isolation. Independently review the replacement before reporting completion. - -### Final Rust replacement evidence - -The Python probe file is removed and no Python snippets remain in the new CI or shell entry point. The native-only helper uses the exact existing Tokio 1.52.3, serde_json 1.0.150, tempfile 3.27.0 and TOML 1.1.2 pins in its own locked test workspace. Its eight tests cover exact request bytes, HTTP chunk/length/EOF framing, header/body bounds, semantic rejection of the UTF-8 regressions and owned process cancellation/reaping. Native formatting and strict clippy pass; CI checks this separate workspace explicitly. - -A full locked-build run passes all 47 fixed raw TCP cases against the saved observations: Fastly 17, Cloudflare 15 and Spin 15. Valid UTF-8 C3 A9 and C4 80 remain exact on all three; both framing probes retain Transfer-Encoding: chunked. Workerd FF produces runtime replacement, explicitly unsupported as original preservation; Spin FF never reaches the component. Root also verified shell-bootstrap SIGTERM, Rust build SIGTERM, failed build descendants, failed readiness, failed baseline/probes and concurrent runs. Distinct concurrent ports close and no live owned descendants remain. - -The independent architecture reviewer reproduced an initial shell-bootstrap cancellation leak. Job control now gives bootstrap Cargo its own group, with signal/exit cleanup and reaping before entering the Rust supervisor. Root verified the exact shim reproduction. Real execution also corrected Spin manifest parsing and worker-build's bare-version output. These corrections leave no extra runtime dependency in core or the adapters. - -After the Cloudflare production change, workspace tests again pass 1,440 tests, workspace strict clippy and feature checks pass, and all 11 browser contracts pass. Subsequent reviewer rereads confirm the UTF-8/framing and CI-workspace checks are resolved. External SDK/runtime capability and Trusted Server integration/consumer acceptance gates above remain open. Remote GitHub Actions is configured but not executed locally; no commit or push is performed. - -## Independent Claude audit follow-up - -The external audit prompted three independent read-only scopes: runner/CI, adapters/provenance and consumer-spec alignment. Implement centrally after reproducing the concrete defects; retain the approved Trusted Server contract rather than accepting suggested runtime exceptions. - -- [x] **Blocked-read cancellation:** The reviewer reproduced SIGTERM leaving the native runner spinning at 91.6% CPU. `read_until` and `read_exact` retry `Interrupted`, so socket cancellation now returns a terminal `Other` error. A bounded silent-server regression covers header and exact-body reads and failed before the fix. SIGHUP joins SIGINT/SIGTERM on the cancellation-and-await path; the shell bootstrap explicitly handles HUP. Independent post-fix probes cover all six signal/read combinations, exit within 297–308 ms and leave no runtime/descendant alive. -- [x] **Fastly canonical origin:** Record original-target `NotExposed` before any URL access, then recover origin from a native client runtime URI and exactly one validated Host with authority/default-port agreement. Validate the full runtime authority, including user-info and port syntax. Origin uses `RuntimeUri`, never a guessed HTTP scheme from absent TLS. Three regression tests cover matching, missing/duplicate/conflicting Host and invalid runtime authority; the invalid-authority test failed before the final correction. An actual Viceroy probe changes the native URL and Host after capture and proves explicit-snapshot dispatch retains original origin and client metadata. SDK URL parsing may normalize paths and can panic; public docs now state that behavior without claiming raw-path support. -- [x] **Honest fidelity and caller assertions:** Cloudflare common octet/multiplicity metadata is Unknown; a potentially transforming runtime does not prove each field changed. Public docs agree. Metadata constructors and Fastly snapshot overloads state caller responsibility for provenance/fidelity; the useful snapshot APIs are not hidden. -- [x] **Exact cookie evidence:** Add fixed comma/semicolon join verdicts and compound invalid-FF versus original-valid-EF-BF-BD probes. Actual Worker repeated Cookies are comma-joined, and both compound inputs produce identical verdicts. This proves ambiguity, not an implemented Trusted Server activation defect. A replacement-character fallback would reject some original valid UTF-8 and cannot recover original reserved-name counts or byte bounds; it remains an unapproved consumer decision. -- [x] **CI coverage:** Add locked feature/target-matched clippy for the isolated WASM fixture workspace; install clippy/rustfmt in both test jobs. Independent reread confirms coverage of the Fastly enabled binary/library and Cloudflare/Spin libraries. Remote Actions execution remains pending. -- [x] **Existing consumer plan:** Update F0/F4 against `RequestIngress` and explicit-snapshot conversion, require hardened terminal responses rather than generic hook errors, remove stale ByteString guidance and record dependency-graph re-verification. F5 no longer demands transport-level 413 for identical duplicate zero lengths. No approved consumer spec, runtime-boundary exception, replacement-character rule or platform exclusion is changed. - -Final local evidence executes 54 exact wire cases: Fastly 20, Cloudflare 17 and Spin 17. Normal locked-build runs compare the complete checked-in baseline; Node uses 24.12.0. Fastly origin is available and consistent in every hook verdict, and the original-origin snapshot probe passes. The native harness has nine passing tests, the browser converter has eleven, and all 1,440 workspace tests pass. Strict workspace/isolated fixture lint and feature checks remain required. Original Fastly path access, Worker normalization/replacement/pre-invocation rejection and Spin invalid-UTF-8 rejection remain external consumer gates. No production deployment, commit, push or release is performed. - -## Approved consumer amendment and dependency handoff - -On 2026-10-05, the user accepted the independently reviewed runtime-boundary -amendment to the existing Trusted Server trace spec and single plan and -authorized implementation. This supersedes the historical original-path/ -original-wire consumer gates and unapproved-fallback statements above. -EdgeZero retains honest capability metadata and does not reconstruct erased -information. Trusted Server owns visible-path classification and conservative -Cookie ambiguity suppression under its amended contract. - -The pre-dispatch hook and converter implementation passed fresh required -workspace gates, adapter/browser/WASM contracts and 54 source-built local -runtime probes before the dependency handoff. Independent PR-readiness -review found one CI-shell assignment/export lint issue, corrected centrally; -the remaining two actionlint warnings reproduce against the baseline. - -A reviewed immutable dependency revision, consumer repinning and tests on -Trusted Server's resolved graph remain required. Do not claim consumer F0 or -the full trace feature complete from toolkit checks. Follow the repository -PR-creator workflow, including its linked-issue requirement, for publication. - -### Approved external-review corrections — 2026-10-06 - -- [x] Prove canonical Cloudflare URLs and Axum absent/single/repeated visible - Content-Length fields do not claim per-request `Transformed` merely because - the runtime can normalize or fold them; use `Unknown`. -- [x] Retain actual parser folding and conversion behavior; rerun real browser - contract tests, the native TCP hook and the fixed raw observation matrix. -- [x] Document Fastly identical Content-Length folding and conflicting-length - rejection. Verify the required full gates and independent review, then update - the existing PR and Trusted Server consumer pin. No Python is introduced. - -Verification: all five required workspace gates and docs lint/format/build pass. -Native Axum contract tests pass 116/0, real-browser Cloudflare 11/0, Fastly -Viceroy 6/0 and Spin Wasmtime 12/0. The rebuilt raw matrix retains all 54 -committed observations (20 Fastly, 17 Cloudflare, 17 Spin). Independent review -approved the metadata correction without changing request conversion or claims -about original wire bytes. - -## Latest independent review follow-up — 2026-10-06 - -The earlier 47-case records above describe earlier execution checkpoints. The -final checked-in baseline contains 56 cases: Fastly 20, Cloudflare 19 and -Spin 17, including the two direct-workerd no-hook routing regressions -(`route-unicode` and `route-backslash`). The 54-case execution records above -are historical checkpoints; this baseline count does not claim a fresh -56-probe runtime execution. - -Clarify the Axum guide: common field multiplicity remains `Unknown`, including -Content-Length. `InboundOrigin::parse` validates URI authorities, -not DNS-label-only names. Commas, asterisks and semicolons are permitted -registered-name characters under [RFC 3986 section 3.2.2](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2), -and a trailing dot is valid. The proposed DNS-only rejection would narrow the -approved contract and reject valid authorities, so no parser change is made. -These clarifications do not change the consumer dependency API or runtime code. - -## PR review follow-up — 2026-10-15 - -The adapters share the conservative `HeaderFidelity::RUNTIME_HEADER_MAP` baseline. -Axum uses the common value for Content-Length without a redundant override. -Fastly retains the validated runtime URI authority after checking Host consistency; -authority spelling is not canonicalized by the shared API. Spin's pure ingress -helper and existing host tests now live in `src/request/ingress.rs`, compiled -independently of its WASM-only request conversion module. -The public Spin contract command and CI runner return to `wasmtime run`, matching -main's unchanged contract suite and dependency graph. Earlier explicit-flag runs -above remain historical evidence, not a requirement introduced by ingress metadata. -The full WASM library artifact still requires Spin-specific host interfaces. +## Implemented surfaces + +- `crates/edgezero-core/src/router.rs`: optional `PreDispatchHook` before lookup, + state, introspection, context and middleware. Register a value with + `pre_dispatch_hook` or an Arc with `pre_dispatch_hook_arc`; duplicate + registration panics. Futures may be non-Send; hook values are Send + Sync. +- `crates/edgezero-core/src/request.rs`: bounded target/origin metadata, + infallible `RequestIngress::new`, fallible `with_header_overrides` with an + internal error for duplicate names, redacted Debug, public authority/target + limits and shared host/effective-port comparison. Authorities are requested + hosts, not operator approval; port spelling is retained. +- Fastly `src/request.rs`: capture before native mutation, explicit snapshot + conversion and `dispatch_with_registries_and_ingress(app, req, stores, + ingress, extend)`. Capture reports target NotExposed before reading the SDK + URL for origin. Ordinary runners use the same registry dispatcher. +- Cloudflare `src/request.rs`: original runtime method token and one runtime URL + read; parse the captured copy with `worker::Url`; preserve runtime UTF-8 + header bytes. Bounded conversion errors become Worker errors through standard + runners and the fetch wrapper returns 500. +- Axum `src/request.rs`: preserve incoming URI/headers, trusted plain-HTTP + transport binding and one consistent Host. Existing JSON buffering and + non-JSON streaming remain in place. +- Spin `src/ingress.rs`: pure metadata helper shared by WASM conversion and host + tests, separate from the WASM-only `src/request.rs`. +- `tests/fixtures/request-fidelity`: store-free runtime application and fixed + boolean/count observations. `request-fidelity-runner` is an isolated native + Rust supervisor; its Tokio dependency does not enter core or WASM adapters. + +## Fidelity decisions + +Axum, Cloudflare and Spin use `HeaderFidelity::RUNTIME_HEADER_MAP`: octets, +multiplicity and same-name order Unknown, global order Unavailable. Fastly uses +all Unknown. Its original-header names/count APIs provide partial ordering and +aggregate count but do not associate original fields with converted values. +Viceroy can lowercase/deduplicate names; production behavior needs verification. +No per-header preservation is inferred from aggregate count equality. + +The hook cannot recover erased wire data, intercept an earlier native shortcut, +remove buffering or bypass outer response finalizers. Metadata capture remains +eager; changing it to lazy/opt-in requires separate lifecycle/performance work. +Generated `app!` routers cannot install a hook; use manual `Hooks::routes()`. + +## Dependency refresh + +The isolated fixture pins Fastly 0.13.1, Worker 0.8.5, wasm-bindgen 0.2.128 and +Spin SDK 7.0.0. Its lockfile must be regenerated with these pins together. +CI and the runner require worker-build 0.8.5 and Spin CLI 4.1.0. Rust, Node, +Viceroy and Wasmtime versions come from `.tool-versions`. The shared Spin pin +check includes this fixture. CI resolves fixture wasm-bindgen from its own lock, +and worker-build uses the npm-integrity-locked esbuild executable. + +## Verification checklist + +- [x] Run core, affected adapter and native runner tests after code changes. +- [x] Run workspace fmt, strict all-feature clippy and full workspace tests. +- [x] Check workspace `fastly cloudflare spin` features and Spin wasm32-wasip2. +- [x] Format/lint both isolated fixture workspaces; lint WASM fixture on each of + wasm32-wasip1/Fastly, wasm32-unknown-unknown/Cloudflare and + wasm32-wasip2/Spin with `--locked`. +- [x] Execute browser Cloudflare, Viceroy Fastly and Wasmtime Spin contracts. + Spin uses plain `wasmtime run`; full Spin lib-test artifacts additionally + need Spin-specific host interfaces and are not plain-Wasmtime contracts. +- [x] Run `./scripts/test-request-fidelity.sh all --record-observations` into a + temporary file, inspect per-case differences, then replace the baseline. + Run the ordinary comparison afterward. See the fixture README commands. +- [x] Run docs lint, format and build; review the final diff independently. + +## Evidence and baseline provenance + +The baseline contains 56 cases: Fastly 20, Cloudflare 19 and Spin 17, including +two direct-workerd no-hook routing regressions. Every case was freshly built and +recorded with the refreshed pins; the resulting JSON exactly matches the +checked-in baseline. The ordinary locked-build comparison also passes. +Earlier 47/54-case checkpoints and explicit Wasmtime flags are superseded by +the current registry and commands. + +Spin 4.1.0 consumes chunked framing before component invocation: `chunked` and +`te-cl` now expose no Transfer-Encoding (count 0, chunked false), unlike Spin 4.0. +Fresh probes independently reproduce the [review's report](https://github.com/IABTechLab/edgezero/pull/403#pullrequestreview-5544176343). +Fastly's global-order boolean changes with the metadata correction, without +asserting stronger wire preservation. Other recorded outcomes remain unchanged. + +## Current review validation + +Rust 1.98.1 workspace tests pass 1,546 tests with zero failures and one ignored. +All five required workspace gates pass, including strict all-feature Clippy, +combined adapter feature compilation and Spin wasm32-wasip2 compilation. +Both isolated workspaces pass formatting and strict lint; the WASM fixture is +linted on all three target/feature combinations with its regenerated lock. +The native runner passes ten tests; its ignored direct-workerd routing test is +covered by the full runtime probe run. + +Fastly/Viceroy passes seven WASM contracts and 97 runtime unit tests. Spin passes +12 contracts under plain Wasmtime 48.0.1. The complete 56-case runtime fixture +matrix passes with Viceroy 0.21.0, Spin 4.1.0, Worker/worker-build 0.8.5, +wasm-bindgen 0.2.128 and Node 24.20.0. Public matrices identify these tested +versions. Docs lint/format/build, dependency-pin checks, actionlint and whitespace +checks pass. All 12 Cloudflare browser contracts pass under the CI-pinned +Chrome/ChromeDriver 154.0.8037.92 with wasm-bindgen 0.2.128. + +## Decision log + +- Worker runtime UTF-8 strings must be copied as UTF-8; browser ByteString + construction is not evidence of Worker wire bytes. Exact C3 A9/C4 80 assertions + prevent the earlier encoding defect. +- Origin availability and hook dispatch prerequisites must pass before the + UTF-8 regression can prove anything; assert the specific encoding error and + include a passing valid verdict. +- Baseline failures print changed/added/removed cases. Child stdin is null, and + direct child termination backs up external process-group signals before wait. +- The consumer owns visible-path classification, authentication, cookie policy, + native shortcut ordering and terminal finalization. Its integration requires + an immutable reviewed toolkit revision and tests on its own dependency graph. +- Prebuilt installers, cache redesign and opt-in capture remain separate work. + No production capability or performance claim follows from local fixtures. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index ce0d0fa6..a5ad6e92 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -1,6 +1,6 @@ # Pre-dispatch interception and inbound request fidelity -**Status:** Approved for implementation after independent review; capability evidence and remaining gates are recorded in the matching plan. +**Status:** Implemented API contract; current verification requirements and dependency pins are recorded in the matching plan. **Date:** 2026-10-05. **Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. **Branch:** `spec/pre-dispatch-request-fidelity`. @@ -10,7 +10,7 @@ Applications need to intercept a reserved namespace independently of the registered method/path routes, before router-owned state, introspection, request context and middleware run. A consumer must be able to authenticate a reserved request and return its own complete response even when its method has no registered handler. -At the planning baseline the router performs `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare reduces extension methods to GET and re-encodes header strings as UTF-8; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. +At the historical planning baseline the router performed `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare previously reduced extension methods to GET and previously converted Worker UTF-8 strings as ByteString; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. Provide one optional generic pre-dispatch hook, preserve runtime-visible method/target/header data without additional avoidable loss, and expose explicit fidelity information. Preserve the concrete `RouterService` and existing routes when no hook is installed. This is toolkit infrastructure, not an implementation of Trusted Server tracing. @@ -38,9 +38,12 @@ Included: core hook, bounded ingress metadata, lossless runtime method/header co Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processing, auction evidence, telemetry, browser UI, generic hook chains, macro grammar additions, forwarding-header trust, transport deadlines/size protection, deployment, and unrelated proxy/response refactors. -## 4. Verified baseline and limits +## 4. Historical planning baseline and limits -| Surface | Current source | Consequence | +This table describes the pre-implementation checkout, not current dependencies. +The matching plan records current pins and verification requirements. + +| Surface | Planning source | Consequence | | ---------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Router | `crates/edgezero-core/src/router.rs`, `RouterInner::dispatch` | Lookup precedes state/introspection/context/middleware; no interception hook exists. | | Bootstrap | `crates/edgezero-core/src/app.rs`; `crates/edgezero-macros/src/app.rs` | `Hooks::routes`, `App` and generated routers use `RouterService`; retain that type. | @@ -48,7 +51,7 @@ Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processi | Cloudflare | `crates/edgezero-adapter-cloudflare/src/request.rs`, `into_core_request` | `req.method()` is a closed Workers enum; extension methods become GET. `req.url()` parses the runtime URL again. Header entries become Rust strings. Body is buffered. | | Axum | `crates/edgezero-adapter-axum/src/request.rs` | `Request::from_parts` preserves the incoming `http::Uri` and `HeaderMap`. Hyper may already reject/fold fields; JSON bodies are buffered. | | Spin | `crates/edgezero-adapter-spin/src/request.rs` | SDK request parts contain runtime URI and byte-oriented headers. Original wire interleaving is already unavailable in `HeaderMap`. Body is buffered. | -| Pins | `Cargo.lock` | This checkout pins worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122; verify again during execution. Do not substitute the consumer's worker 0.8.5 pin. | +| Pins | `Cargo.lock` | The planning checkout pinned Worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122. The matching plan supersedes these pins with the dependency refresh. | Fastly SDK `RequestHandle::get_url` calls `uri_get` then `Url::parse`. Its request handle getter is private; the public consuming handle round trip rebuilds the request without original client provenance, changing IP/TLS/original-header accessors. It is not an acceptable raw-target workaround. A safe borrowed SDK accessor is an external prerequisite for an original-target claim. @@ -74,7 +77,7 @@ pub type BoxPreDispatchHook = Arc; `Request`/`Response` are existing `edgezero_core::http` aliases; the hook future has no Send requirement. Use the existing async-trait dependency. New public items receive the repository's normal documentation. -Add consuming builder method `RouterBuilder::pre_dispatch_hook(hook: BoxPreDispatchHook) -> Self`. Store one `Option` in builder/inner. Default is None. A repeated builder call replaces the previous hook; this is one hook, not a new middleware chain. `RouterService` clones share the same Arc-backed instance. +Builder methods `pre_dispatch_hook(hook: H)` and `pre_dispatch_hook_arc(hook: BoxPreDispatchHook)` install one optional hook. Default is None. Repeated registration panics during router construction, so another registration cannot silently remove an authentication gate. `RouterService` clones share the same Arc-backed instance. The first operation in `RouterInner::dispatch` becomes: @@ -112,12 +115,12 @@ Use private fields, Clone, and borrowed accessors. Do not derive value-bearing D - `TargetSource`: `TransportRequestTarget`, `RuntimeUrl`, `RuntimePathAndQuery`. Source and fidelity are independent: a runtime URL is not automatically the original request target. - `Preservation`: `Preserved`, `Transformed`, `Unknown`, `Unavailable`. - `TargetUnavailable`: `NotExposed`, `TooLarge`, `ReadFailed`. -- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source; TransportBinding records a trusted transport scheme combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. +- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source, normally derived from client Host or :authority; TransportBinding records a trusted transport scheme combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. - `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from trusted inbound/runtime request information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. - `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. - `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. -Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. The plan owns the exact minimum constructor/accessor set. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. +Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. `RequestIngress::new(target, origin, fidelity)` is infallible; `with_header_overrides` rejects duplicate names with an internal error. `InboundOrigin::same_authority` compares host case and effective ports without asserting scheme equality. `MAX_AUTHORITY_BYTES` exposes the 1 KiB bound; source port spelling is retained, including leading zeros and syntactic port zero. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. HeaderFidelity axes describe original received HTTP field-value octets, field multiplicity and ordering compared with the resulting core headers, not merely a successful runtime-to-core copy. Octets mean field-value bytes, not header-name case, delimiters, HTTP framing or complete request serialization. Trimming/combining or parser/runtime loss must be reflected as Transformed, Unknown or Unavailable; Preserved requires pinned runtime/parser evidence and actual transport tests. Document and test runtime-to-core copying separately when it is all that can be established. @@ -137,21 +140,21 @@ Do not turn optional target-capture failure into an unrelated new ordinary-reque - Obtain a reviewed safe SDK accessor that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance. Pin the resolved compatible SDK revision/version; do not fabricate one or add a local Cargo-cache patch. - Do not use consuming handle round trips, clone_without_body, raw pointers or a new unsafe fastly-sys wrapper to bypass the SDK gate. - After the accessor exists, parse scheme/authority without normalizing the captured path. Prove encoded separators/dot segments/repeated separators survive in metadata and that client IP/TLS accessors retain behavior. -- Runtime header bytes and same-name order must be tested. Original-header names/count alone do not prove value-field multiplicity; do not reconstruct fields from mismatched/FIFO guesses. -- Add `into_core_request_with_ingress` and a public `dispatch_with_registries_and_ingress` overload that retains the existing App/StoresMetadata/EnvConfig/extension-callback semantics. Add a metadata-aware conversion path for an already captured snapshot: retain that same snapshot through later native mutation/dispatch. Ordinary converters capture once and delegate. Insert adapter-captured ingress after generic scratch extension merging so it cannot be accidentally overwritten. +- Runtime header bytes and same-name order must be tested. `get_original_header_names()` and `get_original_header_count()` expose name order and total count, not original field/value association. Local Viceroy lowercases and deduplicates names. Report global order Unknown pending production verification; aggregate count equality cannot prove per-name multiplicity. Do not reconstruct fields from mismatched/FIFO guesses. +- Add `into_core_request_with_ingress` and a public `dispatch_with_registries_and_ingress` overload that retains the existing App/StoresMetadata/extension-callback semantics. Add a metadata-aware conversion path for an already captured snapshot: retain that same snapshot through later native mutation/dispatch. Ordinary converters capture once and delegate. Insert adapter-captured ingress after generic scratch extension merging so it cannot be accidentally overwritten. - Existing request-extensions callbacks can carry native facts. They are not the new router policy hook and cannot return local responses. ### 7.2 Cloudflare Use `req.inner().method()` and parse with core `Method::from_bytes`. Preserve any valid extension token exposed by the runtime; malformed values produce a bounded conversion error. Do not pass through Workers' closed method enum for inbound conversion. -Capture `req.inner().url()` before `req.url()`/`url::Url` parsing, then preserve it separately with runtime-visible provenance. This does not recover a wire target normalized by the Fetch runtime. +Capture `req.inner().url()` once before parsing that string with `worker::Url`, then preserve it separately with runtime-visible provenance. This does not recover a wire target normalized by the Fetch runtime. For each `Headers.entries()` value, preserve the pinned Worker runtime's UTF-8 string representation with `HeaderValue::from_bytes(value.as_bytes())`. Header names retain validated construction; invalid HeaderValues produce bounded conversion errors rather than silent omission. Pinned workerd represents headers as UTF-8, which differs from browser ByteString expectations. Do not cast Unicode scalars to single bytes: that changes valid wire C3 A9 to E9 and rejects valid C4 80. Browser-created high-byte strings are not evidence of original Worker wire bytes. -Real workerd probes must assert exact valid UTF-8 preservation for C3 A9 and C4 80. A raw invalid FF is already represented by U+FFFD before Rust; its runtime UTF-8 copy EF BF BD is not the original FF. Expose conservative transformed fidelity and keep this consumer capability unresolved rather than invent lost bytes or add an ordinary-request rejection for a valid runtime string. +Real workerd probes must assert exact valid UTF-8 preservation for C3 A9 and C4 80. A raw invalid FF is already represented by U+FFFD before Rust; its runtime UTF-8 copy EF BF BD is not the original FF. Expose Unknown octet and multiplicity fidelity: runtime replacement capability does not establish transformation of every field. Never invent lost bytes or reject an otherwise valid runtime string solely for that capability. -Web Headers has already normalized/combined entries: mark original wire multiplicity/global order unavailable or transformed according to verified behavior. Per-name value order and octet provenance must also be stated precisely. Never comma-split Origin/Cookie/control values to invent original fields. +Web Headers has already normalized/combined entries: original wire multiplicity remains Unknown and global order is Unavailable. Per-name value order and octet provenance must also be stated precisely. Never comma-split Origin/Cookie/control values to invent original fields. Keep existing body buffering and Env/Context/proxy wiring intact. Distinguish browser Fetch construction constraints from actual incoming Workers HTTP behavior in tests. @@ -183,14 +186,14 @@ Initial investigation, not a verified release matrix: | Original field multiplicity | Runtime proof required | Fetch coalescing can make it unavailable | Per-field parser limits, especially Content-Length | Runtime/SDK proof required | | Original global wire order | Not implied by grouped iteration | Unavailable after sorted/combined entries | Not retained by HeaderMap | Not retained by HeaderMap | -The release review must replace each required unknown with Preserved, Transformed, Unavailable or a clearly pending environment test backed by evidence. Unknown is never success. +The release matrix records established guarantees and unresolved limits. Unknown is a valid conservative metadata value; it does not prove preservation or satisfy a consumer requirement for original-wire equality. Two completion claims are distinct: 1. **EdgeZero infrastructure complete:** hook/API, concrete conversion fixes, metadata that honestly describes remaining limits, tests and docs pass. -2. **Trusted Server F0 satisfied across all platforms:** consumer-required path/cookie/control properties are proved, or the consumer spec receives explicit approval for a supported runtime contract. EdgeZero cannot grant that approval. +2. **Consumer integration satisfied across all platforms:** consumer-required path/cookie/control properties are proved, or the consumer spec receives explicit approval for a supported runtime contract. EdgeZero cannot grant that approval. -If a runtime irreversibly hides required path spellings or original duplicate control fields, record the exact limitation and a proposed consumer resolution. Do not silently weaken the approved consumer spec, advertise universal original-wire preservation, disable a consumer platform without approval or report F0 complete solely because a hook exists. +If a runtime irreversibly hides required path spellings or original duplicate control fields, record the exact limitation and a proposed consumer resolution. Do not silently weaken the approved consumer spec, advertise universal original-wire preservation, disable a consumer platform without approval or report consumer integration complete solely because a hook exists. Trusted Server owns early Fastly native shortcut exclusion, classification, authentication, lifecycle gates and terminal response finalization. The ingress helper must be callable before those shortcuts; changing Trusted Server's `main.rs` is outside this EdgeZero branch. @@ -209,45 +212,26 @@ Trusted Server owns early Fastly native shortcut exclusion, classification, auth Use fictional/example.com fixtures only. Never log actual headers, cookies, credentials, raw target strings or exception payloads in new diagnostic code. -## 10. Review and implementation status - -This spec and its matching plan received independent review before the user authorized implementation. The matching plan records implemented surfaces, executed evidence and remaining gates. These documents are not a published API or proof of runtime preservation. The Fastly SDK accessor and any consumer-contract resolution remain explicit external gates. - -The plan records independent review results after reviewer findings are resolved. Neither document authorizes publishing, deployment or downstream spec changes. - -## 11. Fresh review correction - -A subsequent independent review disproved the unconditional browser ByteString assumption in the Cloudflare design above. Pinned workerd exposes header strings as UTF-8; the initial scalar-to-byte converter changed valid wire C3 A9 to E9 and rejected valid C4 80 before the hook. Conversion now copies Worker UTF-8 runtime strings; the matching plan records real Worker verification before release. Invalid UTF-8 replacement remains an independent upstream limitation. This correction does not authorize changing the approved Trusted Server contract. The matching plan records the reproduction, corrective work and F0 resolution choices. - -## 12. Approved consumer runtime-boundary amendment - -On 2026-10-05, the user approved the independently reviewed amendment to the -existing Trusted Server trace spec and single plan, then authorized its -implementation. The original consumer guarantees described above are the -historical baseline; original-target recovery and original-wire reconstruction -are no longer prerequisites for that consumer v1. - -The amended consumer classifies its application-visible URI pathname, keeps -literal-path authentication, and distinguishes runtime rejection, adapter -bootstrap/conversion failure and successful application dispatch. It rejects -still-visible reserved aliases and validates complete same-origin controls. -Runtime-visible Cookie fields are bounded before sanitation. Unproved octets -with U+FFFD, or unproved multiplicity with a literal comma, yield -`unavailable/runtime_header_ambiguous` and suppress trace capture; missing or -Unknown metadata alone permits readable marker-free sessions. The consumer -owns that policy; EdgeZero metadata does not impose it on other applications. - -The SDK/runtime limits remain documented capabilities. Consumer F0 now -requires a reviewed immutable EdgeZero revision and verification against the -consumer's resolved dependency graph. Successful normal browser workflows on -all four adapters, early native ordering and terminal finalization remain -consumer acceptance work. Toolkit tests alone do not complete those gates. - -### Per-observation preservation clarification — 2026-10-06 - -`Transformed` means a change is established for the specific observation. A -runtime capability to normalize URLs or fold Content-Length fields does not -prove that every request changed. Cloudflare runtime targets and Axum -Content-Length multiplicity therefore use `Unknown` when original equality or -change cannot be established. Conversion behavior and recorded capability -observations remain unchanged. +## 10. Decision log and consumer boundary + +- Worker header conversion copies UTF-8 runtime strings. The earlier ByteString + assumption corrupted C3 A9 and rejected C4 80; the fixture asserts both exact + byte sequences. Invalid UTF-8 replacement remains a runtime limitation. +- Fidelity is per observation. Runtime normalization or folding capability does + not establish that a particular request changed; use Unknown when unproved. +- Fastly original-name/count APIs provide partial information, so global order + is Unknown rather than Unavailable. Original-target acquisition still needs a + safe borrowed SDK accessor; no consuming-handle workaround is used. +- The consumer's runtime-boundary amendment classifies application-visible URI + paths and applies its own conservative cookie ambiguity policy. Toolkit metadata + does not impose that policy on other applications. Earlier original-wire + consumer requirements are historical context, not toolkit acceptance gates. +- Consumer integration still requires an immutable reviewed toolkit revision, + checks against the consumer's resolved graph, early native shortcut ordering, + authentication and terminal response finalization on all supported adapters. + Toolkit tests alone do not establish those outcomes. + +The hook runs after adapter conversion and eager ingress capture. It does not +avoid current buffering, logger/store bootstrap, transport rejection or outer +finalizers. Lazy capture and installer/cache redesign are separate performance +work; they are not needed to correct the fidelity contract. diff --git a/scripts/check_shared_dep_pins.sh b/scripts/check_shared_dep_pins.sh index 1b7a70fc..d6f0e6b4 100755 --- a/scripts/check_shared_dep_pins.sh +++ b/scripts/check_shared_dep_pins.sh @@ -74,7 +74,8 @@ elif [[ "$got" != "$WANT" ]]; then VIOLATIONS=$((VIOLATIONS + 1)) fi -# `spin-sdk` is pinned in four places and shares no lockfile across them. +# `spin-sdk` pins span isolated workspaces and the scaffold; they do not +# share one lockfile. # A mismatch does not fail at the pin: the fixture resolves a second copy # of the SDK and the build dies on `expected IncomingBody, found # a different IncomingBody`, naming neither crate nor version. @@ -95,6 +96,7 @@ fi for file in \ examples/app-demo/Cargo.toml \ tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml \ + tests/fixtures/request-fidelity/Cargo.toml \ crates/edgezero-cli/src/generator.rs; do got="$(spin_major_in "$file")" if [[ -z "$got" ]]; then diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index 7fab6c10..478ad64a 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -1,6 +1,7 @@ //! Fixed, loopback-only HTTP ingress probes for the three WASM adapters. use serde_json::{Value, json}; +use std::collections::BTreeSet; use std::env; use std::fs::{self, File}; use std::io::Seek as _; @@ -149,7 +150,7 @@ struct OwnedProcess { impl OwnedProcess { fn spawn(command: &mut Command) -> io::Result { - command.process_group(0); + command.process_group(0).stdin(Stdio::null()); let child = command.spawn()?; let group = child.id(); Ok(Self { child, group }) @@ -194,6 +195,8 @@ impl Drop for OwnedProcess { thread::sleep(POLL_INTERVAL); } signal_group(self.group, "-KILL"); + // Also stop the direct child if the external group signal failed. + let _result = self.child.kill(); let _result = self.child.wait(); } } @@ -204,6 +207,9 @@ fn command(fixture: &Path, program: &str, arguments: &[&str]) -> Command { .args(arguments) .current_dir(fixture) .env("WRANGLER_SEND_METRICS", "false"); + if program == "worker-build" { + command.env("ESBUILD_BIN", fixture.join("node_modules/.bin/esbuild")); + } command } @@ -256,15 +262,8 @@ fn verify_version( } fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { - run_command( - fixture, - "cargo", - &["metadata", "--locked", "--format-version", "1", "--no-deps"], - cancelled, - true, - )?; if platform == "cloudflare" { - verify_version(fixture, "worker-build", "0.8.3", cancelled)?; + verify_version(fixture, "worker-build", "0.8.5", cancelled)?; run_command(fixture, "npm", &["ci"], cancelled, false)?; let lock = fs::read(fixture.join("Cargo.lock"))?; run_command( @@ -317,6 +316,36 @@ fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<() Ok(()) } +fn report_observation_diff( + output: &mut impl Write, + platform: &str, + expected: &Value, + observed: &Value, +) -> io::Result<()> { + let cases: BTreeSet<_> = expected + .as_object() + .into_iter() + .flat_map(|cases| cases.keys()) + .chain( + observed + .as_object() + .into_iter() + .flat_map(|cases| cases.keys()), + ) + .collect(); + for case in cases { + if expected.get(case) != observed.get(case) { + writeln!( + output, + "{platform}/{case}: expected={} observed={}", + expected.get(case).unwrap_or(&Value::Null), + observed.get(case).unwrap_or(&Value::Null) + )?; + } + } + Ok(()) +} + fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { let fixture = options.root.join("tests/fixtures/request-fidelity"); let mut observations = json!({}); @@ -427,6 +456,12 @@ fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { serde_json::from_slice(&fs::read(fixture.join("observations.json"))?) .map_err(io::Error::other)?; if expected.get(*platform) != Some(&observed) { + report_observation_diff( + &mut io::stderr(), + platform, + &expected[*platform], + &observed, + )?; return Err(io::Error::other(format!( "{platform}: full runtime observations changed; inspect before updating baseline" ))); @@ -768,13 +803,13 @@ fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) )?; } require( - verdict["global_order_unavailable"] == true + verdict["global_order_unavailable"] == (platform != "fastly") && verdict["spoofed_origin_ignored"] == true && verdict["origin_available"] == true && verdict["origin_matches_expected"] == true, platform, case, - "unavailable order and origin provenance must remain explicit", + "order capability and origin provenance must remain explicit", )?; require( verdict["target_available"] == (platform != "fastly"), @@ -891,7 +926,7 @@ fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) )?, ("spin", "high-byte" | "compound-invalid", 500) => writeln!( io::stderr(), - "PENDING Spin 4.0 rejects non-UTF-8 header bytes before component invocation (500)." + "PENDING Spin rejects non-UTF-8 header bytes before component invocation (500)." )?, (_, "length-identical" | "length-conflict" | "te-cl" | "absolute", 400) => {} _ => { @@ -1136,6 +1171,21 @@ mod tests { } } + #[test] + fn baseline_diff_reports_changed_added_and_removed_cases_once() { + let expected = json!({"same": {"hook": true}, "changed": {"count": 2}, "removed": true}); + let observed = json!({"same": {"hook": true}, "changed": {"count": 1}, "added": false}); + let mut output = Vec::new(); + super::report_observation_diff(&mut output, "spin", &expected, &observed) + .expect("should report changes"); + let output = String::from_utf8(output).expect("should write UTF-8"); + assert_eq!(output.lines().count(), 3); + assert!(output.contains("spin/changed: expected={\"count\":2} observed={\"count\":1}")); + assert!(output.contains("spin/added: expected=null observed=false")); + assert!(output.contains("spin/removed: expected=true observed=null")); + assert!(!output.contains("spin/same")); + } + #[test] fn fixed_registry_contains_unicode_and_standalone_chunked_probes() { let cases = cases(); @@ -1166,9 +1216,16 @@ mod tests { #[test] fn observation_verification_rejects_utf8_corruption_even_during_collection() { - let verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); - let _error = verify_observation("cloudflare", "utf8", 200, &verdict) + let mut verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "origin_available": true, "origin_matches_expected": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); + let error = verify_observation("cloudflare", "utf8", 200, &verdict) .expect_err("should reject the former C3 A9 to E9 defect"); + assert!( + error.to_string().contains("C3 A9"), + "should reach the UTF-8 check: {error}" + ); + verdict["cookie_utf8"] = json!(true); + verdict["cookie_latin1"] = json!(false); + verify_observation("cloudflare", "utf8", 200, &verdict).expect("should accept exact UTF-8"); let _error = verify_observation("cloudflare", "unicode", 500, &serde_json::Value::Null) .expect_err("should reject the former C4 80 conversion error"); } diff --git a/tests/fixtures/request-fidelity/Cargo.lock b/tests/fixtures/request-fidelity/Cargo.lock index 0daa5c89..ab5f9993 100644 --- a/tests/fixtures/request-fidelity/Cargo.lock +++ b/tests/fixtures/request-fidelity/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -23,13 +23,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -49,9 +64,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -98,6 +113,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "bitflags" version = "1.3.2" @@ -106,9 +127,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -121,11 +142,11 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -134,19 +155,40 @@ version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", + "brotli-decompressor 5.0.3", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", ] [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -163,9 +205,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.63" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "shlex", @@ -173,9 +215,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chrono" @@ -192,11 +234,11 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ - "brotli", + "brotli 9.0.0", "compression-core", "flate2", "memchr", @@ -204,9 +246,15 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "core-foundation-sys" @@ -223,30 +271,38 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -254,11 +310,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -268,9 +323,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", @@ -283,7 +338,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -298,23 +352,24 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.10.4", + "block-buffer 0.12.1", + "const-oid", "crypto-common", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -329,7 +384,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "brotli", + "brotli 8.0.4", "bytes", "edgezero-core", "flate2", @@ -347,7 +402,8 @@ dependencies = [ "anyhow", "async-stream", "async-trait", - "brotli", + "base16ct", + "brotli 8.0.4", "bytes", "chrono", "edgezero-core", @@ -360,8 +416,8 @@ dependencies = [ "log-fastly", "serde", "serde_json", - "sha2 0.10.9", - "thiserror 2.0.20", + "sha2 0.11.0", + "thiserror 2.0.21", ] [[package]] @@ -370,7 +426,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "brotli", + "brotli 8.0.4", "bytes", "edgezero-core", "flate2", @@ -381,7 +437,7 @@ dependencies = [ "serde_json", "spin-sdk", "subtle", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -392,6 +448,7 @@ dependencies = [ "async-compression", "async-stream", "async-trait", + "base16ct", "bytes", "edgezero-macros", "futures", @@ -405,8 +462,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha2 0.10.9", - "thiserror 2.0.20", + "sha2 0.11.0", + "thiserror 2.0.21", "toml", "tower-service", "tracing", @@ -423,16 +480,16 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 3.0.3", + "syn 3.0.6", "toml", "validator", ] [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elsa" @@ -452,9 +509,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" dependencies = [ "anyhow", "bytes", @@ -465,7 +522,6 @@ dependencies = [ "fastly-sys", "http", "itertools", - "lazy_static", "mime", "serde", "serde_json", @@ -480,9 +536,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" dependencies = [ "proc-macro2", "quote", @@ -491,9 +547,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", "http", @@ -501,9 +557,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -523,26 +579,21 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.2.0" @@ -560,9 +611,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -575,9 +626,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -585,15 +636,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -602,38 +653,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -673,9 +724,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -683,9 +734,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -704,6 +755,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -730,9 +790,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -744,9 +804,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -757,9 +817,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -771,16 +831,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -791,15 +852,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -845,9 +906,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -872,22 +933,15 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -896,27 +950,27 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.30" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "log-fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ "fastly", "log", @@ -937,9 +991,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -949,9 +1003,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -1018,18 +1072,18 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "prettyplease" @@ -1042,50 +1096,50 @@ dependencies = [ ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1095,9 +1149,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1106,9 +1160,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "request-fidelity" @@ -1129,9 +1183,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1147,9 +1201,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1168,22 +1222,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1212,13 +1266,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1250,20 +1304,20 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest 0.10.7", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1274,9 +1328,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "slab" @@ -1286,15 +1340,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "spin-macro" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" dependencies = [ "proc-macro2", "quote", @@ -1303,9 +1357,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" dependencies = [ "anyhow", "bytes", @@ -1314,7 +1368,7 @@ dependencies = [ "http-body", "http-body-util", "spin-macro", - "thiserror 2.0.20", + "thiserror 2.0.21", "wasip3", ] @@ -1381,9 +1435,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1392,13 +1446,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1412,11 +1466,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -1432,20 +1486,20 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -1457,15 +1511,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -1473,9 +1527,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1483,18 +1537,18 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "pin-project-lite", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -1516,18 +1570,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower-service" @@ -1574,9 +1628,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-xid" @@ -1604,12 +1658,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "validator" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" dependencies = [ "idna", - "once_cell", "regex", "serde", "serde_derive", @@ -1620,13 +1673,12 @@ dependencies = [ [[package]] name = "validator_derive" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" dependencies = [ "darling", - "once_cell", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "syn 2.0.119", @@ -1640,31 +1692,31 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] [[package]] name = "wasip3" -version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +version = "0.7.1+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", "http", "http-body", - "thiserror 2.0.20", + "thiserror 2.0.21", "wit-bindgen 0.57.1", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -1675,9 +1727,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -1685,9 +1737,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1695,22 +1747,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -1739,9 +1791,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" dependencies = [ "futures-util", "js-sys", @@ -1756,7 +1808,7 @@ version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "hashbrown", "indexmap", "semver", @@ -1764,9 +1816,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -1843,9 +1895,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "wit-bindgen" @@ -1853,7 +1905,7 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -1862,7 +1914,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "futures", "wit-bindgen-rust-macro", ] @@ -1916,7 +1968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -1949,9 +2001,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" dependencies = [ "async-trait", "bytes", @@ -1980,9 +2032,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" dependencies = [ "async-trait", "proc-macro2", @@ -1990,16 +2042,15 @@ dependencies = [ "strum", "syn 2.0.119", "wasm-bindgen", - "wasm-bindgen-futures", "wasm-bindgen-macro-support", "worker-sys", ] [[package]] name = "worker-sys" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" dependencies = [ "cfg-if", "js-sys", @@ -2009,15 +2060,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2026,13 +2077,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -2047,21 +2098,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2070,9 +2121,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2081,17 +2132,23 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml index 3240c8df..6fb15838 100644 --- a/tests/fixtures/request-fidelity/Cargo.toml +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [package.metadata.request-fidelity] -spin-cli = "4.0.0" +spin-cli = "4.1.0" [workspace] @@ -29,9 +29,9 @@ edgezero-core = { path = "../../../crates/edgezero-core" } edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"], optional = true } edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"], optional = true } edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"], optional = true } -fastly = { version = "=0.12.1", optional = true } -worker = { version = "=0.8.3", default-features = false, features = ["http"], optional = true } -wasm-bindgen = { version = "=0.2.122", optional = true } -spin-sdk = { version = "=6.0.0", default-features = false, features = ["http"], optional = true } +fastly = { version = "=0.13.1", optional = true } +worker = { version = "=0.8.5", default-features = false, features = ["http"], optional = true } +wasm-bindgen = { version = "=0.2.128", optional = true } +spin-sdk = { version = "=7.0.0", default-features = false, features = ["http"], optional = true } anyhow = { version = "=1.0.103", optional = true } serde_json = "=1.0.150" diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index a153c23a..83b61ed8 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -1,6 +1,6 @@ # Request ingress runtime fixtures -Run from the repository root with Rust 1.95.0 and Node 24.12.0: +Run from the repository root with the Rust and Node versions in `.tool-versions`: ```sh ./scripts/test-request-fidelity.sh all @@ -11,7 +11,7 @@ Run from the repository root with Rust 1.95.0 and Node 24.12.0: prove source freshness. Use the default locked build for review evidence. The isolated Cargo workspace and npm lock pin fixture dependencies. Install -Viceroy 0.17.0, Spin 4.0.0, worker-build 0.8.3 and wasm-bindgen CLI 0.2.122. +Viceroy 0.21.0, Spin 4.1.0, worker-build 0.8.5 and wasm-bindgen CLI 0.2.128. Wrangler 4.83.0 and its local workerd are installed by `npm ci`. Each runtime uses its own target directory, an ephemeral loopback port, bounded readiness and an owned process group that is cleaned up on success, failure or interruption. @@ -20,7 +20,20 @@ No platform credentials or deployment are required. The runner sends 56 fixed raw HTTP cases across Fastly, Cloudflare and Spin. The client and process orchestration use an isolated native Rust harness with exact existing Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. `observations.json` records the pinned local runtimes' observed outcomes, and -subsequent runs compare them exactly. Inspect changes before updating it. +subsequent runs compare them exactly and report each changed case. Inspect +changes before updating it. Record all platforms to a temporary file first: + +```sh +./scripts/test-request-fidelity.sh all --record-observations > /tmp/request-fidelity-observations.json +diff -u tests/fixtures/request-fidelity/observations.json /tmp/request-fidelity-observations.json +# After a successful run and inspection: +cp /tmp/request-fidelity-observations.json tests/fixtures/request-fidelity/observations.json +``` + +Recording a single platform emits only that platform; replacing the baseline +with that output would discard the other platforms. A failed run may leave an +empty output file. All 56 cases were freshly source-built and rerun with the versions above; +the ordinary comparison matched the checked-in baseline. A passing regression run includes explicitly reported unsupported cases; it does not establish consumer compliance or production wire preservation. Axum's real TCP ingress cases live alongside its request converter tests. @@ -32,7 +45,7 @@ Known gates: which normalizes these targets before Worker invocation. A no-hook application must match `/caf%C3%A9` and `/a/b` while ingress metadata independently retains the raw runtime URL with unknown wire preservation. -- Fastly 0.12.1 lacks a safe borrowed original-target getter. Ingress reports +- Fastly 0.13.1 lacks a safe borrowed original-target getter. Ingress reports `NotExposed`; a preceding native path shortcut can see a normalized target. Its canonical origin is recovered separately from the client runtime URI and one validated consistent Host, before native mutation. @@ -48,8 +61,12 @@ Known gates: unrelated invalid FF against original valid EF BF BD: their Worker verdicts are identical. They do not implement or prove Trusted Server cookie parsing. Absolute-form and duplicate Content-Length requests return local 500 errors. -- Spin 4.0.0 rejects non-UTF-8 header values before component invocation. -- Global original field order is unavailable on every adapter. Local evidence +- Spin 4.1.0 rejects non-UTF-8 header values before component invocation. + It consumes chunked framing and removes Transfer-Encoding before the hook in + `chunked` and `te-cl`; these observations differ from the old Spin 4.0 baseline. +- Global original field order is unavailable on Axum, Cloudflare and Spin. + Fastly reports Unknown: original-name/count APIs provide partial evidence, + without associating original fields with converted values. Local evidence cannot certify production runtime or TLS/client provenance guarantees. The single [spec](../../../docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md) diff --git a/tests/fixtures/request-fidelity/observations.json b/tests/fixtures/request-fidelity/observations.json index 1ad21841..4fcfb02d 100644 --- a/tests/fixtures/request-fidelity/observations.json +++ b/tests/fixtures/request-fidelity/observations.json @@ -453,7 +453,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -486,7 +486,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -519,7 +519,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -552,7 +552,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -591,7 +591,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -624,7 +624,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -657,7 +657,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": true, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -690,7 +690,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -727,7 +727,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -774,7 +774,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -807,7 +807,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -840,7 +840,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -873,7 +873,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -906,7 +906,7 @@ "cookie_unicode": false, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -939,7 +939,7 @@ "cookie_unicode": true, "cookie_utf8": false, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -972,7 +972,7 @@ "cookie_unicode": false, "cookie_utf8": true, "extension_method": false, - "global_order_unavailable": true, + "global_order_unavailable": false, "hook": true, "native_client_preserved": true, "ordinary": false, @@ -1052,8 +1052,8 @@ "target_available": true, "target_matches_original": true, "target_matches_runtime": true, - "transfer_encoding_chunked": true, - "transfer_encoding_count": 1 + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 } }, "compound-invalid": { @@ -1367,8 +1367,8 @@ "target_available": true, "target_matches_original": true, "target_matches_runtime": true, - "transfer_encoding_chunked": true, - "transfer_encoding_count": 1 + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 } }, "unicode": { diff --git a/tests/fixtures/request-fidelity/src/lib.rs b/tests/fixtures/request-fidelity/src/lib.rs index 16687658..4c3da19a 100644 --- a/tests/fixtures/request-fidelity/src/lib.rs +++ b/tests/fixtures/request-fidelity/src/lib.rs @@ -34,7 +34,7 @@ impl Hooks for Fixture { fn routes() -> RouterService { let ordinary_calls = Arc::new(AtomicUsize::new(0)); RouterService::builder() - .pre_dispatch_hook(Arc::new(Probe { + .pre_dispatch_hook_arc(Arc::new(Probe { ordinary_calls: Arc::clone(&ordinary_calls), })) .middleware(CountOrdinary(ordinary_calls)) From 52121d95770f3544c6d20b3f8cb51d7231d3ba25 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 19 Oct 2026 17:49:19 +0530 Subject: [PATCH 07/11] fix: address approved request fidelity review nits --- .github/workflows/test.yml | 14 +++- CHANGELOG.md | 10 +++ .../tests/contract.rs | 15 ++++ crates/edgezero-adapter-fastly/src/request.rs | 17 +++- crates/edgezero-core/src/request.rs | 5 ++ ...026-10-05-pre-dispatch-request-fidelity.md | 13 +++- ...05-pre-dispatch-request-fidelity-design.md | 3 + scripts/check_shared_dep_pins.sh | 50 +++++++++++- .../request-fidelity-runner/src/main.rs | 77 ++++++++++++++----- tests/fixtures/request-fidelity/Cargo.toml | 1 + tests/fixtures/request-fidelity/README.md | 4 +- 11 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84824055..b5b21408 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,8 +36,10 @@ jobs: ~/.cargo/git/db/ target/ tests/fixtures/reusable-app/target/ - key: ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-${{ hashFiles('**/Cargo.lock') }} + tests/fixtures/request-fidelity-runner/target/ + key: ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-ingress-v1-${{ hashFiles('**/Cargo.lock') }} restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-ingress-v1- ${{ runner.os }}-cargo-${{ matrix.suite }}-tests- - name: Retrieve Rust version @@ -186,8 +188,10 @@ jobs: ~/.wasmtime/bin/ target/ tests/fixtures/reusable-app/target/ - key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} + tests/fixtures/request-fidelity/target/ + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-ingress-v1-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.adapter }}-ingress-v1- ${{ runner.os }}-cargo-${{ matrix.adapter }}- - name: Retrieve Rust version @@ -337,8 +341,10 @@ jobs: if: matrix.adapter == 'cloudflare' shell: bash run: | - if [[ "$(worker-build --version 2>/dev/null || true)" != "0.8.5" ]]; then - cargo install worker-build --version 0.8.5 --locked --force + version="$(awk '$1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + test -n "$version" + if [[ "$(worker-build --version 2>/dev/null || true)" != "$version" ]]; then + cargo install worker-build --version "$version" --locked --force fi - name: Install pinned Spin HTTP runtime diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4662879c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased + +### Changed + +- Cloudflare now preserves valid extension HTTP method tokens instead of + coercing them to GET. For example, `PROPFIND /x` no longer invokes a `GET /x` + handler; normal routing returns 404 or 405 where no matching route exists. + Malformed method tokens fail conversion with a bounded bad-request error. diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 6558f4a7..07408b1f 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -80,6 +80,21 @@ mod tests { } } + #[wasm_bindgen_test] + async fn ingress_invalid_runtime_header_value_is_a_bad_request() { + let raw = web_sys::Request::new_with_str("https://example.com/reserved") + .expect("should construct Web Request"); + raw.headers() + .append("x-bad", "a\u{1}b") + .expect("Fetch should accept SOH inside a header value"); + let (env, ctx) = test_env_ctx(); + let error = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect_err("core headers should reject SOH"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.to_string(), "invalid runtime header value"); + } + #[wasm_bindgen_test] async fn ingress_preserves_runtime_extension_methods_and_header_strings() { for token in [ diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 9d86b15e..ad96aa7d 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -384,9 +384,13 @@ where /// Dispatch with logical store bindings and an already captured ingress snapshot. /// /// The snapshot is inserted after the extension callback, preventing scratch -/// metadata from replacing adapter-captured facts. Store semantics match +/// metadata from replacing the supplied snapshot. Store semantics match /// [`dispatch_with_registries`]. /// +/// The caller must supply a snapshot captured from this same native request +/// before mutation. Metadata construction and syntax validation do not establish +/// its provenance or authenticate caller assertions. +/// /// # Errors /// Returns an error if a declared KV store cannot be opened or dispatch fails. #[inline] @@ -434,9 +438,13 @@ where /// Convert with store registries and an already captured ingress snapshot. /// /// The snapshot is inserted after the extension callback, preventing scratch -/// metadata from replacing adapter-captured facts. Store semantics match +/// metadata from replacing the supplied snapshot. Store semantics match /// [`into_core_request_with_registries`]. /// +/// The caller must supply a snapshot captured from this same native request +/// before mutation. Metadata construction and syntax validation do not establish +/// its provenance or authenticate caller assertions. +/// /// # Errors /// Returns an error if a declared KV store cannot be opened or the request /// cannot be converted, including an inbound body read failure. @@ -594,6 +602,11 @@ fn build_secret_registry(secret_meta: Option) -> Option &CapturedTarget { &self.target } + /// Set unambiguous per-header fidelity overrides. /// /// # Errors @@ -416,6 +419,7 @@ impl fmt::Debug for RequestIngress { /// Source of captured target information, independent of fidelity. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum TargetSource { /// A runtime path/query before additional `EdgeZero` parsing. RuntimePathAndQuery, @@ -427,6 +431,7 @@ pub enum TargetSource { /// Why no complete target was retained. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum TargetUnavailable { /// The runtime offers no safe acquisition API. NotExposed, diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index 2e722593..be2b9afa 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -19,7 +19,8 @@ verification checklist, not a chronological session log. - `crates/edgezero-core/src/request.rs`: bounded target/origin metadata, infallible `RequestIngress::new`, fallible `with_header_overrides` with an internal error for duplicate names, redacted Debug, public authority/target - limits and shared host/effective-port comparison. Authorities are requested + limits and shared host/effective-port comparison. The four metadata enums are + non-exhaustive so acquisition sources and guarantees can evolve. Authorities are requested hosts, not operator approval; port spelling is retained. - Fastly `src/request.rs`: capture before native mutation, explicit snapshot conversion and `dispatch_with_registries_and_ingress(app, req, stores, @@ -59,7 +60,11 @@ Spin SDK 7.0.0. Its lockfile must be regenerated with these pins together. CI and the runner require worker-build 0.8.5 and Spin CLI 4.1.0. Rust, Node, Viceroy and Wasmtime versions come from `.tool-versions`. The shared Spin pin check includes this fixture. CI resolves fixture wasm-bindgen from its own lock, -and worker-build uses the npm-integrity-locked esbuild executable. +and worker-build uses the npm-integrity-locked esbuild executable. Its version +is declared once beside Spin CLI in fixture metadata. SDK drift checks compare +exact Fastly, Worker, wasm-bindgen and Spin SDK pins against both lockfiles. +CI caches the actual native-runner and WASM-fixture target directories with +versioned cache keys; debug lint and release probe builds keep their profiles. ## Verification checklist @@ -99,7 +104,7 @@ All five required workspace gates pass, including strict all-feature Clippy, combined adapter feature compilation and Spin wasm32-wasip2 compilation. Both isolated workspaces pass formatting and strict lint; the WASM fixture is linted on all three target/feature combinations with its regenerated lock. -The native runner passes ten tests; its ignored direct-workerd routing test is +The native runner passes eleven tests; its ignored direct-workerd routing test is covered by the full runtime probe run. Fastly/Viceroy passes seven WASM contracts and 97 runtime unit tests. Spin passes @@ -107,7 +112,7 @@ Fastly/Viceroy passes seven WASM contracts and 97 runtime unit tests. Spin passe matrix passes with Viceroy 0.21.0, Spin 4.1.0, Worker/worker-build 0.8.5, wasm-bindgen 0.2.128 and Node 24.20.0. Public matrices identify these tested versions. Docs lint/format/build, dependency-pin checks, actionlint and whitespace -checks pass. All 12 Cloudflare browser contracts pass under the CI-pinned +checks pass. All 13 Cloudflare browser contracts pass under the CI-pinned Chrome/ChromeDriver 154.0.8037.92 with wasm-bindgen 0.2.128. ## Decision log diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index a5ad6e92..948048fe 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -120,6 +120,9 @@ Use private fields, Clone, and borrowed accessors. Do not derive value-bearing D - `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. - `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. +`Preservation`, `OriginSource`, `TargetSource` and `TargetUnavailable` are +non-exhaustive public enums; downstream matches must handle future variants. + Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. `RequestIngress::new(target, origin, fidelity)` is infallible; `with_header_overrides` rejects duplicate names with an internal error. `InboundOrigin::same_authority` compares host case and effective ports without asserting scheme equality. `MAX_AUTHORITY_BYTES` exposes the 1 KiB bound; source port spelling is retained, including leading zeros and syntactic port zero. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. HeaderFidelity axes describe original received HTTP field-value octets, field multiplicity and ordering compared with the resulting core headers, not merely a successful runtime-to-core copy. Octets mean field-value bytes, not header-name case, delimiters, HTTP framing or complete request serialization. Trimming/combining or parser/runtime loss must be reflected as Transformed, Unknown or Unavailable; Preserved requires pinned runtime/parser evidence and actual transport tests. Document and test runtime-to-core copying separately when it is all that can be established. diff --git a/scripts/check_shared_dep_pins.sh b/scripts/check_shared_dep_pins.sh index d6f0e6b4..fb2ee0a6 100755 --- a/scripts/check_shared_dep_pins.sh +++ b/scripts/check_shared_dep_pins.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# check_shared_dep_pins.sh — keep every `validator` pin on one major. +# check_shared_dep_pins.sh — keep shared manifest and fixture SDK pins in sync. # # `edgezero-core` re-exports the `Validate` derive, so any crate that # derives `AppConfig` links against the exact `validator` the core crate @@ -108,8 +108,54 @@ for file in \ fi done +# Fixture SDKs must use the same resolved packages as the workspace. Check +# both the exact manifest pins and the isolated lockfile, not just SDK majors. +locked_version_in() { + awk -v package="$2" ' + $0 == "[[package]]" { name=""; next } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { + version=$3; gsub(/"/, "", version); count++; found=version + } + END { if (count == 1) print found } + ' "$1" +} + +fixture_manifest="tests/fixtures/request-fidelity/Cargo.toml" +fixture_lock="tests/fixtures/request-fidelity/Cargo.lock" +for package in fastly worker wasm-bindgen spin-sdk; do + want="$(locked_version_in Cargo.lock "$package")" + pin="$(awk -v package="$package" ' + $1 == package && $2 == "=" { + if (match($0, /version[[:space:]]*=[[:space:]]*"=[^"]+"/)) { + version=substr($0, RSTART, RLENGTH) + sub(/^[^"]*"=/, "", version); sub(/"$/, "", version) + count++; found=version + } + } + END { if (count == 1) print found } + ' "$fixture_manifest")" + locked="$(locked_version_in "$fixture_lock" "$package")" + if [[ -z "$want" || -z "$pin" || -z "$locked" ]]; then + echo "$fixture_manifest / $fixture_lock: violation: $package requires one exact pin and one resolved version in each lock" + VIOLATIONS=$((VIOLATIONS + 1)) + elif [[ "$pin" != "$want" || "$locked" != "$want" ]]; then + echo "$fixture_manifest / $fixture_lock: violation: $package pin=$pin lock=$locked, workspace lock has $want" + VIOLATIONS=$((VIOLATIONS + 1)) + fi +done + +# worker-build's release must match the fixture's Worker SDK, but its pin is +# declared once in metadata and consumed by CI and the native runner. +worker_build="$(awk '$1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' "$fixture_manifest")" +worker_version="$(locked_version_in Cargo.lock worker)" +if [[ -z "$worker_build" || "$worker_build" != "$worker_version" ]]; then + echo "$fixture_manifest: violation: worker-build $worker_build, workspace worker is $worker_version" + VIOLATIONS=$((VIOLATIONS + 1)) +fi + if ((VIOLATIONS > 0)); then echo "check_shared_dep_pins: $VIOLATIONS pin(s) out of sync" >&2 exit 1 fi -echo "check_shared_dep_pins: validator pins agree ($WANT); spin-sdk majors agree ($SPIN_WANT)" +echo "check_shared_dep_pins: validator pins agree ($WANT); spin-sdk majors agree ($SPIN_WANT); fixture SDK locks and worker-build agree" diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index 478ad64a..51c3e1da 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -261,9 +261,23 @@ fn verify_version( Ok(()) } +fn fixture_tool_pin(fixture: &Path, tool: &str) -> io::Result { + let manifest: toml::Value = toml::from_str(&fs::read_to_string(fixture.join("Cargo.toml"))?) + .map_err(io::Error::other)?; + manifest + .get("package") + .and_then(|package| package.get("metadata")) + .and_then(|metadata| metadata.get("request-fidelity")) + .and_then(|pins| pins.get(tool)) + .and_then(toml::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| io::Error::other("missing fixture tool pin")) +} + fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { if platform == "cloudflare" { - verify_version(fixture, "worker-build", "0.8.5", cancelled)?; + let pin = fixture_tool_pin(fixture, "worker-build")?; + verify_version(fixture, "worker-build", &pin, cancelled)?; run_command(fixture, "npm", &["ci"], cancelled, false)?; let lock = fs::read(fixture.join("Cargo.lock"))?; run_command( @@ -316,6 +330,19 @@ fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<() Ok(()) } +fn with_runtime_log( + result: io::Result, + log_path: &Path, + output: &mut impl Write, +) -> io::Result { + if result.is_err() + && let Ok(log) = fs::read_to_string(log_path) + { + let _result = writeln!(output, "{log}"); + } + result +} + fn report_observation_diff( output: &mut impl Write, platform: &str, @@ -352,13 +379,8 @@ fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { for platform in &options.platforms { check_cancelled(cancelled)?; if *platform == "spin" { - let manifest: toml::Value = - toml::from_str(&fs::read_to_string(fixture.join("Cargo.toml"))?) - .map_err(io::Error::other)?; - let pin = manifest["package"]["metadata"]["request-fidelity"]["spin-cli"] - .as_str() - .ok_or_else(|| io::Error::other("missing Spin runtime pin"))?; - verify_version(&fixture, "spin", pin, cancelled)?; + let pin = fixture_tool_pin(&fixture, "spin-cli")?; + verify_version(&fixture, "spin", &pin, cancelled)?; } else if *platform == "fastly" { let versions = fs::read_to_string(options.root.join(".tool-versions"))?; let pin = versions @@ -470,15 +492,7 @@ fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { Ok(observed) })(); drop(process); - match result { - Ok(observed) => observations[*platform] = observed, - Err(error) => { - if let Ok(log) = fs::read_to_string(&log_path) { - writeln!(io::stderr(), "{log}")?; - } - return Err(error); - } - } + observations[*platform] = with_runtime_log(result, &log_path, &mut io::stderr())?; } serde_json::to_writer_pretty(io::stdout(), &observations).map_err(io::Error::other)?; writeln!(io::stdout()) @@ -1017,10 +1031,7 @@ fn cloudflare_routing_probes( Ok(observed) })(); drop(process); - if result.is_err() { - writeln!(io::stderr(), "{}", fs::read_to_string(log_path)?)?; - } - result + with_runtime_log(result, &log_path, &mut io::stderr()) } fn probes(platform: &str, port: u16, cancelled: &Cancelled) -> io::Result { @@ -1110,6 +1121,30 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; + #[test] + fn diagnostic_failures_preserve_the_original_runtime_error() { + let directory = tempfile::tempdir().expect("should create log directory"); + for (name, contents) in [ + ("missing", None), + ("invalid-utf8", Some(b"\xff".as_slice())), + ("failed-output", Some(b"runtime diagnostics".as_slice())), + ] { + let path = directory.path().join(name); + if let Some(contents) = contents { + std::fs::write(&path, contents).expect("should create diagnostic log"); + } + let mut output = Cursor::new([].as_mut_slice()); + let result = Err::<(), _>(io::Error::new( + io::ErrorKind::TimedOut, + "original readiness failure", + )); + let error = super::with_runtime_log(result, &path, &mut output) + .expect_err("should preserve runtime failure"); + assert_eq!(error.kind(), io::ErrorKind::TimedOut, "{name}"); + assert_eq!(error.to_string(), "original readiness failure", "{name}"); + } + } + #[test] #[ignore = "requires a freshly built Cloudflare fixture and pinned workerd"] fn cloudflare_primary_uri_preserves_no_hook_routing() { diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml index 6fb15838..8779a140 100644 --- a/tests/fixtures/request-fidelity/Cargo.toml +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -6,6 +6,7 @@ publish = false [package.metadata.request-fidelity] spin-cli = "4.1.0" +worker-build = "0.8.5" [workspace] diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index 83b61ed8..d5d82225 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -12,13 +12,15 @@ prove source freshness. Use the default locked build for review evidence. The isolated Cargo workspace and npm lock pin fixture dependencies. Install Viceroy 0.21.0, Spin 4.1.0, worker-build 0.8.5 and wasm-bindgen CLI 0.2.128. +The fixture manifest's `[package.metadata.request-fidelity]` is authoritative for +Spin CLI and worker-build versions; the runner and CI read those pins. Wrangler 4.83.0 and its local workerd are installed by `npm ci`. Each runtime uses its own target directory, an ephemeral loopback port, bounded readiness and an owned process group that is cleaned up on success, failure or interruption. No platform credentials or deployment are required. The runner sends 56 fixed raw HTTP cases across Fastly, Cloudflare and Spin. -The client and process orchestration use an isolated native Rust harness with exact existing Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. +The client and process orchestration use an isolated native Rust harness with exact isolated Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. `observations.json` records the pinned local runtimes' observed outcomes, and subsequent runs compare them exactly and report each changed case. Inspect changes before updating it. Record all platforms to a temporary file first: From ca252139659678d92c1708a825621d65e9516309 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 10:42:38 +0530 Subject: [PATCH 08/11] fix(ci): verify cache seeds and retry throttled writes --- .../setup-rust-build-cache/tests/fixture.sh | 20 +++++- .../setup-rust-build-cache/tests/run.sh | 1 + .../tests/seed-compiler-cache.sh | 34 +++++++++ .../setup-rust-build-cache/tests/seed.sh | 71 +++++++++++++++++++ .github/workflows/deploy-action.yml | 53 ++++++++++++-- 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100755 .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh create mode 100755 .github/actions/setup-rust-build-cache/tests/seed.sh diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 019740f5..1d600333 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -30,8 +30,7 @@ grep -Fxq 'fixture-id=417-1' "$WORK_DIR/seed/output" || fail 'seed does not expo if grep -q 'cargo clean' "$ACTION_DIR/tests/assert-compiler-reuse.sh"; then fail 'reuse probe accepts same-job rebuilds'; fi workflow="$ACTION_DIR/../../workflows/deploy-action.yml" seed=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Seed native and WASM compiler outputs") | .run' "$workflow") -# shellcheck disable=SC2016 # Assert the literal shell command in workflow metadata. -[[ "$seed" == *'cargo build --locked --lib --target "$host"'* && "$seed" == *'cargo build --locked --lib --target wasm32-wasip1'* ]] || fail 'seed job does not compile both targets' +[[ "$seed" == 'bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh' ]] || fail 'seed does not verify compiler publication' for job in cache-restore-seed cache-restore-smoke cache-new-revision-smoke; do JOB_ID="$job" yq -e '.jobs[strenv(JOB_ID)].steps[] | select(.name == "Install native and WASM compiler targets") | .run == "bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh"' "$workflow" >/dev/null || fail "$job does not share its Rust toolchain" done @@ -41,7 +40,22 @@ for job in cache-restore-smoke cache-new-revision-smoke; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.id == "fixture") | .env.CACHE_FIXTURE_ID' "$workflow") [[ "$actual" == "$expected_identity" ]] || fail "$job does not reuse the seed fixture identity" done -[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == true ]] || fail 'seed does not opt into saving' +[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == false ]] || fail 'seed relies on unverified post-job saving' +for job in fixture-release store-free-release; do + for probe in cache-restore-smoke cache-new-revision-smoke; do + JOB_ID="$job" PROBE_ID="$probe" yq -e '.jobs[strenv(JOB_ID)].needs | contains([strenv(PROBE_ID)])' "$workflow" >/dev/null || fail "$job competes with cache probes" + done +done +for attempt in 1 2; do + STEP_ID="target-save-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .uses == "actions/cache/save@v5"' "$workflow" >/dev/null || fail 'missing explicit target save' + STEP_ID="target-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'target publication is not verified' +done +yq -e '.jobs."cache-restore-seed".steps[] | select(.id == "target-verify-2") | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted target publication retries stay green' +publication_assertion=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Assert target seed is available") | .run' "$workflow") +if TARGET_SEED_AVAILABLE=false bash -c "$publication_assertion" >"$WORK_DIR/publication.log" 2>&1; then + fail 'seed accepts cache service warnings without a verified hit' +fi +TARGET_SEED_AVAILABLE=true bash -c "$publication_assertion" || fail 'seed rejects verified publication' for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | .with."cache-target"' "$workflow") [[ "$actual" == false ]] || fail "$job enables full target caching for a disposable fixture" diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh index ba2af9eb..ea488cd5 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -143,3 +143,4 @@ printf 'setup-rust-build-cache action tests passed\n' bash "$ACTION_DIR/tests/revision.sh" bash "$ACTION_DIR/tests/review.sh" bash "$ACTION_DIR/tests/fixture.sh" +bash "$ACTION_DIR/tests/seed.sh" diff --git a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh new file mode 100755 index 00000000..7788c273 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Publish each target before downstream jobs test first-build compiler reuse. +fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" +target_dir="${CARGO_TARGET_DIR:?}" +expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target" +[[ "$(realpath "$target_dir")" == "$expected" ]] || { + printf '::error::compiler seed must use its confined fixture target directory\n' >&2 + exit 1 +} +cd "$fixture" +host=$(rustc -vV | sed -n 's/^host: //p') +[[ -n "$host" ]] || exit 1 +for target in "$host" wasm32-wasip1; do + for attempt in 1 2 3; do + # Cargo must call sccache again after a failed publication, rather than + # reporting locally fresh outputs as a successful cache seed. + rm -rf -- "$target_dir/$target/debug" + sccache --zero-stats + cargo build --locked --lib --target "$target" + stats=$(sccache --show-stats --stats-format json) + printf 'Compiler seed for %s (attempt %s): %s\n' "$target" "$attempt" "$stats" + if jq -e '((.stats.cache_hits.counts.Rust // 0) + (.stats.cache_writes // 0)) > 0 and (.stats.cache_errors.counts.Rust // 0) == 0 and (.stats.cache_read_errors // 0) == 0 and (.stats.cache_write_errors // 0) == 0' <<<"$stats" >/dev/null; then + break + fi + if [[ "$attempt" == 3 ]]; then + printf '::error::failed to publish compiler cache for %s after three attempts\n' "$target" >&2 + exit 1 + fi + printf '::warning::compiler cache seed failed; retrying after backoff\n' + sleep "$((attempt * 60))" + done +done diff --git a/.github/actions/setup-rust-build-cache/tests/seed.sh b/.github/actions/setup-rust-build-cache/tests/seed.sh new file mode 100755 index 00000000..8a8720b7 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/seed.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-seed-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" +mkdir -p "$WORK_DIR/bin" "$WORK_DIR/workspace/cache-restore-fixture" "$WORK_DIR/runner" +export GITHUB_WORKSPACE="$WORK_DIR/workspace" RUNNER_TEMP="$WORK_DIR/runner" +export CARGO_TARGET_DIR="$RUNNER_TEMP/edgezero-rust-cache/cache-restore-smoke-v2/target" +export PROBE_LOG="$WORK_DIR/calls" PROBE_COUNT="$WORK_DIR/count" +cat >"$WORK_DIR/bin/rustc" <<'MOCK' +#!/usr/bin/env bash +printf 'host: test-host\n' +MOCK +cat >"$WORK_DIR/bin/sleep" <<'MOCK' +#!/usr/bin/env bash +printf 'sleep %s\n' "$*" >>"$PROBE_LOG" +MOCK +cat >"$WORK_DIR/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +printf 'build %s\n' "$*" >>"$PROBE_LOG" +[[ "${PROBE_MODE:-}" != compile-failure ]] || exit 42 +[[ ! -d "$CARGO_TARGET_DIR/${*: -1}/debug" ]] || exit 43 +mkdir -p "$CARGO_TARGET_DIR/${*: -1}/debug" +MOCK +cat >"$WORK_DIR/bin/sccache" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == --show-stats ]] || exit 0 +count=$(cat "$PROBE_COUNT") +printf '%s\n' "$((count + 1))" >"$PROBE_COUNT" +if [[ "$PROBE_MODE" == permanent || ( "$PROBE_MODE" == transient && "$count" == 0 ) ]]; then + printf '{"stats":{"cache_write_errors":1,"cache_writes":0}}\n' +elif [[ "$PROBE_MODE" == empty ]]; then + printf '{"stats":{}}\n' +elif [[ "$PROBE_MODE" == hit ]]; then + printf '{"stats":{"cache_hits":{"counts":{"Rust":1}}}}\n' +else + printf '{"stats":{"cache_writes":1}}\n' +fi +MOCK +chmod +x "$WORK_DIR/bin/"* +export PATH="$WORK_DIR/bin:$PATH" +run_seed() { + rm -rf "$CARGO_TARGET_DIR" + mkdir -p "$CARGO_TARGET_DIR" + printf '0\n' >"$PROBE_COUNT" + : >"$PROBE_LOG" + PROBE_MODE="$1" bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1 +} +run_seed transient || fail 'compiler seed does not recover from a transient write failure' +[[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'retry does not rebuild fresh outputs' +[[ "$(grep -c '^sleep ' "$PROBE_LOG")" == 1 ]] || fail 'transient error does not back off' +run_seed hit || fail 'existing compiler hit is not accepted as published' +for mode in permanent empty compile-failure; do + if run_seed "$mode"; then fail "accepted unsuccessful compiler seeding: $mode"; fi + if [[ "$mode" == compile-failure ]]; then + [[ "$(grep -c '^build ' "$PROBE_LOG")" == 1 ]] || fail 'compiler errors are retried' + else + [[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'cache retries are not bounded to three attempts' + fi +done +# A mistaken directory must never be removed by retry cleanup. +export CARGO_TARGET_DIR="$WORK_DIR/outside" +mkdir -p "$CARGO_TARGET_DIR" +printf keep >"$CARGO_TARGET_DIR/marker" +if PROBE_MODE=hit bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1; then + fail 'accepted unconfined compiler seed directory' +fi +[[ -f "$CARGO_TARGET_DIR/marker" ]] || fail 'removed unconfined output' +printf 'compiler seed backoff contracts passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 15e797ce..b9ed75fe 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -138,7 +138,7 @@ jobs: with: app-name: cache-restore-smoke-v2 cache-target: true - save-target: true + save-target: false target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture @@ -149,12 +149,51 @@ jobs: printf '%s\n' 'edgezero-source-cache-smoke-v2' >"$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" - name: Seed native and WASM compiler outputs - working-directory: cache-restore-fixture + run: bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh + + # Cache save warnings do not fail the action. Verify publication explicitly + # so a throttled seed cannot report success and fail both consumer jobs. + - name: Save seed target cache + id: target-save-1 + uses: actions/cache/save@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + - name: Verify seed target publication + id: target-verify-1 + uses: actions/cache/restore@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + lookup-only: true + - name: Back off before retrying target publication + if: steps.target-verify-1.outputs.cache-hit != 'true' + run: sleep 60 + - name: Retry seed target cache save + id: target-save-2 + if: steps.target-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + - name: Require seed target publication + id: target-verify-2 + if: steps.target-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + lookup-only: true + fail-on-cache-miss: true + + - name: Assert target seed is available + env: + TARGET_SEED_AVAILABLE: ${{ steps.target-verify-1.outputs.cache-hit == 'true' || steps.target-verify-2.outputs.cache-hit == 'true' }} run: | - host=$(rustc -vV | sed -n 's/^host: //p') - test -n "$host" - cargo build --locked --lib --target "$host" - cargo build --locked --lib --target wasm32-wasip1 + if [[ "$TARGET_SEED_AVAILABLE" != true ]]; then + echo '::error::target seed was not published after two attempts' + exit 1 + fi cache-restore-smoke: needs: cache-restore-seed @@ -232,6 +271,7 @@ jobs: # Build the store-aware application once. Its production/staging matrix and # lifecycle actions consume these exact CLI, package, and manifest bytes. fixture-release: + needs: [cache-restore-smoke, cache-new-revision-smoke] runs-on: ubuntu-latest outputs: app-release-sha256: ${{ steps.release.outputs.archive-sha256 }} @@ -291,6 +331,7 @@ jobs: # This is a separate application release because its bundled edgezero.toml # declares no stores. It is built once and is never varied by a deployer. store-free-release: + needs: [cache-restore-smoke, cache-new-revision-smoke] runs-on: ubuntu-latest outputs: app-release-sha256: ${{ steps.release.outputs.archive-sha256 }} From 251a47253db673639929e9fba0d678dc90843dd8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 13:34:12 +0530 Subject: [PATCH 09/11] Move cache seed hardening into its own change --- .../setup-rust-build-cache/tests/fixture.sh | 20 +----- .../setup-rust-build-cache/tests/run.sh | 1 - .../tests/seed-compiler-cache.sh | 34 --------- .../setup-rust-build-cache/tests/seed.sh | 71 ------------------- .github/workflows/deploy-action.yml | 53 ++------------ 5 files changed, 9 insertions(+), 170 deletions(-) delete mode 100755 .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh delete mode 100755 .github/actions/setup-rust-build-cache/tests/seed.sh diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 1d600333..019740f5 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -30,7 +30,8 @@ grep -Fxq 'fixture-id=417-1' "$WORK_DIR/seed/output" || fail 'seed does not expo if grep -q 'cargo clean' "$ACTION_DIR/tests/assert-compiler-reuse.sh"; then fail 'reuse probe accepts same-job rebuilds'; fi workflow="$ACTION_DIR/../../workflows/deploy-action.yml" seed=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Seed native and WASM compiler outputs") | .run' "$workflow") -[[ "$seed" == 'bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh' ]] || fail 'seed does not verify compiler publication' +# shellcheck disable=SC2016 # Assert the literal shell command in workflow metadata. +[[ "$seed" == *'cargo build --locked --lib --target "$host"'* && "$seed" == *'cargo build --locked --lib --target wasm32-wasip1'* ]] || fail 'seed job does not compile both targets' for job in cache-restore-seed cache-restore-smoke cache-new-revision-smoke; do JOB_ID="$job" yq -e '.jobs[strenv(JOB_ID)].steps[] | select(.name == "Install native and WASM compiler targets") | .run == "bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh"' "$workflow" >/dev/null || fail "$job does not share its Rust toolchain" done @@ -40,22 +41,7 @@ for job in cache-restore-smoke cache-new-revision-smoke; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.id == "fixture") | .env.CACHE_FIXTURE_ID' "$workflow") [[ "$actual" == "$expected_identity" ]] || fail "$job does not reuse the seed fixture identity" done -[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == false ]] || fail 'seed relies on unverified post-job saving' -for job in fixture-release store-free-release; do - for probe in cache-restore-smoke cache-new-revision-smoke; do - JOB_ID="$job" PROBE_ID="$probe" yq -e '.jobs[strenv(JOB_ID)].needs | contains([strenv(PROBE_ID)])' "$workflow" >/dev/null || fail "$job competes with cache probes" - done -done -for attempt in 1 2; do - STEP_ID="target-save-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .uses == "actions/cache/save@v5"' "$workflow" >/dev/null || fail 'missing explicit target save' - STEP_ID="target-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'target publication is not verified' -done -yq -e '.jobs."cache-restore-seed".steps[] | select(.id == "target-verify-2") | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted target publication retries stay green' -publication_assertion=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Assert target seed is available") | .run' "$workflow") -if TARGET_SEED_AVAILABLE=false bash -c "$publication_assertion" >"$WORK_DIR/publication.log" 2>&1; then - fail 'seed accepts cache service warnings without a verified hit' -fi -TARGET_SEED_AVAILABLE=true bash -c "$publication_assertion" || fail 'seed rejects verified publication' +[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == true ]] || fail 'seed does not opt into saving' for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | .with."cache-target"' "$workflow") [[ "$actual" == false ]] || fail "$job enables full target caching for a disposable fixture" diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh index ea488cd5..ba2af9eb 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -143,4 +143,3 @@ printf 'setup-rust-build-cache action tests passed\n' bash "$ACTION_DIR/tests/revision.sh" bash "$ACTION_DIR/tests/review.sh" bash "$ACTION_DIR/tests/fixture.sh" -bash "$ACTION_DIR/tests/seed.sh" diff --git a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh deleted file mode 100755 index 7788c273..00000000 --- a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Publish each target before downstream jobs test first-build compiler reuse. -fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" -target_dir="${CARGO_TARGET_DIR:?}" -expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target" -[[ "$(realpath "$target_dir")" == "$expected" ]] || { - printf '::error::compiler seed must use its confined fixture target directory\n' >&2 - exit 1 -} -cd "$fixture" -host=$(rustc -vV | sed -n 's/^host: //p') -[[ -n "$host" ]] || exit 1 -for target in "$host" wasm32-wasip1; do - for attempt in 1 2 3; do - # Cargo must call sccache again after a failed publication, rather than - # reporting locally fresh outputs as a successful cache seed. - rm -rf -- "$target_dir/$target/debug" - sccache --zero-stats - cargo build --locked --lib --target "$target" - stats=$(sccache --show-stats --stats-format json) - printf 'Compiler seed for %s (attempt %s): %s\n' "$target" "$attempt" "$stats" - if jq -e '((.stats.cache_hits.counts.Rust // 0) + (.stats.cache_writes // 0)) > 0 and (.stats.cache_errors.counts.Rust // 0) == 0 and (.stats.cache_read_errors // 0) == 0 and (.stats.cache_write_errors // 0) == 0' <<<"$stats" >/dev/null; then - break - fi - if [[ "$attempt" == 3 ]]; then - printf '::error::failed to publish compiler cache for %s after three attempts\n' "$target" >&2 - exit 1 - fi - printf '::warning::compiler cache seed failed; retrying after backoff\n' - sleep "$((attempt * 60))" - done -done diff --git a/.github/actions/setup-rust-build-cache/tests/seed.sh b/.github/actions/setup-rust-build-cache/tests/seed.sh deleted file mode 100755 index 8a8720b7..00000000 --- a/.github/actions/setup-rust-build-cache/tests/seed.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-seed-test.XXXXXX") -trap 'rm -rf -- "$WORK_DIR"' EXIT -# shellcheck source=common.sh -source "$ACTION_DIR/tests/common.sh" -mkdir -p "$WORK_DIR/bin" "$WORK_DIR/workspace/cache-restore-fixture" "$WORK_DIR/runner" -export GITHUB_WORKSPACE="$WORK_DIR/workspace" RUNNER_TEMP="$WORK_DIR/runner" -export CARGO_TARGET_DIR="$RUNNER_TEMP/edgezero-rust-cache/cache-restore-smoke-v2/target" -export PROBE_LOG="$WORK_DIR/calls" PROBE_COUNT="$WORK_DIR/count" -cat >"$WORK_DIR/bin/rustc" <<'MOCK' -#!/usr/bin/env bash -printf 'host: test-host\n' -MOCK -cat >"$WORK_DIR/bin/sleep" <<'MOCK' -#!/usr/bin/env bash -printf 'sleep %s\n' "$*" >>"$PROBE_LOG" -MOCK -cat >"$WORK_DIR/bin/cargo" <<'MOCK' -#!/usr/bin/env bash -printf 'build %s\n' "$*" >>"$PROBE_LOG" -[[ "${PROBE_MODE:-}" != compile-failure ]] || exit 42 -[[ ! -d "$CARGO_TARGET_DIR/${*: -1}/debug" ]] || exit 43 -mkdir -p "$CARGO_TARGET_DIR/${*: -1}/debug" -MOCK -cat >"$WORK_DIR/bin/sccache" <<'MOCK' -#!/usr/bin/env bash -[[ "$1" == --show-stats ]] || exit 0 -count=$(cat "$PROBE_COUNT") -printf '%s\n' "$((count + 1))" >"$PROBE_COUNT" -if [[ "$PROBE_MODE" == permanent || ( "$PROBE_MODE" == transient && "$count" == 0 ) ]]; then - printf '{"stats":{"cache_write_errors":1,"cache_writes":0}}\n' -elif [[ "$PROBE_MODE" == empty ]]; then - printf '{"stats":{}}\n' -elif [[ "$PROBE_MODE" == hit ]]; then - printf '{"stats":{"cache_hits":{"counts":{"Rust":1}}}}\n' -else - printf '{"stats":{"cache_writes":1}}\n' -fi -MOCK -chmod +x "$WORK_DIR/bin/"* -export PATH="$WORK_DIR/bin:$PATH" -run_seed() { - rm -rf "$CARGO_TARGET_DIR" - mkdir -p "$CARGO_TARGET_DIR" - printf '0\n' >"$PROBE_COUNT" - : >"$PROBE_LOG" - PROBE_MODE="$1" bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1 -} -run_seed transient || fail 'compiler seed does not recover from a transient write failure' -[[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'retry does not rebuild fresh outputs' -[[ "$(grep -c '^sleep ' "$PROBE_LOG")" == 1 ]] || fail 'transient error does not back off' -run_seed hit || fail 'existing compiler hit is not accepted as published' -for mode in permanent empty compile-failure; do - if run_seed "$mode"; then fail "accepted unsuccessful compiler seeding: $mode"; fi - if [[ "$mode" == compile-failure ]]; then - [[ "$(grep -c '^build ' "$PROBE_LOG")" == 1 ]] || fail 'compiler errors are retried' - else - [[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'cache retries are not bounded to three attempts' - fi -done -# A mistaken directory must never be removed by retry cleanup. -export CARGO_TARGET_DIR="$WORK_DIR/outside" -mkdir -p "$CARGO_TARGET_DIR" -printf keep >"$CARGO_TARGET_DIR/marker" -if PROBE_MODE=hit bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1; then - fail 'accepted unconfined compiler seed directory' -fi -[[ -f "$CARGO_TARGET_DIR/marker" ]] || fail 'removed unconfined output' -printf 'compiler seed backoff contracts passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index b9ed75fe..15e797ce 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -138,7 +138,7 @@ jobs: with: app-name: cache-restore-smoke-v2 cache-target: true - save-target: false + save-target: true target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture @@ -149,51 +149,12 @@ jobs: printf '%s\n' 'edgezero-source-cache-smoke-v2' >"$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" - name: Seed native and WASM compiler outputs - run: bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh - - # Cache save warnings do not fail the action. Verify publication explicitly - # so a throttled seed cannot report success and fail both consumer jobs. - - name: Save seed target cache - id: target-save-1 - uses: actions/cache/save@v5 - with: - path: ${{ steps.cache.outputs.cargo-target-dir }} - key: ${{ steps.cache.outputs.target-cache-key }} - - name: Verify seed target publication - id: target-verify-1 - uses: actions/cache/restore@v5 - with: - path: ${{ steps.cache.outputs.cargo-target-dir }} - key: ${{ steps.cache.outputs.target-cache-key }} - lookup-only: true - - name: Back off before retrying target publication - if: steps.target-verify-1.outputs.cache-hit != 'true' - run: sleep 60 - - name: Retry seed target cache save - id: target-save-2 - if: steps.target-verify-1.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: ${{ steps.cache.outputs.cargo-target-dir }} - key: ${{ steps.cache.outputs.target-cache-key }} - - name: Require seed target publication - id: target-verify-2 - if: steps.target-verify-1.outputs.cache-hit != 'true' - uses: actions/cache/restore@v5 - with: - path: ${{ steps.cache.outputs.cargo-target-dir }} - key: ${{ steps.cache.outputs.target-cache-key }} - lookup-only: true - fail-on-cache-miss: true - - - name: Assert target seed is available - env: - TARGET_SEED_AVAILABLE: ${{ steps.target-verify-1.outputs.cache-hit == 'true' || steps.target-verify-2.outputs.cache-hit == 'true' }} + working-directory: cache-restore-fixture run: | - if [[ "$TARGET_SEED_AVAILABLE" != true ]]; then - echo '::error::target seed was not published after two attempts' - exit 1 - fi + host=$(rustc -vV | sed -n 's/^host: //p') + test -n "$host" + cargo build --locked --lib --target "$host" + cargo build --locked --lib --target wasm32-wasip1 cache-restore-smoke: needs: cache-restore-seed @@ -271,7 +232,6 @@ jobs: # Build the store-aware application once. Its production/staging matrix and # lifecycle actions consume these exact CLI, package, and manifest bytes. fixture-release: - needs: [cache-restore-smoke, cache-new-revision-smoke] runs-on: ubuntu-latest outputs: app-release-sha256: ${{ steps.release.outputs.archive-sha256 }} @@ -331,7 +291,6 @@ jobs: # This is a separate application release because its bundled edgezero.toml # declares no stores. It is built once and is never varied by a deployer. store-free-release: - needs: [cache-restore-smoke, cache-new-revision-smoke] runs-on: ubuntu-latest outputs: app-release-sha256: ${{ steps.release.outputs.archive-sha256 }} From 7b6f913a64498ba98e4ec752fa9b632d2e9193b3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 13:34:46 +0530 Subject: [PATCH 10/11] Resolve request fidelity review documentation and tooling gaps --- .github/workflows/test.yml | 33 ++- CHANGELOG.md | 10 - crates/edgezero-core/src/request.rs | 4 +- .../tests/fixtures/hooked_app.toml | 19 ++ crates/edgezero-macros/tests/hooked_app.rs | 159 +++++++++++++ docs/guide/adapters/fastly.md | 6 +- docs/guide/adapters/overview.md | 4 +- docs/guide/middleware.md | 3 +- docs/guide/routing.md | 58 ++++- ...026-10-05-pre-dispatch-request-fidelity.md | 49 ++-- ...05-pre-dispatch-request-fidelity-design.md | 10 +- scripts/check_shared_dep_pins.sh | 20 +- scripts/test_shared_dep_pins.sh | 127 ++++++++++ .../request-fidelity-runner/src/main.rs | 219 +++++++++++++++++- tests/fixtures/request-fidelity/README.md | 8 +- 15 files changed, 651 insertions(+), 78 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 crates/edgezero-macros/tests/fixtures/hooked_app.toml create mode 100644 crates/edgezero-macros/tests/hooked_app.rs create mode 100644 scripts/test_shared_dep_pins.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5b21408..5c2a3e66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,9 @@ jobs: # deploy fixture). A mismatch only surfaces as a confusing # "trait bound not satisfied" in a deploy smoke job, so gate it here. - name: Shared dependency pins agree - run: ./scripts/check_shared_dep_pins.sh + run: | + ./scripts/check_shared_dep_pins.sh + bash scripts/test_shared_dep_pins.sh - name: Nested AppConfig audit if: matrix.suite == 'cli' @@ -337,15 +339,36 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib - - name: Install lock-matched worker-build + - name: Read fixture worker-build pin if: matrix.adapter == 'cloudflare' + id: worker-build-pin shell: bash run: | - version="$(awk '$1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + version="$(awk '/^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" test -n "$version" - if [[ "$(worker-build --version 2>/dev/null || true)" != "$version" ]]; then - cargo install worker-build --version "$version" --locked --force + echo "version=$version" >> "$GITHUB_OUTPUT" + + # The pinned release has no Linux prebuilt. Keep its compiled executable + # separate from Cargo's broad bin cache and verify it on every restore. + - name: Cache pinned worker-build executable + if: matrix.adapter == 'cloudflare' + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/worker-build + key: worker-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.worker-build-pin.outputs.version }} + + - name: Install lock-matched worker-build + if: matrix.adapter == 'cloudflare' + shell: bash + env: + VERSION: ${{ steps.worker-build-pin.outputs.version }} + run: | + install_dir="$RUNNER_TEMP/worker-build" + if [[ "$("$install_dir/bin/worker-build" --version 2>/dev/null || true)" != "$VERSION" ]]; then + cargo install worker-build --version "$VERSION" --locked --root "$install_dir" --force fi + test "$("$install_dir/bin/worker-build" --version)" = "$VERSION" + echo "$install_dir/bin" >> "$GITHUB_PATH" - name: Install pinned Spin HTTP runtime if: matrix.adapter == 'spin' diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 4662879c..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# Changelog - -## Unreleased - -### Changed - -- Cloudflare now preserves valid extension HTTP method tokens instead of - coercing them to GET. For example, `PROPFIND /x` no longer invokes a `GET /x` - handler; normal routing returns 404 or 405 where no matching route exists. - Malformed method tokens fail conversion with a bounded bad-request error. diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs index b49b9299..fb31980d 100644 --- a/crates/edgezero-core/src/request.rs +++ b/crates/edgezero-core/src/request.rs @@ -157,7 +157,7 @@ impl HeaderFidelity { } } -/// Bounded validated origin facts supplied by a trusted adapter source. +/// Bounded validated origin facts established by the adapter. /// /// Syntax validation does not make a forwarded header trustworthy. Adapters /// must establish provenance before calling the constructor. @@ -342,7 +342,7 @@ impl RequestIngress { } } - /// Borrow the validated origin, when its trusted source is available. + /// Borrow the validated origin, when an adapter-established source is available. #[inline] #[must_use] pub const fn origin(&self) -> Option<&InboundOrigin> { diff --git a/crates/edgezero-macros/tests/fixtures/hooked_app.toml b/crates/edgezero-macros/tests/fixtures/hooked_app.toml new file mode 100644 index 00000000..ad839a51 --- /dev/null +++ b/crates/edgezero-macros/tests/fixtures/hooked_app.toml @@ -0,0 +1,19 @@ +[app] +name = "hooked-manifest-app" +middleware = ["crate::tests::MarkMiddleware"] + +[[triggers.http]] +path = "/ordinary" +method = "GET" +handler = "crate::tests::ordinary" + +[stores.kv] +ids = ["sessions"] +[stores.config] +ids = ["settings"] +[stores.secrets] +ids = ["tokens"] + +[adapters.fastly.logging] +endpoint = "fixture_logs" +level = "debug" diff --git a/crates/edgezero-macros/tests/hooked_app.rs b/crates/edgezero-macros/tests/hooked_app.rs new file mode 100644 index 00000000..9f0696a0 --- /dev/null +++ b/crates/edgezero-macros/tests/hooked_app.rs @@ -0,0 +1,159 @@ +//! Executable counterpart of the routing guide's manual `Hooks` workaround. + +#[cfg(test)] +mod tests { + mod manifest_app { + edgezero_core::app!( + "tests/fixtures/hooked_app.toml", + ManifestApp, + owns_logging = true, + state = 42_u32 + ); + } + + use async_trait::async_trait; + use edgezero_core::action; + use edgezero_core::app::{App, Hooks, StoresMetadata}; + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::State; + use edgezero_core::http::{Request, Response, request_builder, response_builder}; + use edgezero_core::introspection::ManifestJson; + use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::router::{PreDispatchHook, RouterService}; + use futures::executor::block_on; + + use manifest_app::ManifestApp; + + struct HookedApp; + impl Hooks for HookedApp { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + fn configure(app: &mut App) { + ManifestApp::configure(app); + } + fn logging_for(adapter: &str) -> ResolvedLoggingConfig { + ManifestApp::logging_for(adapter) + } + fn name() -> &'static str { + ManifestApp::name() + } + fn owns_logging() -> bool { + ManifestApp::owns_logging() + } + fn routes() -> RouterService { + let manifest = ManifestLoader::load_from_str(include_str!("fixtures/hooked_app.toml")); + RouterService::builder() + .pre_dispatch_hook(ReservedGate) + .with_manifest_json( + serde_json::to_string(manifest.manifest()).expect("valid manifest"), + ) + .with_state(42_u32) + .middleware(MarkMiddleware) + .get("/ordinary", ordinary) + .build() + } + fn stores() -> StoresMetadata { + ManifestApp::stores() + } + } + + struct ReservedGate; + #[async_trait(?Send)] + impl PreDispatchHook for ReservedGate { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!( + request.extensions().get::().is_none(), + "hook precedes state" + ); + assert!( + request.extensions().get::().is_none(), + "hook precedes introspection" + ); + if request.uri().path() == "/reserved" { + return response_builder() + .status(204) + .body(Body::empty()) + .map(Some) + .map_err(EdgeError::internal); + } + Ok(None) + } + } + + pub struct MarkMiddleware; + #[async_trait(?Send)] + impl Middleware for MarkMiddleware { + #[inline] + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let mut response = next.run(ctx).await?; + response + .headers_mut() + .insert("x-middleware", "present".parse().expect("header")); + Ok(response) + } + } + + #[action(manifest)] + async fn ordinary(State(value): State, ManifestJson(json): ManifestJson) -> &'static str { + assert_eq!(value, 42); + assert!(json.contains("hooked-manifest-app"), "manifest retained"); + "continued" + } + + #[test] + fn manual_hook_wrapper_preserves_every_manifest_metadata_surface() { + assert_eq!(HookedApp::stores(), ManifestApp::stores()); + assert_eq!(HookedApp::stores().kv.expect("kv").ids, &["sessions"]); + assert_eq!( + HookedApp::stores().config.expect("config").ids, + &["settings"] + ); + assert_eq!( + HookedApp::stores().secrets.expect("secrets").ids, + &["tokens"] + ); + assert_eq!(HookedApp::name(), "hooked-manifest-app"); + assert!(HookedApp::owns_logging()); + let actual = HookedApp::logging_for("fastly"); + let expected = ManifestApp::logging_for("fastly"); + assert_eq!(actual.endpoint, expected.endpoint); + assert_eq!(actual.level, expected.level); + assert_eq!(actual.echo_stdout, expected.echo_stdout); + } + + #[test] + fn manual_router_preserves_continuation_and_terminal_hook_boundaries() { + block_on(async { + let router = HookedApp::routes(); + let continued = router + .oneshot( + request_builder() + .uri("/ordinary") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(continued.status(), 200); + assert_eq!(continued.headers()["x-middleware"], "present"); + let terminal = router + .oneshot( + request_builder() + .method("EXAMPLE-METHOD") + .uri("/reserved") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(terminal.status(), 204); + assert!(terminal.headers().get("x-middleware").is_none()); + }); + } +} diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 6e9236f4..9e8f323d 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -435,15 +435,15 @@ from an unavailable first snapshot. Warnings through the log facade may be lost before a logger is installed; absence of a warning does not establish a healthy configuration read. -SDK 0.12.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and +SDK 0.13.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and `with_max_memory`. A value of zero disables the request-count or memory limit; it does not request zero callbacks or zero memory use. The lifetime limit is measured from `Serve` construction, including time before the first callback. `with_timeout` bounds only the idle wait for the next request; it does not bound handler or backend time. If your service is billed by memory × wall-clock time, idle waiting is billed too. Limits do not guarantee reuse; any request may start a -fresh sandbox. The standalone Viceroy 0.17.0 pinned for the fixtures admits at most -six requests per guest even with a larger SDK request limit. `edgezero serve` runs +fresh sandbox. The fixtures pin standalone Viceroy 0.21.0. Local request ceilings depend on +the runtime version and must be checked when pins change. `edgezero serve` runs `fastly compute serve`, whose bundled Viceroy can differ. Neither local ceiling establishes deployed behavior. Lifetime and memory checks occur between callbacks and cannot interrupt diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index af367c1c..d8bb5421 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -198,8 +198,8 @@ values. Aggregate count equality cannot prove per-name multiplicity, and full global order is not established. Production verification remains pending. The matrix below shows observed runtime behavior, not `RequestIngress` guarantees. -Local evidence below uses the listed SDK/runtime versions and freshly built -raw HTTP/1.1 fixtures. All 56 recorded cases were rerun with the refreshed pins. +The table reflects the checked-in `observations.json`; +`./scripts/test-request-fidelity.sh all` compares against it exactly. These results describe the tested local runtimes; they do not establish production-edge behavior. diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index 1ee9aad0..4837b6e6 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -58,7 +58,8 @@ middleware = [ ] ``` -Middleware are applied in order before routes are matched. +Middleware runs in registration order after a route matches, before its handler. +Use a pre-dispatch hook for work that must happen before route lookup. ### Programmatically diff --git a/docs/guide/routing.md b/docs/guide/routing.md index b94e2abd..4fd13411 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -245,9 +245,61 @@ let ingress = request.extensions() If the hook consumes the body and returns `Ok(None)`, the handler receives only what remains unless the hook replaces the body. Generated `app!` applications -cannot install a hook through the macro or modify the built router. Use a -hand-written `Hooks::routes()` returning a configured `RouterService`, as in -`tests/fixtures/request-fidelity/src/lib.rs`. +cannot install a hook through the macro or modify the built router. A +hand-written `Hooks` implementation does not inherit the generated metadata. +Keep `app!` in a separate module and delegate stores, logging, name and +application configuration: + +```rust +use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; + +mod manifest_app { + edgezero_core::app!("edgezero.toml", ManifestApp); +} +use manifest_app::ManifestApp; + +pub struct HookedApp; + +impl Hooks for HookedApp { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + + fn routes() -> RouterService { + let manifest = ManifestLoader::load_from_str(include_str!("../edgezero.toml")); + RouterService::builder() + .pre_dispatch_hook(ExampleHook) + .with_manifest_json( + serde_json::to_string(manifest.manifest()).expect("valid manifest"), + ) + // Re-register every manifest route and middleware here, in order. + // Repeat any app!(..., state = ...) registration with .with_state(...). + .build() + } + + fn stores() -> StoresMetadata { ManifestApp::stores() } + fn logging_for(adapter: &str) -> ResolvedLoggingConfig { + ManifestApp::logging_for(adapter) + } + fn owns_logging() -> bool { ManifestApp::owns_logging() } + fn name() -> &'static str { ManifestApp::name() } + fn configure(app: &mut App) { ManifestApp::configure(app); } +} +``` + +This outline assumes `src/lib.rs` beside a root `edgezero.toml` and a direct +`serde_json` dependency. Adjust the `include_str!` path to the source file. +Use `HookedApp` as the adapter entry point. Its `routes()` must explicitly +re-register **all routes, manifest middleware and configured state**; delegating +metadata alone does not preserve them. `with_manifest_json` above preserves +manifest introspection and parses/serializes the manifest when constructing the +router. This manual duplication must stay synchronized with `app!` and the +manifest. The complete executable example and regression tests are in +`crates/edgezero-macros/tests/hooked_app.rs`. The store-free ingress fixture is +not a metadata-preserving example for manifest applications. Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) before classifying a request using its target or duplicate headers. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index be2b9afa..a3cfc5c7 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -24,7 +24,7 @@ verification checklist, not a chronological session log. hosts, not operator approval; port spelling is retained. - Fastly `src/request.rs`: capture before native mutation, explicit snapshot conversion and `dispatch_with_registries_and_ingress(app, req, stores, - ingress, extend)`. Capture reports target NotExposed before reading the SDK +ingress, extend)`. Capture reports target NotExposed before reading the SDK URL for origin. Ordinary runners use the same registry dispatcher. - Cloudflare `src/request.rs`: original runtime method token and one runtime URL read; parse the captured copy with `worker::Url`; preserve runtime UTF-8 @@ -51,7 +51,9 @@ No per-header preservation is inferred from aggregate count equality. The hook cannot recover erased wire data, intercept an earlier native shortcut, remove buffering or bypass outer response finalizers. Metadata capture remains eager; changing it to lazy/opt-in requires separate lifecycle/performance work. -Generated `app!` routers cannot install a hook; use manual `Hooks::routes()`. +Generated `app!` routers cannot install a hook. A manual `Hooks` wrapper must +delegate manifest metadata and explicitly preserve routes, middleware, state and +manifest introspection; see the routing guide. ## Dependency refresh @@ -82,38 +84,13 @@ versioned cache keys; debug lint and release probe builds keep their profiles. Run the ordinary comparison afterward. See the fixture README commands. - [x] Run docs lint, format and build; review the final diff independently. -## Evidence and baseline provenance - -The baseline contains 56 cases: Fastly 20, Cloudflare 19 and Spin 17, including -two direct-workerd no-hook routing regressions. Every case was freshly built and -recorded with the refreshed pins; the resulting JSON exactly matches the -checked-in baseline. The ordinary locked-build comparison also passes. -Earlier 47/54-case checkpoints and explicit Wasmtime flags are superseded by -the current registry and commands. - -Spin 4.1.0 consumes chunked framing before component invocation: `chunked` and -`te-cl` now expose no Transfer-Encoding (count 0, chunked false), unlike Spin 4.0. -Fresh probes independently reproduce the [review's report](https://github.com/IABTechLab/edgezero/pull/403#pullrequestreview-5544176343). -Fastly's global-order boolean changes with the metadata correction, without -asserting stronger wire preservation. Other recorded outcomes remain unchanged. - -## Current review validation - -Rust 1.98.1 workspace tests pass 1,546 tests with zero failures and one ignored. -All five required workspace gates pass, including strict all-feature Clippy, -combined adapter feature compilation and Spin wasm32-wasip2 compilation. -Both isolated workspaces pass formatting and strict lint; the WASM fixture is -linted on all three target/feature combinations with its regenerated lock. -The native runner passes eleven tests; its ignored direct-workerd routing test is -covered by the full runtime probe run. - -Fastly/Viceroy passes seven WASM contracts and 97 runtime unit tests. Spin passes -12 contracts under plain Wasmtime 48.0.1. The complete 56-case runtime fixture -matrix passes with Viceroy 0.21.0, Spin 4.1.0, Worker/worker-build 0.8.5, -wasm-bindgen 0.2.128 and Node 24.20.0. Public matrices identify these tested -versions. Docs lint/format/build, dependency-pin checks, actionlint and whitespace -checks pass. All 13 Cloudflare browser contracts pass under the CI-pinned -Chrome/ChromeDriver 154.0.8037.92 with wasm-bindgen 0.2.128. +## Baseline maintenance + +The checked-in `tests/fixtures/request-fidelity/observations.json` records local +runtime observations. `./scripts/test-request-fidelity.sh all` compares them +exactly. Inspect differences when updating SDK or runtime pins; baseline results +are not production preservation guarantees. Record run-specific validation in +the PR description. ## Decision log @@ -128,5 +105,7 @@ Chrome/ChromeDriver 154.0.8037.92 with wasm-bindgen 0.2.128. - The consumer owns visible-path classification, authentication, cookie policy, native shortcut ordering and terminal finalization. Its integration requires an immutable reviewed toolkit revision and tests on its own dependency graph. -- Prebuilt installers, cache redesign and opt-in capture remain separate work. +- The worker-build binary uses a version-specific CI cache; cold installs build + from source while the pinned release has no Ubuntu prebuilt. Opt-in capture + remains separate work. No production capability or performance claim follows from local fixtures. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index 948048fe..87e57e4a 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -10,7 +10,7 @@ Applications need to intercept a reserved namespace independently of the registered method/path routes, before router-owned state, introspection, request context and middleware run. A consumer must be able to authenticate a reserved request and return its own complete response even when its method has no registered handler. -At the historical planning baseline the router performed `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare previously reduced extension methods to GET and previously converted Worker UTF-8 strings as ByteString; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. +At the historical planning baseline the router performed `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare previously reduced extension methods to GET; header values were already copied as UTF-8 strings, with invalid values failing as internal errors; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. Provide one optional generic pre-dispatch hook, preserve runtime-visible method/target/header data without additional avoidable loss, and expose explicit fidelity information. Preserve the concrete `RouterService` and existing routes when no hook is installed. This is toolkit infrastructure, not an implementation of Trusted Server tracing. @@ -43,7 +43,7 @@ Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processi This table describes the pre-implementation checkout, not current dependencies. The matching plan records current pins and verification requirements. -| Surface | Planning source | Consequence | +| Surface | Planning source | Consequence | | ---------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Router | `crates/edgezero-core/src/router.rs`, `RouterInner::dispatch` | Lookup precedes state/introspection/context/middleware; no interception hook exists. | | Bootstrap | `crates/edgezero-core/src/app.rs`; `crates/edgezero-macros/src/app.rs` | `Hooks::routes`, `App` and generated routers use `RouterService`; retain that type. | @@ -51,7 +51,7 @@ The matching plan records current pins and verification requirements. | Cloudflare | `crates/edgezero-adapter-cloudflare/src/request.rs`, `into_core_request` | `req.method()` is a closed Workers enum; extension methods become GET. `req.url()` parses the runtime URL again. Header entries become Rust strings. Body is buffered. | | Axum | `crates/edgezero-adapter-axum/src/request.rs` | `Request::from_parts` preserves the incoming `http::Uri` and `HeaderMap`. Hyper may already reject/fold fields; JSON bodies are buffered. | | Spin | `crates/edgezero-adapter-spin/src/request.rs` | SDK request parts contain runtime URI and byte-oriented headers. Original wire interleaving is already unavailable in `HeaderMap`. Body is buffered. | -| Pins | `Cargo.lock` | The planning checkout pinned Worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122. The matching plan supersedes these pins with the dependency refresh. | +| Pins | `Cargo.lock` | The planning checkout pinned Worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122. The matching plan supersedes these pins with the dependency refresh. | Fastly SDK `RequestHandle::get_url` calls `uri_get` then `Url::parse`. Its request handle getter is private; the public consuming handle round trip rebuilds the request without original client provenance, changing IP/TLS/original-header accessors. It is not an acceptable raw-target workaround. A safe borrowed SDK accessor is an external prerequisite for an original-target claim. @@ -140,7 +140,7 @@ Do not turn optional target-capture failure into an unrelated new ordinary-reque - Preserve `req.get_method()` and existing byte-oriented repeated header conversion. - Add a reusable `capture_request_ingress(&fastly::Request) -> RequestIngress` helper in the Fastly request module. A consumer calls it before native health/debug shortcuts or mutations. Record the raw-target capture result before reading the SDK URL. With the current missing accessor, retain `NotExposed`, then recover canonical origin separately from the runtime URL plus exactly one valid, consistent Host on a native client request. URL normalization is acceptable for origin acquisition only; it never proves original pathname preservation. Use `RuntimeUri` provenance and do not infer HTTP from missing TLS metadata. Supplied snapshots remain caller assertions and are retained through subsequent mutation. - With current SDK, mark original target unavailable or runtime-normalized/unknown; do not call `get_url_str` “raw”. -- Obtain a reviewed safe SDK accessor that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance. Pin the resolved compatible SDK revision/version; do not fabricate one or add a local Cargo-cache patch. +- Future work: once a reviewed safe SDK accessor exists that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance, pin its compatible SDK revision/version and add evidence. Do not fabricate one or add a local Cargo-cache patch. - Do not use consuming handle round trips, clone_without_body, raw pointers or a new unsafe fastly-sys wrapper to bypass the SDK gate. - After the accessor exists, parse scheme/authority without normalizing the captured path. Prove encoded separators/dot segments/repeated separators survive in metadata and that client IP/TLS accessors retain behavior. - Runtime header bytes and same-name order must be tested. `get_original_header_names()` and `get_original_header_count()` expose name order and total count, not original field/value association. Local Viceroy lowercases and deduplicates names. Report global order Unknown pending production verification; aggregate count equality cannot prove per-name multiplicity. Do not reconstruct fields from mismatched/FIFO guesses. @@ -189,7 +189,7 @@ Initial investigation, not a verified release matrix: | Original field multiplicity | Runtime proof required | Fetch coalescing can make it unavailable | Per-field parser limits, especially Content-Length | Runtime/SDK proof required | | Original global wire order | Not implied by grouped iteration | Unavailable after sorted/combined entries | Not retained by HeaderMap | Not retained by HeaderMap | -The release matrix records established guarantees and unresolved limits. Unknown is a valid conservative metadata value; it does not prove preservation or satisfy a consumer requirement for original-wire equality. +The release matrix records observed behavior and unresolved limits. Unknown is a valid conservative metadata value; it does not prove preservation or satisfy a consumer requirement for original-wire equality. Two completion claims are distinct: diff --git a/scripts/check_shared_dep_pins.sh b/scripts/check_shared_dep_pins.sh index fb2ee0a6..d01a8fb7 100755 --- a/scripts/check_shared_dep_pins.sh +++ b/scripts/check_shared_dep_pins.sh @@ -110,6 +110,8 @@ done # Fixture SDKs must use the same resolved packages as the workspace. Check # both the exact manifest pins and the isolated lockfile, not just SDK majors. +# SDK pins intentionally accept only spaced inline tables (`name = { ... }`); +# other Cargo spellings fail closed rather than bypassing this guard. locked_version_in() { awk -v package="$2" ' $0 == "[[package]]" { name=""; next } @@ -126,11 +128,15 @@ fixture_lock="tests/fixtures/request-fidelity/Cargo.lock" for package in fastly worker wasm-bindgen spin-sdk; do want="$(locked_version_in Cargo.lock "$package")" pin="$(awk -v package="$package" ' - $1 == package && $2 == "=" { - if (match($0, /version[[:space:]]*=[[:space:]]*"=[^"]+"/)) { + { sub(/[[:space:]]*#.*/, "") } + /^\[/ { in_deps=($0 == "[dependencies]") } + in_deps && $1 == package && $2 == "=" { + count++ + if (match($0, /[,{][[:space:]]*version[[:space:]]*=[[:space:]]*"=[^"]+"/)) { version=substr($0, RSTART, RLENGTH) sub(/^[^"]*"=/, "", version); sub(/"$/, "", version) - count++; found=version + sub(/^[[:space:]]+/, "", version) + found=version } } END { if (count == 1) print found } @@ -147,7 +153,13 @@ done # worker-build's release must match the fixture's Worker SDK, but its pin is # declared once in metadata and consumed by CI and the native runner. -worker_build="$(awk '$1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' "$fixture_manifest")" +worker_build="$(awk ' + /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } + in_pins && $1 == "worker-build" && $2 == "=" { + value=$3; gsub(/"/, "", value); count++; found=value + } + END { if (count == 1) print found } +' "$fixture_manifest")" worker_version="$(locked_version_in Cargo.lock worker)" if [[ -z "$worker_build" || "$worker_build" != "$worker_version" ]]; then echo "$fixture_manifest: violation: worker-build $worker_build, workspace worker is $worker_version" diff --git a/scripts/test_shared_dep_pins.sh b/scripts/test_shared_dep_pins.sh new file mode 100644 index 00000000..1998d100 --- /dev/null +++ b/scripts/test_shared_dep_pins.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Exercise the real guard in isolated repositories, including malformed pins. +set -euo pipefail +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-pin-guard.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +FILES=( + scripts/check_shared_dep_pins.sh Cargo.toml Cargo.lock + examples/app-demo/Cargo.toml + .github/actions/deploy-fastly/tests/make-smoke-fixture.sh + crates/edgezero-cli/src/generator.rs + tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml + tests/fixtures/request-fidelity/Cargo.toml + tests/fixtures/request-fidelity/Cargo.lock +) +MANIFEST=tests/fixtures/request-fidelity/Cargo.toml +LOCK=tests/fixtures/request-fidelity/Cargo.lock + +reset_fixture() { + local file + rm -rf -- "$WORK_DIR/repo" + mkdir -p "$WORK_DIR/repo" + for file in "${FILES[@]}"; do + mkdir -p "$WORK_DIR/repo/$(dirname -- "$file")" + cp "$ROOT/$file" "$WORK_DIR/repo/$file" + done +} + +check_case() { + local name="$1" expected="$2" status=0 + bash "$WORK_DIR/repo/scripts/check_shared_dep_pins.sh" >"$WORK_DIR/output" 2>&1 || status=$? + if [[ "$status" != "$expected" ]]; then + printf 'FAIL %s: expected exit %s, got %s\n' "$name" "$expected" "$status" >&2 + cat "$WORK_DIR/output" >&2 + exit 1 + fi + printf 'PASS %s\n' "$name" +} + +mutate_manifest() { + local package="$1" mode="$2" + awk -v package="$package" -v mode="$mode" ' + $1 == package && /version[[:space:]]*=/ { + if (mode == "missing") next + if (mode == "duplicate") print + if (mode == "drift") sub(/version = "=[^"]+"/, "version = \"=99.0.0\"") + if (mode == "caret") sub(/version = "=/, "version = \"^") + if (mode == "misleading-key") { + match($0, /version = "=[^"]+"/) + exact=substr($0, RSTART, RLENGTH) + sub(/version = "=/, "version = \"^") + sub(/ }/, ", test_" exact " }") + } + if (mode == "comment") { + match($0, /version = "=[^"]+"/) + exact=substr($0, RSTART, RLENGTH) + sub(/version = "=/, "version = \"^") + $0=$0 " # " exact + } + if (mode == "duplicate-caret") { + print + sub(/version = "=/, "version = \"^") + } + if (mode == "space") sub(/version = "=/, "version = \"= ") + if (mode == "reordered") { + match($0, /version = "=[^"]+", /) + version=substr($0, RSTART, RLENGTH) + sub(/version = "=[^"]+", /, "") + sub(/ }/, ", " version "}") + $0=$0 " # trailing comment" + } + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" +} + +mutate_lock() { + local file="$1" package="$2" mode="$3" + awk -v package="$package" -v mode="$mode" ' + $0 == "[[package]]" { name="" } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { + if (mode == "missing") next + if (mode == "drift") $0="version = \"99.0.0\"" + if (mode == "duplicate") print "version = \"99.0.0\"\n\n[[package]]\nname = \"" package "\"" + } + { print } + ' "$WORK_DIR/repo/$file" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$file" +} + +reset_fixture +check_case unchanged 0 +for package in fastly worker wasm-bindgen spin-sdk; do + for mode in drift caret missing duplicate reordered space comment duplicate-caret misleading-key; do + reset_fixture + mutate_manifest "$package" "$mode" + expected=1 + if [[ "$mode" == reordered || "$mode" == space ]]; then expected=0; fi + check_case "$package $mode" "$expected" + done + for mode in drift duplicate missing; do + reset_fixture + mutate_lock "$LOCK" "$package" "$mode" + check_case "$package fixture lock $mode" 1 + done + reset_fixture + mutate_lock Cargo.lock "$package" duplicate + check_case "$package duplicate workspace lock" 1 +done +for mode in missing wrong duplicate misplaced; do + reset_fixture + awk -v mode="$mode" ' + $0 == "[package.metadata.request-fidelity]" && mode == "misplaced" { + print "[package.metadata.unrelated]"; next + } + $1 == "worker-build" { + if (mode == "missing") next + if (mode == "wrong") $0="worker-build = \"0.8.4\"" + if (mode == "duplicate") print + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" + check_case "worker-build $mode" 1 +done diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index 51c3e1da..486d6d18 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -151,7 +151,12 @@ struct OwnedProcess { impl OwnedProcess { fn spawn(command: &mut Command) -> io::Result { command.process_group(0).stdin(Stdio::null()); - let child = command.spawn()?; + let child = command.spawn().map_err(|error| { + io::Error::new( + error.kind(), + format!("{}: {error}", command.get_program().to_string_lossy()), + ) + })?; let group = child.id(); Ok(Self { child, group }) } @@ -207,10 +212,70 @@ fn command(fixture: &Path, program: &str, arguments: &[&str]) -> Command { .args(arguments) .current_dir(fixture) .env("WRANGLER_SEND_METRICS", "false"); - if program == "worker-build" { - command.env("ESBUILD_BIN", fixture.join("node_modules/.bin/esbuild")); + command +} + +fn configure_worker_build( + command: &mut Command, + fixture: &Path, + bindgen: &Path, + cancelled: &Cancelled, +) -> io::Result<()> { + let lock: toml::Value = toml::from_str(&fs::read_to_string(fixture.join("Cargo.lock"))?) + .map_err(io::Error::other)?; + let versions: Vec<_> = lock + .get("package") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter(|package| package.get("name").and_then(toml::Value::as_str) == Some("wasm-bindgen")) + .filter_map(|package| package.get("version").and_then(toml::Value::as_str)) + .collect(); + let [version] = versions.as_slice() else { + return Err(io::Error::other( + "fixture lock requires one wasm-bindgen version", + )); + }; + let bindgen = bindgen.canonicalize().map_err(|error| { + io::Error::new( + error.kind(), + format!("wasm-bindgen {}: {error}", bindgen.display()), + ) + })?; + verify_version(fixture, &bindgen.to_string_lossy(), version, cancelled)?; + let esbuild = fixture.join("node_modules/.bin/esbuild"); + let esbuild = esbuild.canonicalize().map_err(|error| { + io::Error::new( + error.kind(), + format!( + "npm-locked esbuild {}: {error}; run npm ci", + esbuild.display() + ), + ) + })?; + let npm_lock: Value = serde_json::from_slice(&fs::read(fixture.join("package-lock.json"))?) + .map_err(io::Error::other)?; + let version = npm_lock + .pointer("/packages/node_modules~1esbuild/version") + .and_then(Value::as_str) + .ok_or_else(|| io::Error::other("missing npm-locked esbuild version"))?; + let actual = run_command( + fixture, + &esbuild.to_string_lossy(), + &["--version"], + cancelled, + true, + )?; + if actual.trim() != version { + return Err(io::Error::other(format!( + "esbuild requires version {version}; found {:?}", + actual.trim() + ))); } command + .env("WASM_BINDGEN_BIN", bindgen) + .env("ESBUILD_BIN", esbuild); + Ok(()) } fn run_command( @@ -222,6 +287,18 @@ fn run_command( ) -> io::Result { check_cancelled(cancelled)?; let mut command = command(fixture, program, arguments); + if program == "worker-build" && arguments != ["--version"] { + let bindgen = if let Some(path) = env::var_os("WASM_BINDGEN_BIN") { + PathBuf::from(path) + } else { + env::split_paths(&env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join("wasm-bindgen")) + .find(|path| path.is_file()) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, + "wasm-bindgen not found on PATH; install the fixture-locked CLI or set WASM_BINDGEN_BIN"))? + }; + configure_worker_build(&mut command, fixture, &bindgen, cancelled)?; + } // A file avoids pipe backpressure while waiting interruptibly for build tools. let mut output = tempfile::tempfile()?; command.stdout(output.try_clone()?); @@ -255,7 +332,8 @@ fn verify_version( }; if actual != expected { return Err(io::Error::other(format!( - "{program} requires version {expected}" + "{program} requires version {expected}; found {actual:?} (output: {:?})", + output.trim() ))); } Ok(()) @@ -271,7 +349,7 @@ fn fixture_tool_pin(fixture: &Path, tool: &str) -> io::Result { .and_then(|pins| pins.get(tool)) .and_then(toml::Value::as_str) .map(str::to_owned) - .ok_or_else(|| io::Error::other("missing fixture tool pin")) + .ok_or_else(|| io::Error::other(format!("missing fixture tool pin: {tool}"))) } fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { @@ -336,8 +414,9 @@ fn with_runtime_log( output: &mut impl Write, ) -> io::Result { if result.is_err() - && let Ok(log) = fs::read_to_string(log_path) + && let Ok(log) = fs::read(log_path) { + let log = String::from_utf8_lossy(&log); let _result = writeln!(output, "{log}"); } result @@ -1121,6 +1200,134 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; + #[test] + fn missing_program_diagnostic_names_the_executable() { + let program = "/missing-edgezero-runtime"; + let error = OwnedProcess::spawn(&mut Command::new(program)) + .err() + .expect("missing program must fail"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains(program)); + } + + #[test] + fn version_diagnostic_includes_expected_and_actual() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("tool directory"); + let tool = directory.path().join("fixture-tool"); + std::fs::write(&tool, "#!/bin/sh\necho 'fixture-tool 9.9.9'\n").expect("binary"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + let cancelled = Arc::new(AtomicBool::new(false)); + let error = super::verify_version( + directory.path(), + &tool.to_string_lossy(), + "expected-pin", + &cancelled, + ) + .expect_err("wrong tool version"); + assert!(error.to_string().contains("expected-pin")); + assert!(error.to_string().contains("9.9.9")); + } + + #[test] + fn missing_tool_pin_names_the_tool() { + let directory = tempfile::tempdir().expect("temporary fixture"); + std::fs::write( + directory.path().join("Cargo.toml"), + "[package]\nname = 'fixture'\n", + ) + .expect("manifest"); + let error = + super::fixture_tool_pin(directory.path(), "worker-build").expect_err("missing pin"); + assert!(error.to_string().contains("worker-build")); + } + + #[test] + fn non_utf8_runtime_logs_are_printed_lossily() { + let directory = tempfile::tempdir().expect("temporary log"); + let path = directory.path().join("runtime.log"); + std::fs::write(&path, b"before\xffafter").expect("log bytes"); + let mut output = Vec::new(); + let error = super::with_runtime_log( + Err::<(), _>(io::Error::other("original failure")), + &path, + &mut output, + ) + .expect_err("original error"); + assert_eq!(error.to_string(), "original failure"); + assert_eq!( + String::from_utf8(output).expect("lossy log"), + "before\u{fffd}after\n" + ); + } + + #[test] + fn worker_build_requires_locked_local_tools() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("fixture directory"); + let fixture = directory.path(); + std::fs::write( + fixture.join("Cargo.lock"), + "[[package]]\nname = 'wasm-bindgen'\nversion = '0.2.128'\n", + ) + .expect("lock"); + std::fs::write( + fixture.join("package-lock.json"), + r#"{"packages":{"node_modules/esbuild":{"version":"0.27.3"}}}"#, + ) + .expect("npm lock"); + let bindgen = fixture.join("wasm-bindgen"); + std::fs::write(&bindgen, "#!/bin/sh\necho 'wasm-bindgen 0.2.128'\n").expect("binary"); + std::fs::set_permissions(&bindgen, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("worker-build"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("missing npm-locked esbuild must not trigger a download"); + assert!(error.to_string().contains("esbuild")); + let esbuild = fixture.join("node_modules/.bin/esbuild"); + std::fs::create_dir_all(esbuild.parent().expect("parent")).expect("bin directory"); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.27.3'\n").expect("binary"); + std::fs::set_permissions(&esbuild, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect("pinned tools"); + let envs: std::collections::BTreeMap<_, _> = command.get_envs().collect(); + assert_eq!( + envs[std::ffi::OsStr::new("WASM_BINDGEN_BIN")], + Some( + bindgen + .canonicalize() + .expect("canonical binary") + .as_os_str() + ) + ); + assert_eq!( + envs[std::ffi::OsStr::new("ESBUILD_BIN")], + Some( + esbuild + .canonicalize() + .expect("canonical binary") + .as_os_str() + ) + ); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.28.1'\n").expect("wrong esbuild"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("reject unlocked esbuild"); + assert!(error.to_string().contains("0.28.1")); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.27.3'\n").expect("restore esbuild"); + std::fs::write(&bindgen, "#!/bin/sh\necho 'wasm-bindgen 0.2.127'\n").expect("wrong tool"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("reject wrong bindgen"); + assert!(error.to_string().contains("0.2.127")); + std::fs::remove_file(&bindgen).expect("remove tool"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("missing override must fail instead of downloading"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains("wasm-bindgen")); + } + #[test] fn diagnostic_failures_preserve_the_original_runtime_error() { let directory = tempfile::tempdir().expect("should create log directory"); diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index d5d82225..989385d9 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -14,6 +14,11 @@ The isolated Cargo workspace and npm lock pin fixture dependencies. Install Viceroy 0.21.0, Spin 4.1.0, worker-build 0.8.5 and wasm-bindgen CLI 0.2.128. The fixture manifest's `[package.metadata.request-fidelity]` is authoritative for Spin CLI and worker-build versions; the runner and CI read those pins. +The runner uses `WASM_BINDGEN_BIN` when set, otherwise resolves `wasm-bindgen` +from `PATH`, checks it against the fixture Cargo lock, and passes its absolute +path to worker-build. An invalid override fails instead of falling back to a +download. After `npm ci`, the runner requires the local esbuild binary, checks +its npm-locked version, and passes it as `ESBUILD_BIN`. Wrangler 4.83.0 and its local workerd are installed by `npm ci`. Each runtime uses its own target directory, an ephemeral loopback port, bounded readiness and an owned process group that is cleaned up on success, failure or interruption. @@ -34,8 +39,7 @@ cp /tmp/request-fidelity-observations.json tests/fixtures/request-fidelity/obser Recording a single platform emits only that platform; replacing the baseline with that output would discard the other platforms. A failed run may leave an -empty output file. All 56 cases were freshly source-built and rerun with the versions above; -the ordinary comparison matched the checked-in baseline. +empty output file. A passing regression run includes explicitly reported unsupported cases; it does not establish consumer compliance or production wire preservation. Axum's real TCP ingress cases live alongside its request converter tests. From 83b7fae4d96a816ae45994869e74fdf0c08a2eaa Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 13:21:39 +0530 Subject: [PATCH 11/11] fix: close request fidelity review gaps --- .github/workflows/test.yml | 9 +- crates/edgezero-core/src/request.rs | 3 +- .../tests/fixtures/hooked_app.toml | 2 +- crates/edgezero-macros/tests/hooked_app.rs | 39 +++- docs/guide/adapters/fastly.md | 2 +- docs/guide/routing.md | 50 +++-- ...026-10-05-pre-dispatch-request-fidelity.md | 36 ++- ...05-pre-dispatch-request-fidelity-design.md | 14 +- scripts/check_shared_dep_pins.sh | 1 + scripts/test_shared_dep_pins.sh | 88 +++++++- .../request-fidelity-runner/src/main.rs | 207 +++++++++++++++--- tests/fixtures/request-fidelity/README.md | 9 +- tests/fixtures/reusable-app/README.md | 5 +- 13 files changed, 368 insertions(+), 97 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c2a3e66..463813f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,6 +73,7 @@ jobs: # deploy fixture). A mismatch only surfaces as a confusing # "trait bound not satisfied" in a deploy smoke job, so gate it here. - name: Shared dependency pins agree + if: matrix.suite == 'workspace' run: | ./scripts/check_shared_dep_pins.sh bash scripts/test_shared_dep_pins.sh @@ -344,7 +345,7 @@ jobs: id: worker-build-pin shell: bash run: | - version="$(awk '/^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "worker-build" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + version="$(awk '{ sub(/[[:space:]]*#.*/, "") } /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "worker-build" && $2 == "=" { gsub(/"/, "", $3); count++; found=$3 } END { if (count == 1) print found }' tests/fixtures/request-fidelity/Cargo.toml)" test -n "$version" echo "version=$version" >> "$GITHUB_OUTPUT" @@ -374,7 +375,7 @@ jobs: if: matrix.adapter == 'spin' shell: bash run: | - version="$(awk '$1 == "spin-cli" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + version="$(awk '{ sub(/[[:space:]]*#.*/, "") } /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "spin-cli" && $2 == "=" { gsub(/"/, "", $3); count++; found=$3 } END { if (count == 1) print found }' tests/fixtures/request-fidelity/Cargo.toml)" test "$version" = "4.1.0" install_dir="$RUNNER_TEMP/spin" mkdir -p "$install_dir" @@ -401,8 +402,8 @@ jobs: if [ "${{ matrix.adapter }}" = "cloudflare" ]; then version="$(awk '$1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next } in_pkg && $1 == "version" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.lock)" test -n "$version" - WASM_BINDGEN_BIN="$(command -v wasm-bindgen)" - if [[ "$("$WASM_BINDGEN_BIN" --version)" != "wasm-bindgen $version" ]]; then + WASM_BINDGEN_BIN="$(command -v wasm-bindgen || true)" + if [[ -z "$WASM_BINDGEN_BIN" ]] || [[ "$("$WASM_BINDGEN_BIN" --version 2>/dev/null || true)" != "wasm-bindgen $version" ]]; then cargo install wasm-bindgen-cli --version "$version" --locked --root "$RUNNER_TEMP/fixture-wasm-bindgen" WASM_BINDGEN_BIN="$RUNNER_TEMP/fixture-wasm-bindgen/bin/wasm-bindgen" fi diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs index fb31980d..3e71e3f9 100644 --- a/crates/edgezero-core/src/request.rs +++ b/crates/edgezero-core/src/request.rs @@ -278,7 +278,8 @@ pub enum OriginSource { /// It identifies the requested origin; it does not establish that an operator /// approves or owns that host. RuntimeUri, - /// A trusted transport scheme combined with one validated inbound request authority. + /// A transport scheme established by the adapter's listener, combined with + /// one validated inbound request authority. /// /// The authority identifies the requested origin; it does not establish /// that an operator approves or owns that host. diff --git a/crates/edgezero-macros/tests/fixtures/hooked_app.toml b/crates/edgezero-macros/tests/fixtures/hooked_app.toml index ad839a51..2058229d 100644 --- a/crates/edgezero-macros/tests/fixtures/hooked_app.toml +++ b/crates/edgezero-macros/tests/fixtures/hooked_app.toml @@ -4,7 +4,7 @@ middleware = ["crate::tests::MarkMiddleware"] [[triggers.http]] path = "/ordinary" -method = "GET" +methods = ["GET"] handler = "crate::tests::ordinary" [stores.kv] diff --git a/crates/edgezero-macros/tests/hooked_app.rs b/crates/edgezero-macros/tests/hooked_app.rs index 9f0696a0..3fec3263 100644 --- a/crates/edgezero-macros/tests/hooked_app.rs +++ b/crates/edgezero-macros/tests/hooked_app.rs @@ -24,9 +24,17 @@ mod tests { use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::router::{PreDispatchHook, RouterService}; use futures::executor::block_on; + use std::sync::{Arc, LazyLock}; use manifest_app::ManifestApp; + static MANIFEST_JSON: LazyLock> = LazyLock::new(|| { + let manifest = ManifestLoader::load_from_str(include_str!("fixtures/hooked_app.toml")); + serde_json::to_string(manifest.manifest()) + .expect("valid manifest") + .into() + }); + struct HookedApp; impl Hooks for HookedApp { fn build_app() -> App { @@ -47,12 +55,9 @@ mod tests { ManifestApp::owns_logging() } fn routes() -> RouterService { - let manifest = ManifestLoader::load_from_str(include_str!("fixtures/hooked_app.toml")); RouterService::builder() .pre_dispatch_hook(ReservedGate) - .with_manifest_json( - serde_json::to_string(manifest.manifest()).expect("valid manifest"), - ) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) .with_state(42_u32) .middleware(MarkMiddleware) .get("/ordinary", ordinary) @@ -100,10 +105,9 @@ mod tests { } #[action(manifest)] - async fn ordinary(State(value): State, ManifestJson(json): ManifestJson) -> &'static str { + async fn ordinary(State(value): State, ManifestJson(json): ManifestJson) -> String { assert_eq!(value, 42); - assert!(json.contains("hooked-manifest-app"), "manifest retained"); - "continued" + json.to_string() } #[test] @@ -130,7 +134,9 @@ mod tests { #[test] fn manual_router_preserves_continuation_and_terminal_hook_boundaries() { block_on(async { - let router = HookedApp::routes(); + let app = HookedApp::build_app(); + assert_eq!(app.name(), "hooked-manifest-app"); + let router = app.router(); let continued = router .oneshot( request_builder() @@ -142,6 +148,23 @@ mod tests { .expect("response"); assert_eq!(continued.status(), 200); assert_eq!(continued.headers()["x-middleware"], "present"); + let generated = ManifestApp::build_app() + .into_router() + .oneshot( + request_builder() + .uri("/ordinary") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("macro response"); + assert_eq!(generated.status(), 200); + assert_eq!(generated.headers()["x-middleware"], "present"); + assert_eq!( + continued.body().as_bytes().expect("buffered manifest"), + generated.body().as_bytes().expect("macro manifest"), + "manifest retained byte for byte" + ); let terminal = router .oneshot( request_builder() diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 9e8f323d..ea3880ea 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -444,7 +444,7 @@ handler or backend time. If your service is billed by memory × wall-clock time, idle waiting is billed too. Limits do not guarantee reuse; any request may start a fresh sandbox. The fixtures pin standalone Viceroy 0.21.0. Local request ceilings depend on the runtime version and must be checked when pins change. `edgezero serve` runs -`fastly compute serve`, whose bundled Viceroy can differ. Neither local ceiling +`fastly compute serve`, whose bundled Viceroy can differ. Neither local runtime establishes deployed behavior. Lifetime and memory checks occur between callbacks and cannot interrupt blocked application work. Before using a local memory limit, verify that the diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 4fd13411..b1fa1b50 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -247,18 +247,30 @@ If the hook consumes the body and returns `Ok(None)`, the handler receives only what remains unless the hook replaces the body. Generated `app!` applications cannot install a hook through the macro or modify the built router. A hand-written `Hooks` implementation does not inherit the generated metadata. -Keep `app!` in a separate module and delegate stores, logging, name and -application configuration: +Move your existing `app!` invocation **unchanged, including all arguments** +(`owns_logging = …`, `state = …`), into a separate module. Delegate stores, +logging, name and application configuration. For example, an application whose +existing invocation uses `owns_logging = true, state = 42_u32` would keep them: ```rust use edgezero_core::app::{App, Hooks, StoresMetadata}; use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; +use edgezero_core::router::RouterService; +use std::sync::{Arc, LazyLock}; mod manifest_app { - edgezero_core::app!("edgezero.toml", ManifestApp); + edgezero_core::app!( + "edgezero.toml", ManifestApp, + owns_logging = true, state = 42_u32 + ); } use manifest_app::ManifestApp; +static MANIFEST_JSON: LazyLock> = LazyLock::new(|| { + let manifest = ManifestLoader::load_from_str(include_str!("../edgezero.toml")); + serde_json::to_string(manifest.manifest()).expect("valid manifest").into() +}); + pub struct HookedApp; impl Hooks for HookedApp { @@ -269,14 +281,11 @@ impl Hooks for HookedApp { } fn routes() -> RouterService { - let manifest = ManifestLoader::load_from_str(include_str!("../edgezero.toml")); RouterService::builder() .pre_dispatch_hook(ExampleHook) - .with_manifest_json( - serde_json::to_string(manifest.manifest()).expect("valid manifest"), - ) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) + .with_state(42_u32) // Re-register every manifest route and middleware here, in order. - // Repeat any app!(..., state = ...) registration with .with_state(...). .build() } @@ -290,14 +299,29 @@ impl Hooks for HookedApp { } ``` -This outline assumes `src/lib.rs` beside a root `edgezero.toml` and a direct -`serde_json` dependency. Adjust the `include_str!` path to the source file. +This outline uses `ExampleHook` from above, with code in `src/lib.rs` and +`edgezero.toml` at the crate root. Add direct `async-trait` and `serde_json` +dependencies: core does not re-export those crates. The `app!` path is relative +to the crate root; the `include_str!` path is relative to the source file. +Import any names used by the original arguments into the nested module (for +example, `use super::app_state;` for `state = app_state()`). Use `HookedApp` as the adapter entry point. Its `routes()` must explicitly re-register **all routes, manifest middleware and configured state**; delegating metadata alone does not preserve them. `with_manifest_json` above preserves -manifest introspection and parses/serializes the manifest when constructing the -router. This manual duplication must stay synchronized with `app!` and the -manifest. The complete executable example and regression tests are in +manifest introspection. The lazy static parses and serializes the manifest once +per runtime instance. Standard Fastly, Cloudflare and Spin runners construct the +application on every request. Persistent instances (Cloudflare isolates, Axum, +or Fastly `serve_app`) reuse this cached JSON. Classic Fastly Compute starts a +fresh instance per request, so it still pays the initialization cost on each +request. Spin's [instance reuse depends on the component and runtime](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse): +WASI P3 HTTP components can reuse the cache, while fresh instances always pay +the initialization cost. Do not assume any minimum degree of reuse. +The macro embeds its JSON at compile time; exposing that literal +or registering hooks directly through `app!` is tracked in +[issue #434](https://github.com/IABTechLab/edgezero/issues/434). +This manual duplication must stay synchronized with `app!` and the manifest, +including matching `.with_state(...)` to the original macro's state argument. +The complete executable example and regression tests are in `crates/edgezero-macros/tests/hooked_app.rs`. The store-free ingress fixture is not a metadata-preserving example for manifest applications. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md index a3cfc5c7..9e083ddf 100644 --- a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -3,12 +3,10 @@ **Goal:** Method-independent interception before router dispatch, with bounded, confidential metadata that accurately describes runtime-visible request facts. -**Status:** Core feature implemented. This document supersedes earlier execution -notes, signatures, case counts and runtime flags; it is a maintenance and -verification checklist, not a chronological session log. +**Status:** Core feature implemented. This document records the implemented +surfaces, maintenance requirements and verification procedure. **Contract:** [Design](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). -**Branch:** `spec/pre-dispatch-request-fidelity`. ## Implemented surfaces @@ -30,7 +28,7 @@ ingress, extend)`. Capture reports target NotExposed before reading the SDK read; parse the captured copy with `worker::Url`; preserve runtime UTF-8 header bytes. Bounded conversion errors become Worker errors through standard runners and the fetch wrapper returns 500. -- Axum `src/request.rs`: preserve incoming URI/headers, trusted plain-HTTP +- Axum `src/request.rs`: preserve incoming URI/headers, listener-established plain-HTTP transport binding and one consistent Host. Existing JSON buffering and non-JSON streaming remain in place. - Spin `src/ingress.rs`: pure metadata helper shared by WASM conversion and host @@ -70,19 +68,19 @@ versioned cache keys; debug lint and release probe builds keep their profiles. ## Verification checklist -- [x] Run core, affected adapter and native runner tests after code changes. -- [x] Run workspace fmt, strict all-feature clippy and full workspace tests. -- [x] Check workspace `fastly cloudflare spin` features and Spin wasm32-wasip2. -- [x] Format/lint both isolated fixture workspaces; lint WASM fixture on each of - wasm32-wasip1/Fastly, wasm32-unknown-unknown/Cloudflare and - wasm32-wasip2/Spin with `--locked`. -- [x] Execute browser Cloudflare, Viceroy Fastly and Wasmtime Spin contracts. - Spin uses plain `wasmtime run`; full Spin lib-test artifacts additionally - need Spin-specific host interfaces and are not plain-Wasmtime contracts. -- [x] Run `./scripts/test-request-fidelity.sh all --record-observations` into a - temporary file, inspect per-case differences, then replace the baseline. - Run the ordinary comparison afterward. See the fixture README commands. -- [x] Run docs lint, format and build; review the final diff independently. +- Run core, affected adapter and native runner tests after code changes. +- Run workspace fmt, strict all-feature clippy and full workspace tests. +- Check workspace `fastly cloudflare spin` features and Spin wasm32-wasip2. +- Format/lint both isolated fixture workspaces; lint WASM fixture on each of + wasm32-wasip1/Fastly, wasm32-unknown-unknown/Cloudflare and + wasm32-wasip2/Spin with `--locked`. +- Execute browser Cloudflare, Viceroy Fastly and Wasmtime Spin contracts. + Spin uses plain `wasmtime run`; full Spin lib-test artifacts additionally + need Spin-specific host interfaces and are not plain-Wasmtime contracts. +- Run `./scripts/test-request-fidelity.sh all --record-observations` into a + temporary file, inspect per-case differences, then replace the baseline. + Run the ordinary comparison afterward. See the fixture README commands. +- Run docs lint, format and build; review the final diff independently. ## Baseline maintenance @@ -96,7 +94,7 @@ the PR description. - Worker runtime UTF-8 strings must be copied as UTF-8; browser ByteString construction is not evidence of Worker wire bytes. Exact C3 A9/C4 80 assertions - prevent the earlier encoding defect. + guard against corruption from a ByteString/Latin-1 conversion. - Origin availability and hook dispatch prerequisites must pass before the UTF-8 regression can prove anything; assert the specific encoding error and include a passing valid verdict. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md index 87e57e4a..ce784e59 100644 --- a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -3,7 +3,6 @@ **Status:** Implemented API contract; current verification requirements and dependency pins are recorded in the matching plan. **Date:** 2026-10-05. **Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. -**Branch:** `spec/pre-dispatch-request-fidelity`. **Implementation plan:** [Matching plan](../plans/2026-10-05-pre-dispatch-request-fidelity.md). ## 1. Problem and outcome @@ -101,7 +100,10 @@ Only after continuation may the router clone the method/path, call `find_route`, The hook has no implicit router-state access because it precedes injection. Consumers capture their own `Arc` when building the hook. They own authentication, method validation, complete local response headers and any diagnostic policy. Outer adapter/consumer finalizers still exist; a terminal core response is not a promise to bypass arbitrary external finalization. -Manual builder registration is sufficient; no new `Hooks` method or `app!` argument is required. +Manual builder registration provides the hook without changing `Hooks` or the +`app!` grammar. Applications using `app!` need the +[routing guide's manual `Hooks` delegation](../../guide/routing.md#intercepting-before-dispatch); +direct macro registration is tracked in [#434](https://github.com/IABTechLab/edgezero/issues/434). ## 6. Ingress metadata contract @@ -115,8 +117,8 @@ Use private fields, Clone, and borrowed accessors. Do not derive value-bearing D - `TargetSource`: `TransportRequestTarget`, `RuntimeUrl`, `RuntimePathAndQuery`. Source and fidelity are independent: a runtime URL is not automatically the original request target. - `Preservation`: `Preserved`, `Transformed`, `Unknown`, `Unavailable`. - `TargetUnavailable`: `NotExposed`, `TooLarge`, `ReadFailed`. -- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source, normally derived from client Host or :authority; TransportBinding records a trusted transport scheme combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. -- `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from trusted inbound/runtime request information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. +- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source, normally derived from client Host or :authority; TransportBinding records a scheme established by the adapter's listener combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. +- `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from adapter-established transport binding or runtime URI information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. - `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. - `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. @@ -217,8 +219,8 @@ Use fictional/example.com fixtures only. Never log actual headers, cookies, cred ## 10. Decision log and consumer boundary -- Worker header conversion copies UTF-8 runtime strings. The earlier ByteString - assumption corrupted C3 A9 and rejected C4 80; the fixture asserts both exact +- Worker header conversion copies UTF-8 runtime strings. A ByteString/Latin-1 + conversion would corrupt C3 A9 and reject C4 80; the fixture asserts both exact byte sequences. Invalid UTF-8 replacement remains a runtime limitation. - Fidelity is per observation. Runtime normalization or folding capability does not establish that a particular request changed; use Unknown when unproved. diff --git a/scripts/check_shared_dep_pins.sh b/scripts/check_shared_dep_pins.sh index d01a8fb7..588b90e7 100755 --- a/scripts/check_shared_dep_pins.sh +++ b/scripts/check_shared_dep_pins.sh @@ -154,6 +154,7 @@ done # worker-build's release must match the fixture's Worker SDK, but its pin is # declared once in metadata and consumed by CI and the native runner. worker_build="$(awk ' + { sub(/[[:space:]]*#.*/, "") } /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "worker-build" && $2 == "=" { value=$3; gsub(/"/, "", value); count++; found=value diff --git a/scripts/test_shared_dep_pins.sh b/scripts/test_shared_dep_pins.sh index 1998d100..7be673ce 100644 --- a/scripts/test_shared_dep_pins.sh +++ b/scripts/test_shared_dep_pins.sh @@ -27,10 +27,10 @@ reset_fixture() { } check_case() { - local name="$1" expected="$2" status=0 + local name="$1" expected="$2" diagnostic="${3:-check_shared_dep_pins: validator pins agree}" status=0 bash "$WORK_DIR/repo/scripts/check_shared_dep_pins.sh" >"$WORK_DIR/output" 2>&1 || status=$? - if [[ "$status" != "$expected" ]]; then - printf 'FAIL %s: expected exit %s, got %s\n' "$name" "$expected" "$status" >&2 + if [[ "$status" != "$expected" ]] || ! grep -Fq -- "$diagnostic" "$WORK_DIR/output"; then + printf 'FAIL %s: expected exit %s and diagnostic %s, got exit %s\n' "$name" "$expected" "$diagnostic" "$status" >&2 cat "$WORK_DIR/output" >&2 exit 1 fi @@ -90,24 +90,77 @@ mutate_lock() { mv "$WORK_DIR/changed" "$WORK_DIR/repo/$file" } +mutate_shared_pin() { + local file="$1" package="$2" mode="$3" + awk -v package="$package" -v mode="$mode" ' + $0 ~ "^[[:space:]]*\"?" package "[[:space:]]*=" { + if (mode == "missing") next + sub(/[0-9]+(\.[0-9]+)*/, "99.0.0") + } + { print } + ' "$WORK_DIR/repo/$file" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$file" +} + reset_fixture check_case unchanged 0 +for package in validator spin-sdk; do + reset_fixture + mutate_shared_pin Cargo.toml "$package" missing + check_case "missing root $package" 1 "check_shared_dep_pins: no $package pin in root Cargo.toml" +done +for file in examples/app-demo/Cargo.toml .github/actions/deploy-fastly/tests/make-smoke-fixture.sh crates/edgezero-cli/src/generator.rs; do + for mode in missing drift; do + reset_fixture + mutate_shared_pin "$file" validator "$mode" + diagnostic="$file: violation: no validator pin found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: validator 99.0.0"; fi + if [[ "$file" == crates/edgezero-cli/src/generator.rs ]]; then + diagnostic="$file: violation: no validator seed found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: seeds validator 99.0.0"; fi + fi + check_case "validator $file $mode" 1 "$diagnostic" + done +done +for file in examples/app-demo/Cargo.toml tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml "$MANIFEST" crates/edgezero-cli/src/generator.rs; do + for mode in missing drift; do + reset_fixture + mutate_shared_pin "$file" spin-sdk "$mode" + diagnostic="$file: violation: no spin-sdk pin found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: spin-sdk major 99"; fi + check_case "spin-sdk $file $mode" 1 "$diagnostic" + done +done for package in fastly worker wasm-bindgen spin-sdk; do + version=$(awk -v package="$package" ' + $0 == "[[package]]" { name="" } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { gsub(/"/, "", $3); print $3 } + ' "$ROOT/Cargo.lock") for mode in drift caret missing duplicate reordered space comment duplicate-caret misleading-key; do reset_fixture mutate_manifest "$package" "$mode" expected=1 + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=99.0.0"; fi if [[ "$mode" == reordered || "$mode" == space ]]; then expected=0; fi - check_case "$package $mode" "$expected" + if [[ "$expected" == 0 ]]; then diagnostic="check_shared_dep_pins: validator pins agree"; fi + check_case "$package $mode" "$expected" "$diagnostic" done for mode in drift duplicate missing; do reset_fixture mutate_lock "$LOCK" "$package" "$mode" - check_case "$package fixture lock $mode" 1 + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=$version lock=99.0.0"; fi + check_case "$package fixture lock $mode" 1 "$diagnostic" + done + for mode in drift duplicate missing; do + reset_fixture + mutate_lock Cargo.lock "$package" "$mode" + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=$version lock=$version, workspace lock has 99.0.0"; fi + check_case "$package workspace lock $mode" 1 "$diagnostic" done - reset_fixture - mutate_lock Cargo.lock "$package" duplicate - check_case "$package duplicate workspace lock" 1 done for mode in missing wrong duplicate misplaced; do reset_fixture @@ -123,5 +176,22 @@ for mode in missing wrong duplicate misplaced; do { print } ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" - check_case "worker-build $mode" 1 + check_case "worker-build $mode" 1 "violation: worker-build" +done + +for mode in section-comment attached-comment spaced-comment unrelated-section; do + reset_fixture + awk -v mode="$mode" ' + $0 == "[package.metadata.request-fidelity]" { + if (mode == "section-comment") $0=$0 " # tools" + if (mode == "unrelated-section") print "[package.metadata.unrelated]\nworker-build = \"99.0.0\"\n" + } + $1 == "worker-build" { + if (mode == "attached-comment") $0=$0 "# tools" + if (mode == "spaced-comment") $0=$0 " # tools" + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" + check_case "worker-build $mode" 0 done diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index 486d6d18..5a3f5a68 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -285,35 +285,65 @@ fn run_command( cancelled: &Cancelled, capture: bool, ) -> io::Result { - check_cancelled(cancelled)?; let mut command = command(fixture, program, arguments); - if program == "worker-build" && arguments != ["--version"] { - let bindgen = if let Some(path) = env::var_os("WASM_BINDGEN_BIN") { - PathBuf::from(path) - } else { - env::split_paths(&env::var_os("PATH").unwrap_or_default()) - .map(|directory| directory.join("wasm-bindgen")) - .find(|path| path.is_file()) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, - "wasm-bindgen not found on PATH; install the fixture-locked CLI or set WASM_BINDGEN_BIN"))? - }; - configure_worker_build(&mut command, fixture, &bindgen, cancelled)?; - } + run_prepared_command(&mut command, cancelled, capture) +} + +fn run_prepared_command( + command: &mut Command, + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + let description = std::iter::once(command.get_program()) + .chain(command.get_args()) + .map(|value| value.to_string_lossy()) + .collect::>() + .join(" "); + let describe = + |error: io::Error| io::Error::new(error.kind(), format!("{description}: {error}")); + check_cancelled(cancelled).map_err(describe)?; // A file avoids pipe backpressure while waiting interruptibly for build tools. - let mut output = tempfile::tempfile()?; - command.stdout(output.try_clone()?); - command.stderr(Stdio::inherit()); - let result = OwnedProcess::spawn(&mut command)?.wait(cancelled); - output.rewind()?; - if !capture { - io::copy(&mut output, &mut io::stderr())?; - } - result?; + let mut output = tempfile::tempfile().map_err(describe)?; + let mut stderr = tempfile::tempfile().map_err(describe)?; + command.stdout(output.try_clone().map_err(describe)?); + command.stderr(if capture { + Stdio::from(stderr.try_clone().map_err(describe)?) + } else { + Stdio::inherit() + }); + let result = OwnedProcess::spawn(command) + .map_err(describe)? + .wait(cancelled); + output.rewind().map_err(describe)?; if capture { - let mut value = String::new(); - output.read_to_string(&mut value)?; - Ok(value) + let mut bytes = Vec::new(); + output.read_to_end(&mut bytes).map_err(describe)?; + stderr.rewind().map_err(describe)?; + let mut diagnostics = Vec::new(); + stderr.read_to_end(&mut diagnostics).map_err(describe)?; + result.map_err(|error| { + describe(io::Error::new( + error.kind(), + format!( + "{error}; stdout: {}; stderr: {}", + String::from_utf8_lossy(&bytes), + String::from_utf8_lossy(&diagnostics) + ), + )) + })?; + io::stderr().write_all(&diagnostics).map_err(describe)?; + String::from_utf8(bytes).map_err(|error| { + describe(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{error}; output: {}", + String::from_utf8_lossy(error.as_bytes()) + ), + )) + }) } else { + io::copy(&mut output, &mut io::stderr()).map_err(describe)?; + result.map_err(describe)?; Ok(String::new()) } } @@ -352,13 +382,46 @@ fn fixture_tool_pin(fixture: &Path, tool: &str) -> io::Result { .ok_or_else(|| io::Error::other(format!("missing fixture tool pin: {tool}"))) } +fn resolve_wasm_bindgen( + override_path: Option, + path: &std::ffi::OsStr, +) -> io::Result { + use std::os::unix::fs::PermissionsExt as _; + let executable = + |metadata: &fs::Metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0; + if let Some(path) = override_path { + let path = PathBuf::from(path); + let metadata = path.metadata().map_err(|error| { + io::Error::new( + error.kind(), + format!("WASM_BINDGEN_BIN {}: {error}", path.display()), + ) + })?; + if !executable(&metadata) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "WASM_BINDGEN_BIN {} must be an executable file", + path.display() + ), + )); + } + return Ok(path); + } + env::split_paths(path) + .map(|directory| directory.join("wasm-bindgen")) + .find(|path| path.metadata().is_ok_and(|metadata| executable(&metadata))) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, + "wasm-bindgen not found on PATH; install the fixture-locked CLI or set WASM_BINDGEN_BIN")) +} + fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { if platform == "cloudflare" { let pin = fixture_tool_pin(fixture, "worker-build")?; verify_version(fixture, "worker-build", &pin, cancelled)?; run_command(fixture, "npm", &["ci"], cancelled, false)?; let lock = fs::read(fixture.join("Cargo.lock"))?; - run_command( + let mut command = command( fixture, "worker-build", &[ @@ -374,9 +437,13 @@ fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<() "--target-dir", "target/cloudflare", ], - cancelled, - false, + ); + let bindgen = resolve_wasm_bindgen( + env::var_os("WASM_BINDGEN_BIN"), + &env::var_os("PATH").unwrap_or_default(), )?; + configure_worker_build(&mut command, fixture, &bindgen, cancelled)?; + run_prepared_command(&mut command, cancelled, false)?; if fs::read(fixture.join("Cargo.lock"))? != lock { return Err(io::Error::other( "worker-build changes the locked fixture dependencies", @@ -1210,6 +1277,88 @@ mod tests { assert!(error.to_string().contains(program)); } + #[test] + fn command_failures_include_arguments_and_captured_diagnostics() { + let directory = tempfile::tempdir().expect("command directory"); + let cancelled = Arc::new(AtomicBool::new(false)); + for (script, kind, diagnostics) in [ + ( + "printf 'probe stdout'; printf 'probe stderr' >&2; exit 3", + io::ErrorKind::Other, + "probe stdout", + ), + ("printf '\\377'", io::ErrorKind::InvalidData, "�"), + ] { + let error = super::run_command( + directory.path(), + "/bin/sh", + &["-c", script], + &cancelled, + true, + ) + .expect_err("command must fail"); + assert_eq!(error.kind(), kind); + let message = error.to_string(); + assert!(message.contains("/bin/sh -c"), "{message}"); + assert!(message.contains(diagnostics), "{message}"); + if kind == io::ErrorKind::Other { + assert!(message.contains("probe stderr"), "{message}"); + assert!(message.contains('3'), "{message}"); + } + } + } + + #[test] + fn captured_success_keeps_stdout_separate_from_stderr() { + let directory = tempfile::tempdir().expect("command directory"); + let output = super::run_command( + directory.path(), + "/bin/sh", + &["-c", "printf 'tool 1.2.3'; printf 'tool warning' >&2"], + &Arc::new(AtomicBool::new(false)), + true, + ) + .expect("successful command"); + assert_eq!(output, "tool 1.2.3"); + } + + #[test] + fn bindgen_resolution_skips_nonexecutables_and_honors_overrides() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("tool directories"); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir_all(&first).expect("first directory"); + std::fs::create_dir_all(&second).expect("second directory"); + let shadow = first.join("wasm-bindgen"); + let executable = second.join("wasm-bindgen"); + for (file, mode) in [(&shadow, 0o644), (&executable, 0o755)] { + std::fs::write(file, "#!/bin/sh\nexit 0\n").expect("tool"); + std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)) + .expect("permissions"); + } + let path = std::env::join_paths([&first, &second]).expect("PATH"); + assert_eq!( + super::resolve_wasm_bindgen(None, &path).expect("executable lookup"), + executable + ); + let override_tool = directory.path().join("override-wasm-bindgen"); + std::fs::copy(&executable, &override_tool).expect("distinct override outside PATH"); + assert_eq!( + super::resolve_wasm_bindgen(Some(override_tool.clone().into_os_string()), &path) + .expect("override"), + override_tool + ); + for invalid in [shadow, directory.path().join("missing"), first] { + let error = super::resolve_wasm_bindgen(Some(invalid.into_os_string()), &path) + .expect_err("invalid override must not fall back to PATH"); + assert!(error.to_string().contains("WASM_BINDGEN_BIN"), "{error}"); + } + std::fs::remove_file(&executable).expect("remove executable"); + let error = super::resolve_wasm_bindgen(None, &path).expect_err("no executable"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + #[test] fn version_diagnostic_includes_expected_and_actual() { use std::os::unix::fs::PermissionsExt as _; @@ -1323,7 +1472,7 @@ mod tests { assert!(error.to_string().contains("0.2.127")); std::fs::remove_file(&bindgen).expect("remove tool"); let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) - .expect_err("missing override must fail instead of downloading"); + .expect_err("missing configured tool must fail instead of downloading"); assert_eq!(error.kind(), io::ErrorKind::NotFound); assert!(error.to_string().contains("wasm-bindgen")); } diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index 989385d9..9473a31b 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -11,9 +11,10 @@ Run from the repository root with the Rust and Node versions in `.tool-versions` prove source freshness. Use the default locked build for review evidence. The isolated Cargo workspace and npm lock pin fixture dependencies. Install -Viceroy 0.21.0, Spin 4.1.0, worker-build 0.8.5 and wasm-bindgen CLI 0.2.128. -The fixture manifest's `[package.metadata.request-fidelity]` is authoritative for -Spin CLI and worker-build versions; the runner and CI read those pins. +Viceroy at the version in `.tool-versions`, Spin CLI and worker-build at the +versions in the fixture manifest's `[package.metadata.request-fidelity]`, and +wasm-bindgen CLI at the `wasm-bindgen` version in the fixture `Cargo.lock`. +The runner and CI read these authoritative pins. The runner uses `WASM_BINDGEN_BIN` when set, otherwise resolves `wasm-bindgen` from `PATH`, checks it against the fixture Cargo lock, and passes its absolute path to worker-build. An invalid override fails instead of falling back to a @@ -69,7 +70,7 @@ Known gates: Absolute-form and duplicate Content-Length requests return local 500 errors. - Spin 4.1.0 rejects non-UTF-8 header values before component invocation. It consumes chunked framing and removes Transfer-Encoding before the hook in - `chunked` and `te-cl`; these observations differ from the old Spin 4.0 baseline. + `chunked` and `te-cl` cases. - Global original field order is unavailable on Axum, Cloudflare and Spin. Fastly reports Unknown: original-name/count APIs provide partial evidence, without associating original fields with converted values. Local evidence diff --git a/tests/fixtures/reusable-app/README.md b/tests/fixtures/reusable-app/README.md index 4b03747a..ab734c36 100644 --- a/tests/fixtures/reusable-app/README.md +++ b/tests/fixtures/reusable-app/README.md @@ -148,8 +148,9 @@ logger fails on its second installation. ## Runtime and measurement limits -Raising `--max-requests` cannot extend the pinned Viceroy's per-guest request -ceiling (see the Fastly guide). Verify deployed eviction and long-lived memory +Local request ceilings depend on the Viceroy version; check them when pins change +(see the [Fastly guide](../../../docs/guide/adapters/fastly.md#reusing-a-sandbox-and-retaining-an-app)). +Verify deployed eviction and long-lived memory behavior separately. The runner checks the Viceroy version against `.tool-versions` and reports a mismatch as unverified.