diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eff6eb8e..463813f6 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 @@ -49,6 +51,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Install wasm targets for generated-project checks if: matrix.suite == 'cli' @@ -70,7 +73,10 @@ 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 + if: matrix.suite == 'workspace' + run: | + ./scripts/check_shared_dep_pins.sh + bash scripts/test_shared_dep_pins.sh - name: Nested AppConfig audit if: matrix.suite == 'cli' @@ -80,6 +86,13 @@ jobs: if: matrix.suite == 'cli' run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + - name: Verify native request ingress harness + if: matrix.suite == 'workspace' + 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: Lifecycle fixture host tests if: matrix.suite == 'workspace' run: >- @@ -178,8 +191,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 @@ -191,10 +206,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-file: .tool-versions + 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 @@ -285,6 +325,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 @@ -298,6 +340,77 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + - name: Read fixture worker-build pin + if: matrix.adapter == 'cloudflare' + id: worker-build-pin + shell: bash + run: | + 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" + + # 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' + shell: bash + run: | + 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" + 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. + 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 + + - 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 + 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 || 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 + export WASM_BINDGEN_BIN + fi + ./scripts/test-request-fidelity.sh ${{ matrix.adapter }} + - name: Fastly reusable HTTP smoke if: matrix.adapter == 'fastly' run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index d8d1adc4..b2725329 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 { @@ -748,6 +753,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, Request, Response, response_builder}; + use edgezero_core::ingress::{CapturedTarget, Preservation, RequestIngress}; + use edgezero_core::middleware::{Middleware, Next}; + 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, @@ -759,6 +781,143 @@ 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 + ); + 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" => { + 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(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"GET /ordinary 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..b8bdb1c8 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_TYPE, HOST}; +use edgezero_core::http::{HeaderMap, HeaderValue, Uri}; +use edgezero_core::ingress::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use edgezero_core::proxy::ProxyHandle; 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::RUNTIME_HEADER_MAP; + RequestIngress::new(target, inbound_origin(uri, headers, transport), common) +} + +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()?; + if !origin.same_authority(&supplied) { + return None; + } + } + Some(origin) +} + fn is_json_content_type(value: &HeaderValue) -> bool { let Ok(raw) = value.to_str() else { return false; @@ -203,3 +258,163 @@ 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::ingress::{ + 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_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)); + 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 41a317f2..cf558808 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::ingress::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; 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; @@ -242,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] @@ -251,19 +254,36 @@ 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 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::Unknown, + ); + let url: worker::Url = runtime_url + .parse() + .map_err(|_error| EdgeError::bad_request("invalid runtime URL"))?; let uri: Uri = url .as_str() .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::RUNTIME_HEADER_MAP; + let ingress = RequestIngress::new(target, origin, fidelity); 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)?; @@ -272,6 +292,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() @@ -475,17 +496,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))), @@ -574,26 +584,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 4097601e..4e9e6409 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -29,8 +29,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::ingress::{ + 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; @@ -40,6 +44,10 @@ mod tests { wasm_bindgen_test_configure!(run_in_browser); + struct IngressHook { + continue_routing: bool, + } + struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] @@ -49,6 +57,200 @@ 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 { + 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_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 [ + "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?q=a%2Fb", + "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::Unknown); + } + 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(Arc::new(IngressHook { continue_routing })) + .route( + "/uri", + Method::from_bytes(b"EXAMPLE-METHOD").expect("method"), + capture_uri_for_ingress, + ) + .build(), + ); + let init = web_sys::RequestInit::new(); + init.set_method("EXAMPLE-METHOD"); + let url = if continue_routing { + "https://example.com/uri" + } else { + "https://example.com/missing" + }; + let raw = web_sys::Request::new_with_str_and_init(url, &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/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index bbaf6c23..e1f5962a 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,13 +1,18 @@ 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}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; 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::ingress::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, RequestIngress, TargetUnavailable, +}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; @@ -122,6 +127,7 @@ impl<'app> FastlyService<'app> { /// the underlying handler returns an error. #[inline] pub fn dispatch(self, req: FastlyRequest) -> Result { + let ingress = capture_request_ingress(&req); let config_store = match self.config { ConfigSource::Handle(handle) => Some(handle), ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { @@ -150,6 +156,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + ingress, |_req, _extensions| {}, ) } @@ -310,6 +317,7 @@ fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, + ingress: RequestIngress, extend: F, ) -> Result where @@ -317,8 +325,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) } @@ -354,7 +363,34 @@ pub fn dispatch_with_registries( where F: FnOnce(&FastlyRequest, &mut Extensions), { - let core_request = into_core_request_with_registries(req, stores, extend)?; + let ingress = capture_request_ingress(&req); + dispatch_with_registries_and_ingress(app, req, stores, ingress, extend) +} + +/// Dispatch with logical store bindings and an already captured ingress snapshot. +/// +/// The snapshot is inserted after the extension callback, preventing scratch +/// 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] +pub fn dispatch_with_registries_and_ingress( + app: &App, + req: FastlyRequest, + stores: StoresMetadata, + ingress: RequestIngress, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let core_request = into_core_request_with_registries_and_ingress(req, stores, ingress, extend)?; let response = executor::block_on(app.router().oneshot(core_request)) .map_err(|err| map_edge_error(&err))?; from_core_response(response).map_err(|err| map_edge_error(&err)) @@ -378,6 +414,33 @@ pub fn into_core_request_with_registries( stores: StoresMetadata, extend: F, ) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let ingress = capture_request_ingress(&req); + into_core_request_with_registries_and_ingress(req, stores, ingress, extend) +} + +/// Convert with store registries and an already captured ingress snapshot. +/// +/// The snapshot is inserted after the extension callback, preventing scratch +/// 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. +#[inline] +pub(crate) fn into_core_request_with_registries_and_ingress( + req: FastlyRequest, + stores: StoresMetadata, + ingress: RequestIngress, + extend: F, +) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { @@ -385,8 +448,9 @@ where let config_registry = build_config_registry(stores.config); let secret_registry = build_secret_registry(stores.secrets); 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); insert_store_registries( &mut core_request, Stores { @@ -511,10 +575,87 @@ fn build_secret_registry(secret_meta: Option) -> Option 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::default()) +} + +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()?; + if !runtime_origin.same_authority(&origin) { + return None; + } + Some(runtime_origin) +} + +/// Convert a native request, capturing ingress before native mutation. +/// +/// # Errors +/// Returns an error if method, URI or body conversion fails. +#[inline] +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 [`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(crate) 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())?; @@ -806,3 +947,214 @@ mod synthesis_tests { let _handle = resolve_secret_handle(true); } } + +#[cfg(test)] +mod ingress_tests { + use super::{ + 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::error::EdgeError; + use edgezero_core::http::header::HOST; + use edgezero_core::http::{HeaderValue, Request, Response, Uri, response_builder}; + use edgezero_core::ingress::{ + CapturedTarget, HeaderFidelity, OriginSource, Preservation, RequestIngress, TargetSource, + TargetUnavailable, + }; + use edgezero_core::proxy::ProxyHandle; + 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(), + uri.authority() + .expect("should have runtime authority") + .as_str(), + "should retain runtime authority rather than Host spelling" + ); + 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(), + ) + } + + #[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) + )); + 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(Arc::new(VerifySnapshot { + scratch_expected: true, + })) + .build(), + ); + let response = dispatch_with_registries_and_ingress( + &app, + FastlyRequest::get("https://example.com/later"), + StoresMetadata::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); + } +} diff --git a/crates/edgezero-adapter-spin/src/ingress.rs b/crates/edgezero-adapter-spin/src/ingress.rs new file mode 100644 index 00000000..afc8f521 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/ingress.rs @@ -0,0 +1,65 @@ +use edgezero_core::http::Uri; +use edgezero_core::ingress::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; + +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) +} + +#[cfg(test)] +mod ingress_tests { + use super::capture_request_ingress; + use edgezero_core::http::Uri; + use edgezero_core::ingress::{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 f1d5426b..e67817b5 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -7,6 +7,8 @@ pub mod cli; pub mod config_store; pub mod context; mod decompress; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +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 bf9543bf..9c08e1e3 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; 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; @@ -50,6 +51,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 +82,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/ingress.rs b/crates/edgezero-core/src/ingress.rs new file mode 100644 index 00000000..1fc72b86 --- /dev/null +++ b/crates/edgezero-core/src/ingress.rs @@ -0,0 +1,620 @@ +//! Confidential adapter-owned ingress facts and explicit preservation limits. +//! +//! Metadata describes what a runtime exposes; it cannot recover information +//! 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; + +/// 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 { + /// 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::ingress::{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. +/// `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, + global_field_order: Preservation, + octets: Preservation, + same_name_order: Preservation, +} + +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] + 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 established by the adapter. +/// +/// Syntax validation does not make a forwarded header trustworthy. Adapters +/// must establish provenance before calling the constructor. +#[derive(Clone)] +pub struct InboundOrigin { + authority: Authority, + scheme: &'static str, + source: OriginSource, +} + +impl InboundOrigin { + /// Borrow the validated authority in its source-provided spelling. + /// + /// Host case and explicit default ports are retained when the source exposes + /// 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.as_str() + } + + /// 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() > MAX_AUTHORITY_BYTES + || authority + .chars() + .any(|character| matches!(character, '@' | '/' | '?' | '#' | '\\')) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let parsed: Authority = authority + .parse() + .map_err(|_error| 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: 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] + 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)] +#[non_exhaustive] +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 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. + TransportBinding, +} + +/// Degree of preservation established for a particular fact. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[non_exhaustive] +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 without header-specific overrides. + /// + /// 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, + ) -> Self { + Self { + common_header_fidelity, + origin, + overrides: Vec::new(), + target, + } + } + + /// Borrow the validated origin, when an adapter-established 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 + } + + /// 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. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +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)] +#[non_exhaustive] +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::StatusCode; + use crate::http::header::{CONTENT_LENGTH, COOKIE}; + use crate::ingress::{ + 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, + ) + .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); + 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(), + ) + .with_header_overrides(vec![ + (COOKIE, HeaderFidelity::default()), + (COOKIE, HeaderFidelity::default()), + ]); + assert_eq!( + result + .expect_err("should reject ambiguous overrides") + .status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + } + + #[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] + 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/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa8545..c0f1e89e 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod ingress; pub mod introspection; pub mod key_value_store; pub mod manifest; diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index ae77619a..a5965f67 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -1,7 +1,9 @@ use std::collections::{HashMap, HashSet}; +use std::io::Error as IoError; 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 +16,27 @@ 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. + /// Continuing hooks may change headers, extensions, the body and URI query, + /// but must preserve the method and URI path. A violation is an internal error. + /// + /// # 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, @@ -74,6 +97,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. @@ -122,6 +146,7 @@ impl RouterBuilder { route_index, self.manifest_json, self.state_extensions, + self.pre_dispatch_hook, ) } @@ -175,6 +200,37 @@ impl RouterBuilder { self.route(path, Method::POST, handler) } + /// Install one hook before route lookup, state injection and middleware. + /// + /// 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_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 + } + #[must_use] #[inline] pub fn put(self, path: &str, handler: H) -> Self @@ -227,6 +283,7 @@ impl RouterBuilder { struct RouterInner { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_index: Arc<[RouteInfo]>, routes: HashMap>, state_extensions: Extensions, @@ -236,6 +293,16 @@ impl RouterInner { async fn dispatch(&self, mut request: Request) -> Result { let method = request.method().clone(); let path = request.uri().path().to_owned(); + if let Some(hook) = &self.pre_dispatch_hook { + if let Some(response) = hook.handle(&mut request).await? { + return Ok(response); + } + if request.method() != method || request.uri().path() != path { + return Err(EdgeError::internal(IoError::other( + "pre-dispatch hook changed the request method or path", + ))); + } + } match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { @@ -336,11 +403,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, @@ -1092,3 +1161,382 @@ 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 RoutingMutationHook { + method: bool, + terminal: bool, + } + + #[async_trait(?Send)] + impl PreDispatchHook for RoutingMutationHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + if self.method { + *request.method_mut() = Method::POST; + } else { + *request.uri_mut() = "/after".parse().expect("fixed URI"); + } + if self.terminal { + return response_builder() + .status(204) + .body(Body::empty()) + .map(Some) + .map_err(EdgeError::internal); + } + Ok(None) + } + } + + struct ContinueHook; + + #[async_trait(?Send)] + impl PreDispatchHook for ContinueHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!(request.extensions().get::().is_none()); + *request.uri_mut() = "/after?hook=present" + .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().uri().query(), Some("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(ContinueHook) + .post("/after", after_handler) + .build(); + let response = block_on(router.oneshot(input(Method::POST, "/after", Body::empty()))) + .expect("should dispatch continuing request"); + assert_eq!(response.body().as_bytes(), Some(b"continued".as_slice())); + assert_eq!(clones.load(Ordering::SeqCst), 1); + } + + #[test] + fn pre_dispatch_rejects_continued_method_or_path_changes() { + for method in [false, true] { + 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), + }) + .middleware(CountingMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook(RoutingMutationHook { + method, + terminal: false, + }) + .post("/before", unreachable_handler) + .get("/after", unreachable_handler) + .build(); + let error = block_on( + router + .clone() + .call(input(Method::GET, "/before", Body::empty())), + ) + .expect_err("routing mutation must fail before lookup"); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + error + .to_string() + .contains("pre-dispatch hook changed the request method or path") + ); + let response = block_on(router.oneshot(input(Method::GET, "/before", Body::empty()))) + .expect("oneshot renders routing mutation error"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn pre_dispatch_terminal_response_allows_method_or_path_changes() { + for method in [false, true] { + let router = RouterService::builder() + .pre_dispatch_hook(RoutingMutationHook { + method, + terminal: true, + }) + .build(); + let response = block_on(router.clone().call(input( + Method::GET, + "/before", + Body::empty(), + ))) + .expect("terminal response precedes mutation guard"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let oneshot_response = + block_on(router.oneshot(input(Method::GET, "/before", Body::empty()))) + .expect("terminal response through oneshot"); + assert_eq!(oneshot_response.status(), StatusCode::NO_CONTENT); + } + } + + #[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(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(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(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] + #[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(ContinueHook); + } + + #[test] + fn pre_dispatch_rejects_nonempty_or_failed_streams() { + let router = RouterService::builder() + .pre_dispatch_hook_arc(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(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/crates/edgezero-macros/tests/fixtures/hooked_app.toml b/crates/edgezero-macros/tests/fixtures/hooked_app.toml new file mode 100644 index 00000000..2058229d --- /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" +methods = ["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..d97db01c --- /dev/null +++ b/crates/edgezero-macros/tests/hooked_app.rs @@ -0,0 +1,197 @@ +//! 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", + 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::{LogLevel, ManifestLoader, ResolvedLoggingConfig}; + 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::App as 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 { + 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 { + RouterService::builder() + .pre_dispatch_hook(ReservedGate) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) + .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" + || request.headers().contains_key("x-example-stop") + { + 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) -> String { + assert_eq!(value, 42); + json.to_string() + } + + #[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"); + assert_eq!(actual.endpoint.as_deref(), Some("fixture_logs")); + assert_eq!(actual.level, LogLevel::Debug); + 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 app = HookedApp::build_app(); + assert_eq!(app.name(), "hooked-manifest-app"); + let router = app.router(); + 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 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() + .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()); + let matched_terminal = router + .oneshot( + request_builder() + .uri("/ordinary") + .header("x-example-stop", "true") + .body(Body::empty()) + .expect("matched request"), + ) + .await + .expect("matched_terminal response on matched route"); + assert_eq!(matched_terminal.status(), 204); + assert!(matched_terminal.headers().get("x-middleware").is_none()); + }); + } +} diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 80b3f3e0..fff6da6d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -284,6 +284,26 @@ 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. +## 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. 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 diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index dad084e8..c31cf367 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -348,6 +348,39 @@ 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. +## 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). + +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 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 +captured separately before that conversion. + ## Next Steps - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 8e77876f..0173aa8d 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -435,16 +435,16 @@ 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 -`fastly compute serve`, whose bundled Viceroy can differ. Neither local ceiling +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 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 @@ -601,6 +601,45 @@ 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. +## 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.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 +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 `dispatch_with_registries_and_ingress(app, req, stores, ingress, extend)` +to retain that snapshot after later native mutations. The registry dispatcher +preserves manifest store IDs through logical resource-link aliases and uses +each logical config store ID as its entry key. 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 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 +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 4bb4ac36..69e25e17 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` @@ -155,6 +155,76 @@ Fastly is the only adapter implementing `gc_config_entries` and the staging life actions (`DeployStaged`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return an unsupported error for those. +## Inbound request fidelity + +Adapters insert `edgezero_core::ingress::RequestIngress` into request extensions. +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 +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 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 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. +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 +fields, excluding HTTP framing and header-name spelling. Faithful copying from +a runtime HeaderMap does not establish original-wire preservation. Unproved +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. + +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. + +| 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 | 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 +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. 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 +buffers JSON and streams other bodies. Existing store/capability bootstrap and +outer finalizers retain their behavior. + ## Opt-in application retention Use retention when measurements show that constructing the app is expensive. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 38c5129e..2b3f22dd 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -318,7 +318,7 @@ export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" 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 @@ -349,9 +349,9 @@ async fn handle(req: Request) -> anyhow::Result { } ``` -The pinned SDK 6 macro exports a P3 HTTP interface. The `wasm32-wasip2` Rust target +The pinned SDK 7 macro exports a P3 HTTP interface. The `wasm32-wasip2` Rust target name does not establish the component's HTTP lifecycle. Verify the emitted -interface and host together. spin-sdk 6 requires Spin 3.7 or later. See Spin's +interface and host together. spin-sdk 7 requires Spin 4.1 or later. See Spin's [instance-reuse documentation](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse) for the reuse and concurrency controls your host version supports. @@ -361,6 +361,30 @@ 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. +## 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 +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.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 +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 diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index efefe3d3..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 @@ -228,6 +229,14 @@ EdgeZero provides these middleware out of the box: If you already hold an `Arc` (`BoxMiddleware`), register it with `.middleware_arc(...)` instead of `.middleware(...)`. +## 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. + ## Next Steps - Learn about [Streaming](/guide/streaming) for progressive responses diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 9c58c81d..24628fc6 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -182,6 +182,162 @@ 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. +## 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 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(ExampleHook) + .build(); +``` + +`Ok(None)` continues with modified headers, extensions, remaining body and URI +query. The method and URI path must remain unchanged; changing either returns +an internal error before route lookup or middleware. 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. + +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. A +hand-written `Hooks` implementation does not inherit the generated metadata. +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. +If the invocation has no app name, scaffolded projects use +`app!("../../edgezero.toml")`, which defines `App`; import it as +`use manifest_app::App as ManifestApp;` instead of the import below. + +When combining this with the hook example above, omit its illustrative +`let router = …build();` statement and duplicate `RouterService` import: + +```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, + 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 { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + + fn routes() -> RouterService { + RouterService::builder() + .pre_dispatch_hook(ExampleHook) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) + .with_state(42_u32) + // Re-register every manifest route and middleware here, in order. + .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 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()`). +Point every adapter entry point (`run_app::<…>`, `serve_app::<…>` and +`dispatch_app::<…>`) at `HookedApp`. 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. The lazy static parses and serializes the manifest once +per runtime instance. Cloudflare isolates and reused Spin P3 instances share +this cached JSON. Axum and Fastly `serve_app` build the application once, so +`routes()` already runs once there. Classic Fastly Compute starts a fresh +instance per request and pays the initialization cost each time. EdgeZero's +spin-sdk 7 `#[http_service]` entry points export WASI P3 HTTP components. +[Spin 4.1 defaults](https://github.com/spinframework/spin/blob/v4.1.0/crates/trigger-http/src/lib.rs) +allow up to 128 requests per instance and 16 concurrent requests, controlled by +`--max-instance-reuse-count` and `--max-instance-concurrent-reuse-count`. +Fresh instances still pay the initialization cost; reuse is not guaranteed. +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. + +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 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..18442b34 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -0,0 +1,112 @@ +# Pre-dispatch interception and inbound request fidelity implementation plan + +**Goal:** Method-independent interception before router dispatch, with bounded, +confidential metadata that accurately describes runtime-visible request facts. + +**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). + +## 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. Continued method/path changes return an internal error; + header, extension, body and query changes remain allowed. Futures may be + non-Send; hook values are Send + Sync. +- `crates/edgezero-core/src/ingress.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. 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, + 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, 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 + 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. A manual `Hooks` wrapper must +delegate manifest metadata and explicitly preserve routes, middleware, state and +manifest introspection; see the routing guide. + +## 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. The +worker-build version is declared once beside Spin CLI in +`[package.metadata.request-fidelity]`. 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 + +- 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 + +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 + +- 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 + 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. +- 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. +- 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 new file mode 100644 index 00000000..e7b65d10 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -0,0 +1,249 @@ +# Pre-dispatch interception and inbound request fidelity + +**Status:** Implemented API contract; current verification requirements and dependency pins are recorded in the matching plan. +**Date:** 2026-10-05. +**Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**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 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. + +## 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. + +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. Historical planning baseline and limits + +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. | +| 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` | 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. + +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. + +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. + +Before route lookup, `RouterInner::dispatch` snapshots the routing inputs and invokes the hook: + +```rust +let method = request.method().clone(); +let path = request.uri().path().to_owned(); +if let Some(hook) = &self.pre_dispatch_hook { + if let Some(response) = hook.handle(&mut request).await? { + return Ok(response); + } + if request.method() != method || request.uri().path() != path { + return Err(EdgeError::internal(IoError::other( + "pre-dispatch hook changed the request method or path", + ))); + } +} +``` + +The router snapshots the method/path before the hook and validates them after continuation. Only then may it 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 with modified headers, extensions, remaining body and URI query, preserving the method and URI path. Changing either routing input returns an internal error before lookup or lifecycle work. The consumer requires reserved requests to terminate locally and other paths to continue normally; it does not require method/path rewriting. +- `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 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 + +Create `crates/edgezero-core/src/ingress.rs` and export `pub mod ingress`. 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, 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. + +`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. + +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”. +- 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. +- Keep snapshot conversion helpers crate-private and expose 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()` 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 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: 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. + +### 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. Use the listener-established scheme (plain HTTP) plus one validated Host as `TransportBinding`; an origin-form URI without that binding has no 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 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: + +1. **EdgeZero infrastructure complete:** hook/API, concrete conversion fixes, metadata that honestly describes remaining limits, tests and docs pass. +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 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 EdgeZero's scope. + +## 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 method/path changes fail before lookup, state or middleware; headers, extensions, body and query mutations reach the handler. Terminal responses remain allowed after mutation; 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. Decision log and consumer boundary + +- 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. +- 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..287c2bca 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 @@ -31,12 +31,38 @@ cd "$REPO_ROOT" # crates/edgezero-cli/src/generator.rs -- the two parsers describe the # same grammar, and a difference between them shows up as this gate # reporting a missing pin for one that is merely reformatted. -# Prints nothing when there is no match; callers treat empty as missing. +# Require exactly one declaration and read its version value, independently +# of numeric feature names. Missing or duplicate declarations print nothing. +shared_version_in() { + awk -v package="$2" ' + { + gsub(/\\/, "") + sub(/^[[:space:]]*"/, "") + sub(/[[:space:]]*#.*/, "") + } + $0 ~ "^[[:space:]]*" package "[[:space:]]*=" { + count++ + sub(/^[^=]*=[[:space:]]*/, "") + if ($0 ~ /^\{/) { + if (!match($0, /[,{][[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]+"/)) next + value=substr($0, RSTART, RLENGTH) + sub(/^[^"]*"/, "", value) + } else { + if ($0 !~ /^"/) next + value=substr($0, 2) + } + sub(/".*/, "", value) + if (match(value, /^[~^=[:space:]]*[0-9]+(\.[0-9]+)*/)) { + found=substr(value, RSTART, RLENGTH) + sub(/^[~^=[:space:]]*/, "", found) + } + } + END { if (count == 1) print found } + ' "$1" +} + pin_in() { - grep -hoE '^[[:space:]]*(\\?")?validator[[:space:]]*=[^#]*' "$1" 2>/dev/null | - head -1 | - grep -oE '[0-9]+(\.[0-9]+)*' | - head -1 || true + shared_version_in "$1" validator } WANT="$(pin_in Cargo.toml)" @@ -74,16 +100,16 @@ 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. # Only the major is comparable -- the root spells it `~7.0`, the seeds `7`. spin_major_in() { - grep -hoE '^[[:space:]]*(\\?")?spin-sdk[[:space:]]*=[^#]*' "$1" 2>/dev/null | - head -1 | - grep -oE '[0-9]+' | - head -1 || true + local version + version="$(shared_version_in "$1" spin-sdk)" + printf '%s' "${version%%.*}" } SPIN_WANT="$(spin_major_in Cargo.toml)" @@ -95,6 +121,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 @@ -106,8 +133,67 @@ 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. +# 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 } + $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" ' + { 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) + sub(/^[[:space:]]+/, "", version) + 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 ' + { sub(/[[:space:]]*#.*/, "") } + /^\[/ { 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" + 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/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/scripts/test_shared_dep_pins.sh b/scripts/test_shared_dep_pins.sh new file mode 100644 index 00000000..0745fab6 --- /dev/null +++ b/scripts/test_shared_dep_pins.sh @@ -0,0 +1,219 @@ +#!/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" 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" ]] || ! 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 + 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" +} + +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 + if (mode == "duplicate") print + if (mode == "numeric-feature" || mode == "valid-numeric-feature") { + match($0, /"[~^=]*[0-9]+(\.[0-9]+)*"/) + version=substr($0, RSTART, RLENGTH); gsub(/"/, "", version) + if (mode == "numeric-feature") version="99.0.0" + $0=package " = { features = [\"wasi7\"], version = \"" version "\" }" + print + 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 validator spin-sdk; do + reset_fixture + mutate_shared_pin examples/app-demo/Cargo.toml "$package" duplicate + check_case "duplicate shared $package" 1 "violation: no $package pin found" + reset_fixture + mutate_shared_pin examples/app-demo/Cargo.toml "$package" numeric-feature + diagnostic="violation: validator 99.0.0" + if [[ "$package" == spin-sdk ]]; then diagnostic="violation: spin-sdk major 99"; fi + check_case "numeric feature cannot hide $package drift" 1 "$diagnostic" + reset_fixture + mutate_shared_pin examples/app-demo/Cargo.toml "$package" valid-numeric-feature + check_case "numeric feature before valid $package version" 0 +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 + 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" + 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 +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 "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/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..7f2fa0fe --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -0,0 +1,1777 @@ +//! 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 _; +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).stdin(Stdio::null()); + 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 }) + } + + 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"); + // Also stop the direct child if the external group signal failed. + let _result = self.child.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 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( + fixture: &Path, + program: &str, + arguments: &[&str], + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + let mut command = command(fixture, program, arguments); + 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 program_prefix = format!("{}: ", command.get_program().to_string_lossy()); + let describe = |error: io::Error| { + let message = error.to_string(); + let message = message.strip_prefix(&program_prefix).unwrap_or(&message); + io::Error::new(error.kind(), format!("{description}: {message}")) + }; + check_cancelled(cancelled).map_err(describe)?; + if !capture { + use std::os::fd::AsFd as _; + command.stdout(Stdio::from( + io::stderr() + .as_fd() + .try_clone_to_owned() + .map_err(describe)?, + )); + command.stderr(Stdio::inherit()); + return OwnedProcess::spawn(command) + .map_err(describe)? + .wait(cancelled) + .map(|()| String::new()) + .map_err(describe); + } + // A file avoids pipe backpressure while waiting interruptibly for build tools. + 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(Stdio::from(stderr.try_clone().map_err(describe)?)); + let result = OwnedProcess::spawn(command) + .map_err(describe)? + .wait(cancelled); + output.rewind().map_err(describe)?; + 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) + ), + )) + })?; + eprint!("{}", String::from_utf8_lossy(&diagnostics)); + String::from_utf8(bytes).map_err(|error| { + describe(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{error}; output: {}", + String::from_utf8_lossy(error.as_bytes()) + ), + )) + }) +} + +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}; found {actual:?} (output: {:?})", + output.trim() + ))); + } + 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(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"))?; + let mut command = command( + fixture, + "worker-build", + &[ + ".", + "--release", + "--no-opt", + "--out-dir", + "build/cloudflare", + "--", + "--features", + "cloudflare", + "--locked", + "--target-dir", + "target/cloudflare", + ], + ); + 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", + )); + } + } 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 with_runtime_log( + result: io::Result, + log_path: &Path, + output: &mut impl Write, +) -> io::Result { + if result.is_err() + && let Ok(log) = fs::read(log_path) + { + let log = String::from_utf8_lossy(&log); + let _result = writeln!(output, "{log}"); + } + result +} + +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!({}); + for platform in &options.platforms { + check_cancelled(cancelled)?; + if *platform == "spin" { + 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 + .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 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"))?) + .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" + ))); + } + } + Ok(observed) + })(); + drop(process); + 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()) +} + +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"GET"; + case.target = b"/ordinary"; + } + _ => 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"] == (platform != "fastly") + && verdict["spoofed_origin_ignored"] == true + && verdict["origin_available"] == true + && verdict["origin_matches_expected"] == true, + platform, + case, + "order capability 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 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 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); + with_runtime_log(result, &log_path, &mut io::stderr()) +} + +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, 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] + 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)); + let error = super::run_command( + Path::new("/tmp"), + program, + &["--version"], + &Arc::new(AtomicBool::new(false)), + true, + ) + .expect_err("missing command must fail"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(error.to_string().matches(program).count(), 1); + assert!(error.to_string().contains("--version")); + } + + #[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 '%s %s' probe out; printf '%s %s' probe err >&2; exit 3", + io::ErrorKind::Other, + "stdout: probe out", + ), + ("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("stderr: probe err"), "{message}"); + assert!(message.contains("exit status: 3"), "{message}"); + } + } + } + + #[test] + fn noncaptured_command_streams_in_order_and_preserves_failure() { + const CHILD: &str = "EDGEZERO_TEST_NONCAPTURE_CHILD"; + if std::env::var_os(CHILD).is_some() { + let error = super::run_command( + Path::new("/tmp"), + "/bin/sh", + &["-c", "printf 'out-first\\n'; printf 'err-middle\\n' >&2; printf 'out-last\\n'; exit 7"], + &Arc::new(AtomicBool::new(false)), + false, + ).expect_err("noncapture must preserve command failure"); + assert!(error.to_string().contains("exit status: 7"), "{error}"); + return; + } + let output = Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + "tests::noncaptured_command_streams_in_order_and_preserves_failure", + ]) + .env(CHILD, "1") + .output() + .expect("child test"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "out-first\nerr-middle\nout-last\n" + ); + } + + #[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 _; + 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 configured tool 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"); + 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() { + 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 [ + 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 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(); + 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 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"); + } + + #[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..ab5f9993 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.lock @@ -0,0 +1,2154 @@ +# 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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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-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.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 = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", +] + +[[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.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +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 = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "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 = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", +] + +[[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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "brotli 9.0.0", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +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 = [ + "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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 8.0.4", + "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", + "base16ct", + "brotli 8.0.4", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli 8.0.4", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.21", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "base16ct", + "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.11.0", + "thiserror 2.0.21", + "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.6", + "toml", + "validator", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" +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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "log-fastly" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "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-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spin-macro" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.21", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +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 = "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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" +dependencies = [ + "idna", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.7.1+wasi-0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.21", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" +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.13.2", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.13.2", + "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.13.2", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml new file mode 100644 index 00000000..8779a140 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "request-fidelity" +version = "0.1.0" +edition = "2024" +publish = false + +[package.metadata.request-fidelity] +spin-cli = "4.1.0" +worker-build = "0.8.5" + +[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.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 new file mode 100644 index 00000000..d09ddc3d --- /dev/null +++ b/tests/fixtures/request-fidelity/README.md @@ -0,0 +1,82 @@ +# Request ingress runtime fixtures + +Run from the repository root with the Rust and Node versions in `.tool-versions`: + +```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 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 +download. After `npm ci`, the runner requires the local esbuild binary, checks +its npm-locked version, and passes it as `ESBUILD_BIN`. +Wrangler (pinned in `package.json` / `package-lock.json`) 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 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: + +```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. +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: + +- 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.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. + 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.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` 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 + 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..4fcfb02d --- /dev/null +++ b/tests/fixtures/request-fidelity/observations.json @@ -0,0 +1,1441 @@ +{ + "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 + } + }, + "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": { + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "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": false, + "transfer_encoding_count": 0 + } + }, + "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": false, + "transfer_encoding_count": 0 + } + }, + "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..3fe12104 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/bin/fastly.rs @@ -0,0 +1,32 @@ +use edgezero_adapter_fastly::request::{ + capture_request_ingress, dispatch_with_registries_and_ingress, +}; +use edgezero_core::app::{Hooks, StoresMetadata}; +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(), + 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..25bb0b6e --- /dev/null +++ b/tests/fixtures/request-fidelity/src/cloudflare.rs @@ -0,0 +1,55 @@ +use crate::Fixture; +use edgezero_adapter_cloudflare::request::CloudflareService; +use edgezero_core::action; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::context::RequestContext; +use edgezero_core::ingress::{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 { + 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/src/lib.rs b/tests/fixtures/request-fidelity/src/lib.rs new file mode 100644 index 00000000..7286026f --- /dev/null +++ b/tests/fixtures/request-fidelity/src/lib.rs @@ -0,0 +1,153 @@ +//! 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::{Request, Response, response_builder}; +use edgezero_core::ingress::{CapturedTarget, Preservation, RequestIngress}; +use edgezero_core::middleware::{Middleware, Next}; +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(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" { + 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/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")] +); 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" 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.