diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90fb74172..293944ecf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,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' @@ -73,6 +74,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: >- @@ -154,7 +162,7 @@ jobs: - adapter: spin target: wasm32-wasip2 runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER - runner_value: wasmtime run + runner_value: wasmtime run -W component-model-async=y -S p3=y,http=y steps: - uses: actions/checkout@v6 with: @@ -184,10 +192,35 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Add wasm target run: rustup target add ${{ matrix.target }} + - name: Set up matching browser and driver + if: matrix.adapter == 'cloudflare' + id: chrome + uses: browser-actions/setup-chrome@v2 + with: + chrome-version: 154.0.8037.92 + install-chromedriver: true + install-dependencies: true + + - name: Set up Node for local Worker tooling + if: matrix.adapter == 'cloudflare' + uses: actions/setup-node@v6 + with: + node-version: 24.12.0 + cache: npm + cache-dependency-path: tests/fixtures/request-fidelity/package-lock.json + + - name: Bind browser tests to pinned Chrome + if: matrix.adapter == 'cloudflare' + env: + CHROME_BINARY: ${{ steps.chrome.outputs.chrome-path }} + run: | + node -e 'require("node:fs").writeFileSync(require("node:path").join(process.env.RUNNER_TEMP, "edgezero-webdriver.json"), JSON.stringify({"goog:chromeOptions": {binary: process.env.CHROME_BINARY}}))' + - name: Resolve wasm-bindgen CLI version if: matrix.adapter == 'cloudflare' id: wasm-bindgen-version @@ -278,6 +311,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 @@ -291,6 +326,43 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + # Spin's metadata helper runs on the host; its full lib test artifact + # imports Spin-specific interfaces unavailable in plain Wasmtime. + # The real Spin HTTP fixture below executes the converter instead. + - name: Install lock-matched worker-build + if: matrix.adapter == 'cloudflare' + run: cargo install worker-build --version 0.8.3 --locked --force + + - name: Install pinned Spin HTTP runtime + if: matrix.adapter == 'spin' + shell: bash + run: | + version="$(awk '$1 == "spin-cli" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.toml)" + install_dir="$RUNNER_TEMP/spin" + mkdir -p "$install_dir" + curl -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" + tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir" + echo "$install_dir" >> "$GITHUB_PATH" + "$install_dir/spin" --version + + - name: Check isolated fixture formatting + run: cargo fmt --manifest-path tests/fixtures/request-fidelity/Cargo.toml --all -- --check + + - name: Lint isolated ingress fixture + run: >- + cargo clippy --manifest-path tests/fixtures/request-fidelity/Cargo.toml + --features ${{ matrix.adapter }} --target ${{ matrix.target }} + --all-targets --locked -- -D warnings + + - name: Run local HTTP ingress probes + shell: bash + run: | + if [ "${{ matrix.adapter }}" = "cloudflare" ]; then + WASM_BINDGEN_BIN="$(command -v wasm-bindgen)" + export WASM_BINDGEN_BIN + fi + ./scripts/test-request-fidelity.sh ${{ matrix.adapter }} + - name: 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 1a5405c0d..acb969151 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -6,8 +6,11 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::sync::Arc; +use crate::request::HttpTransport; use anyhow::Context as _; use axum::Router; +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; @@ -299,10 +302,12 @@ async fn serve_with_stores( } service }; - let axum_router = Router::new().fallback_service(service_fn(move |req| { - let mut svc = service.clone(); - async move { svc.call(req).await } - })); + let axum_router = + Router::new().fallback_service(service_fn(move |mut req: AxumRequest| { + req.extensions_mut().insert(HttpTransport); + let mut svc = service.clone(); + async move { svc.call(req).await } + })); let make_service = axum_router.into_make_service_with_connect_info::(); let shutdown = enable_ctrl_c.then_some(async { @@ -745,6 +750,23 @@ mod integration_tests { use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::sleep; + use async_trait::async_trait; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderValue, Method, Request, Response, response_builder}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress}; + use edgezero_core::router::PreDispatchHook; + use std::io::{Read as _, Write as _}; + use std::net::TcpStream; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct IngressHook { + calls: Arc, + } + struct IngressMiddleware { + calls: Arc, + } + struct TestServer { _temp_dir: tempfile::TempDir, base_url: String, @@ -756,6 +778,145 @@ 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" => { + *request.method_mut() = Method::GET; + *request.uri_mut() = "/ordinary".parse().expect("should parse fixed route"); + return Ok(None); + } + "error" => return Err(EdgeError::bad_request("fixture rejection")), + "dot" => assert_eq!(request.uri().path(), "/reserved/%2e/../%2F//"), + _ => panic!("should use fixed fixture"), + } + Ok(Some( + response_builder() + .status(204) + .header("x-hook", "yes") + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + #[action] + async fn ingress_ordinary() -> &'static str { + "continued" + } + + fn raw_ingress_response(addr: SocketAddr, fixture: &[u8]) -> Vec { + let mut connection = TcpStream::connect(addr).expect("should connect listener"); + connection + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("should set timeout"); + connection + .write_all(fixture) + .expect("should send exact request"); + let mut response = Vec::new(); + connection + .read_to_end(&mut response) + .expect("should read response"); + response + } + + #[tokio::test(flavor = "multi_thread")] + async fn ingress_raw_tcp_distinguishes_parser_and_hook_behaviour() { + let hook_calls = Arc::new(AtomicUsize::new(0)); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .middleware(IngressMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook(Arc::new(IngressHook { + calls: Arc::clone(&hook_calls), + })) + .get("/ordinary", ingress_ordinary) + .build(); + let server = start_test_server(router).await; + let addr: SocketAddr = server + .base_url + .strip_prefix("http://") + .expect("should have HTTP binding") + .parse() + .expect("should parse listener address"); + let cases: [(&[u8], &[u8], usize); 7] = [ + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: repeat\r\nCookie: a=\xff\r\nOrigin: https://example.com\r\nX-Control: first\r\nCOOKIE: b=2\r\nOrigin: https://other.example.com\r\nX-Control: second\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", b"HTTP/1.1 400", 0), + (b"GET /reserved/%2e/../%2F// HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: dot\r\n\r\n", b"HTTP/1.1 204", 1), + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: continue\r\n\r\n", b"HTTP/1.1 200", 1), + (b"GET /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: error\r\n\r\n", b"HTTP/1.1 400", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: te-cl\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n0\r\n\r\n", b"HTTP/1.1 204", 1), + ]; + for (fixture, status, calls) in cases { + let before = hook_calls.load(Ordering::SeqCst); + let response = spawn_blocking(move || raw_ingress_response(addr, fixture)) + .await + .expect("should join socket probe"); + assert!( + response.starts_with(status), + "should return expected fixed fixture status" + ); + assert_eq!(hook_calls.load(Ordering::SeqCst) - before, calls); + } + assert_eq!(middleware_calls.load(Ordering::SeqCst), 1); + server.handle.abort(); + } + async fn start_test_server(router: RouterService) -> TestServer { let listener = TokioTcpListener::bind("127.0.0.1:0") .await diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index 9e3f5976d..b096101cc 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -4,14 +4,22 @@ use axum::body::{Body as AxumBody, to_bytes}; use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; -use edgezero_core::http::HeaderValue; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::CONTENT_TYPE; +use edgezero_core::http::header::{CONTENT_LENGTH, CONTENT_TYPE, HOST}; +use edgezero_core::http::{HeaderMap, HeaderValue, Uri}; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use crate::context::AxumRequestContext; use crate::proxy::AxumProxyClient; +/// Marks the plain HTTP listener owned by this adapter. +#[derive(Clone, Copy)] +pub(crate) struct HttpTransport; + /// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. /// @@ -19,7 +27,9 @@ use crate::proxy::AxumProxyClient; /// Returns an error if a buffered (`application/json`) body cannot be read into memory. #[inline] pub async fn into_core_request(request: Request) -> Result { - let (parts, axum_body) = request.into_parts(); + let (mut parts, axum_body) = request.into_parts(); + let transport = parts.extensions.remove::(); + let ingress = capture_request_ingress(&parts.uri, &parts.headers, transport); let body = match parts.headers.get(CONTENT_TYPE) { Some(value) if is_json_content_type(value) => { @@ -35,6 +45,7 @@ pub async fn into_core_request(request: Request) -> Result) -> Result, +) -> RequestIngress { + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let common = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + let content_length = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + RequestIngress::new( + target, + inbound_origin(uri, headers, transport), + common, + vec![(CONTENT_LENGTH, content_length)], + ) + .expect("should use unique header overrides") +} + +fn inbound_origin( + uri: &Uri, + headers: &HeaderMap, + transport: Option, +) -> Option { + transport?; + let mut hosts = headers.get_all(HOST).iter(); + let host = hosts.next()?.to_str().ok()?; + if hosts.next().is_some() { + return None; + } + let origin = InboundOrigin::parse("http", host, OriginSource::TransportBinding).ok()?; + if uri + .scheme_str() + .is_some_and(|scheme| !scheme.eq_ignore_ascii_case("http")) + { + return None; + } + if let Some(authority) = uri.authority() { + let supplied = + InboundOrigin::parse("http", authority.as_str(), OriginSource::TransportBinding) + .ok()?; + let parsed_host = format!("http://{host}/").parse::().ok()?; + let parsed_uri = format!("http://{}/", supplied.authority()) + .parse::() + .ok()?; + if !parsed_host.host()?.eq_ignore_ascii_case(parsed_uri.host()?) + || parsed_host.port_u16().unwrap_or(80) != parsed_uri.port_u16().unwrap_or(80) + { + return None; + } + } + Some(origin) +} + fn is_json_content_type(value: &HeaderValue) -> bool { let Ok(raw) = value.to_str() else { return false; @@ -203,3 +285,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::request::{ + CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, + TargetUnavailable, + }; + + #[tokio::test] + async fn ingress_retains_runtime_parts_without_trusting_headers() { + let mut request = request_builder() + .method(Method::from_bytes(b"EXAMPLE-METHOD").expect("should parse method")) + .uri("/reserved/%2e//%2F?q=a%2Fb") + .header("host", "example.com") + .header("forwarded", "proto=https;host=spoof.example.com") + .body(AxumBody::empty()) + .expect("should build request"); + request.headers_mut().append( + "cookie", + HeaderValue::from_bytes(b"a=\xff").expect("should parse bytes"), + ); + request + .headers_mut() + .append("cookie", HeaderValue::from_static("b=2")); + let core = into_core_request(request) + .await + .expect("should convert request"); + let ingress = core + .extensions() + .get::() + .expect("should insert metadata"); + assert!(ingress.origin().is_none()); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture whole target") + }; + assert_eq!(target.value(), "/reserved/%2e//%2F?q=a%2Fb"); + assert_eq!(core.method().as_str(), "EXAMPLE-METHOD"); + let values: Vec<_> = core + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(values, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + assert_eq!( + ingress + .header_fidelity(&"content-length".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } + + #[tokio::test] + async fn ingress_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 5dbb80fc8..c2e4dfc5c 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 21c5e1cdc..6bee0ff41 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -7,16 +7,18 @@ use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Method as CoreMethod, Request, Uri, request_builder}; +use edgezero_core::http::{HeaderValue, Method as CoreMethod, Request, Uri, request_builder}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; -use worker::{ - Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, -}; +use worker::{Context, Env, Error as WorkerError, Request as CfRequest, Response as CfResponse}; use crate::config_store::CloudflareConfigStore; use crate::context::CloudflareRequestContext; @@ -245,19 +247,41 @@ pub async fn into_core_request( env: Env, ctx: Context, ) -> Result { - let method = into_core_method(&req.method()); + 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 = req .url() - .map_err(|err| EdgeError::bad_request(format!("invalid URL: {err}")))?; + .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::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + let ingress = RequestIngress::new(target, origin, fidelity, Vec::new())?; let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); for (name, value) in headers.entries() { - builder = builder.header(name.as_str(), value); + let header_value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_error| EdgeError::bad_request("invalid runtime header value"))?; + builder = builder.header(name.as_str(), header_value); } let bytes = req.bytes().await.map_err(EdgeError::internal)?; @@ -266,6 +290,7 @@ pub async fn into_core_request( .body(Body::from(bytes)) .map_err(EdgeError::internal)?; + request.extensions_mut().insert(ingress); CloudflareRequestContext::insert(&mut request, env, ctx); request .extensions_mut() @@ -469,17 +494,6 @@ fn edge_error_to_worker(err: &EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } -fn into_core_method(method: &Method) -> CoreMethod { - let bytes = method.as_ref().as_bytes(); - CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { - log::warn!( - "unknown HTTP method {:?}, defaulting to GET", - method.as_ref() - ); - CoreMethod::GET - }) -} - fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { match CloudflareConfigStore::from_env(env, binding_name) { Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), @@ -568,26 +582,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 f2646a20b..f503f8192 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -25,8 +25,12 @@ mod tests { use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{Method, Response, StatusCode, response_builder}; - use edgezero_core::router::RouterService; + use edgezero_core::http::{Method, Request, Response, StatusCode, response_builder}; + use edgezero_core::request::{ + CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, + TargetUnavailable, + }; + use edgezero_core::router::{PreDispatchHook, RouterService}; use futures::stream; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; use worker::js_sys::Object; @@ -36,6 +40,10 @@ mod tests { wasm_bindgen_test_configure!(run_in_browser); + struct IngressHook { + continue_routing: bool, + } + struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] @@ -45,6 +53,180 @@ mod tests { } } + #[async_trait::async_trait(?Send)] + impl PreDispatchHook for IngressHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!(request.extensions().get::().is_some()); + assert_eq!(request.method().as_str(), "EXAMPLE-METHOD"); + if self.continue_routing { + *request.method_mut() = Method::GET; + *request.uri_mut() = "https://example.com/uri" + .parse() + .expect("should parse fixed URI"); + Ok(None) + } else { + Ok(Some( + response_builder() + .status(418) + .header("allow", "POST") + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + } + + #[wasm_bindgen_test] + async fn ingress_preserves_runtime_extension_methods_and_header_strings() { + for token in [ + "EXAMPLE-METHOD", + "example-method", + "GET", + "POST", + "PUT", + "DELETE", + ] { + let init = web_sys::RequestInit::new(); + init.set_method(token); + let headers = web_sys::Headers::new().expect("should create headers"); + headers + .append("x-octets", "\u{ff}\u{80}\u{c3}\u{a9}") + .expect("should append ByteString header"); + init.set_headers_headers(&headers); + let raw = + web_sys::Request::new_with_str_and_init("https://example.com/reserved", &init) + .expect("should create raw Web Request"); + let expected_method = raw.method(); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert raw request"); + assert_eq!(converted.method().as_str(), expected_method); + assert_eq!( + converted.headers()["x-octets"].as_bytes(), + "\u{ff}\u{80}\u{c3}\u{a9}".as_bytes() + ); + } + } + + #[wasm_bindgen_test] + async fn ingress_metadata_tracks_runtime_url_origin_and_coalescing() { + let oversized = format!("https://example.com/{}", "a".repeat(MAX_TARGET_BYTES)); + for url in [ + "https://example.com/reserved?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::new(IngressHook { continue_routing })) + .get("/uri", capture_uri_for_ingress) + .build(), + ); + let init = web_sys::RequestInit::new(); + init.set_method("EXAMPLE-METHOD"); + let raw = web_sys::Request::new_with_str_and_init("https://example.com/missing", &init) + .expect("should create extension request"); + let (env, ctx) = test_env_ctx(); + let response = CloudflareService::new(&app) + .dispatch(CfRequest::from(raw), env, ctx) + .await + .expect("should dispatch hook"); + assert_eq!( + response.status_code(), + if continue_routing { 200 } else { 418 } + ); + if !continue_routing { + assert_eq!( + response + .headers() + .get("allow") + .expect("should read header") + .as_deref(), + Some("POST") + ); + } + } + } + + #[edgezero_core::action] + async fn capture_uri_for_ingress() -> &'static str { + "continued" + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { let body = Body::text(ctx.request().uri().to_string()); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 6ba195a63..1a4862d84 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -151,6 +151,7 @@ where A: Hooks, F: FnOnce(&fastly::Request, &mut Extensions), { + let ingress = request::capture_request_ingress(&req); let stores = A::stores(); let logging = FastlyLogging::from(A::logging_for("fastly")); if logging.use_fastly_logger && !A::owns_logging() { @@ -158,7 +159,7 @@ where init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores, extend) + request::dispatch_with_registries_and_ingress(&app, req, stores, ingress, extend) } /// Dispatch with a config store wired explicitly. Its name and default key are diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index a0998f936..efcae752b 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,15 +1,21 @@ 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::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetUnavailable, +}; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -99,6 +105,7 @@ pub struct FastlyService<'app> { app: &'app App, config: ConfigSource, kv: Option, + request_ingress: Option, secrets: SecretSource, } @@ -122,6 +129,9 @@ impl<'app> FastlyService<'app> { /// the underlying handler returns an error. #[inline] pub fn dispatch(self, req: FastlyRequest) -> Result { + let ingress = self + .request_ingress + .unwrap_or_else(|| capture_request_ingress(&req)); let config_store = match self.config { ConfigSource::Handle(handle) => Some(handle), ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { @@ -150,6 +160,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + ingress, |_req, _extensions| {}, ) } @@ -163,6 +174,7 @@ impl<'app> FastlyService<'app> { app, config: ConfigSource::None, kv: None, + request_ingress: None, secrets: SecretSource::Off, } } @@ -234,6 +246,16 @@ impl<'app> FastlyService<'app> { self } + /// Retain metadata captured before native request mutations or shortcuts. + /// + /// The caller must establish the supplied snapshot's provenance and fidelity. + #[must_use] + #[inline] + pub fn with_request_ingress(mut self, ingress: RequestIngress) -> Self { + self.request_ingress = Some(ingress); + self + } + /// Enable the Fastly Secret Store and inject its handle. /// Non-required by default: an absent store leaves no secret /// handle in extensions and dispatch continues. Pair with @@ -304,6 +326,7 @@ fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, + ingress: RequestIngress, extend: F, ) -> Result where @@ -311,8 +334,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) } @@ -348,7 +372,30 @@ 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 adapter-captured facts. Store semantics match +/// [`dispatch_with_registries`]. +/// +/// # Errors +/// Returns an error if a declared KV store cannot be opened or dispatch fails. +#[inline] +pub fn dispatch_with_registries_and_ingress( + app: &App, + req: FastlyRequest, + stores: StoresMetadata, + 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)) @@ -372,6 +419,29 @@ 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 adapter-captured facts. Store semantics match +/// [`into_core_request_with_registries`]. +/// +/// # 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 fn into_core_request_with_registries_and_ingress( + req: FastlyRequest, + stores: StoresMetadata, + ingress: RequestIngress, + extend: F, +) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { @@ -379,8 +449,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 { @@ -505,10 +576,103 @@ 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::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ), + Vec::new(), + ) + .expect("should use unique header overrides") +} + +fn origin_from_runtime_uri<'header>( + uri: &Uri, + mut hosts: impl Iterator, +) -> Option { + let host = from_utf8(hosts.next()?).ok()?; + if hosts.next().is_some() { + return None; + } + let scheme = uri.scheme_str()?; + let _runtime_origin = + InboundOrigin::parse(scheme, uri.authority()?.as_str(), OriginSource::RuntimeUri).ok()?; + let origin = InboundOrigin::parse(scheme, host, OriginSource::RuntimeUri).ok()?; + let host_uri = Uri::builder() + .scheme(origin.scheme()) + .authority(origin.authority()) + .path_and_query("/") + .build() + .ok()?; + let default_port = if origin.scheme() == "https" { 443 } else { 80 }; + if !uri.host()?.eq_ignore_ascii_case(host_uri.host()?) + || uri.port_u16().unwrap_or(default_port) != host_uri.port_u16().unwrap_or(default_port) + { + return None; + } + Some(origin) +} + +/// Convert a native request, capturing ingress before native mutation. +/// +/// # Errors +/// Returns 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 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())?; @@ -800,3 +964,221 @@ mod synthesis_tests { let _handle = resolve_secret_handle(true); } } + +#[cfg(test)] +mod ingress_tests { + use super::{ + FastlyService, capture_request_ingress, dispatch_with_registries_and_ingress, + into_core_request_with_ingress, origin_from_runtime_uri, + }; + use crate::context::FastlyRequestContext; + use async_trait::async_trait; + use edgezero_core::app::{App, StoresMetadata}; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderValue, Request, Response, Uri, response_builder}; + use edgezero_core::proxy::ProxyHandle; + use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, OriginSource, Preservation, RequestIngress, TargetSource, + TargetUnavailable, + }; + use edgezero_core::router::{PreDispatchHook, RouterService}; + use fastly::Request as FastlyRequest; + use std::sync::Arc; + + #[derive(Clone)] + struct ScratchMarker; + + struct VerifySnapshot { + scratch_expected: bool, + } + + #[async_trait(?Send)] + impl PreDispatchHook for VerifySnapshot { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert_eq!( + request.extensions().get::().is_some(), + self.scratch_expected + ); + let ingress = request + .extensions() + .get::() + .expect("should expose chosen snapshot"); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should retain captured target") + }; + assert_eq!(target.value(), "/reserved/%2e"); + Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + #[test] + fn ingress_runtime_origin_accepts_one_matching_authority() { + for (url, host, expected_scheme) in [ + ("http://example.com/reserved", "EXAMPLE.com:80", "http"), + ("https://example.com/reserved", "example.com:443", "https"), + ( + "https://example.com:8443/reserved", + "example.com:8443", + "https", + ), + ("https://[::1]/reserved", "[::1]:443", "https"), + ] { + let uri: Uri = url.parse().expect("should parse runtime URI"); + let origin = origin_from_runtime_uri(&uri, [host.as_bytes()].into_iter()) + .expect("should recover the matching runtime origin"); + assert_eq!( + origin.scheme(), + expected_scheme, + "should use runtime scheme" + ); + assert_eq!( + origin.authority(), + host, + "should retain validated authority" + ); + assert_eq!( + origin.source(), + OriginSource::RuntimeUri, + "should identify runtime provenance" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_missing_duplicate_and_conflicting_hosts() { + let uri: Uri = "https://example.com/reserved" + .parse() + .expect("should parse URI"); + for hosts in [ + vec![], + vec![b"example.com".as_slice(), b"example.com".as_slice()], + vec![b"other.example.com".as_slice()], + vec![b"example.com:80".as_slice()], + vec![b"example.com, other.example.com".as_slice()], + vec![b"example.com:invalid".as_slice()], + vec![b"a=\xff".as_slice()], + ] { + assert!( + origin_from_runtime_uri(&uri, hosts.into_iter()).is_none(), + "should not invent an origin from unavailable or inconsistent authority" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_invalid_runtime_authorities() { + for url in [ + "https://user@example.com/reserved", + "https://example.com:invalid/reserved", + "https://example.com:65536/reserved", + ] { + let uri: Uri = url + .parse() + .expect("should represent invalid runtime authority"); + assert!( + origin_from_runtime_uri(&uri, [b"example.com".as_slice()].into_iter()).is_none(), + "should validate the complete runtime authority before comparing host or default port" + ); + } + } + + fn snapshot() -> RequestIngress { + RequestIngress::new( + CapturedTarget::capture( + "/reserved/%2e", + TargetSource::TransportRequestTarget, + Preservation::Unknown, + ), + None, + HeaderFidelity::default(), + Vec::new(), + ) + .expect("should build snapshot") + } + + #[test] + fn ingress_current_sdk_reports_missing_target_and_no_synthetic_origin() { + let native = FastlyRequest::get("https://example.com/reserved"); + let ingress = capture_request_ingress(&native); + assert!(matches!( + ingress.target(), + CapturedTarget::Unavailable(TargetUnavailable::NotExposed) + )); + assert!(ingress.origin().is_none()); + } + + #[test] + fn ingress_snapshot_and_header_bytes_survive_conversion() { + let mut native = FastlyRequest::new("EXAMPLE-METHOD", "https://example.com/later"); + native.append_header("cookie", b"a=\xff".as_slice()); + native.append_header("cookie", "b=2"); + let client_ip = native.get_client_ip_addr(); + let core = + into_core_request_with_ingress(native, snapshot()).expect("should convert snapshot"); + assert_eq!( + FastlyRequestContext::get(&core) + .expect("should preserve context") + .client_ip, + client_ip + ); + assert!(core.extensions().get::().is_some()); + assert_eq!(core.method().as_str(), "EXAMPLE-METHOD"); + let values: Vec<_> = core + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(values, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + let CapturedTarget::Complete(target) = core + .extensions() + .get::() + .expect("should carry snapshot") + .target() + else { + panic!("should retain target") + }; + assert_eq!(target.value(), "/reserved/%2e"); + } + + #[test] + fn ingress_registry_dispatch_wins_scratch_collision() { + let app = App::new( + RouterService::builder() + .pre_dispatch_hook(Arc::new(VerifySnapshot { + scratch_expected: true, + })) + .build(), + ); + let response = dispatch_with_registries_and_ingress( + &app, + FastlyRequest::get("https://example.com/later"), + StoresMetadata::default(), + snapshot(), + |native, scratch| { + scratch.insert(capture_request_ingress(native)); + scratch.insert(ScratchMarker); + }, + ) + .expect("should dispatch with chosen snapshot"); + assert_eq!(response.get_status().as_u16(), 204); + let service_app = App::new( + RouterService::builder() + .pre_dispatch_hook(Arc::new(VerifySnapshot { + scratch_expected: false, + })) + .build(), + ); + let service_response = FastlyService::new(&service_app) + .with_request_ingress(snapshot()) + .dispatch(FastlyRequest::get("https://example.com/later")) + .expect("should dispatch service snapshot"); + assert_eq!(service_response.get_status().as_u16(), 204); + } +} diff --git a/crates/edgezero-adapter-spin/src/context.rs b/crates/edgezero-adapter-spin/src/context.rs index 4061d4715..88b70e393 100644 --- a/crates/edgezero-adapter-spin/src/context.rs +++ b/crates/edgezero-adapter-spin/src/context.rs @@ -4,6 +4,13 @@ use std::net::SocketAddr; use edgezero_core::http::Request; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::http::Uri; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; /// Platform-specific request context for Spin. /// /// Spin exposes client information via special headers @@ -32,6 +39,34 @@ impl SpinRequestContext { } } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[expect( + clippy::expect_used, + reason = "the empty override list cannot contain duplicate names" +)] +pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let headers = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + RequestIngress::new(target, origin, headers, Vec::new()) + .expect("should use unique header overrides") +} + /// Parse an IP address from a `host:port` string. /// /// Falls back to parsing the raw value as a bare IP (no port) and also @@ -116,3 +151,45 @@ mod tests { assert_eq!(ip, IpAddr::from_str("::1").unwrap()); } } +#[cfg(test)] +mod ingress_tests { + use super::capture_request_ingress; + use edgezero_core::http::Uri; + use edgezero_core::request::{CapturedTarget, OriginSource, Preservation}; + + #[test] + fn ingress_captures_runtime_uri_and_origin_only() { + let uri: Uri = "https://example.com/reserved/%2e//%2F?q=a%2Fb" + .parse() + .expect("should parse URI"); + let ingress = capture_request_ingress(&uri); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture target") + }; + assert_eq!(target.value(), uri.to_string()); + let origin = ingress.origin().expect("should capture runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!( + ingress + .header_fidelity(&"cookie".parse().expect("should parse name")) + .global_field_order(), + Preservation::Unavailable + ); + assert!( + capture_request_ingress(&"/reserved".parse().expect("should parse path")) + .origin() + .is_none() + ); + assert!( + capture_request_ingress( + &"ftp://example.com/reserved" + .parse() + .expect("should parse URI") + ) + .origin() + .is_none() + ); + } +} diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index bf9543bf0..9c47e22fc 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -5,7 +5,7 @@ use anyhow::Context as _; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; -use crate::context::{SpinRequestContext, parse_client_addr}; +use crate::context::{SpinRequestContext, capture_request_ingress, parse_client_addr}; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; @@ -50,6 +50,7 @@ pub(crate) struct Stores { #[inline] pub async fn into_core_request(req: SpinRequest) -> Result { let (parts, body) = req.into_parts(); + let ingress = capture_request_ingress(&parts.uri); let client_addr = parts .headers @@ -80,6 +81,7 @@ pub async fn into_core_request(req: SpinRequest) -> Result { .body(Body::from(body_bytes.to_vec())) .map_err(|err| EdgeError::bad_request(format!("failed to build request: {err}")))?; + request.extensions_mut().insert(ingress); SpinRequestContext::insert( &mut request, SpinRequestContext { diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa85456..58bb9187a 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod manifest; pub mod middleware; pub mod params; pub mod proxy; +pub mod request; pub mod responder; pub mod response; pub mod router; diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs new file mode 100644 index 000000000..8872aef40 --- /dev/null +++ b/crates/edgezero-core/src/request.rs @@ -0,0 +1,485 @@ +//! Confidential adapter-owned ingress facts and explicit preservation limits. +//! +//! Metadata describes what a runtime exposes; it cannot recover information +//! erased before conversion. It is intentionally neither serializable nor +//! printable because captured targets and authorities can contain private data. + +use std::net::Ipv6Addr; +use std::sync::Arc; + +use crate::error::EdgeError; +use crate::http::{HeaderName, Uri}; + +/// Maximum complete target metadata, measured in UTF-8 bytes. +pub const MAX_TARGET_BYTES: usize = 16 * 1024; + +/// A complete bounded target or an explicit acquisition failure. +#[derive(Clone)] +pub enum CapturedTarget { + /// A complete value created only by the bounded capture constructor. + Complete(CompleteTarget), + /// No complete target is available. + Unavailable(TargetUnavailable), +} + +impl CapturedTarget { + /// Capture a whole runtime target without retaining an oversized prefix. + #[inline] + #[must_use] + pub fn capture(value: &str, source: TargetSource, fidelity: Preservation) -> Self { + if value.len() > MAX_TARGET_BYTES { + return Self::Unavailable(TargetUnavailable::TooLarge); + } + Self::Complete(CompleteTarget { + fidelity, + source, + value: Arc::from(value), + }) + } +} + +/// A complete bounded target with explicit provenance. +/// +/// Fields remain private so callers cannot bypass the capture bound. +/// +/// ```compile_fail +/// use edgezero_core::request::{CompleteTarget, Preservation, TargetSource}; +/// let target = CompleteTarget { +/// value: std::sync::Arc::from("http://example.com/"), +/// source: TargetSource::RuntimeUrl, +/// fidelity: Preservation::Unknown, +/// }; +/// ``` +#[derive(Clone)] +pub struct CompleteTarget { + fidelity: Preservation, + source: TargetSource, + value: Arc, +} + +impl CompleteTarget { + /// Return the established target preservation guarantee. + #[inline] + #[must_use] + pub const fn fidelity(&self) -> Preservation { + self.fidelity + } + + /// Return the runtime acquisition source. + #[inline] + #[must_use] + pub const fn source(&self) -> TargetSource { + self.source + } + + /// Borrow the complete captured target. + #[inline] + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + +/// Independent original-field preservation facts. +/// +/// These axes compare original HTTP field values with core headers; successful +/// runtime-to-core copying alone does not establish original-wire preservation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct HeaderFidelity { + field_multiplicity: Preservation, + global_field_order: Preservation, + octets: Preservation, + same_name_order: Preservation, +} + +impl HeaderFidelity { + /// Return original field-count preservation for this header. + #[inline] + #[must_use] + pub const fn field_multiplicity(&self) -> Preservation { + self.field_multiplicity + } + + /// Return original ordering across different field names. + #[inline] + #[must_use] + pub const fn global_field_order(&self) -> Preservation { + self.global_field_order + } + + /// Construct four independent fidelity guarantees. + #[inline] + #[must_use] + pub const fn new( + octets: Preservation, + field_multiplicity: Preservation, + same_name_order: Preservation, + global_field_order: Preservation, + ) -> Self { + Self { + field_multiplicity, + global_field_order, + octets, + same_name_order, + } + } + + /// Return original field-value byte preservation. + #[inline] + #[must_use] + pub const fn octets(&self) -> Preservation { + self.octets + } + + /// Return original value ordering within a single field name. + #[inline] + #[must_use] + pub const fn same_name_order(&self) -> Preservation { + self.same_name_order + } +} + +/// Bounded validated origin facts supplied by a trusted adapter source. +/// +/// Syntax validation does not make a forwarded header trustworthy. Adapters +/// must establish provenance before calling the constructor. +#[derive(Clone)] +pub struct InboundOrigin { + authority: Arc, + scheme: &'static str, + source: OriginSource, +} + +impl InboundOrigin { + /// Borrow the validated authority, including any explicit port. + #[inline] + #[must_use] + pub fn authority(&self) -> &str { + &self.authority + } + + /// Validate a bounded HTTP(S) scheme and authority. + /// + /// # Errors + /// + /// Returns a bounded validation error for invalid or oversized input. + #[inline] + pub fn parse(scheme: &str, authority: &str, source: OriginSource) -> Result { + let normalized_scheme = if scheme.eq_ignore_ascii_case("http") { + "http" + } else if scheme.eq_ignore_ascii_case("https") { + "https" + } else { + return Err(EdgeError::bad_request("invalid ingress origin")); + }; + if authority.is_empty() + || authority.len() > 1024 + || authority + .chars() + .any(|character| matches!(character, '@' | '/' | '?' | '#' | '\\')) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let uri = Uri::builder() + .scheme(normalized_scheme) + .authority(authority) + .path_and_query("/") + .build() + .map_err(|_error| EdgeError::bad_request("invalid ingress origin"))?; + let parsed = uri + .authority() + .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; + let host = parsed.host(); + if host.chars().any(|character| matches!(character, '[' | ']')) + && host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .is_none_or(|inner| inner.parse::().is_err()) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let suffix = authority + .strip_prefix(parsed.host()) + .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; + if parsed.host().is_empty() + || (!suffix.is_empty() + && suffix.strip_prefix(':').is_none_or(|port| { + port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err() + })) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + Ok(Self { + authority: Arc::from(authority), + scheme: normalized_scheme, + source, + }) + } + + /// Return the normalized HTTP(S) scheme. + #[inline] + #[must_use] + pub const fn scheme(&self) -> &str { + self.scheme + } + + /// Return the adapter-owned origin acquisition source. + #[inline] + #[must_use] + pub const fn source(&self) -> OriginSource { + self.source + } +} + +/// Provenance of a validated inbound origin. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OriginSource { + /// Scheme and authority supplied by the runtime request URI. + RuntimeUri, + /// A trusted transport scheme combined with one validated inbound request authority. + /// + /// The authority identifies the requested origin; it does not establish + /// that an operator approves or owns that host. + TransportBinding, +} + +/// Degree of preservation established for a particular fact. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Preservation { + /// Pinned source and transport evidence establish equality. + Preserved, + /// A known transformation changed the original information. + Transformed, + /// The runtime does not expose the required information. + Unavailable, + /// Preservation has not been established. + #[default] + Unknown, +} + +/// Confidential adapter-owned metadata stored in request extensions. +/// +/// Absence means unknown capability, not complete preservation. Existing +/// headers remain in the request `HeaderMap` and are never duplicated here. +#[derive(Clone)] +pub struct RequestIngress { + common_header_fidelity: HeaderFidelity, + origin: Option, + overrides: Vec<(HeaderName, HeaderFidelity)>, + target: CapturedTarget, +} + +impl RequestIngress { + /// Return a header-specific guarantee or the common conservative default. + #[inline] + #[must_use] + pub fn header_fidelity(&self, name: &HeaderName) -> HeaderFidelity { + self.overrides + .iter() + .find_map(|(candidate, fidelity)| (candidate == name).then_some(*fidelity)) + .unwrap_or(self.common_header_fidelity) + } + + /// Construct metadata with unambiguous per-header fidelity overrides. + /// + /// Sources and preservation guarantees are caller assertions. Validation + /// checks override names, not the provenance or fidelity of captured facts. + /// + /// # Errors + /// + /// Returns a bounded validation error for duplicate override names. + #[inline] + pub fn new( + target: CapturedTarget, + origin: Option, + common_header_fidelity: HeaderFidelity, + mut overrides: Vec<(HeaderName, HeaderFidelity)>, + ) -> Result { + overrides.sort_unstable_by(|left, right| left.0.as_str().cmp(right.0.as_str())); + if overrides.windows(2).any(|pair| { + let [left, right] = pair else { return false }; + left.0 == right.0 + }) { + return Err(EdgeError::bad_request("duplicate ingress header override")); + } + Ok(Self { + common_header_fidelity, + origin, + overrides, + target, + }) + } + + /// Borrow the validated origin, when its trusted source is available. + #[inline] + #[must_use] + pub const fn origin(&self) -> Option<&InboundOrigin> { + self.origin.as_ref() + } + + /// Borrow complete target metadata or its explicit unavailability reason. + #[inline] + #[must_use] + pub const fn target(&self) -> &CapturedTarget { + &self.target + } +} + +/// Source of captured target information, independent of fidelity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetSource { + /// A runtime path/query before additional `EdgeZero` parsing. + RuntimePathAndQuery, + /// A runtime-exposed URL; earlier normalization may have occurred. + RuntimeUrl, + /// A transport-exposed request target with evidence of its provenance. + TransportRequestTarget, +} + +/// Why no complete target was retained. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TargetUnavailable { + /// The runtime offers no safe acquisition API. + NotExposed, + /// Acquisition failed without retaining error text. + ReadFailed, + /// The target exceeded the metadata bound. + TooLarge, +} + +#[cfg(test)] +mod tests { + use crate::http::header::{CONTENT_LENGTH, COOKIE}; + use crate::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, TargetUnavailable, + }; + + #[test] + fn ingress_fidelity_defaults_are_unknown() { + let fidelity = HeaderFidelity::default(); + assert_eq!(fidelity.octets(), Preservation::Unknown); + assert_eq!(fidelity.field_multiplicity(), Preservation::Unknown); + assert_eq!(fidelity.same_name_order(), Preservation::Unknown); + assert_eq!(fidelity.global_field_order(), Preservation::Unknown); + } + + #[test] + fn ingress_header_override_is_field_specific() { + let common = HeaderFidelity::default(); + let folded = HeaderFidelity::new( + Preservation::Unknown, + Preservation::Transformed, + Preservation::Unknown, + Preservation::Unavailable, + ); + let metadata = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + common, + vec![(CONTENT_LENGTH, folded)], + ) + .expect("should accept distinct header overrides"); + assert_eq!(metadata.header_fidelity(&CONTENT_LENGTH), folded); + assert_eq!(metadata.header_fidelity(&COOKIE), common); + assert!(metadata.origin().is_none()); + } + + #[test] + fn ingress_origin_accepts_bounded_http_authorities() { + for (scheme, authority) in [ + ("HTTP", "example.com"), + ("https", "example.com:443"), + ("http", "[::1]:8787"), + ] { + let origin = InboundOrigin::parse(scheme, authority, OriginSource::TransportBinding) + .expect("should accept a validated HTTP origin"); + assert_eq!(origin.scheme(), scheme.to_ascii_lowercase()); + assert_eq!(origin.authority(), authority); + assert_eq!(origin.source(), OriginSource::TransportBinding); + } + } + + #[test] + fn ingress_origin_rejects_untrusted_or_invalid_input() { + for (scheme, authority) in [ + ("ftp", "example.com"), + ("https", "user@example.com"), + ("https", "example.com/path"), + ("https", "example.com?secret=1"), + ("https", "example.com#fragment"), + ("https", "example.com:invalid"), + ("https", "example.com:65536"), + ("https", "example.com:+443"), + ("https", "[::1]:+443"), + ("https", "[]"), + ("https", "[not-an-ip]"), + ("https", "example[bad]"), + ("https", ""), + ("https", "example.com\\evil"), + ] { + assert!( + InboundOrigin::parse(scheme, authority, OriginSource::RuntimeUri) + .err() + .is_some(), + "should reject malformed authority without retaining it", + ); + } + assert!( + InboundOrigin::parse("https", &"a".repeat(1025), OriginSource::RuntimeUri) + .err() + .is_some(), + "should bound origin metadata", + ); + } + + #[test] + fn ingress_rejects_duplicate_overrides() { + let result = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::ReadFailed), + None, + HeaderFidelity::default(), + vec![ + (COOKIE, HeaderFidelity::default()), + (COOKIE, HeaderFidelity::default()), + ], + ); + assert!( + result.err().is_some(), + "should reject ambiguous override selection" + ); + } + + #[test] + fn ingress_target_bound_is_atomic() { + let mut value = "a".repeat(16 * 1024); + let target = + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Transformed); + assert!( + matches!(&target, CapturedTarget::Complete(complete) if complete.value() == value), + "should preserve the entire value at the bound", + ); + value.push('b'); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should retain no truncated prefix", + ); + } + + #[test] + fn ingress_target_counts_utf8_bytes() { + let value = "\u{e9}".repeat(8193); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should bound bytes rather than Unicode characters", + ); + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index e2e58a5c5..22e399fc7 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::task::{Context, Poll}; +use async_trait::async_trait; use matchit::Router as PathRouter; use tower_service::Service; @@ -14,6 +15,25 @@ use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; +/// Shared optional interception performed before router lookup and state injection. +pub type BoxPreDispatchHook = Arc; + +/// Inspect or terminate a request before method/path lookup and route middleware. +/// +/// The hook receives adapter extensions but no router-injected state or +/// introspection. It may capture application state in its own implementation. +/// Futures deliberately do not require `Send` so WASM runtimes remain supported. +#[async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + /// Return a terminal response, or continue with the mutated request. + /// + /// # Errors + /// + /// Errors prevent dispatch. [`Service::call`] propagates them, while + /// [`RouterService::oneshot`] uses the existing error-response rendering. + async fn handle(&self, request: &mut Request) -> Result, EdgeError>; +} + struct RouteEntry { handler: BoxHandler, introspection_needs: IntrospectionNeeds, @@ -71,6 +91,7 @@ enum RouteMatch<'route> { pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_info: Vec, routes: HashMap>, /// App state registered via [`RouterBuilder::with_state`], keyed by type. @@ -119,6 +140,7 @@ impl RouterBuilder { route_index, self.manifest_json, self.state_extensions, + self.pre_dispatch_hook, ) } @@ -172,6 +194,18 @@ impl RouterBuilder { self.route(path, Method::POST, handler) } + /// Install one hook before route lookup, state injection and middleware. + /// + /// Repeated registration replaces the previous hook. Router clones share + /// the same instance. None of the normal route lifecycle runs after a + /// terminal response; outer adapter/application finalizers may still run. + #[must_use] + #[inline] + pub fn pre_dispatch_hook(mut self, hook: BoxPreDispatchHook) -> Self { + self.pre_dispatch_hook = Some(hook); + self + } + #[must_use] #[inline] pub fn put(self, path: &str, handler: H) -> Self @@ -221,6 +255,7 @@ impl RouterBuilder { struct RouterInner { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_index: Arc<[RouteInfo]>, routes: HashMap>, state_extensions: Extensions, @@ -228,6 +263,11 @@ struct RouterInner { impl RouterInner { async fn dispatch(&self, mut request: Request) -> Result { + if let Some(hook) = &self.pre_dispatch_hook + && let Some(response) = hook.handle(&mut request).await? + { + return Ok(response); + } let method = request.method().clone(); let path = request.uri().path().to_owned(); @@ -330,11 +370,13 @@ impl RouterService { route_index: Arc<[RouteInfo]>, manifest_json: Option>, state_extensions: Extensions, + pre_dispatch_hook: Option, ) -> Self { Self { inner: Arc::new(RouterInner { manifest_json, middlewares, + pre_dispatch_hook, route_index, routes, state_extensions, @@ -1086,3 +1128,307 @@ mod tests { assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); } } + +#[cfg(test)] +mod pre_dispatch_tests { + use std::io::Error as IoError; + use std::mem::take; + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use async_trait::async_trait; + use bytes::Bytes; + use futures::executor::block_on; + use futures::future::ready; + use futures::stream; + use tower_service::Service as _; + + use crate::action; + use crate::body::Body; + use crate::context::RequestContext; + use crate::error::EdgeError; + use crate::http::{Method, Request, Response, StatusCode, request_builder, response_builder}; + use crate::introspection::{ManifestJson, RouteTable}; + use crate::middleware::{Middleware, Next}; + use crate::router::{PreDispatchHook, RouterService}; + + struct CountedState { + clones: Arc, + } + + impl Clone for CountedState { + fn clone(&self) -> Self { + self.clones.fetch_add(1, Ordering::SeqCst); + Self { + clones: Arc::clone(&self.clones), + } + } + + fn clone_from(&mut self, source: &Self) { + *self = source.clone(); + } + } + + struct CountingMiddleware { + calls: Arc, + } + + #[async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + #[derive(Clone)] + struct HookMarker; + + struct InspectHook; + + #[async_trait(?Send)] + impl PreDispatchHook for InspectHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + let local = Rc::new(7_u32); + let body = take(request.body_mut()); + body.into_bytes_bounded(0).await?; + ready(()).await; + assert_eq!(*local, 7_u32, "should support a non-Send hook future"); + Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )) + } + } + + struct RewriteHook; + + #[async_trait(?Send)] + impl PreDispatchHook for RewriteHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!(request.extensions().get::().is_none()); + *request.method_mut() = Method::POST; + *request.uri_mut() = "/after".parse().expect("should parse fixed URI"); + request.headers_mut().insert( + "x-hook", + "present".parse().expect("should parse fixed header"), + ); + request.extensions_mut().insert(HookMarker); + *request.body_mut() = Body::text("changed"); + Ok(None) + } + } + + struct TerminalHook { + calls: Arc, + fail: bool, + } + + #[async_trait(?Send)] + impl PreDispatchHook for TerminalHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + self.calls.fetch_add(1, Ordering::SeqCst); + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + assert!(request.extensions().get::().is_none()); + if self.fail { + return Err(EdgeError::bad_request("hook rejected request")); + } + Ok(Some( + response_builder() + .status(418) + .header("allow", "POST") + .header("www-authenticate", "Basic") + .body(Body::text("local")) + .map_err(EdgeError::internal)?, + )) + } + } + + #[action] + async fn after_handler(context: RequestContext) -> Result { + assert!(context.request().extensions().get::().is_some()); + assert!( + context + .request() + .extensions() + .get::() + .is_some() + ); + assert_eq!(context.request().headers()["x-hook"], "present"); + assert_eq!( + context.request().body().as_bytes(), + Some(b"changed".as_slice()) + ); + response_builder() + .body(Body::text("continued")) + .map_err(EdgeError::internal) + } + + #[action(manifest, routes)] + async fn unreachable_handler() -> &'static str { + panic!("should short-circuit before handler"); + } + + fn input(method: Method, path: &str, body: Body) -> Request { + request_builder() + .method(method) + .uri(path) + .body(body) + .expect("should build hook input") + } + + #[test] + fn pre_dispatch_continuation_uses_mutated_request() { + let clones = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .pre_dispatch_hook(Arc::new(RewriteHook)) + .post("/after", after_handler) + .build(); + let response = block_on(router.oneshot(input(Method::GET, "/before", Body::empty()))) + .expect("should dispatch rewritten request"); + assert_eq!(response.body().as_bytes(), Some(b"continued".as_slice())); + assert_eq!(clones.load(Ordering::SeqCst), 1); + } + + #[test] + fn pre_dispatch_errors_stop_routing_in_both_service_apis() { + let clones = Arc::new(AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: true, + })) + .post("/after", after_handler) + .build(); + let error = block_on( + router + .clone() + .call(input(Method::POST, "/after", Body::empty())), + ) + .expect_err("should propagate hook error from Service"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + let response = block_on(router.oneshot(input(Method::POST, "/after", Body::empty()))) + .expect("should render hook error from oneshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_hook_is_shared_across_router_clones() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .build(); + for service in [router.clone(), router] { + let response = block_on(service.oneshot(input(Method::GET, "/missing", Body::empty()))) + .expect("should reuse shared hook"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_inspects_empty_stream_without_send_bound() { + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::new()), Ok(Bytes::new())]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should inspect clean streamed EOF"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[test] + fn pre_dispatch_registration_replaces_previous_hook() { + let first = Arc::new(AtomicUsize::new(0)); + let second = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&first), + fail: true, + })) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&second), + fail: false, + })) + .build(); + let response = block_on(router.oneshot(input(Method::GET, "/unknown", Body::empty()))) + .expect("should run replacement hook only"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(first.load(Ordering::SeqCst), 0); + assert_eq!(second.load(Ordering::SeqCst), 1); + } + + #[test] + fn pre_dispatch_rejects_nonempty_or_failed_streams() { + let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::from_static(b"x"))]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should render nonempty body rejection"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let failure = stream::iter([Err::(IoError::other("fixture failure"))]); + let failure_response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(failure)))) + .expect("should render stream failure"); + assert_eq!(failure_response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn pre_dispatch_terminal_response_precedes_method_path_and_lifecycle() { + let calls = Arc::new(AtomicUsize::new(0)); + let clones = Arc::new(AtomicUsize::new(0)); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .with_manifest_json("{}") + .middleware(CountingMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .post("/after", unreachable_handler) + .build(); + for (method, path) in [ + (Method::POST, "/after"), + (Method::GET, "/after"), + ( + Method::from_bytes(b"EXAMPLE-METHOD").expect("should parse extension method"), + "/after", + ), + (Method::GET, "/missing"), + ] { + let response = block_on(router.oneshot(input(method, path, Body::empty()))) + .expect("should intercept before method/path lookup"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(response.headers()["allow"], "POST"); + assert_eq!(response.headers()["www-authenticate"], "Basic"); + assert_eq!(response.body().as_bytes(), Some(b"local".as_slice())); + } + assert_eq!(calls.load(Ordering::SeqCst), 4); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 80b3f3e01..0e26a89c1 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -289,3 +289,27 @@ performance or resource lifetimes. - Deploy to [Fastly Compute](/guide/adapters/fastly) for production - Deploy to [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative - Explore [Configuration](/guide/configuration) for manifest options + +## Ingress provenance + +Conversion captures runtime URI parts and retains the original HeaderMap and +extensions. JSON buffering and non-JSON streaming are unchanged. The standard +plain HTTP listener supplies a private transport marker. Origin is available +only with that marker and one valid Host consistent with any URI authority and +scheme; forwarded headers do not supply a scheme. Generic conversion without +the marker leaves origin unavailable. + +Raw TCP tests retain extension methods, tested path spelling, same-name +duplicates and FF values. Hyper folds identical Content-Length values and +rejects conflicting lengths before the hook. The established server path reports common field multiplicity as `Unknown`; +the Content-Length override retains that same conservative value rather than +adding a field-specific verdict. Global wire order remains unavailable. See [the capability matrix](./overview#inbound-request-fidelity). + +Content-Length field multiplicity is `Unknown`: an HTTP binding does not reveal +whether the parser folded original fields. This differs from the independently +tested capability that identical repeated lengths fold and conflicting lengths +are rejected before dispatch. + +Origin validation checks HTTP(S) URI authority syntax, including registered +names and trailing-dot hosts. It does not establish DNS resolution or ownership. +Consumers that require DNS-label syntax must apply that additional policy. diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index dad084e8b..28f55d487 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -352,3 +352,24 @@ explicit retention. The generated default remains unchanged. - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative - Explore the [Axum adapter](/guide/adapters/axum) for local development + +## Runtime request fidelity + +Inbound conversion reads `req.inner().method()` rather than the closed Workers +method enum. Valid runtime extension tokens retain their spelling. +`Headers.entries()` values follow workerd's UTF-8 string convention and are copied through `HeaderValue::from_bytes(value.as_bytes())`. Invalid runtime HeaderValues produce bounded conversion errors. Conversion never comma-splits combined fields. Browser ByteString construction is not evidence of Worker wire-byte preservation. + +Ingress captures `req.inner().url()` before additional parsing. It records a +runtime URL and validated runtime origin. Fetch can already have normalized dot +segments and combined repeated fields; those changes cannot be reversed by +EdgeZero. Header fidelity remains conservative. + +Browser converter tests cover custom methods, high bytes, metadata and service +hook dispatch. Locked local workerd transport tests additionally distinguish +pre-Worker rejections: its HTTP parser returns 501 for extension methods. +Real Worker tests assert valid UTF-8 values C3 A9 and C4 80 survive conversion. Local wire FF becomes U+FFFD in Web Headers before Rust, so its runtime UTF-8 copy EF BF BD cannot prove original bytes. Common octet and multiplicity fidelity is `Unknown`: the observed transformations do not prove every particular field changed. Local absolute-form and duplicate Content-Length requests return 500 without a hook verdict. These transport/runtime limits remain separate from runtime-to-core conversion correctness. +See [the capability matrix](./overview#inbound-request-fidelity). + +Captured runtime-target preservation is `Unknown`. Fetch URL normalization is +a known runtime capability, but the original request target is unavailable, so +a transformation cannot be established for each individual incoming request. diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 8913e843e..09563100d 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -605,3 +605,37 @@ entry point and removing retained state, not merely setting a request limit of o - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target - Explore [Configuration](/guide/configuration) for manifest details + +## Early ingress snapshots + +Call `request::capture_request_ingress(&req)` immediately after acquiring the +native request and before URL-based shortcuts or native mutation. It borrows +the same request without round-tripping handles. With Fastly SDK 0.12.1, +safe raw-target acquisition is unavailable: the snapshot records `NotExposed` +first, then uses the SDK runtime URL only to recover the canonical origin. +This URL access can normalize the path; it never establishes original-target +preservation. Client requests need one valid Host consistent with the runtime +authority, including equivalent default ports. Origin provenance is +`RuntimeUri`; absent TLS metadata does not establish an HTTP scheme. Synthetic +requests and missing, duplicate, invalid or inconsistent Host have no origin. + +Use `into_core_request_with_ingress(req, ingress)`, +`FastlyService::with_request_ingress(ingress)`, or +`dispatch_with_registries_and_ingress(app, req, stores, 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). diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 4bb4ac368..178cb3733 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -13,7 +13,7 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in ## Request Conversion -Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion must: +Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion preserves the runtime-visible request subject to the fidelity limits below. It must: - **Preserve the HTTP method** exactly (`GET`, `POST`, etc.) - **Parse the full URI** (path and query string) into an `http::Uri`. Reject invalid URIs with `EdgeError::bad_request` @@ -110,7 +110,7 @@ Spin's adapter targets `wasm32-wasip2` and its contract suite runs under Wasmtim ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` @@ -155,6 +155,65 @@ 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::request::RequestIngress` into request extensions. +It records runtime-visible target and trusted origin facts separately from the +primary URI and headers. Missing metadata means unknown capability. + +`CapturedTarget::Complete` holds the whole captured string, its acquisition +source and preservation status. Capture has a 16 KiB UTF-8 byte bound; oversize +input becomes `Unavailable(TooLarge)`, without retaining a prefix. Fastly's +current safe API reports `Unavailable(NotExposed)`. Metadata has borrowed +getters and intentionally has no value-bearing `Debug`, `Display` or +serialization implementation. Do not log or serialize captured values. + +`InboundOrigin` validates an HTTP(S) scheme and an authority of at most 1 KiB, +including IPv6 and port syntax. Parsing validates syntax; the adapter still +establishes provenance. Runtime URI facts use `RuntimeUri`; Axum's plain HTTP +listener uses `TransportBinding` plus one consistent Host. Origin-form Axum +conversion without that binding has no trusted origin. Ordinary Origin, +Forwarded and Spin special headers do not establish ingress origin. + +Fastly records target unavailability before reading its SDK runtime URL for +origin, and requires a client request with one consistent validated Host. +This recovers canonical origin without claiming an original path. + +`HeaderFidelity` has separate octet, field-multiplicity, same-name-order and +global-order axes, with per-name overrides. They concern original received +fields, excluding HTTP framing and header-name spelling. Faithful copying from +a runtime HeaderMap does not establish original-wire preservation. Unproved +axes remain `Unknown`; global field order is `Unavailable` on all four adapters. +Cloudflare's common octet and multiplicity guarantees remain `Unknown`: a +runtime capable of replacement/coalescing does not prove a particular field +changed. The observed transformations below remain capability limitations. + +Local evidence uses locked SDKs and fixed raw HTTP/1.1 fixtures. These results +describe the tested local runtimes; they do not establish production-edge +behavior. + +| Surface | Fastly 0.12.1 / Viceroy 0.17.0 | worker 0.8.3 / workerd 1.20260415.1 | Axum / Hyper 1.10.1 | Spin SDK 6.0.0 / Spin 4.0.0 | +| ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | +| Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | +| Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | +| High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | +| Repeated control fields | Tested ordinary duplicates retained; identical Content-Length folded | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | +| Global wire order | Unavailable | Unavailable | Unavailable | Unavailable | + +Run `./scripts/test-request-fidelity.sh all` for local transport probes. They +assert pinned observations, including unsupported outcomes; a green regression +run does not turn an unsupported capability into a preservation guarantee. +Fastly raw-target support requires a safe borrowed SDK accessor and new ingress +evidence. Consumers that require original dot spellings or duplicate fields +must separately resolve Fetch normalization/coalescing and platform rejection +limits. EdgeZero does not approve a weaker consumer contract. + +The [pre-dispatch hook](../routing#intercepting-before-dispatch) runs after +adapter conversion. Fastly, Cloudflare and Spin still buffer bodies; Axum still +buffers JSON and streams other bodies. Existing store/capability bootstrap and +outer finalizers retain their behavior. + ## 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 4fd7b0d11..aaaa8d77c 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -306,11 +306,11 @@ Run contract tests for the Spin adapter: ```bash rustup target add wasm32-wasip2 -export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` -The tests execute the adapter's real `wasm32-wasip2` request path under Wasmtime. The +These contracts execute under Wasmtime. Incoming conversion is additionally exercised by the actual Spin HTTP fixture. The `wasmtime` version CI uses is pinned in `.tool-versions`. ## Manifest Configuration @@ -359,3 +359,24 @@ and remove the cache to roll back; default generated entry points are unchanged. pre-rewrite store schema - [Adapters overview](/guide/adapters/overview) — cross-adapter contracts - [Configuration](/guide/configuration) — full manifest reference + +## Runtime ingress metadata + +Conversion captures SDK URI parts before rebuilding the core request. Origin +comes from HTTP(S) URI scheme/authority, with runtime provenance. +`spin-full-url`, `spin-client-addr`, Origin and forwarding headers do not become +trusted ingress-origin facts. Existing context fields and store/body behavior +remain unchanged. + +The Spin 4.0 HTTP component fixture verifies methods, tested target spelling, +duplicates and hook ordering through the actual converter. Spin 4.0's request +preparation requires UTF-8 header values; a Cookie containing FF returns 500 +before the component starts. This needs an upstream runtime fix and cannot be +restored inside EdgeZero. Same-name runtime values are copied byte-for-byte, +while original-wire fidelity remains conservative and global order unavailable. + +Pure metadata tests execute on the host. The WASM contract suite runs with +Wasmtime's explicit async/P3/HTTP flags. The full WASM library test artifact +also imports Spin-specific host interfaces, so plain Wasmtime cannot execute +that artifact. The real Spin HTTP fixture supplies runtime conversion evidence. +See [the capability matrix](./overview#inbound-request-fidelity). diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index efefe3d3b..2ef14a2a0 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -232,3 +232,11 @@ If you already hold an `Arc` (`BoxMiddleware`), register it with - Learn about [Streaming](/guide/streaming) for progressive responses - Explore [Proxying](/guide/proxying) for upstream forwarding + +## Work before route lookup + +Ordinary middleware runs after route selection and router-owned extension setup. +For method-independent interception before 404/405, use the +[pre-dispatch hook](./routing#intercepting-before-dispatch). A terminal hook +response or error bypasses ordinary middleware and handlers. A continuing hook +passes its request mutations into the existing middleware chain. diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 9c58c81da..d1e45fafe 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -186,3 +186,56 @@ Run the app under the Axum dev server before deploying to an edge target. - Learn about [Handlers & Extractors](/guide/handlers) for processing requests - Explore [Middleware](/guide/middleware) for cross-cutting concerns + +## Intercepting before dispatch + +Use an optional `PreDispatchHook` when handling a request must precede method +and path lookup, including router-generated 404/405 responses. The hook receives +the converted request before router state, introspection, request context, +middleware or handlers. Its future can be non-`Send`. + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Request, Response, response_builder}; +use edgezero_core::router::{PreDispatchHook, RouterService}; + +struct ExampleHook; + +#[async_trait(?Send)] +impl PreDispatchHook for ExampleHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + if request.headers().contains_key("x-example-stop") { + return Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )); + } + Ok(None) + } +} + +let router = RouterService::builder() + .pre_dispatch_hook(Arc::new(ExampleHook)) + .build(); +``` + +`Ok(None)` continues with the mutated request: method, URI, headers, extensions +and remaining body all participate in normal dispatch. Consuming a stream does +not restore it. `Ok(Some(response))` returns that response without normal router +lifecycle work. Return a complete response when exact challenge or hardening +headers matter. `Err(error)` stops dispatch; direct `Service::call` propagates it, +while `RouterService::oneshot` renders it through the existing error response +conversion. + +Repeated registration replaces the hook. Router clones share its `Arc`. +Without a hook, existing routing is unchanged. Adapter buffering, capability +bootstrap and outer response finalizers can still run before or after this +router boundary; the hook does not replace those layers. + +Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) +before classifying a request using its target or duplicate headers. diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md new file mode 100644 index 000000000..7583264ba --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -0,0 +1,422 @@ +# Pre-dispatch interception and inbound request fidelity implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use @superpowers:subagent-driven-development or @superpowers:executing-plans to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add generic method-independent interception before router dispatch and preserve/report inbound request information honestly across all four adapters. + +**Architecture:** One optional Arc-backed non-Send pre-dispatch hook on the existing RouterService. Adapter-owned bounded RequestIngress metadata preserves runtime-visible target/origin information and declares header fidelity without duplicating sensitive headers. Narrow converter fixes and actual local runtime fixtures prove each supported claim. + +**Tech Stack:** Rust 1.95.0, edition 2024, existing async-trait/futures/http facade, Fastly 0.12.1, worker 0.8.3, Axum/Hyper, spin-sdk 6.0.0, Viceroy 0.17.0, wasm-bindgen-test, Wasmtime and VitePress. + +**Source:** [Corresponding spec](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). +**Baseline:** `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Branch:** `spec/pre-dispatch-request-fidelity`. +**Status:** Implementation authorized after independent review. Toolkit work is implemented; verification and external consumer gates are recorded below. + +--- + +## Execution boundary and dependency order + +The user authorized implementation after independent review with no material findings. Work remains on this EdgeZero branch. The downstream approved spec is unchanged; no push, publication or release approval is implied. + +Read AGENTS.md and CLAUDE.md before executing. Keep the feature generic. Do not add trace namespace checks, cookie names, report schemas, authentication policy or consumer finalization into EdgeZero. New handlers use `#[action]` and HTTP aliases come from `edgezero_core::http`. Preserve non-Send futures and avoid new runtime dependencies in core. Apply strict alphabetical/lint/public-documentation conventions at changed sites only. + +Order: P0 capability decisions → P1 ingress contract and P2 hook (independent core work) → P3 Cloudflare / P4 Axum / P5 Spin (independent implementations with central integration) → P6 Fastly after the SDK prerequisite → P7 real ingress fixtures → P8 CI/docs/release evidence. + +Pure core/Cloudflare/Axum/Spin work can proceed while the Fastly SDK getter is unavailable. That is progress on infrastructure, not completed downstream F0. Record every environment prerequisite explicitly; zero selected tests, host-only WASM checks, parser rejection and unavailable runtimes are not passing ingress evidence. + +Default no-hook behavior must remain identical. Cloudflare method/header conversion fixes are intentional correctness changes even without a hook: update existing tests that codify information loss and document the behavior change. + +## File map + +| Action | Exact path | Responsibility | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Modify | `crates/edgezero-core/src/router.rs` | Hook trait/alias, optional builder/inner field, dispatch ordering and colocated tests | +| Create | `crates/edgezero-core/src/request.rs` | Bounded RequestIngress types/constructors/accessors and colocated tests | +| Modify | `crates/edgezero-core/src/lib.rs` | Export request module | +| Modify | `crates/edgezero-adapter-cloudflare/src/request.rs` | Runtime method/URL capture and Worker UTF-8 header conversion | +| Modify | `crates/edgezero-adapter-cloudflare/tests/contract.rs` | Executed browser converter regressions | +| Modify if required | `crates/edgezero-adapter-cloudflare/Cargo.toml` | Explicit test Web RequestInit features | +| Modify | `crates/edgezero-adapter-axum/src/request.rs`, `crates/edgezero-adapter-axum/src/service.rs`, `crates/edgezero-adapter-axum/src/dev_server.rs` | Runtime parts metadata, trusted transport binding, real native parser/hook tests | +| Modify | `crates/edgezero-adapter-spin/src/request.rs`, `crates/edgezero-adapter-spin/tests/contract.rs` | Runtime parts metadata and converter/dispatch regression seams | +| Modify | `crates/edgezero-adapter-fastly/src/request.rs`, `crates/edgezero-adapter-fastly/src/lib.rs`, `crates/edgezero-adapter-fastly/tests/contract.rs` | Early safe capture, pre-captured metadata path, runners and regression coverage | +| Modify only after approved SDK revision | `Cargo.toml`, `Cargo.lock` | Reviewed dependency pin; no imaginary API or version | +| Create | `tests/fixtures/request-fidelity/Cargo.toml`, `tests/fixtures/request-fidelity/Cargo.lock`, `tests/fixtures/request-fidelity/src/lib.rs` | Isolated test-only fixture workspace and shared store-free router | +| Create | `tests/fixtures/request-fidelity/src/bin/fastly.rs`, `tests/fixtures/request-fidelity/src/spin.rs`, `tests/fixtures/request-fidelity/src/cloudflare.rs` | Actual runtime entry points and generic early-shortcut fixture | +| Create | `tests/fixtures/request-fidelity/fastly.toml`, `tests/fixtures/request-fidelity/spin.toml`, `tests/fixtures/request-fidelity/wrangler.toml` | Loopback-only local runtime configuration | +| Create | `tests/fixtures/request-fidelity/package.json`, `tests/fixtures/request-fidelity/package-lock.json` | Exact verified Wrangler/tooling pins and scripts | +| Create | `scripts/test-request-fidelity.sh`, `tests/fixtures/request-fidelity-runner/{Cargo.toml,Cargo.lock,src/main.rs}` | Runtime startup/build/readiness/cleanup and raw socket probes | +| Modify | `.github/workflows/test.yml` | Executed WASM unit/contract and local runtime transport gates | +| Modify | `docs/guide/routing.md`, `docs/guide/middleware.md`, `docs/guide/adapters/overview.md`, `docs/guide/adapters/fastly.md`, `docs/guide/adapters/cloudflare.md`, `docs/guide/adapters/axum.md`, `docs/guide/adapters/spin.md` | Hook API, ordering limits, platform matrix and consumer handoff | + +The fixture is a test-only separate workspace (`[workspace]` in its manifest); do not add it to production workspace members or inherit root workspace dependencies incorrectly. Use explicit local path dependencies and optional platform features; each build selects only its runtime. Any needed fixture-path exclusions/package publication guards must be explicit. Root all-feature clippy must not compile a multi-runtime fixture accidentally. + +## P0: Establish capability decisions before promising downstream compliance + +**Files:** source investigation only, spec capability matrix; dependency files only after an approved pin exists. + +- [x] Reconfirm clean implementation baseline, `git rev-parse HEAD`, `git status --short --branch` and `cargo metadata --format-version 1 --no-deps`. Re-read consumer section 8's explicit pre-buffering allowance. +- [x] Verify pinned SDK/runtime sources from Cargo.lock. Record worker 0.8.3 versus the consumer's 0.8.5, wasm-bindgen 0.2.122, Fastly 0.12.1, Hyper 1.10.1 and Spin SDK 6.0.0. Do not rely on floating package documentation. +- [x] For Fastly, investigate a safe borrowed raw-runtime-URI accessor. Existing get_url/get_path normalize; into_handles/from_handles loses client provenance, and clone_without_body forces lazy URL parsing. Rule out those workarounds. A proposed accessor name is not an existing SDK API. +- [ ] Prepare the external SDK change requirements: bounded native URI acquisition before Url parsing; no request/body consumption or cloning; no second from_client acquisition; client IP/TLS/original-header provenance retained; errors bounded; no fabricated original-wire promise. Obtain a reviewed immutable compatible revision/version before P6's complete-target implementation. +- [x] Record per-field runtime limitations, including Cloudflare Headers combining and Hyper identical Content-Length folding. Decide what the generic metadata can truthfully expose. +- [x] Separate adapter conversion guarantees from original wire guarantees. Where consumer requirements are impossible with the runtime, record an explicit downstream design-resolution item; do not edit or approve that spec from this branch. +- [x] Select the recommended hook plus metadata design over middleware-only or per-adapter policy wrappers; record scope/CI impact. External SDK or contract gates cannot be replaced by synthetic fixture success. +- [x] Checkpoint: approved upstream design before code; unresolved external SDK work may remain pending while independent tasks proceed. + +## P1: Define bounded adapter-owned RequestIngress + +**Files:** create `crates/edgezero-core/src/request.rs`; modify `crates/edgezero-core/src/lib.rs`. + +- [x] Add red tests named `ingress_target_bound_is_atomic`, `ingress_fidelity_defaults_are_unknown`, `ingress_header_override_is_field_specific` and `ingress_origin_rejects_untrusted_or_invalid_input`. +- [x] Run `cargo test -p edgezero-core ingress`. Expected red for missing types/behavior, not an unrelated compile/setup failure; make the initial test harness compile before judging behavioral red. +- [x] Implement the spec's closed enums/types. Exact minimum API: `RequestIngress::new(target, origin, common_header_fidelity, overrides)`, `target()`, `origin()` and `header_fidelity(&HeaderName)`. The CompleteTarget payload is public with private fields and borrowed getters; only CapturedTarget::capture constructs it. Add a compile-fail doctest proving external callers cannot bypass its bound with a struct literal. Use an owned small override list/map and reject duplicate override keys at construction rather than ambiguous selection. No copied header values. +- [x] Add `CapturedTarget::capture(value, source, fidelity)` applying the 16 KiB complete-or-unavailable bound. Add `InboundOrigin::parse(scheme, authority, source)` with a 1 KiB authority bound, HTTP(S)-only scheme and no user-info/path/query/fragment. Include the spec’s OriginSource enum/borrowed source accessor. Parsing validates input; adapter callers must still prove its trusted source. Origin-form requests without transport binding remain unavailable. +- [x] Define header-fidelity axes against original received field values/multiplicity/order, excluding HTTP framing/name spelling. Runtime-to-core copying alone cannot set Preserved. Tests/docs separately report converter-copy guarantees versus pinned parser/runtime/transport evidence, including trimming/coalescing loss. +- [x] Define private fields/borrowed getters and Clone-only value-bearing metadata. Use existing Arc/HTTP facade types. No Serialize or secret-bearing Debug/Display/logging. +- [x] Test exact cap and one byte over, non-ASCII UTF-8 byte counting, read-failure/unavailable states, same-name versus global order, Content-Length override, missing extension and IPv6/default-port authority. Never store a prefix as a complete target. +- [x] Run `cargo test -p edgezero-core ingress`, `cargo test -p edgezero-core` and scoped clippy. Expected green; all tests selected. +- [ ] Planned commit after checks: `Add bounded inbound request metadata and fidelity contracts`. + +## P2: Add the optional pre-dispatch hook to the concrete router + +**Files:** `crates/edgezero-core/src/router.rs`. + +- [x] Add behavioral tests for terminal response on unknown route and `EXAMPLE-METHOD`; continuation that changes method/URI; hook error; no-hook equivalence; absent router state/introspection in hook; state-clone count; cloned-router shared hook; streamed-body inspection; and Rc retained across await. +- [x] Run `cargo test -p edgezero-core pre_dispatch`. Expected meaningful red for route-first ordering, then implement the trait/alias and builder method from spec section 5. +- [x] Add one optional Arc field to builder/inner, thread it through build/new and invoke it before method/path copies. Document repeated registration replacing the existing hook. +- [x] Test mutations of header/extensions/body in addition to method/URI. Continued dispatch uses the remaining mutated body exactly; the hook cannot consume a stream and silently restore it. +- [x] Distinguish error APIs: direct `Service::call` propagates EdgeError; existing `RouterService::oneshot` renders it through IntoResponse. Both stop before normal dispatch. A consumer requiring custom challenge/hardening returns `Some(Response)` with those exact headers. +- [x] Test a terminal custom status/body/Allow/WWW-Authenticate is preserved by core. Document that outer adapter/application response finalizers may still operate. +- [x] Verify every existing core router/introspection/state/middleware test, plus manual `Hooks::routes` build compatibility. Keep macros/App/runner return types unchanged. +- [x] Run `cargo test -p edgezero-core` and `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings`. +- [ ] Planned commit: `Add method-independent router pre-dispatch interception`. + +## P3: Correct Cloudflare runtime method and header conversion + +**Files:** `crates/edgezero-adapter-cloudflare/src/request.rs`, `crates/edgezero-adapter-cloudflare/tests/contract.rs`, optional test feature changes in its Cargo.toml. + +- [x] Add raw Web Request fixtures using `web_sys::RequestInit::set_method`, `Request::new_with_str_and_init` and `CfRequest::from(raw)`. Worker enum-only RequestInit cannot create the regression. Cover uppercase and lowercase extension tokens and existing supported methods. +- [x] Add runtime URL metadata equality tests independently of parsed core URI equality. Use dot/encoded-dot/encoded-separator/repeated-slash/query cases, comparing what Web Request actually exposes rather than requiring it to preserve the input string. +- [x] Add browser converter tests for ASCII/high-byte runtime strings. Assert UTF-8 string copying; browser ByteString semantics are not Worker wire evidence. The incorrect helper-only lib test is removed; actual workerd tests cover valid C3 A9/C4 80 and invalid FF transformation. +- [x] Run the browser contract command in Shared verification. Red/green covers extension-token preservation and the corrected UTF-8 runtime-string convention. +- [x] Replace inbound method enum conversion with `CoreMethod::from_bytes(req.inner().method().as_bytes())` and bounded bad-request errors. Capture `req.inner().url()` before additional parsing and insert RequestIngress after core request construction. +- [x] Populate InboundOrigin from the runtime URL’s HTTP(S) scheme and validated authority with RuntimeUri provenance, after target capture. Never use Origin or forwarding headers for this fact. Add invalid/non-HTTP scheme, user-info/authority bounds and spoofed Origin/forwarding-header tests; missing or untrustworthy facts stay unavailable. Runtime URL parsing for origin must not replace the captured target. +- [x] Construct HeaderValue with `HeaderValue::from_bytes(value.as_bytes())`, following pinned workerd's UTF-8 header convention. Reject invalid runtime HeaderValues with bounded errors; preserve append behavior and validated names. Do not single-byte-cast Unicode scalars, silently omit fields or comma-split combined values. +- [x] Verify exact real Worker wire C3 A9 and C4 80 preservation, and distinguish invalid FF replacement from original-byte availability. Update the baseline only after semantic assertions pass. +- [x] Locate all `into_core_method` uses. Replace/update the old unknown-method→GET test; remove the helper only if unused, retaining supported-method tests through actual inbound conversion. +- [x] Keep buffering, context/proxy/store paths unchanged. Add hook-through-CloudflareService coverage, then run contract tests plus target clippy/check. +- [ ] Planned commit: `Preserve Cloudflare runtime methods and header octets`. + +## P4: Preserve Axum parts and declare parser limitations + +**Files:** Axum `src/request.rs`, `src/service.rs`, `src/dev_server.rs`. + +- [x] Add `ingress` converter tests for method/URI/bytes/repeated same-name values/extensions, origin-form and absolute-form targets, and missing trusted origin. +- [x] Run `cargo test -p edgezero-adapter-axum --features axum ingress`. Expected red for absent metadata. Add metadata around existing parts/from_parts; retain JSON buffering and non-JSON streaming. +- [x] If the standard dev server supplies an HTTP transport binding, insert only that adapter-owned scheme fact before conversion and combine it with exactly one validated inbound authority. Reject contradictory URI/Host origin facts as unavailable metadata; never guess HTTPS from forwarded headers. Generic converter callers without binding get unavailable origin. +- [x] Declare global wire order unavailable. Add a Content-Length-specific multiplicity limitation grounded in the pinned parser; preserve known same-name runtime values without claiming all original controls survived. +- [x] Extend service tests to show early hook response, continuation, extension method and router error behavior. Capability registries constructed before the hook are not ordinary consumer KV operations. +- [x] Add raw TCP cases to the existing dev-server listener harness: dot spellings, Cookie/Origin/control duplicates, identical/conflicting lengths, non-UTF-8 bytes and TE/CL combinations. Assert whether Hyper rejects or the hook sees the request. +- [x] Run `cargo test -p edgezero-adapter-axum --features axum` and native target clippy. +- [ ] Planned commit: `Expose Axum ingress fidelity without changing body behavior`. + +## P5: Capture Spin runtime parts and test the actual seam + +**Files:** Spin `src/request.rs` and `tests/contract.rs`. + +- [x] Add parts-conversion helper tests, or actual runtime tests where SDK incoming-body construction permits it, for runtime method/URI/header metadata. +- [x] Run host tests for host-covered helpers and the WASM contract/lib commands for runtime code. Existing core-request-only contract success is not converter coverage. +- [x] Capture runtime parts before rebuilding core Request. Preserve byte HeaderValues/method and source URI scheme/authority. Do not grant trust to spin-full-url/spin-client-addr solely because those headers exist. +- [x] Preserve existing buffering and store registry setup. Document earlier capability setup separately from ordinary app lifecycle work. +- [x] Verify extension `Method::Other` conversion, same-name values, unavailable global wire order, missing origin/facts, and metadata through actual dispatch. +- [x] Use P7's Spin invocation to establish URI/header behavior and spoofed special-header outcomes. If a synthetic shared-parts helper was used, explicitly identify which claims still need runtime evidence. +- [x] Run affected host tests and wasm32-wasip2 check/clippy/contract. Full WASM lib execution remains unavailable because plain Wasmtime lacks Spin-specific imports; actual Spin HTTP fixture covers the converter instead. +- [ ] Planned commit: `Expose Spin runtime ingress provenance and conversion tests`. + +## P6: Add safe Fastly capture and pre-captured metadata integration + +**Files:** Fastly `src/request.rs`, `src/lib.rs`, `tests/contract.rs`; dependency files only after P0's reviewed SDK pin. + +- [x] Checkpoint the external SDK accessor. If unavailable, implement honest unavailable/runtime-normalized metadata and finish byte-path regression work, but leave original-target/client-provenance acceptance pending. +- [ ] Add red target-cap/capture-failure/client-provenance tests before changing converters. Execution: bounded supplied snapshots, unavailable SDK target and actual HTTP client preservation are tested; raw acquisition failure and TLS coverage await the SDK accessor. Synthetic `FastlyRequest::new` already parses URLs and does not establish raw incoming preservation. +- [ ] Implement `capture_request_ingress(&FastlyRequest) -> RequestIngress` using the reviewed safe borrowed accessor before any URL accessor. It cannot consume/clone/reacquire the request. Bounded capture errors create unavailable metadata without raw error strings. +- [x] Add `into_core_request_with_ingress(req, ingress)`; existing `into_core_request(req)` captures once and delegates. A consumer that captures immediately after from_client can carry that exact snapshot through later conversion, avoiding recapture of mutated native state. +- [x] Add public `dispatch_with_registries_and_ingress(app, req, stores, env, ingress, extend)` with the existing return type/generic callback. The existing `dispatch_with_registries` captures and delegates; preserve per-id registries, EnvConfig/default-key selectors and required-store errors, rather than replacing it with legacy bare-handle defaults. Thread captured facts through FastlyService and registry/request-extensions dispatch with one internal conversion/store-wiring path. Add `FastlyService::with_request_ingress` only if required by that dispatch API. Ordinary runners capture before their first native URL access/callback; custom early-entry callers use the explicit snapshot path. +- [x] Ensure application scratch extension merging cannot accidentally replace adapter-captured ingress facts; define adapter snapshot insertion after generic scratch merge. Test collision, same snapshot retention and unchanged device/client extensions. Do not alter normal service/store selection. +- [x] Preserve existing byte-oriented header append path. Add all-values/high-byte/same-name-order tests, explicitly excluding unproved global wire order/original-name reconstruction. +- [x] Run Fastly contract and lib WASM/Viceroy suites, no-feature/CLI-feature and WASM clippy. Real ingress and native-shortcut evidence is recorded in P7. +- [ ] Planned commit: `Add safe early Fastly ingress capture and snapshot handoff`. + +## P7: Prove actual local transport behavior + +**Files:** proposed test fixture workspace/runtime configs and `scripts/test-request-fidelity.sh` and `tests/fixtures/request-fidelity-runner/`. + +- [x] Build a store-free generic fixture router with one early hook and an ordinary route/middleware counter. Test namespace is `/reserved`, not Trusted Server policy. New handlers use #[action]. +- [x] Expose only synthetic fixture verdicts: method equality, captured-target equality/availability, expected header-byte equality, per-field counts/fidelity and ordinary-handler-called. Use fixed fictional fixtures; do not build a reusable unrestricted header/cookie/URI echo endpoint. +- [x] Add Fastly entry fixture that captures before a generic native shortcut and carries the snapshot into conversion. Test reserved dot spellings remain visible when the SDK/runtime supports them. Raw target unavailable is an explicit unsupported verdict, not a passed bypass test. +- [x] Add Cloudflare Worker fixture selecting the cloudflare feature and matching worker-build/Wrangler tooling. Pin Wrangler as an exact npm dev dependency with package-lock. Resolve/pin worker-build as a Rust CLI compatible with the fixture’s locked worker version, installed with cargo --version/--locked; npm locking does not pin it. Use lock-matched wasm-bindgen tooling. Verify exact compatible tool versions at implementation; do not copy floating ^4.38.0 or invent a release. +- [x] Add a feature-gated #[http_service] entry in src/spin.rs, included by the fixture library, targeting wasm32-wasip2 as a cdylib HTTP component; do not build Spin as a main/bin WASI command. For package name request-fidelity, spin.toml references target/spin/wasm32-wasip2/release/request_fidelity.wasm, built with cargo build --manifest-path tests/fixtures/request-fidelity/Cargo.toml --target-dir tests/fixtures/request-fidelity/target/spin --target wasm32-wasip2 --features spin --lib --release --locked. Resolve that path relative to spin.toml. Fastly selects its required-feature binary; Cloudflare selects its feature-gated library via worker-build. Reuse existing runtime build conventions; new fixture dependency pins/lockfile must be reproducible. +- [x] The shell runner builds the host Rust helper and accepts `fastly`, `cloudflare`, `spin` or `all`, allocates an available loopback port, builds the selected fixture, starts the runtime, waits with a bounded readiness deadline, runs raw socket probes, and always stops its process group on failure/signal/success. No platform credentials, remote services or publisher requests. +- [x] Native Rust socket client replaces the original Python test helper and sends exact HTTP/1.1 bytes with Host example.com. Include extension methods, origin/absolute forms where supported, literal/encoded dot paths, repeated/encoded separators, non-UTF-8 Cookie octets, interleaved/case-varied repeated fields, duplicate controls and identical/conflicting length cases. +- [x] Record transport rejection versus runtime transformation versus EdgeZero conversion versus hook response. A browser Fetch/reqwest-normalized input cannot replace a raw request probe. Local Wrangler/workerd/Viceroy/Spin results cannot prove all production-edge behavior. +- [x] Test the Rust replacement’s cleanup after failed readiness/probe/build, parallel port isolation and no lingering process/artifact mutation of baseline demo config. +- [x] Run the Rust-backed `./scripts/test-request-fidelity.sh fastly`, `cloudflare` and `spin` with the required tools installed. Each supported verdict is asserted; unresolved guarantees fail the compliance claim or remain a specifically listed pending gate, not a blanket skip/pass. +- [ ] Planned commit after checks: `Add local runtime probes for inbound request fidelity`. + +## P8: Connect CI, document capabilities and hand off the reviewed revision + +**Files:** .github/workflows/test.yml, guide paths in file map, dependency/fixture locks if changed. + +- [x] Cloudflare converter regressions execute in the browser contract target; the incorrect scalar helper and its helper-only lib gate are removed. Spin adjustment: host helper tests, WASM contract/check/lint and actual Spin HTTP fixture cover the converter; its full WASM lib artifact requires unavailable Spin host interfaces and is not reported as passing. Fastly already has contract/lib execution; host workspace tests do not run gated WASM conversion code. +- [x] Provision browser/WebDriver for Cloudflare run_in_browser tests, matching wasm-bindgen-cli from Cargo.lock. Reuse current version-resolution workflow rather than independently pinning a drifting runner. +- [x] Add local fixture transport jobs with exact installed tool versions/locks and deterministic cleanup. Spin CLI availability must be explicit; Wasmtime contract execution alone is not Spin HTTP ingress. +- [x] Document core API example, hook result/error/state/finalizer semantics, buffering limitation, origin trust rules, per-field fidelity and every runtime limitation. Separate capability availability from consumer approval. +- [x] Build the final platform evidence matrix with source pin, exact fixture, observed outcome and status for each requirement. Record local versus production verification limits. +- [x] Run all Shared verification gates and confirm tests selected the new modules. Scope red/green at tasks; full gates at implementation handoff. +- [x] Independent reviewers check spec/plan/implementation alignment, correctness, minimum scope, supported target coverage and unresolved gates. No upstream/downstream compliance approval by inference. +- [ ] Handoff an immutable reviewed EdgeZero commit/tag only after release approval. Record it for Trusted Server to pin all EdgeZero dependencies consistently. Downstream changes stay on its existing branch and require its own acceptance checks. +- [ ] Planned commit: `Document and verify pre-dispatch ingress contracts across adapters`. + +## Shared verification + +Focused commands run from repository root with the relevant features/targets. Require nonzero selected tests and a passing intended red/green cycle. New code must not be committed before tests required by CLAUDE.md pass. + +```bash +cargo test -p edgezero-core ingress +cargo test -p edgezero-core pre_dispatch +cargo test -p edgezero-core +cargo test -p edgezero-adapter-axum --features axum +cargo test -p edgezero-adapter-spin +CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract +CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --lib +CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y,http=y" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract +# Full Spin WASM lib needs Spin-specific host imports; use host helper tests +# plus the actual Spin HTTP fixture instead of claiming plain Wasmtime execution. +``` + +Fastly feature tests require the WASM/Viceroy runner; a host feature test may fail at linking and cannot substitute. Cloudflare contract uses a real headless browser/WebDriver and the lock-resolved wasm-bindgen-cli (currently 0.2.122). Existing matrix provisions Viceroy and Wasmtime; new local runtime fixtures may require additional CLI/tool installation. + +Full implementation handoff gates: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 +cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown +cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 +cargo clippy -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --all-targets -- -D warnings +cargo doc -p edgezero-core --no-deps --all-features +cargo test --locked --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets +cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check +cargo clippy --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets --all-features -- -D warnings +./scripts/test-request-fidelity.sh all +npm --prefix docs run lint +npm --prefix docs run format +npm --prefix docs run build +git diff --check +``` + +Expected: exit 0 for all available required environments, unchanged baseline behavior except documented Cloudflare conversion corrections, and no unexecuted suite reported as a pass. No runtime/CI pass is claimed by this planning document. + +## Spec-to-task acceptance map + +| Spec requirement | Tasks | Evidence | +| -------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------- | +| Generic hook and no-hook compatibility | P2 | Ordering, error/response/continuation/state/clone/non-Send regressions | +| Bounded confidential metadata and origin provenance | P1/P3–P6 | Target caps, borrowed getters, missing facts, no logged/serialized values | +| Correct runtime method/header conversion | P3–P6 | Actual converters with extension tokens and high bytes | +| Fastly safe early capture and consumer shortcut seam | P0/P6/P7 | Reviewed SDK accessor, client provenance, actual Viceroy wire paths | +| Honest multiplicity/normalization limits | P0/P1/P4/P7/P8 | Hyper/Fetch/runtime rejection/transformation matrix | +| Buffered-body and outer-finalizer limits preserved | P2–P6/P8 | Existing body/store/response contracts and accurate docs | +| All-adapter runtime evidence and target gates | P7/P8 | Native parser, WASM contract/lib and local runtime fixture results | +| Downstream F0 readiness distinguished from infrastructure completion | P0/P8 | Explicit immutable pin and separately approved consumer-contract resolution | + +## Independent document review + +Three independent read-only reviewers assessed both complete documents against main 98930917 and the approved consumer requirements. They proposed minimal approaches during investigation, reviewed the selected hook/metadata design, and reread the corrected documents. This document review completed before the user authorized implementation. + +| Scope | Spec result | Plan result | +| ------------------------------------------------------------------------------------------------ | -------------------------------------------- | ----------------------------------------------- | +| Core router/API, Axum/Spin conversion, body/bootstrap limits and execution feasibility | Approved | Approved after fixture/origin corrections | +| Cloudflare method/ByteString/provenance, bounded public API and browser/runtime tooling | Approved after bounded-payload correction | Approved after matching API/tooling corrections | +| Fastly native ownership, SDK dependency, snapshot/registry integration and Viceroy wire evidence | Approved after header-fidelity clarification | Approved after matching clarification | + +Corrections incorporated: private bounded CompleteTarget payload and compile-fail boundary test; explicit origin provenance/Cloudflare origin construction; Spin HTTP-component library rather than command binary; distinct npm/Cargo tool locks; original-field versus runtime-copy fidelity semantics; and named metadata-aware registry dispatch preserving EnvConfig/store-selector behavior. + +The safe borrowed Fastly SDK accessor remains external work, and original-wire properties require runtime evidence or separately approved consumer-contract resolution. The implementation evidence below is separate from these initial document reviews; neither review nor green infrastructure checks imply completion of Trusted Server F0. + +## Executor handoff + +Use this instruction with EdgeZero's Codex after maintainer approval: + +> Work in EdgeZero on branch spec/pre-dispatch-request-fidelity. Read AGENTS.md and CLAUDE.md, then the corresponding spec and this plan. Implement P0–P8 using the approved API and task verification. Keep RouterService concrete and non-Send-compatible; keep the hook generic. Preserve runtime-visible facts without inventing raw wire guarantees. Resolve the external safe Fastly SDK accessor before claiming original-target support. Verify actual method/header/target conversion and run local wire fixtures. Treat missing runtime facts or test environments as explicit pending gates, never success. Do not change Trusted Server's spec, implement trace policy, patch Cargo caches, push or publish without separate authorization. Report focused/full checks, the final capability matrix, and the immutable dependency revision ready for consumer review. + +Only the spec and this matching plan are planning artifacts for this feature; independent review results are recorded here after corrections. + +## Implementation and verification record + +The fresh pre-implementation review used three independent scopes: core/Axum/Spin; Cloudflare/provenance; Fastly/SDK/native ownership. All three approved both the spec and plan before code work. Subsequent independent implementation reviews found no production correctness defects. Coverage and lint findings were corrected centrally: IPv6/ASCII-port validation, matched-route lifecycle testing, scratch/client extension assertions, transport-specific Content-Length fidelity, executable Spin helper placement, and process-group cleanup races. Final review also corrected pinned-browser selection in CI, exact FF cookie equality and mandatory runtime-observation comparison. + +### Implemented surfaces + +- Core: bounded confidential ingress API, validated origin provenance, field-specific fidelity overrides and sealed target payload; optional shared non-Send hook before method/path/state/introspection/context/middleware. +- Cloudflare: runtime method tokens, checked UTF-8 runtime HeaderValues, runtime URL/origin capture and real browser converter/service regressions. +- Axum: runtime URI/HeaderMap retention, private HTTP binding, consistent Host origin, Content-Length parser limitation and exact TCP converter/service probes. +- Spin: runtime-parts snapshot and byte/method conversion retained; host-executed URI metadata tests and actual SDK6 HTTP component fixture. +- Fastly: safe unavailable-target capture, exact supplied-snapshot retention, registry-aware dispatch, service builder and capture-before-callback runner path; no unsafe handles, URL-cloning workaround or SDK dependency substitution. +- Verification: isolated locked multi-runtime fixtures, fixed verdicts, pinned observations, bounded loopback readiness and process-group cleanup for builds and runtimes; browser/fixture CI gates and public API/capability documentation. + +### Evidence and limits + +| Surface | Executed evidence | Outcome | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Core | 484 unit tests, integration test and compile-fail bounded-payload doctest | Pass; existing ignored docs unchanged | +| Native workspace | Workspace/all-target test run | 1,440 passed, 0 failed, 1 existing ignored; 20 suites | +| Axum | 115 full tests including the cap regression | Pass; raw TCP includes duplicates, FF, dots, identical/conflicting CL, TE/CL, continuation/error | +| Cloudflare | 11 browser contract tests including non-HTTP origin; explicit Chrome binary capabilities; removed incorrect scalar helper/lib-only test | Pass; converter and service hooks execute | +| Fastly | Viceroy 0.17.0 contract 6 and WASM library 91 | Pass; byte/header/snapshot/collision/client/proxy regressions execute | +| Spin | Host 31 and Wasmtime 44.0.1 contract 12 with async/P3/HTTP flags | Pass; host contract's zero selected tests do not count as runtime evidence | +| CLI/audits/scaffold | Fastly CLI-feature 287; nested AppConfig checker 18; placeholder/legacy/nested audits; generated project build test | Pass; generated native/WASM adapters compile | +| Excluded demo | Locked workspace 33 tests, formatting and strict all-feature clippy | Pass; six host adapter targets select zero tests | +| Build/lint/docs | Root/adapter/fixture formatting; workspace, adapter and all three fixture target clippy; feature checks, core rustdoc; docs ESLint/Prettier/VitePress build | Pass; three existing warnings in untouched rustdoc files remain | + +Spin plan adjustment: the new pure helper/tests live in `src/context.rs`, which already has a host-safe test gate. Plain Wasmtime cannot supply `spin:key-value/key-value@3.0.0` to the full WASM library test artifact. That artifact is not reported as passing. Host helper execution, existing WASM contracts with explicit flags, target lint/check and the actual Spin HTTP component establish the implemented seam without inventing a Spin-aware unit runner. CI retains this distinction. + +Local fixture pins: Fastly 0.12.1/Viceroy 0.17.0; worker 0.8.3/wasm-bindgen 0.2.122/worker-build 0.8.3/Wrangler 4.83.0/Miniflare 4.20260415.0/workerd 1.20260415.1; Spin SDK 6.0.0/Spin 4.0.0; Rust 1.95.0/Node 24.12.0. Full fixed observations are recorded in `tests/fixtures/request-fidelity/observations.json`; they are regression evidence, not compliance approval. All 47 cases were executed with the native Rust client on the three pinned local runtimes. Failure probes covered a failed build, interrupted build with child process, forced readiness failure and forced probe failure; parallel Fastly runs used distinct ports and left no live owned processes. CI is configured with the locally verified commands; remote GitHub Actions execution remains unrun. + +### Remaining upstream and consumer gates + +1. Fastly's safe borrowed raw target accessor is absent. Capture reports NotExposed. Actual Viceroy ingress demonstrates `/reserved/../native` can reach a preceding normalized `/native` shortcut. The hook cannot repair earlier native routing. A reviewed SDK revision and fresh raw-target/client/TLS proof are still required. +2. Local workerd's fixed KJ method parser returns 501 for extension methods before Worker execution. Fetch also normalizes literal/encoded dot paths and coalesces Cookie/Origin/control fields. Fresh review corrected the adapter scalar-to-byte bug: valid UTF-8 now copies without casting. Raw FF already appears as U+FFFD in Web Headers before Rust and becomes runtime UTF-8 EF BF BD in core; it cannot be recovered as original FF. Common octet/multiplicity fidelity is Unknown, since the observed transforming path does not prove every particular field changed. Absolute-form and duplicate CL requests also return 500 without a hook verdict. Browser-created method/ByteString success does not establish original wire support. +3. Spin 4.0 requires UTF-8 while preparing incoming headers and returns 500 for FF before the component runs. This requires an upstream runtime fix. Valid runtime values continue through EdgeZero's byte path. +4. General original-field octets/multiplicity/order are not universally proved. Metadata keeps unknown axes conservative and global order unavailable; local fixtures do not prove production edge behavior. Consumer-required original paths/fields need an explicitly approved supported contract where runtime recovery is impossible. + +Pinned primary source: [Spin header preparation](https://github.com/spinframework/spin/blob/v4.0.0/crates/trigger-http/src/headers.rs#L96-L106); [workerd KJ dependency](https://github.com/cloudflare/workerd/blob/v1.20260415.1/build/deps/gen/deps.MODULE.bazel#L27-L35); [KJ method rejection](https://github.com/capnproto/capnproto/blob/d0e9605b288ac4d8b86ee3201a23c43c017a6b61/c%2B%2B/src/kj/compat/http.c%2B%2B#L961-L999); [workerd target rewriting](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/server/server.c%2B%2B#L451-L471). + +The toolkit implementation can be reviewed independently of these gates. Trusted Server F0 remains unresolved; this branch does not change or weaken its approved spec. Planned commits and immutable consumer dependency/release handoff remain pending; no commit, push or publication is performed by this execution record. + +### Earlier independent implementation review + +| Reviewer scope | Final result | +| ------------------------------------------------------------------------------------- | --------------------------------------- | +| Core bounded API/router and Fastly native ownership/registry/snapshot paths | No remaining findings | +| Cloudflare/Axum/Spin conversion, public API docs and adapter CI feasibility | No remaining findings | +| Locked runtime fixtures, exact observations, process cleanup and CI browser selection | No remaining findings after corrections | + +Final corrections were independently rereviewed. The exact FF cookie probe and mandatory full-profile comparison were rebuilt and executed across all three local runtimes, with strict fixture clippy passing on each target. The explicit Chrome binary capability configuration was executed through the real wasm-bindgen browser runner. No production behavior changed during this final review cycle. The results above do not close the listed external SDK/runtime, production provenance or downstream consumer gates. + +### Subsequent skeptical review — supersedes the earlier readiness assessment + +The user requested fresh independent reviewers without relying on the prior review conclusions. Architecture/core/adapters, consumer-contract alignment and runtime/CI/fixture scopes were reviewed independently. The optional router hook and bounded extension metadata remain the smallest appropriate architecture. No additional hook ordering, ownership, body/error propagation, process cleanup, pinning or CI-wiring defect was found. The Cloudflare correction and user-requested Rust tooling replacement are implemented and independently reviewed; the final executed checks below distinguish toolkit correctness from unresolved consumer requirements. + +- [x] **P1 Cloudflare UTF-8 conversion:** The browser ByteString assumption is incorrect for pinned workerd. Actual wire C3 A9 produces core E9; valid C4 80 returns 500 before the hook. Root independently reproduced both with the existing real local Worker fixture. Correct conversion to preserve Worker UTF-8 runtime strings, add exact real-workerd assertions for both cases, revise browser-only expectations and update observations only after those assertions pass. Keep invalid UTF-8 replacement as a distinct upstream capability limitation. The corrected baseline is collected only after exact semantic assertions pass and is compared in normal runs. All 11 browser contracts, all 47 real runtime probes and strict target clippy pass. The invalid FF case exposes runtime replacement rather than inventing original bytes. +- [x] **Origin provenance wording:** Clarify TransportBinding as trusted transport scheme plus a single validated inbound authority. Host is request authority, not operator ownership/approval. No configured host allowlist is required by the consumer spec. Public core documentation and this spec now agree with the Axum implementation. +- [x] **Spin runner documentation:** Public test commands now include the same async/P3/HTTP flags as verified CI. Wasmtime contracts and actual Spin incoming conversion evidence remain distinct. +- [x] **Framing coverage:** Actual TE+CL and standalone empty chunked requests retain one Transfer-Encoding: chunked field on each tested local runtime. The consumer can inspect it and must still implement its rejection semantics. +- [ ] **Consumer semantic coverage:** Add exact repeated reserved-cookie and real action/Fetch Metadata control probes in Trusted Server's integration harness. Strict complete-value comparison may reject coalesced controls safely; do not comma-split or require every metadata fidelity axis to say Preserved without checking the actual feature requirement. + +F0 is a consumer dependency/behavior gate, not a synonym for every header's perfect preservation. Global order across different header names is unnecessary. Cookie rules depend on exact reserved-name counts, bounds and non-UTF-8 detection, with order-independent outcomes. The path requirement concerns original pathname classification; unrelated query preservation is unnecessary. Avoid interpreting a 16 KiB whole-target metadata cap as proof that a short pathname was uninspectable. + +| Resolution work | Owner/location | Required proof | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Fix valid Worker UTF-8 conversion | EdgeZero Cloudflare converter/tests/fixture | C3 A9 and C4 80 retained through real Worker dispatch; no ordinary-request regression | +| Recover canonical Fastly origin separately | EdgeZero conversion or Trusted Server native-origin seam | After raw capture attempt and before mutation, validate HTTP(S), one Host, URL authority agreement; preserve native provenance and unavailable target status | +| Safe borrowed original-target accessor | Fastly Rust SDK | Bounded host URI access before Url parsing; no consume/clone/reacquire; preserved client/TLS metadata and actual reserved-dot-path tests | +| Early native shortcut exclusion | Trusted Server Fastly entry point | Raw reserved spellings cannot reach health/JA4 shortcuts; configured authentication and trace response hardening remain first | +| Hidden Cloudflare raw paths and pre-Worker method rejection | Cloudflare/runtime, or explicitly approved consumer amendment | Original reserved-path ambiguity and authenticated/hardened response guarantees, not only no cookie mutation | +| Invalid UTF-8 headers rejected before Spin invocation / replaced in Worker headers | Spin/Worker runtime, or explicitly approved consumer amendment | Original cookie uninspectability is available to the consumer or the transport-boundary contract explicitly changes | +| Coalesced Cookie semantics | EdgeZero/consumer exact transport tests, then runtime work or an explicitly approved amendment | Reserved-name counts, duplicate precedence, bounds and original non-UTF-8 observability; valid diagnostics cookie plus unrelated FF must never activate | +| Dependency integration | Reviewed EdgeZero immutable commit/release and Trusted Server Cargo.toml/Cargo.lock | Consistent immutable pin, target compilation and actual consumer integration tests | + +Preserving the approved consumer contract requires upstream capability work followed by real ingress verification. An alternative runtime-boundary contract must explicitly define normalized-path classification, transport-level rejection and unavailable-cookie reporting; it requires user approval because it changes the approved feature semantics. Disabling feature support on selected adapters also changes the approved all-four-adapter scope. This review does not silently choose either alternative. + +Pinned primary source for the newly identified encoding assumption: [workerd UTF-8 header strings](https://github.com/cloudflare/workerd/blob/v1.20260415.1/src/workerd/api/headers.c%2B%2B#L276-L292). The reproduction report is `/tmp/edgezero-fresh-review-encoding.json`; only fixed statuses and boolean verdicts are recorded. Production conversion now preserves Worker UTF-8 strings; exact real Worker verification and the Python-to-Rust tooling replacement are complete. + +### User language constraint + +The user rejects shipping Python. Replace the test orchestration/client with an isolated native Rust harness and a small shell entry point; use Node for existing Worker browser capability JSON and shell extraction for the controlled Spin CLI pin. Native-only Tokio cancellation does not enter core or WASM adapters. Retain mandatory observations comparison, exact raw TCP bytes, pinned builds, bounded deadlines, failure/interruption cleanup and concurrent port isolation. Independently review the replacement before reporting completion. + +### Final Rust replacement evidence + +The Python probe file is removed and no Python snippets remain in the new CI or shell entry point. The native-only helper uses the exact existing Tokio 1.52.3, serde_json 1.0.150, tempfile 3.27.0 and TOML 1.1.2 pins in its own locked test workspace. Its eight tests cover exact request bytes, HTTP chunk/length/EOF framing, header/body bounds, semantic rejection of the UTF-8 regressions and owned process cancellation/reaping. Native formatting and strict clippy pass; CI checks this separate workspace explicitly. + +A full locked-build run passes all 47 fixed raw TCP cases against the saved observations: Fastly 17, Cloudflare 15 and Spin 15. Valid UTF-8 C3 A9 and C4 80 remain exact on all three; both framing probes retain Transfer-Encoding: chunked. Workerd FF produces runtime replacement, explicitly unsupported as original preservation; Spin FF never reaches the component. Root also verified shell-bootstrap SIGTERM, Rust build SIGTERM, failed build descendants, failed readiness, failed baseline/probes and concurrent runs. Distinct concurrent ports close and no live owned descendants remain. + +The independent architecture reviewer reproduced an initial shell-bootstrap cancellation leak. Job control now gives bootstrap Cargo its own group, with signal/exit cleanup and reaping before entering the Rust supervisor. Root verified the exact shim reproduction. Real execution also corrected Spin manifest parsing and worker-build's bare-version output. These corrections leave no extra runtime dependency in core or the adapters. + +After the Cloudflare production change, workspace tests again pass 1,440 tests, workspace strict clippy and feature checks pass, and all 11 browser contracts pass. Subsequent reviewer rereads confirm the UTF-8/framing and CI-workspace checks are resolved. External SDK/runtime capability and Trusted Server integration/consumer acceptance gates above remain open. Remote GitHub Actions is configured but not executed locally; no commit or push is performed. + +## Independent Claude audit follow-up + +The external audit prompted three independent read-only scopes: runner/CI, adapters/provenance and consumer-spec alignment. Implement centrally after reproducing the concrete defects; retain the approved Trusted Server contract rather than accepting suggested runtime exceptions. + +- [x] **Blocked-read cancellation:** The reviewer reproduced SIGTERM leaving the native runner spinning at 91.6% CPU. `read_until` and `read_exact` retry `Interrupted`, so socket cancellation now returns a terminal `Other` error. A bounded silent-server regression covers header and exact-body reads and failed before the fix. SIGHUP joins SIGINT/SIGTERM on the cancellation-and-await path; the shell bootstrap explicitly handles HUP. Independent post-fix probes cover all six signal/read combinations, exit within 297–308 ms and leave no runtime/descendant alive. +- [x] **Fastly canonical origin:** Record original-target `NotExposed` before any URL access, then recover origin from a native client runtime URI and exactly one validated Host with authority/default-port agreement. Validate the full runtime authority, including user-info and port syntax. Origin uses `RuntimeUri`, never a guessed HTTP scheme from absent TLS. Three regression tests cover matching, missing/duplicate/conflicting Host and invalid runtime authority; the invalid-authority test failed before the final correction. An actual Viceroy probe changes the native URL and Host after capture and proves explicit-snapshot dispatch retains original origin and client metadata. SDK URL parsing may normalize paths and can panic; public docs now state that behavior without claiming raw-path support. +- [x] **Honest fidelity and caller assertions:** Cloudflare common octet/multiplicity metadata is Unknown; a potentially transforming runtime does not prove each field changed. Public docs agree. Metadata constructors and Fastly snapshot overloads state caller responsibility for provenance/fidelity; the useful snapshot APIs are not hidden. +- [x] **Exact cookie evidence:** Add fixed comma/semicolon join verdicts and compound invalid-FF versus original-valid-EF-BF-BD probes. Actual Worker repeated Cookies are comma-joined, and both compound inputs produce identical verdicts. This proves ambiguity, not an implemented Trusted Server activation defect. A replacement-character fallback would reject some original valid UTF-8 and cannot recover original reserved-name counts or byte bounds; it remains an unapproved consumer decision. +- [x] **CI coverage:** Add locked feature/target-matched clippy for the isolated WASM fixture workspace; install clippy/rustfmt in both test jobs. Independent reread confirms coverage of the Fastly enabled binary/library and Cloudflare/Spin libraries. Remote Actions execution remains pending. +- [x] **Existing consumer plan:** Update F0/F4 against `RequestIngress` and explicit-snapshot conversion, require hardened terminal responses rather than generic hook errors, remove stale ByteString guidance and record dependency-graph re-verification. F5 no longer demands transport-level 413 for identical duplicate zero lengths. No approved consumer spec, runtime-boundary exception, replacement-character rule or platform exclusion is changed. + +Final local evidence executes 54 exact wire cases: Fastly 20, Cloudflare 17 and Spin 17. Normal locked-build runs compare the complete checked-in baseline; Node uses 24.12.0. Fastly origin is available and consistent in every hook verdict, and the original-origin snapshot probe passes. The native harness has nine passing tests, the browser converter has eleven, and all 1,440 workspace tests pass. Strict workspace/isolated fixture lint and feature checks remain required. Original Fastly path access, Worker normalization/replacement/pre-invocation rejection and Spin invalid-UTF-8 rejection remain external consumer gates. No production deployment, commit, push or release is performed. + +## Approved consumer amendment and dependency handoff + +On 2026-10-05, the user accepted the independently reviewed runtime-boundary +amendment to the existing Trusted Server trace spec and single plan and +authorized implementation. This supersedes the historical original-path/ +original-wire consumer gates and unapproved-fallback statements above. +EdgeZero retains honest capability metadata and does not reconstruct erased +information. Trusted Server owns visible-path classification and conservative +Cookie ambiguity suppression under its amended contract. + +The pre-dispatch hook and converter implementation passed fresh required +workspace gates, adapter/browser/WASM contracts and 54 source-built local +runtime probes before the dependency handoff. Independent PR-readiness +review found one CI-shell assignment/export lint issue, corrected centrally; +the remaining two actionlint warnings reproduce against the baseline. + +A reviewed immutable dependency revision, consumer repinning and tests on +Trusted Server's resolved graph remain required. Do not claim consumer F0 or +the full trace feature complete from toolkit checks. Follow the repository +PR-creator workflow, including its linked-issue requirement, for publication. + +### Approved external-review corrections — 2026-10-06 + +- [x] Prove canonical Cloudflare URLs and Axum absent/single/repeated visible + Content-Length fields do not claim per-request `Transformed` merely because + the runtime can normalize or fold them; use `Unknown`. +- [x] Retain actual parser folding and conversion behavior; rerun real browser + contract tests, the native TCP hook and the fixed raw observation matrix. +- [x] Document Fastly identical Content-Length folding and conflicting-length + rejection. Verify the required full gates and independent review, then update + the existing PR and Trusted Server consumer pin. No Python is introduced. + +Verification: all five required workspace gates and docs lint/format/build pass. +Native Axum contract tests pass 116/0, real-browser Cloudflare 11/0, Fastly +Viceroy 6/0 and Spin Wasmtime 12/0. The rebuilt raw matrix retains all 54 +committed observations (20 Fastly, 17 Cloudflare, 17 Spin). Independent review +approved the metadata correction without changing request conversion or claims +about original wire bytes. + +## Latest independent review follow-up — 2026-10-06 + +The earlier 47-case records above describe earlier execution checkpoints. The +final baseline and dependency-handoff evidence contain 54 cases: Fastly 20, +Cloudflare 17 and Spin 17. Those final counts supersede earlier probe totals. + +Clarify the Axum guide: common field multiplicity remains `Unknown`, including +the Content-Length override. `InboundOrigin::parse` validates URI authorities, +not DNS-label-only names. Commas, asterisks and semicolons are permitted +registered-name characters under [RFC 3986 section 3.2.2](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2), +and a trailing dot is valid. The proposed DNS-only rejection would narrow the +approved contract and reject valid authorities, so no parser change is made. +These clarifications do not change the consumer dependency API or runtime code. 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 000000000..79c851f60 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -0,0 +1,255 @@ +# Pre-dispatch interception and inbound request fidelity + +**Status:** Approved for implementation after independent review; capability evidence and remaining gates are recorded in the matching plan. +**Date:** 2026-10-05. +**Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Branch:** `spec/pre-dispatch-request-fidelity`. +**Implementation plan:** [Matching plan](../plans/2026-10-05-pre-dispatch-request-fidelity.md). + +## 1. Problem and outcome + +Applications need to intercept a reserved namespace independently of the registered method/path routes, before router-owned state, introspection, request context and middleware run. A consumer must be able to authenticate a reserved request and return its own complete response even when its method has no registered handler. + +At the planning baseline the router performs `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare reduces extension methods to GET and re-encodes header strings as UTF-8; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. + +Provide one optional generic pre-dispatch hook, preserve runtime-visible method/target/header data without additional avoidable loss, and expose explicit fidelity information. Preserve the concrete `RouterService` and existing routes when no hook is installed. This is toolkit infrastructure, not an implementation of Trusted Server tracing. + +## 2. Consumer requirements and references + +The motivating consumer is approved [Trusted Server PR #1107](https://github.com/IABTechLab/trusted-server/pull/1107), whose [design at the planning baseline](https://github.com/IABTechLab/trusted-server/blob/20f4a0cc0139eac842d1d6ff1410a1af6ee8eba8/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) defines: + +- Section 8: authentication and local handling across reserved spellings and methods before ordinary application lifecycle work. +- Section 9.2: bounded cookie inspection, duplicate detection and non-UTF-8 rejection. +- Sections 12 and 14: hardened local responses and actual adapter ordering/conversion tests. + +Local source: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md`. Local consumer plan: `/Users/prk-jr/Desktop/opensource/rust/trusted-server/docs/superpowers/plans/2026-10-05-mobile-ad-render-trace-implementation-plan.md`, tasks F0/F4. + +Primary interoperability references: [Fetch header sorting/combining](https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine), [URL parsing](https://url.spec.whatwg.org/#url-parsing), and [Web IDL ByteString](https://webidl.spec.whatwg.org/#idl-ByteString). These define platform semantics, not proof of a particular deployed runtime. + +## 3. Scope and alternatives + +Recommended: an optional hook on `RouterBuilder` plus adapter-populated ingress metadata. It keeps manual `Hooks::routes()` and all existing adapter runners on the same concrete router and provides a single method-independent interception point. + +Alternatives considered: + +1. Ordinary middleware or standard-method route registration: smaller apparent edit, but it runs after lookup and cannot cover arbitrary extension methods or route-generated errors. Rejected. +2. Separate wrappers/entry-point bypasses per platform: can intercept native requests but duplicates policy and does not fit existing concrete `RouterService` seams consistently. Consumer-native shortcuts still require native integration; they do not replace the router hook. +3. Replace routing/body/bootstrap wholesale: unnecessary compatibility risk. Existing buffering is explicitly accepted by consumer section 8; no lazy-body or store-initialization redesign is included. + +Included: core hook, bounded ingress metadata, lossless runtime method/header conversion, adapter-specific target capture and fidelity descriptions, native/WASM/runtime contract tests, and docs. + +Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processing, auction evidence, telemetry, browser UI, generic hook chains, macro grammar additions, forwarding-header trust, transport deadlines/size protection, deployment, and unrelated proxy/response refactors. + +## 4. Verified baseline and limits + +| Surface | Current source | Consequence | +| ---------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Router | `crates/edgezero-core/src/router.rs`, `RouterInner::dispatch` | Lookup precedes state/introspection/context/middleware; no interception hook exists. | +| Bootstrap | `crates/edgezero-core/src/app.rs`; `crates/edgezero-macros/src/app.rs` | `Hooks::routes`, `App` and generated routers use `RouterService`; retain that type. | +| Fastly | `crates/edgezero-adapter-fastly/src/request.rs`, `into_core_request` | SDK URL accessor has already parsed with `url::Url`. Runtime header bytes and same-name values pass through byte-oriented construction. Body is buffered. | +| Cloudflare | `crates/edgezero-adapter-cloudflare/src/request.rs`, `into_core_request` | `req.method()` is a closed Workers enum; extension methods become GET. `req.url()` parses the runtime URL again. Header entries become Rust strings. Body is buffered. | +| Axum | `crates/edgezero-adapter-axum/src/request.rs` | `Request::from_parts` preserves the incoming `http::Uri` and `HeaderMap`. Hyper may already reject/fold fields; JSON bodies are buffered. | +| Spin | `crates/edgezero-adapter-spin/src/request.rs` | SDK request parts contain runtime URI and byte-oriented headers. Original wire interleaving is already unavailable in `HeaderMap`. Body is buffered. | +| Pins | `Cargo.lock` | This checkout pins worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122; verify again during execution. Do not substitute the consumer's worker 0.8.5 pin. | + +Fastly SDK `RequestHandle::get_url` calls `uri_get` then `Url::parse`. Its request handle getter is private; the public consuming handle round trip rebuilds the request without original client provenance, changing IP/TLS/original-header accessors. It is not an acceptable raw-target workaround. A safe borrowed SDK accessor is an external prerequisite for an original-target claim. + +Fetch `Headers` may combine/sort fields. Pinned workerd uses UTF-8 header strings, unlike browser ByteString construction; copying the runtime string cannot recover invalid UTF-8 bytes replaced earlier or missing field boundaries. Hyper's HTTP/1 parser can fold identical repeated Content-Length and reject conflicting lengths before application dispatch. Therefore `HeaderMap` fidelity is not a blanket wire-multiplicity guarantee. + +Existing Fastly/Cloudflare/Spin buffering and Axum JSON buffering occur before the hook. The hook guarantees ordering relative to the router/application, not relative to network receipt, SDK parsing, body buffering, logger setup or capability-handle construction. Consumer section 8 explicitly accepts pre-buffering and offers no transport deadline. Do not claim that the hook removes those limits. + +## 5. Core pre-dispatch API + +Add `PreDispatchHook` and `BoxPreDispatchHook` in `crates/edgezero-core/src/router.rs`: + +```rust +#[async_trait::async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + async fn handle( + &self, + request: &mut Request, + ) -> Result, EdgeError>; +} + +pub type BoxPreDispatchHook = Arc; +``` + +`Request`/`Response` are existing `edgezero_core::http` aliases; the hook future has no Send requirement. Use the existing async-trait dependency. New public items receive the repository's normal documentation. + +Add consuming builder method `RouterBuilder::pre_dispatch_hook(hook: BoxPreDispatchHook) -> Self`. Store one `Option` in builder/inner. Default is None. A repeated builder call replaces the previous hook; this is one hook, not a new middleware chain. `RouterService` clones share the same Arc-backed instance. + +The first operation in `RouterInner::dispatch` becomes: + +```rust +if let Some(hook) = &self.pre_dispatch_hook { + if let Some(response) = hook.handle(&mut request).await? { + return Ok(response); + } +} +``` + +Only after continuation may the router clone the method/path, call `find_route`, insert introspection/state, build `RequestContext` or run middleware. + +### Result contract + +- `Ok(Some(response))`: terminal core response returned unchanged. No route matching, state/introspection injection, `RequestContext`, route middleware or handler follows. +- `Ok(None)`: continue using the same mutated request, including method, URI, headers, extensions and remaining body. +- `Err(error)`: direct `Service::call` propagates the existing `EdgeError`; existing `RouterService::oneshot` renders it through `IntoResponse`. Neither dispatches fallback routes or silently continues. Return `Some(Response)` when exact consumer-specific error headers/body are required. +- No hook: retain all current routing/status/middleware behavior. +- Request cancellation: follow the existing future/drop behavior; introduce no tasks, timers or background work. + +The hook has no implicit router-state access because it precedes injection. Consumers capture their own `Arc` when building the hook. They own authentication, method validation, complete local response headers and any diagnostic policy. Outer adapter/consumer finalizers still exist; a terminal core response is not a promise to bypass arbitrary external finalization. + +Manual builder registration is sufficient; no new `Hooks` method or `app!` argument is required. + +## 6. Ingress metadata contract + +Create `crates/edgezero-core/src/request.rs` and export `pub mod request`. This module defines an adapter-populated `RequestIngress` request extension, not a second request implementation. + +Use private fields, Clone, and borrowed accessors. Do not derive value-bearing Debug, Serialize or Display for target/origin/metadata. These values can contain credentials or query strings. Never log them, put them in error messages, serialize them into responses, or clone all header values into a second buffer. + +### Public data model + +- `CapturedTarget::Complete(CompleteTarget)`, or `Unavailable(TargetUnavailable)`. The public CompleteTarget payload has private value/source/fidelity fields and borrowed getters; only the bounded capture constructor can create it. Do not expose constructible enum fields that bypass the cap. +- `TargetSource`: `TransportRequestTarget`, `RuntimeUrl`, `RuntimePathAndQuery`. Source and fidelity are independent: a runtime URL is not automatically the original request target. +- `Preservation`: `Preserved`, `Transformed`, `Unknown`, `Unavailable`. +- `TargetUnavailable`: `NotExposed`, `TooLarge`, `ReadFailed`. +- `OriginSource`: `RuntimeUri` or `TransportBinding`; provenance is explicit. RuntimeUri records the runtime URI source; TransportBinding records a trusted transport scheme combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. +- `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from trusted inbound/runtime request information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. +- `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. +- `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. + +Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. The plan owns the exact minimum constructor/accessor set. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. + +HeaderFidelity axes describe original received HTTP field-value octets, field multiplicity and ordering compared with the resulting core headers, not merely a successful runtime-to-core copy. Octets mean field-value bytes, not header-name case, delimiters, HTTP framing or complete request serialization. Trimming/combining or parser/runtime loss must be reflected as Transformed, Unknown or Unavailable; Preserved requires pinned runtime/parser evidence and actual transport tests. Document and test runtime-to-core copying separately when it is all that can be established. + +Capture target strings only up to 16 KiB UTF-8 bytes. A larger or failed target capture stores Unavailable without a partial string. Raw octets that cannot be represented by the verified platform string API are unavailable, not replacement-decoded. This toolkit bound is a metadata allocation bound, not a server URI acceptance limit. + +Populate metadata before EdgeZero's own URL parse/normalization where possible. It preserves what the runtime actually supplies. Report stronger wire guarantees only with direct transport evidence. Missing metadata means unknown capability; consumers cannot infer full preservation from its absence. + +Do not turn optional target-capture failure into an unrelated new ordinary-request rejection. Retain the current conversion/error behavior for the primary core URI; record optional metadata failure separately. Invalid original method or invalid runtime HeaderValues are conversion errors, never coerced values. + +## 7. Adapter requirements + +### 7.1 Fastly + +- Preserve `req.get_method()` and existing byte-oriented repeated header conversion. +- Add a reusable `capture_request_ingress(&fastly::Request) -> RequestIngress` helper in the Fastly request module. A consumer calls it before native health/debug shortcuts or mutations. Record the raw-target capture result before reading the SDK URL. With the current missing accessor, retain `NotExposed`, then recover canonical origin separately from the runtime URL plus exactly one valid, consistent Host on a native client request. URL normalization is acceptable for origin acquisition only; it never proves original pathname preservation. Use `RuntimeUri` provenance and do not infer HTTP from missing TLS metadata. Supplied snapshots remain caller assertions and are retained through subsequent mutation. +- With current SDK, mark original target unavailable or runtime-normalized/unknown; do not call `get_url_str` “raw”. +- Obtain a reviewed safe SDK accessor that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance. Pin the resolved compatible SDK revision/version; do not fabricate one or add a local Cargo-cache patch. +- Do not use consuming handle round trips, clone_without_body, raw pointers or a new unsafe fastly-sys wrapper to bypass the SDK gate. +- After the accessor exists, parse scheme/authority without normalizing the captured path. Prove encoded separators/dot segments/repeated separators survive in metadata and that client IP/TLS accessors retain behavior. +- Runtime header bytes and same-name order must be tested. Original-header names/count alone do not prove value-field multiplicity; do not reconstruct fields from mismatched/FIFO guesses. +- Add `into_core_request_with_ingress` and a public `dispatch_with_registries_and_ingress` overload that retains the existing App/StoresMetadata/EnvConfig/extension-callback semantics. Add a metadata-aware conversion path for an already captured snapshot: retain that same snapshot through later native mutation/dispatch. Ordinary converters capture once and delegate. Insert adapter-captured ingress after generic scratch extension merging so it cannot be accidentally overwritten. +- Existing request-extensions callbacks can carry native facts. They are not the new router policy hook and cannot return local responses. + +### 7.2 Cloudflare + +Use `req.inner().method()` and parse with core `Method::from_bytes`. Preserve any valid extension token exposed by the runtime; malformed values produce a bounded conversion error. Do not pass through Workers' closed method enum for inbound conversion. + +Capture `req.inner().url()` before `req.url()`/`url::Url` parsing, then preserve it separately with runtime-visible provenance. This does not recover a wire target normalized by the Fetch runtime. + +For each `Headers.entries()` value, preserve the pinned Worker runtime's UTF-8 string representation with `HeaderValue::from_bytes(value.as_bytes())`. Header names retain validated construction; invalid HeaderValues produce bounded conversion errors rather than silent omission. Pinned workerd represents headers as UTF-8, which differs from browser ByteString expectations. Do not cast Unicode scalars to single bytes: that changes valid wire C3 A9 to E9 and rejects valid C4 80. Browser-created high-byte strings are not evidence of original Worker wire bytes. + +Real workerd probes must assert exact valid UTF-8 preservation for C3 A9 and C4 80. A raw invalid FF is already represented by U+FFFD before Rust; its runtime UTF-8 copy EF BF BD is not the original FF. Expose conservative transformed fidelity and keep this consumer capability unresolved rather than invent lost bytes or add an ordinary-request rejection for a valid runtime string. + +Web Headers has already normalized/combined entries: mark original wire multiplicity/global order unavailable or transformed according to verified behavior. Per-name value order and octet provenance must also be stated precisely. Never comma-split Origin/Cookie/control values to invent original fields. + +Keep existing body buffering and Env/Context/proxy wiring intact. Distinguish browser Fetch construction constraints from actual incoming Workers HTTP behavior in tests. + +### 7.3 Axum + +Capture `parts.uri` before any added URL parsing and preserve `parts.headers` through `from_parts`. Source is runtime path/query or URI, not an original TCP request line. Validate inbound scheme/authority from trustworthy runtime binding; an origin-form URI without that information has unavailable origin rather than a guessed scheme. + +Audit Hyper parser changes and report per-field limitations. Identical repeated Content-Length may be folded; conflicting lengths may be rejected before hook invocation. Such rejection is transport evidence, not a hook-built local response. + +Keep current JSON buffering and streaming non-JSON behavior. No new Tokio dependency in core; existing Axum test infrastructure may use its existing Tokio runtime. + +### 7.4 Spin + +Capture `parts.uri` and byte headers before conversion. Prefer runtime URI scheme/authority supplied by the SDK/WIT; do not accept `spin-full-url` or another ordinary header as trusted without proving runtime injection and spoofing protection. + +Preserve runtime method tokens and existing byte-valued header construction. The SDK has already built a HeaderMap, so global wire order cannot be promised. Record same-name multiplicity/order only at the level actually established by a transport fixture. + +Keep current body buffering, store registries and runtime response conversion. Synthetic core-request contract tests alone do not prove SDK request conversion; provide an actual Spin invocation where needed. + +## 8. Capability matrix and consumer integration gate + +Initial investigation, not a verified release matrix: + +| Property | Fastly | Cloudflare | Axum | Spin | +| ---------------------------------- | ------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | ---------------------------- | +| Arbitrary runtime method | Byte-oriented existing method; verify | Fix closed enum loss | Existing method parts; verify | SDK Other/from_bytes; verify | +| Target before EdgeZero parsing | SDK raw accessor dependency | Runtime URL exposed; wire normalization may precede it | Runtime URI parts | SDK runtime URI parts | +| Header octets before EdgeZero loss | Existing byte path | Copy Worker UTF-8 runtime strings | Existing byte HeaderMap | Existing byte HeaderMap | +| Original field multiplicity | Runtime proof required | Fetch coalescing can make it unavailable | Per-field parser limits, especially Content-Length | Runtime/SDK proof required | +| Original global wire order | Not implied by grouped iteration | Unavailable after sorted/combined entries | Not retained by HeaderMap | Not retained by HeaderMap | + +The release review must replace each required unknown with Preserved, Transformed, Unavailable or a clearly pending environment test backed by evidence. Unknown is never success. + +Two completion claims are distinct: + +1. **EdgeZero infrastructure complete:** hook/API, concrete conversion fixes, metadata that honestly describes remaining limits, tests and docs pass. +2. **Trusted Server F0 satisfied across all platforms:** consumer-required path/cookie/control properties are proved, or the consumer spec receives explicit approval for a supported runtime contract. EdgeZero cannot grant that approval. + +If a runtime irreversibly hides required path spellings or original duplicate control fields, record the exact limitation and a proposed consumer resolution. Do not silently weaken the approved consumer spec, advertise universal original-wire preservation, disable a consumer platform without approval or report F0 complete solely because a hook exists. + +Trusted Server owns early Fastly native shortcut exclusion, classification, authentication, lifecycle gates and terminal response finalization. The ingress helper must be callable before those shortcuts; changing Trusted Server's `main.rs` is outside this EdgeZero branch. + +## 9. Tests and acceptance + +1. No-hook routing and existing 404/405/middleware/state behavior remain identical. +2. Hook sees arbitrary methods/paths before any router lookup result; terminal success/error runs zero state/context/middleware/handler effects. +3. Continued mutations are observed by actual route lookup and handler; consumed/replaced body behavior is explicit. +4. Hook sharing across clones and a non-Send future compile/run without introducing Tokio. +5. Metadata captures at/over target cap, omits partial values, rejects spoofed origin sources and exposes conservative missing capability. +6. Cloudflare runtime extension methods and UTF-8 strings survive the real converter, not a synthetic core request. +7. Actual runtime probes distinguish platform rejection, normalization, coalescing, EdgeZero conversion and hook response. +8. Fastly raw-target/client-provenance claims depend on the reviewed SDK accessor and real Viceroy ingress evidence. +9. Existing route/response/header/store contracts and all supported target checks pass. +10. Docs include API usage, buffering/finalization limits, per-platform capability matrix and consumer handoff evidence. + +Use fictional/example.com fixtures only. Never log actual headers, cookies, credentials, raw target strings or exception payloads in new diagnostic code. + +## 10. Review and implementation status + +This spec and its matching plan received independent review before the user authorized implementation. The matching plan records implemented surfaces, executed evidence and remaining gates. These documents are not a published API or proof of runtime preservation. The Fastly SDK accessor and any consumer-contract resolution remain explicit external gates. + +The plan records independent review results after reviewer findings are resolved. Neither document authorizes publishing, deployment or downstream spec changes. + +## 11. Fresh review correction + +A subsequent independent review disproved the unconditional browser ByteString assumption in the Cloudflare design above. Pinned workerd exposes header strings as UTF-8; the initial scalar-to-byte converter changed valid wire C3 A9 to E9 and rejected valid C4 80 before the hook. Conversion now copies Worker UTF-8 runtime strings; the matching plan records real Worker verification before release. Invalid UTF-8 replacement remains an independent upstream limitation. This correction does not authorize changing the approved Trusted Server contract. The matching plan records the reproduction, corrective work and F0 resolution choices. + +## 12. Approved consumer runtime-boundary amendment + +On 2026-10-05, the user approved the independently reviewed amendment to the +existing Trusted Server trace spec and single plan, then authorized its +implementation. The original consumer guarantees described above are the +historical baseline; original-target recovery and original-wire reconstruction +are no longer prerequisites for that consumer v1. + +The amended consumer classifies its application-visible URI pathname, keeps +literal-path authentication, and distinguishes runtime rejection, adapter +bootstrap/conversion failure and successful application dispatch. It rejects +still-visible reserved aliases and validates complete same-origin controls. +Runtime-visible Cookie fields are bounded before sanitation. Unproved octets +with U+FFFD, or unproved multiplicity with a literal comma, yield +`unavailable/runtime_header_ambiguous` and suppress trace capture; missing or +Unknown metadata alone permits readable marker-free sessions. The consumer +owns that policy; EdgeZero metadata does not impose it on other applications. + +The SDK/runtime limits remain documented capabilities. Consumer F0 now +requires a reviewed immutable EdgeZero revision and verification against the +consumer's resolved dependency graph. Successful normal browser workflows on +all four adapters, early native ordering and terminal finalization remain +consumer acceptance work. Toolkit tests alone do not complete those gates. + +### Per-observation preservation clarification — 2026-10-06 + +`Transformed` means a change is established for the specific observation. A +runtime capability to normalize URLs or fold Content-Length fields does not +prove that every request changed. Cloudflare runtime targets and Axum +Content-Length multiplicity therefore use `Unknown` when original equality or +change cannot be established. Conversion behavior and recorded capability +observations remain unchanged. diff --git a/scripts/test-request-fidelity.sh b/scripts/test-request-fidelity.sh new file mode 100755 index 000000000..8bdd2c46d --- /dev/null +++ b/scripts/test-request-fidelity.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +host_triple="$(rustc -vV | awk '$1 == "host:" { print $2 }')" +test -n "$host_triple" + +# Job control gives the bootstrap build its own process group on Linux and macOS. +set -m +bootstrap_pid="" +cleanup_bootstrap() { + if [[ -n "$bootstrap_pid" ]]; then + kill -TERM -- "-$bootstrap_pid" 2>/dev/null || true + kill -KILL -- "-$bootstrap_pid" 2>/dev/null || true + wait "$bootstrap_pid" 2>/dev/null || true + fi +} +trap cleanup_bootstrap EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +cargo build --manifest-path "$repo_root/tests/fixtures/request-fidelity-runner/Cargo.toml" \ + --locked --target "$host_triple" --target-dir "$repo_root/target/request-fidelity-runner" --quiet & +bootstrap_pid=$! +wait "$bootstrap_pid" +cleanup_bootstrap +bootstrap_pid="" +trap - EXIT INT TERM HUP +set +m +exec "$repo_root/target/request-fidelity-runner/$host_triple/debug/request-fidelity-runner" \ + "$@" --repo-root "$repo_root" diff --git a/tests/fixtures/request-fidelity-runner/Cargo.lock b/tests/fixtures/request-fidelity-runner/Cargo.lock new file mode 100644 index 000000000..4d105aa35 --- /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 000000000..90899968b --- /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 000000000..7fab6c10a --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -0,0 +1,1276 @@ +//! Fixed, loopback-only HTTP ingress probes for the three WASM adapters. + +use serde_json::{Value, json}; +use std::env; +use std::fs::{self, File}; +use std::io::Seek as _; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; +use tokio::signal::unix::{SignalKind, signal}; + +const RESPONSE_LIMIT: usize = 1024 * 1024; +const HEADER_LIMIT: usize = 64 * 1024; +const POLL_INTERVAL: Duration = Duration::from_millis(100); +type Cancelled = Arc; + +struct Options { + platforms: Vec<&'static str>, + skip_build: bool, + readiness_timeout: Duration, + record_observations: bool, + root: PathBuf, +} + +impl Options { + fn parse() -> io::Result { + let mut args = env::args().skip(1); + let first = args.next(); + let (root, platform) = if first.as_deref() == Some("--repo-root") { + let root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + (root, args.next()) + } else { + ( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize()?, + first, + ) + }; + let platforms = match platform.as_deref() { + Some("all") => vec!["fastly", "cloudflare", "spin"], + Some("fastly") => vec!["fastly"], + Some("cloudflare") => vec!["cloudflare"], + Some("spin") => vec!["spin"], + _ => { + return Err(io::Error::other( + "usage: request-fidelity-runner [--skip-build] [--readiness-timeout SECONDS] [--record-observations]", + )); + } + }; + let mut options = Self { + platforms, + skip_build: false, + readiness_timeout: Duration::from_secs(40), + record_observations: false, + root, + }; + while let Some(argument) = args.next() { + match argument.as_str() { + "--repo-root" => { + options.root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + } + "--skip-build" => options.skip_build = true, + "--record-observations" => options.record_observations = true, + "--readiness-timeout" => { + let seconds = args + .next() + .ok_or_else(|| io::Error::other("missing readiness timeout"))? + .parse::() + .map_err(io::Error::other)?; + options.readiness_timeout = + Duration::try_from_secs_f64(seconds).map_err(io::Error::other)?; + if options.readiness_timeout.is_zero() { + return Err(io::Error::other("readiness timeout must be positive")); + } + } + _ => return Err(io::Error::other(format!("unknown argument: {argument}"))), + } + } + Ok(options) + } +} + +#[tokio::main] +async fn main() -> ExitCode { + match execute().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let _result = writeln!(io::stderr(), "request fidelity: {error}"); + ExitCode::FAILURE + } + } +} + +async fn execute() -> io::Result<()> { + let options = Options::parse()?; + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; + let mut worker = tokio::task::spawn_blocking(move || run(options, &worker_cancelled)); + tokio::select! { + result = &mut worker => result.map_err(io::Error::other)?, + _ = interrupt.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGINT: owned children stopped")) + } + _ = terminate.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGTERM: owned children stopped")) + } + _ = hangup.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGHUP: owned children stopped")) + } + } +} + +fn check_cancelled(cancelled: &Cancelled) -> io::Result<()> { + if cancelled.load(Ordering::SeqCst) { + Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")) + } else { + Ok(()) + } +} + +struct OwnedProcess { + child: Child, + group: u32, +} + +impl OwnedProcess { + fn spawn(command: &mut Command) -> io::Result { + command.process_group(0); + let child = command.spawn()?; + let group = child.id(); + Ok(Self { child, group }) + } + + fn wait(&mut self, cancelled: &Cancelled) -> io::Result<()> { + loop { + check_cancelled(cancelled)?; + if let Some(status) = self.child.try_wait()? { + return if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "fixture command exits with {status}" + ))) + }; + } + thread::sleep(POLL_INTERVAL); + } + } +} + +fn signal_group(group: u32, name: &str) { + let _result = Command::new("/bin/kill") + .args([name, "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + // Signal the owned group even if its direct wrapper already exited. + signal_group(self.group, "-TERM"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if self.child.try_wait().is_ok_and(|status| status.is_some()) + || Instant::now() >= deadline + { + break; + } + thread::sleep(POLL_INTERVAL); + } + signal_group(self.group, "-KILL"); + let _result = self.child.wait(); + } +} + +fn command(fixture: &Path, program: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(program); + command + .args(arguments) + .current_dir(fixture) + .env("WRANGLER_SEND_METRICS", "false"); + command +} + +fn run_command( + fixture: &Path, + program: &str, + arguments: &[&str], + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + check_cancelled(cancelled)?; + let mut command = command(fixture, program, arguments); + // A file avoids pipe backpressure while waiting interruptibly for build tools. + let mut output = tempfile::tempfile()?; + command.stdout(output.try_clone()?); + command.stderr(Stdio::inherit()); + let result = OwnedProcess::spawn(&mut command)?.wait(cancelled); + output.rewind()?; + if !capture { + io::copy(&mut output, &mut io::stderr())?; + } + result?; + if capture { + let mut value = String::new(); + output.read_to_string(&mut value)?; + Ok(value) + } else { + Ok(String::new()) + } +} + +fn verify_version( + fixture: &Path, + program: &str, + expected: &str, + cancelled: &Cancelled, +) -> io::Result<()> { + let output = run_command(fixture, program, &["--version"], cancelled, true)?; + let actual = if program == "worker-build" { + output.trim() + } else { + output.split_whitespace().nth(1).unwrap_or("") + }; + if actual != expected { + return Err(io::Error::other(format!( + "{program} requires version {expected}" + ))); + } + Ok(()) +} + +fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { + run_command( + fixture, + "cargo", + &["metadata", "--locked", "--format-version", "1", "--no-deps"], + cancelled, + true, + )?; + if platform == "cloudflare" { + verify_version(fixture, "worker-build", "0.8.3", cancelled)?; + run_command(fixture, "npm", &["ci"], cancelled, false)?; + let lock = fs::read(fixture.join("Cargo.lock"))?; + run_command( + fixture, + "worker-build", + &[ + ".", + "--release", + "--no-opt", + "--out-dir", + "build/cloudflare", + "--", + "--features", + "cloudflare", + "--locked", + "--target-dir", + "target/cloudflare", + ], + cancelled, + false, + )?; + if fs::read(fixture.join("Cargo.lock"))? != lock { + return Err(io::Error::other( + "worker-build changes the locked fixture dependencies", + )); + } + } else { + let mut arguments = vec![ + "build", + "--locked", + "--release", + "--features", + platform, + "--target-dir", + ]; + arguments.extend(if platform == "fastly" { + [ + "target/fastly", + "--target", + "wasm32-wasip1", + "--bin", + "request-fidelity-fastly", + ] + .as_slice() + } else { + ["target/spin", "--target", "wasm32-wasip2", "--lib"].as_slice() + }); + run_command(fixture, "cargo", &arguments, cancelled, false)?; + } + Ok(()) +} + +fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { + let fixture = options.root.join("tests/fixtures/request-fidelity"); + let mut observations = json!({}); + for platform in &options.platforms { + check_cancelled(cancelled)?; + if *platform == "spin" { + let manifest: toml::Value = + toml::from_str(&fs::read_to_string(fixture.join("Cargo.toml"))?) + .map_err(io::Error::other)?; + let pin = manifest["package"]["metadata"]["request-fidelity"]["spin-cli"] + .as_str() + .ok_or_else(|| io::Error::other("missing Spin runtime pin"))?; + verify_version(&fixture, "spin", pin, cancelled)?; + } else if *platform == "fastly" { + let versions = fs::read_to_string(options.root.join(".tool-versions"))?; + let pin = versions + .lines() + .find_map(|line| line.strip_prefix("viceroy ")) + .ok_or_else(|| io::Error::other("missing Viceroy pin"))?; + verify_version(&fixture, "viceroy", pin.trim(), cancelled)?; + } + if !options.skip_build { + build(platform, &fixture, cancelled)?; + } + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + writeln!(io::stderr(), "{platform} loopback fixture uses port {port}")?; + let address = format!("127.0.0.1:{port}"); + let port_string = port.to_string(); + let scratch_string = scratch.path().to_string_lossy(); + let log_dir = scratch.path().join("logs"); + let log_dir_string = log_dir.to_string_lossy(); + let mut runtime = match *platform { + "fastly" => command( + &fixture, + "viceroy", + &[ + "serve", + "-C", + "fastly.toml", + "--addr", + &address, + "target/fastly/wasm32-wasip1/release/request-fidelity-fastly.wasm", + ], + ), + "spin" => command( + &fixture, + "spin", + &[ + "up", + "--from", + "spin.toml", + "--listen", + &address, + "--state-dir", + &scratch_string, + "--log-dir", + &log_dir_string, + ], + ), + _ => command( + &fixture, + "node_modules/.bin/wrangler", + &[ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + &port_string, + ], + ), + }; + let log_path = scratch.path().join("runtime.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + options.readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() { + return Err(io::Error::other("runtime exits before readiness")); + } + if Instant::now() >= deadline { + return Err(io::Error::other("runtime readiness deadline exceeded")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let 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) { + return Err(io::Error::other(format!( + "{platform}: full runtime observations changed; inspect before updating baseline" + ))); + } + } + Ok(observed) + })(); + drop(process); + match result { + Ok(observed) => observations[*platform] = observed, + Err(error) => { + if let Ok(log) = fs::read_to_string(&log_path) { + writeln!(io::stderr(), "{log}")?; + } + return Err(error); + } + } + } + serde_json::to_writer_pretty(io::stdout(), &observations).map_err(io::Error::other)?; + writeln!(io::stdout()) +} + +fn request_packet(case: &Case) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(case.method); + packet.push(b' '); + packet.extend_from_slice(case.target); + packet.extend_from_slice(b" HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: "); + packet.extend_from_slice(case.name.as_bytes()); + packet.extend_from_slice(b"\r\n"); + for (name, value) in &case.headers { + packet.extend_from_slice(name); + packet.extend_from_slice(b": "); + packet.extend_from_slice(value); + packet.extend_from_slice(b"\r\n"); + } + packet.extend_from_slice(b"\r\n"); + packet.extend_from_slice(case.body); + packet +} + +struct TimedSocket<'a> { + socket: TcpStream, + cancelled: &'a Cancelled, + deadline: Instant, +} + +impl Read for TimedSocket<'_> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + loop { + // Buffered/exact reads retry Interrupted, so cancellation must be terminal. + if self.cancelled.load(Ordering::SeqCst) { + return Err(io::Error::other("cancelled")); + } + if Instant::now() >= self.deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP response deadline exceeded", + )); + } + match self.socket.read(bytes) { + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + result => return result, + } + } + } +} + +fn exchange( + port: u16, + case: &Case, + cancelled: &Cancelled, + deadline: Instant, +) -> io::Result<(u16, Value)> { + check_cancelled(cancelled)?; + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let timeout = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(5)); + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP connection deadline exceeded", + )); + } + let mut socket = TcpStream::connect_timeout(&address, timeout)?; + socket.set_write_timeout(Some(timeout))?; + socket.set_read_timeout(Some(POLL_INTERVAL))?; + socket.write_all(&request_packet(case))?; + let mut reader = BufReader::new(TimedSocket { + socket, + cancelled, + deadline, + }); + let (status, payload) = read_response(&mut reader)?; + Ok(( + status, + serde_json::from_slice(&payload).unwrap_or(Value::Null), + )) +} + +fn http_line(reader: &mut impl BufRead, remaining: &mut usize) -> io::Result { + let mut bytes = Vec::new(); + reader + .take(u64::try_from(*remaining).map_err(io::Error::other)?) + .read_until(b'\n', &mut bytes)?; + *remaining -= bytes.len(); + if !bytes.ends_with(b"\r\n") { + return Err(io::Error::other("missing CRLF or oversized HTTP header")); + } + bytes.truncate(bytes.len() - 2); + String::from_utf8(bytes).map_err(io::Error::other) +} + +fn read_response(reader: &mut impl BufRead) -> io::Result<(u16, Vec)> { + let mut remaining = HEADER_LIMIT; + loop { + let status_line = http_line(reader, &mut remaining)?; + let mut fields = status_line.split_whitespace(); + if !matches!(fields.next(), Some("HTTP/1.1" | "HTTP/1.0")) { + return Err(io::Error::other("invalid HTTP response status line")); + } + let status = fields + .next() + .ok_or_else(|| io::Error::other("missing response status"))? + .parse::() + .map_err(io::Error::other)?; + let mut length = None; + let mut chunked = false; + loop { + let line = http_line(reader, &mut remaining)?; + if line.is_empty() { + break; + } + let (name, value) = line + .split_once(':') + .ok_or_else(|| io::Error::other("invalid HTTP response header"))?; + if name.eq_ignore_ascii_case("content-length") { + let next = value.trim().parse::().map_err(io::Error::other)?; + if length.is_some_and(|previous| previous != next) { + return Err(io::Error::other("conflicting response lengths")); + } + length = Some(next); + } + if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value + .split(',') + .any(|item| item.trim().eq_ignore_ascii_case("chunked")); + } + } + if (100..200).contains(&status) { + continue; + } + let mut body = Vec::new(); + if chunked { + loop { + let line = http_line(reader, &mut remaining)?; + let size = usize::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16) + .map_err(io::Error::other)?; + if size == 0 { + while !http_line(reader, &mut remaining)?.is_empty() {} + break; + } + append_body(reader, &mut body, size)?; + let mut delimiter = [0; 2]; + reader.read_exact(&mut delimiter)?; + if delimiter != *b"\r\n" { + return Err(io::Error::other("invalid chunk delimiter")); + } + } + } else if let Some(length) = length { + append_body(reader, &mut body, length)?; + } else { + reader + .take(u64::try_from(RESPONSE_LIMIT + 1).map_err(io::Error::other)?) + .read_to_end(&mut body)?; + if body.len() > RESPONSE_LIMIT { + return Err(io::Error::other("oversized HTTP response body")); + } + } + return Ok((status, body)); + } +} + +fn append_body(reader: &mut impl Read, body: &mut Vec, length: usize) -> io::Result<()> { + let end = body + .len() + .checked_add(length) + .filter(|end| *end <= RESPONSE_LIMIT) + .ok_or_else(|| io::Error::other("oversized HTTP response body"))?; + let start = body.len(); + body.resize(end, 0); + reader.read_exact(&mut body[start..]) +} + +fn ready_case() -> Case { + Case { + name: "ready", + method: b"GET", + target: b"/reserved", + headers: Vec::new(), + body: b"", + } +} + +fn cases() -> Vec { + let mut cases: Vec<_> = [ + "extension", + "dot", + "encoded-dot", + "slashes", + "absolute", + "high-byte", + "utf8", + "unicode", + "repeat", + "compound-invalid", + "compound-valid", + "spoof", + "length-identical", + "length-conflict", + "te-cl", + "chunked", + "continue", + ] + .into_iter() + .map(|name| Case { + name, + ..ready_case() + }) + .collect(); + for case in &mut cases { + match case.name { + "extension" => case.method = b"EXAMPLE-METHOD", + "dot" => case.target = b"/reserved/../ordinary", + "encoded-dot" => case.target = b"/reserved/%2e%2e/ordinary", + "slashes" => case.target = b"/reserved//%2F?q=a%2Fb", + "absolute" => case.target = b"http://example.com/reserved?fixed=1", + "high-byte" => case.headers = vec![(b"Cookie", b"a=\xff")], + "utf8" => case.headers = vec![(b"Cookie", b"a=\xc3\xa9")], + "unicode" => case.headers = vec![(b"Cookie", b"a=\xc4\x80")], + "repeat" => { + case.headers = vec![ + (b"Cookie", b"a=1"), + (b"Origin", b"https://example.com"), + (b"X-Control", b"first"), + (b"COOKIE", b"b=2"), + (b"Origin", b"https://other.example.com"), + (b"x-control", b"second"), + ] + } + "compound-invalid" | "compound-valid" => { + case.headers = vec![ + (b"Cookie", b"diagnostics=1"), + ( + b"Cookie", + if case.name == "compound-invalid" { + b"a=\xff" + } else { + b"a=\xef\xbf\xbd" + }, + ), + ]; + } + "spoof" => { + case.headers = vec![ + (b"Origin", b"https://spoof.example.com"), + (b"Forwarded", b"proto=https;host=spoof.example.com"), + (b"spin-full-url", b"https://spoof.example.com/reserved"), + (b"spin-client-addr", b"192.0.2.1:1"), + ] + } + "length-identical" | "length-conflict" => { + case.method = b"POST"; + case.headers = vec![ + (b"Content-Length", b"0"), + ( + b"Content-Length", + if case.name == "length-identical" { + b"0" + } else { + b"1" + }, + ), + ]; + } + "te-cl" | "chunked" => { + case.method = b"POST"; + case.headers = vec![(b"Transfer-Encoding", b"chunked")]; + if case.name == "te-cl" { + case.headers.push((b"Content-Length", b"0")); + } + case.body = b"0\r\n\r\n"; + } + "continue" => { + case.method = b"POST"; + case.target = b"/missing"; + } + _ => return Vec::new(), + } + } + cases +} + +fn require(condition: bool, platform: &str, case: &str, message: &str) -> io::Result<()> { + if condition { + Ok(()) + } else { + Err(io::Error::other(format!("{platform}/{case}: {message}"))) + } +} + +fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) -> io::Result<()> { + if status == 200 { + if case == "continue" { + return require( + *verdict == json!({"ordinary": true}), + platform, + case, + "continuation must run ordinary handler", + ); + } + require( + verdict["hook"] == true && verdict["ordinary"] == false, + platform, + case, + "hook must intercept before ordinary lifecycle", + )?; + if platform == "fastly" { + require( + verdict["native_client_preserved"] == true, + platform, + case, + "native provenance lost", + )?; + } + require( + verdict["global_order_unavailable"] == true + && verdict["spoofed_origin_ignored"] == true + && verdict["origin_available"] == true + && verdict["origin_matches_expected"] == true, + platform, + case, + "unavailable order and origin provenance must remain explicit", + )?; + require( + verdict["target_available"] == (platform != "fastly"), + platform, + case, + "unexpected target availability", + )?; + if platform != "fastly" { + require( + verdict["target_matches_runtime"] == true, + platform, + case, + "target must match runtime", + )?; + } + match case { + "extension" => require( + verdict["extension_method"] == true, + platform, + case, + "extension token lost", + )?, + "high-byte" if platform == "cloudflare" => { + require( + verdict["cookie_replacement"] == true && verdict["cookie_octets"] == false, + platform, + case, + "expected documented UTF-8 replacement, not original FF preservation", + )?; + writeln!( + io::stderr(), + "UNSUPPORTED local workerd replaces invalid UTF-8 FF before Rust; hook sees UTF-8 replacement bytes." + )?; + } + "high-byte" => require( + verdict["cookie_octets"] == true, + platform, + case, + "original cookie FF lost", + )?, + "utf8" => require( + verdict["cookie_utf8"] == true && verdict["cookie_latin1"] == false, + platform, + case, + "valid UTF-8 C3 A9 must remain exact", + )?, + "unicode" => require( + verdict["cookie_unicode"] == true, + platform, + case, + "valid UTF-8 C4 80 must remain exact", + )?, + "repeat" if platform != "cloudflare" => require( + verdict["cookie_count"] == 2 + && verdict["same_name_order"] == true + && verdict["origin_count"] == 2 + && verdict["control_count"] == 2, + platform, + case, + "ordinary repeated fields or same-name order lost", + )?, + "repeat" => require( + verdict["cookie_count"] == 1 + && verdict["cookie_repeat_comma_joined"] == true + && verdict["cookie_repeat_semicolon_joined"] == false, + platform, + case, + "must establish exact comma joining rather than infer cookie semantics from counts", + )?, + "compound-invalid" | "compound-valid" if platform == "cloudflare" => require( + verdict["cookie_count"] == 1 && verdict["cookie_compound_comma_joined"] == true, + platform, + case, + "invalid FF and original UTF-8 replacement must have the same documented runtime representation", + )?, + "compound-invalid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_original"] == true, + platform, + case, + "must retain valid-looking diagnostics alongside unrelated original FF", + )?, + "compound-valid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_runtime_utf8"] == true, + platform, + case, + "must retain the client's original valid UTF-8 replacement bytes", + )?, + "te-cl" | "chunked" => { + let count = verdict["transfer_encoding_count"].as_u64(); + let chunked = verdict["transfer_encoding_chunked"].as_bool(); + require( + matches!(count, Some(0 | 1)) && chunked == count.map(|count| count == 1), + platform, + case, + "must record whether chunked Transfer-Encoding survives conversion", + )?; + } + _ => {} + } + return Ok(()); + } + match (platform, case, status) { + ("cloudflare", "length-identical" | "length-conflict", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler duplicate Content-Length ingress (500); no hook verdict." + )?, + ("cloudflare", "absolute", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler absolute-form ingress (500); no hook verdict." + )?, + ("cloudflare", "extension", 501) => writeln!( + io::stderr(), + "PENDING local workerd rejects extension methods before Worker invocation (501)." + )?, + ("spin", "high-byte" | "compound-invalid", 500) => writeln!( + io::stderr(), + "PENDING Spin 4.0 rejects non-UTF-8 header bytes before component invocation (500)." + )?, + (_, "length-identical" | "length-conflict" | "te-cl" | "absolute", 400) => {} + _ => { + return Err(io::Error::other(format!( + "{platform}/{case}: unexpected HTTP status {status}" + ))); + } + } + require( + verdict.is_null(), + platform, + case, + "transport rejection must have no hook verdict", + ) +} + +fn cloudflare_routing_probes( + fixture: &Path, + cancelled: &Cancelled, + readiness_timeout: Duration, +) -> io::Result> { + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + let address = format!("http=127.0.0.1:{port}"); + let mut runtime = command( + fixture, + "node_modules/.bin/workerd", + &["serve", "workerd.capnp", "--socket-addr", &address], + ); + let log_path = scratch.path().join("workerd.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() || Instant::now() >= deadline { + return Err(io::Error::other("direct workerd readiness failed")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let mut observed = serde_json::Map::new(); + let mut failures = Vec::new(); + for (name, target) in [ + ("route-unicode", b"/caf\xc3\xa9".as_slice()), + ("route-backslash", b"/a\\b".as_slice()), + ] { + let case = Case { + name, + target, + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + if status != 200 + || verdict + != json!({ + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true, + }) + { + failures.push(format!("{name}: HTTP {status}, verdict {verdict}")); + } + observed.insert( + name.to_owned(), + json!({"status": status, "verdict": verdict}), + ); + } + require( + failures.is_empty(), + "cloudflare", + "no-hook routing", + &failures.join("; "), + )?; + Ok(observed) + })(); + drop(process); + if result.is_err() { + writeln!(io::stderr(), "{}", fs::read_to_string(log_path)?)?; + } + result +} + +fn probes(platform: &str, port: u16, cancelled: &Cancelled) -> io::Result { + let mut observed = json!({}); + let cases = cases(); + 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] + #[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 fixed_registry_contains_unicode_and_standalone_chunked_probes() { + let cases = cases(); + assert_eq!(cases.len(), 17, "should include every fixed ingress case"); + let unicode = cases + .iter() + .find(|case| case.name == "unicode") + .expect("should include unicode case"); + assert_eq!( + unicode.headers, + [(b"Cookie".as_slice(), b"a=\xc4\x80".as_slice())], + "should send exact UTF-8 C4 80" + ); + let chunked = cases + .iter() + .find(|case| case.name == "chunked") + .expect("should include chunked case"); + assert_eq!( + chunked.headers, + [(b"Transfer-Encoding".as_slice(), b"chunked".as_slice())], + "should probe Transfer-Encoding without Content-Length" + ); + assert_eq!( + chunked.body, b"0\r\n\r\n", + "should terminate the empty chunked body" + ); + } + + #[test] + fn observation_verification_rejects_utf8_corruption_even_during_collection() { + let verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); + let _error = verify_observation("cloudflare", "utf8", 200, &verdict) + .expect_err("should reject the former C3 A9 to E9 defect"); + let _error = verify_observation("cloudflare", "unicode", 500, &serde_json::Value::Null) + .expect_err("should reject the former C4 80 conversion error"); + } + + #[test] + fn cancellation_stops_and_reaps_the_owned_process_group() { + let cancelled = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("/bin/sleep"); + command + .arg("30") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut process = OwnedProcess::spawn(&mut command).expect("should spawn owned group"); + let group = process.group; + let worker_cancelled = Arc::clone(&cancelled); + let stopper = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + worker_cancelled.store(true, Ordering::SeqCst); + }); + let start = Instant::now(); + let error = process + .wait(&cancelled) + .expect_err("should stop waiting on interruption"); + assert_eq!( + error.kind(), + io::ErrorKind::Interrupted, + "should preserve interruption reason" + ); + drop(process); + stopper.join().expect("should join cancellation trigger"); + assert!( + start.elapsed() < Duration::from_secs(6), + "should bound cleanup even when interrupted" + ); + let status = Command::new("/bin/kill") + .args(["-0", "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("should inspect owned group"); + assert!( + !status.success(), + "should leave no live owned process group" + ); + } + + #[test] + fn packet_preserves_case_order_high_bytes_and_chunk_body() { + let case = Case { + name: "fixed", + method: b"EXAMPLE-METHOD", + target: b"/reserved//%2F?q=a%2Fb", + headers: vec![(b"Cookie", b"a=\xff"), (b"COOKIE", b"b=2")], + body: b"0\r\n\r\n", + }; + assert_eq!( + request_packet(&case), + b"EXAMPLE-METHOD /reserved//%2F?q=a%2Fb HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: fixed\r\nCookie: a=\xff\r\nCOOKIE: b=2\r\n\r\n0\r\n\r\n", + "should preserve the exact raw request bytes" + ); + } + + #[test] + fn response_decodes_chunks_extensions_and_trailers() { + let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4;fixed=yes\r\n{\"a\"\r\n3\r\n:1}\r\n0\r\nX-Fixture: fixed\r\n\r\n"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse chunks"); + assert_eq!(status, 200, "should preserve status"); + assert_eq!(body, b"{\"a\":1}", "should decode the chunk framing"); + } + + #[test] + fn response_handles_interim_status_and_content_length() { + let raw = + b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 418 Fixed\r\nContent-Length: 2\r\n\r\n{}extra"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse response"); + assert_eq!(status, 418, "should use final response status"); + assert_eq!(body, b"{}", "should read exactly Content-Length"); + } + + #[test] + fn response_rejects_truncation_and_oversized_body() { + for raw in [ + b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n{}".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}XX".as_slice(), + ] { + let _error = read_response(&mut Cursor::new(raw)) + .expect_err("should reject incomplete or oversized responses"); + } + } + + #[test] + fn response_reads_eof_framing_and_rejects_excessive_headers() { + let (status, body) = read_response(&mut Cursor::new(b"HTTP/1.0 200 OK\r\n\r\n{}")) + .expect("should parse EOF-framed response"); + assert_eq!(status, 200, "should retain status"); + assert_eq!(body, b"{}", "should read the EOF-framed body"); + let oversized = format!( + "HTTP/1.1 200 OK\r\nX-Fixture: {}\r\n\r\n", + "a".repeat(HEADER_LIMIT) + ); + let _error = read_response(&mut Cursor::new(oversized)) + .expect_err("should enforce a total header budget"); + } +} diff --git a/tests/fixtures/request-fidelity/.gitignore b/tests/fixtures/request-fidelity/.gitignore new file mode 100644 index 000000000..567609b12 --- /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 000000000..0daa5c899 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.lock @@ -0,0 +1,2097 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + +[[package]] +name = "edgezero-adapter-fastly" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "brotli", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit 0.9.2", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.10.9", + "thiserror 2.0.20", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.3", + "toml", + "validator", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "lazy_static", + "mime", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "sha2 0.9.9", + "smallvec", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "fastly-macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "log-fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +dependencies = [ + "fastly", + "log", + "regex", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "request-fidelity" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "fastly", + "serde_json", + "spin-sdk", + "wasm-bindgen", + "worker", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin-macro" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.20", + "wasip3", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.20", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worker" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml new file mode 100644 index 000000000..3240c8dfb --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "request-fidelity" +version = "0.1.0" +edition = "2024" +publish = false + +[package.metadata.request-fidelity] +spin-cli = "4.0.0" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "request-fidelity-fastly" +path = "src/bin/fastly.rs" +required-features = ["fastly"] + +[features] +default = [] +fastly = ["dep:edgezero-adapter-fastly", "dep:fastly"] +cloudflare = ["dep:edgezero-adapter-cloudflare", "dep:worker", "dep:wasm-bindgen"] +spin = ["dep:edgezero-adapter-spin", "dep:spin-sdk", "dep:anyhow"] + +[dependencies] +async-trait = "=0.1.89" +edgezero-core = { path = "../../../crates/edgezero-core" } +edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"], optional = true } +edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"], optional = true } +edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"], optional = true } +fastly = { version = "=0.12.1", optional = true } +worker = { version = "=0.8.3", default-features = false, features = ["http"], optional = true } +wasm-bindgen = { version = "=0.2.122", optional = true } +spin-sdk = { version = "=6.0.0", default-features = false, features = ["http"], optional = true } +anyhow = { version = "=1.0.103", optional = true } +serde_json = "=1.0.150" diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md new file mode 100644 index 000000000..a153c23a2 --- /dev/null +++ b/tests/fixtures/request-fidelity/README.md @@ -0,0 +1,57 @@ +# Request ingress runtime fixtures + +Run from the repository root with Rust 1.95.0 and Node 24.12.0: + +```sh +./scripts/test-request-fidelity.sh all +./scripts/test-request-fidelity.sh fastly --skip-build +``` + +`--skip-build` intentionally reuses existing fixture artifacts; it does not +prove source freshness. Use the default locked build for review evidence. + +The isolated Cargo workspace and npm lock pin fixture dependencies. Install +Viceroy 0.17.0, Spin 4.0.0, worker-build 0.8.3 and wasm-bindgen CLI 0.2.122. +Wrangler 4.83.0 and its local workerd are installed by `npm ci`. Each runtime +uses its own target directory, an ephemeral loopback port, bounded readiness +and an owned process group that is cleaned up on success, failure or interruption. +No platform credentials or deployment are required. + +The runner sends 56 fixed raw HTTP cases across Fastly, Cloudflare and Spin. +The client and process orchestration use an isolated native Rust harness with exact existing Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. +`observations.json` records the pinned local runtimes' observed outcomes, and +subsequent runs compare them exactly. Inspect changes before updating it. +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.12.1 lacks a safe borrowed original-target getter. Ingress reports + `NotExposed`; a preceding native path shortcut can see a normalized target. + Its canonical origin is recovered separately from the client runtime URI + and one validated consistent Host, before native mutation. + A snapshot probe changes the native URL/Host after capture and proves the + original origin remains available through explicit-snapshot dispatch. +- Local workerd rejects extension methods before Worker invocation, normalizes + dot segments and coalesces repeated fields. Raw FF becomes U+FFFD before Rust + and is copied as runtime UTF-8 EF BF BD. Common header fidelity remains + `Unknown`, since a transforming runtime does not prove any particular field + changed. Valid wire UTF-8 C3 A9 and C4 80 are asserted byte-for-byte. + Repeated Cookie values are checked for exact comma joining versus semicolon + joining. Compound probes compare a valid-looking diagnostics value plus + unrelated invalid FF against original valid EF BF BD: their Worker verdicts + are identical. They do not implement or prove Trusted Server cookie parsing. + Absolute-form and duplicate Content-Length requests return local 500 errors. +- Spin 4.0.0 rejects non-UTF-8 header values before component invocation. +- Global original field order is unavailable on every adapter. Local evidence + cannot certify production runtime or TLS/client provenance guarantees. + +The single [spec](../../../docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md) +and [plan](../../../docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md) +record the complete contract, verification and remaining upstream/consumer gates. diff --git a/tests/fixtures/request-fidelity/fastly.toml b/tests/fixtures/request-fidelity/fastly.toml new file mode 100644 index 000000000..13b31829c --- /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 000000000..1ad21841a --- /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": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": true, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": true, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "native": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "native-dot": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "snapshot-origin": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + }, + "spin": { + "absolute": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 500, + "verdict": null + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 500, + "verdict": null + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + } +} diff --git a/tests/fixtures/request-fidelity/package-lock.json b/tests/fixtures/request-fidelity/package-lock.json new file mode 100644 index 000000000..f8cc70695 --- /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 000000000..a944fcdf7 --- /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 000000000..d06c2e536 --- /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 000000000..3fe12104d --- /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 000000000..4d830a871 --- /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::request::{CapturedTarget, Preservation, RequestIngress, TargetSource}; +use edgezero_core::router::RouterService; +use serde_json::json; +use worker::{Context, Env, Request, Response, Result}; + +#[worker::event(fetch)] +async fn main(req: Request, env: Env, ctx: Context) -> Result { + 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 000000000..166876589 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/lib.rs @@ -0,0 +1,155 @@ +//! Fixed local ingress probes; no unrestricted request echo endpoint. + +#[cfg(feature = "cloudflare")] +mod cloudflare; +#[cfg(feature = "spin")] +mod spin; + +use async_trait::async_trait; +use edgezero_core::action; +use edgezero_core::app::Hooks; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Method, Request, Response, response_builder}; +use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::request::{CapturedTarget, Preservation, RequestIngress}; +use edgezero_core::router::{PreDispatchHook, RouterService}; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Store-free fixture entry hooks. +pub struct Fixture; + +/// The original native client fact carried through conversion. +#[cfg(feature = "fastly")] +#[derive(Clone)] +pub struct NativeClient(pub Option); + +impl Hooks for Fixture { + fn owns_logging() -> bool { + true + } + fn routes() -> RouterService { + let ordinary_calls = Arc::new(AtomicUsize::new(0)); + RouterService::builder() + .pre_dispatch_hook(Arc::new(Probe { + ordinary_calls: Arc::clone(&ordinary_calls), + })) + .middleware(CountOrdinary(ordinary_calls)) + .get("/ordinary", ordinary) + .build() + } +} + +struct CountOrdinary(Arc); +struct Probe { + ordinary_calls: Arc, +} + +#[async_trait(?Send)] +impl Middleware for CountOrdinary { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } +} + +#[action] +async fn ordinary() -> &'static str { + r#"{"ordinary":true}"# +} + +#[async_trait(?Send)] +impl PreDispatchHook for Probe { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + let case = request + .headers() + .get("x-fixture") + .and_then(|value| value.to_str().ok()) + .unwrap_or("ready"); + if case == "continue" { + *request.method_mut() = Method::GET; + *request.uri_mut() = "/ordinary".parse().map_err(EdgeError::internal)?; + return Ok(None); + } + let expected_target = match case { + "dot" => "/reserved/../ordinary", + "encoded-dot" => "/reserved/%2e%2e/ordinary", + "slashes" => "/reserved//%2F?q=a%2Fb", + "absolute" => "/reserved?fixed=1", + _ => "/reserved", + }; + let ingress = request + .extensions() + .get::() + .ok_or_else(|| EdgeError::bad_request("missing ingress fixture metadata"))?; + let (available, matches_original, matches_runtime) = match ingress.target() { + CapturedTarget::Complete(target) => { + let original = target.value() == expected_target + || target.value() == format!("http://example.com{expected_target}"); + (true, original, target.value() == *request.uri()) + } + CapturedTarget::Unavailable(_) => (false, false, false), + }; + let cookies: Vec<_> = request + .headers() + .get_all("cookie") + .iter() + .map(|value| value.as_bytes()) + .collect(); + let cookie_octets = cookies == [b"a=\xff".as_slice()]; + let same_name_order = cookies == [b"a=1".as_slice(), b"b=2".as_slice()]; + let cookie_name = "cookie".parse().map_err(EdgeError::internal)?; + let fidelity = ingress.header_fidelity(&cookie_name); + #[cfg(feature = "fastly")] + let native_client_preserved = + request + .extensions() + .get::() + .is_some_and(|native| { + native.0.is_some() + && edgezero_adapter_fastly::context::FastlyRequestContext::get(request) + .is_some_and(|context| context.client_ip == native.0) + }); + #[cfg(not(feature = "fastly"))] + let native_client_preserved = None::; + let verdict = json!({ + "hook": true, + "native_client_preserved": native_client_preserved, + "ordinary": self.ordinary_calls.load(Ordering::SeqCst) != 0, + "extension_method": request.method().as_str() == "EXAMPLE-METHOD", + "cookie_octets": cookie_octets, + "cookie_utf8": cookies == [b"a=\xc3\xa9".as_slice()], + "cookie_latin1": cookies == [b"a=\xe9".as_slice()], + "cookie_unicode": cookies == [b"a=\xc4\x80".as_slice()], + "cookie_replacement": cookies == [b"a=\xef\xbf\xbd".as_slice()], + "cookie_repeat_comma_joined": cookies == [b"a=1, b=2".as_slice()], + "cookie_repeat_semicolon_joined": cookies == [b"a=1; b=2".as_slice()], + "cookie_compound_original": cookies == [b"diagnostics=1".as_slice(), b"a=\xff".as_slice()], + "cookie_compound_runtime_utf8": cookies == [b"diagnostics=1".as_slice(), b"a=\xef\xbf\xbd".as_slice()], + "cookie_compound_comma_joined": cookies == [b"diagnostics=1, a=\xef\xbf\xbd".as_slice()], + "cookie_count": cookies.len(), + "same_name_order": same_name_order, + "origin_count": request.headers().get_all("origin").iter().count(), + "control_count": request.headers().get_all("x-control").iter().count(), + "content_length_count": request.headers().get_all("content-length").iter().count(), + "transfer_encoding_count": request.headers().get_all("transfer-encoding").iter().count(), + "transfer_encoding_chunked": request.headers().get("transfer-encoding").is_some_and(|value| value.as_bytes() == b"chunked"), + "target_available": available, + "target_matches_original": matches_original, + "target_matches_runtime": matches_runtime, + "origin_available": ingress.origin().is_some(), + "origin_matches_expected": ingress.origin().is_some_and(|origin| origin.scheme() == "http" && origin.authority() == "example.com"), + "spoofed_origin_ignored": ingress.origin().is_none_or(|origin| !origin.authority().contains("spoof")), + "global_order_unavailable": fidelity.global_field_order() == Preservation::Unavailable, + }); + Ok(Some( + response_builder() + .header("content-type", "application/json") + .body(Body::text(verdict.to_string())) + .map_err(EdgeError::internal)?, + )) + } +} diff --git a/tests/fixtures/request-fidelity/src/spin.rs b/tests/fixtures/request-fidelity/src/spin.rs new file mode 100644 index 000000000..7da12d168 --- /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 000000000..a8a3eb526 --- /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 000000000..b133beff1 --- /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"