diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eff6eb8e..463813f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,8 +36,10 @@ jobs: ~/.cargo/git/db/ target/ tests/fixtures/reusable-app/target/ - key: ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-${{ hashFiles('**/Cargo.lock') }} + tests/fixtures/request-fidelity-runner/target/ + key: ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-ingress-v1-${{ hashFiles('**/Cargo.lock') }} restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-ingress-v1- ${{ runner.os }}-cargo-${{ matrix.suite }}-tests- - name: Retrieve Rust version @@ -49,6 +51,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Install wasm targets for generated-project checks if: matrix.suite == 'cli' @@ -70,7 +73,10 @@ jobs: # deploy fixture). A mismatch only surfaces as a confusing # "trait bound not satisfied" in a deploy smoke job, so gate it here. - name: Shared dependency pins agree - run: ./scripts/check_shared_dep_pins.sh + if: matrix.suite == 'workspace' + run: | + ./scripts/check_shared_dep_pins.sh + bash scripts/test_shared_dep_pins.sh - name: Nested AppConfig audit if: matrix.suite == 'cli' @@ -80,6 +86,13 @@ jobs: if: matrix.suite == 'cli' run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + - name: Verify native request ingress harness + if: matrix.suite == 'workspace' + run: | + cargo fmt --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all -- --check + cargo test --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --locked + cargo clippy --manifest-path tests/fixtures/request-fidelity-runner/Cargo.toml --all-targets --locked -- -D warnings + - name: Lifecycle fixture host tests if: matrix.suite == 'workspace' run: >- @@ -178,8 +191,10 @@ jobs: ~/.wasmtime/bin/ target/ tests/fixtures/reusable-app/target/ - key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} + tests/fixtures/request-fidelity/target/ + key: ${{ runner.os }}-cargo-${{ matrix.adapter }}-ingress-v1-${{ hashFiles('**/Cargo.lock', '.tool-versions') }} restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.adapter }}-ingress-v1- ${{ runner.os }}-cargo-${{ matrix.adapter }}- - name: Retrieve Rust version @@ -191,10 +206,35 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} + components: clippy, rustfmt - name: Add wasm target run: rustup target add ${{ matrix.target }} + - name: Set up matching browser and driver + if: matrix.adapter == 'cloudflare' + id: chrome + uses: browser-actions/setup-chrome@v2 + with: + chrome-version: 154.0.8037.92 + install-chromedriver: true + install-dependencies: true + + - name: Set up Node for local Worker tooling + if: matrix.adapter == 'cloudflare' + uses: actions/setup-node@v6 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: tests/fixtures/request-fidelity/package-lock.json + + - name: Bind browser tests to pinned Chrome + if: matrix.adapter == 'cloudflare' + env: + CHROME_BINARY: ${{ steps.chrome.outputs.chrome-path }} + run: | + node -e 'require("node:fs").writeFileSync(require("node:path").join(process.env.RUNNER_TEMP, "edgezero-webdriver.json"), JSON.stringify({"goog:chromeOptions": {binary: process.env.CHROME_BINARY}}))' + - name: Resolve wasm-bindgen CLI version if: matrix.adapter == 'cloudflare' id: wasm-bindgen-version @@ -285,6 +325,8 @@ jobs: - name: Run ${{ matrix.adapter }} wasm tests env: + CHROMEDRIVER: ${{ steps.chrome.outputs.chromedriver-path }} + WASM_BINDGEN_TEST_WEBDRIVER_JSON: ${{ runner.temp }}/edgezero-webdriver.json ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract @@ -298,6 +340,77 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + - name: Read fixture worker-build pin + if: matrix.adapter == 'cloudflare' + id: worker-build-pin + shell: bash + run: | + version="$(awk '{ sub(/[[:space:]]*#.*/, "") } /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "worker-build" && $2 == "=" { gsub(/"/, "", $3); count++; found=$3 } END { if (count == 1) print found }' tests/fixtures/request-fidelity/Cargo.toml)" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # The pinned release has no Linux prebuilt. Keep its compiled executable + # separate from Cargo's broad bin cache and verify it on every restore. + - name: Cache pinned worker-build executable + if: matrix.adapter == 'cloudflare' + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/worker-build + key: worker-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.worker-build-pin.outputs.version }} + + - name: Install lock-matched worker-build + if: matrix.adapter == 'cloudflare' + shell: bash + env: + VERSION: ${{ steps.worker-build-pin.outputs.version }} + run: | + install_dir="$RUNNER_TEMP/worker-build" + if [[ "$("$install_dir/bin/worker-build" --version 2>/dev/null || true)" != "$VERSION" ]]; then + cargo install worker-build --version "$VERSION" --locked --root "$install_dir" --force + fi + test "$("$install_dir/bin/worker-build" --version)" = "$VERSION" + echo "$install_dir/bin" >> "$GITHUB_PATH" + + - name: Install pinned Spin HTTP runtime + if: matrix.adapter == 'spin' + shell: bash + run: | + version="$(awk '{ sub(/[[:space:]]*#.*/, "") } /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } in_pins && $1 == "spin-cli" && $2 == "=" { gsub(/"/, "", $3); count++; found=$3 } END { if (count == 1) print found }' tests/fixtures/request-fidelity/Cargo.toml)" + test "$version" = "4.1.0" + install_dir="$RUNNER_TEMP/spin" + mkdir -p "$install_dir" + curl --retry 3 -fL "https://github.com/spinframework/spin/releases/download/v${version}/spin-v${version}-linux-amd64.tar.gz" -o "$RUNNER_TEMP/spin.tar.gz" + # GitHub's published digest for the v4.1.0 linux-amd64 release asset. + # Keep the version and digest paired when updating the fixture pin. + echo "aeeb892f94cf861f5302546faa0bdaa82cc4be30539621299746a6dc011f3b5a $RUNNER_TEMP/spin.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/spin.tar.gz" -C "$install_dir" + echo "$install_dir" >> "$GITHUB_PATH" + "$install_dir/spin" --version + + - name: Check isolated fixture formatting + run: cargo fmt --manifest-path tests/fixtures/request-fidelity/Cargo.toml --all -- --check + + - name: Lint isolated ingress fixture + run: >- + cargo clippy --manifest-path tests/fixtures/request-fidelity/Cargo.toml + --features ${{ matrix.adapter }} --target ${{ matrix.target }} + --all-targets --locked -- -D warnings + + - name: Run local HTTP ingress probes + shell: bash + run: | + if [ "${{ matrix.adapter }}" = "cloudflare" ]; then + version="$(awk '$1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next } in_pkg && $1 == "version" { gsub(/"/, "", $3); print $3; exit }' tests/fixtures/request-fidelity/Cargo.lock)" + test -n "$version" + WASM_BINDGEN_BIN="$(command -v wasm-bindgen || true)" + if [[ -z "$WASM_BINDGEN_BIN" ]] || [[ "$("$WASM_BINDGEN_BIN" --version 2>/dev/null || true)" != "wasm-bindgen $version" ]]; then + cargo install wasm-bindgen-cli --version "$version" --locked --root "$RUNNER_TEMP/fixture-wasm-bindgen" + WASM_BINDGEN_BIN="$RUNNER_TEMP/fixture-wasm-bindgen/bin/wasm-bindgen" + fi + export WASM_BINDGEN_BIN + fi + ./scripts/test-request-fidelity.sh ${{ matrix.adapter }} + - name: Fastly reusable HTTP smoke if: matrix.adapter == 'fastly' run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index d8d1adc4..b72986d4 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -6,8 +6,11 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::sync::Arc; +use crate::request::HttpTransport; use anyhow::Context as _; use axum::Router; +use axum::body::Body as AxumBody; +use axum::http::Request as AxumRequest; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; @@ -299,10 +302,12 @@ async fn serve_with_stores( } service }; - let axum_router = Router::new().fallback_service(service_fn(move |req| { - let mut svc = service.clone(); - async move { svc.call(req).await } - })); + let axum_router = + Router::new().fallback_service(service_fn(move |mut req: AxumRequest| { + req.extensions_mut().insert(HttpTransport); + let mut svc = service.clone(); + async move { svc.call(req).await } + })); let make_service = axum_router.into_make_service_with_connect_info::(); let shutdown = enable_ctrl_c.then_some(async { @@ -748,6 +753,23 @@ mod integration_tests { use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::sleep; + use async_trait::async_trait; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderValue, 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, @@ -759,6 +781,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(Arc::new(IngressHook { + calls: Arc::clone(&hook_calls), + })) + .get("/ordinary", ingress_ordinary) + .build(); + let server = start_test_server(router).await; + let addr: SocketAddr = server + .base_url + .strip_prefix("http://") + .expect("should have HTTP binding") + .parse() + .expect("should parse listener address"); + let cases: [(&[u8], &[u8], usize); 7] = [ + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: repeat\r\nCookie: a=\xff\r\nOrigin: https://example.com\r\nX-Control: first\r\nCOOKIE: b=2\r\nOrigin: https://other.example.com\r\nX-Control: second\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", b"HTTP/1.1 204", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: length\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", b"HTTP/1.1 400", 0), + (b"GET /reserved/%2e/../%2F// HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: dot\r\n\r\n", b"HTTP/1.1 204", 1), + (b"EXAMPLE-METHOD /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: continue\r\n\r\n", b"HTTP/1.1 200", 1), + (b"GET /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: error\r\n\r\n", b"HTTP/1.1 400", 1), + (b"POST /missing HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: te-cl\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n0\r\n\r\n", b"HTTP/1.1 204", 1), + ]; + for (fixture, status, calls) in cases { + let before = hook_calls.load(Ordering::SeqCst); + let response = spawn_blocking(move || raw_ingress_response(addr, fixture)) + .await + .expect("should join socket probe"); + assert!( + response.starts_with(status), + "should return expected fixed fixture status" + ); + assert_eq!(hook_calls.load(Ordering::SeqCst) - before, calls); + } + assert_eq!(middleware_calls.load(Ordering::SeqCst), 1); + server.handle.abort(); + } + async fn start_test_server(router: RouterService) -> TestServer { let listener = TokioTcpListener::bind("127.0.0.1:0") .await diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index 9e3f5976..ddaaa16a 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -4,14 +4,22 @@ use axum::body::{Body as AxumBody, to_bytes}; use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; -use edgezero_core::http::HeaderValue; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::CONTENT_TYPE; +use edgezero_core::http::header::{CONTENT_TYPE, HOST}; +use edgezero_core::http::{HeaderMap, HeaderValue, Uri}; use edgezero_core::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::RUNTIME_HEADER_MAP; + RequestIngress::new(target, inbound_origin(uri, headers, transport), common) +} + +fn inbound_origin( + uri: &Uri, + headers: &HeaderMap, + transport: Option, +) -> Option { + transport?; + let mut hosts = headers.get_all(HOST).iter(); + let host = hosts.next()?.to_str().ok()?; + if hosts.next().is_some() { + return None; + } + let origin = InboundOrigin::parse("http", host, OriginSource::TransportBinding).ok()?; + if uri + .scheme_str() + .is_some_and(|scheme| !scheme.eq_ignore_ascii_case("http")) + { + return None; + } + if let Some(authority) = uri.authority() { + let supplied = + InboundOrigin::parse("http", authority.as_str(), OriginSource::TransportBinding) + .ok()?; + if !origin.same_authority(&supplied) { + return None; + } + } + Some(origin) +} + fn is_json_content_type(value: &HeaderValue) -> bool { let Ok(raw) = value.to_str() else { return false; @@ -203,3 +258,163 @@ mod tests { ))); } } + +#[cfg(test)] +mod ingress_tests { + use super::{HttpTransport, into_core_request}; + use axum::body::Body as AxumBody; + use edgezero_core::http::{HeaderValue, Method, request_builder}; + use edgezero_core::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 5dbb80fc..c2e4dfc5 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -51,6 +51,7 @@ web-sys = { version = "0.3", features = [ "Window", "Response", "Request", + "RequestInit", "Headers", "ReadableStream", "Blob", diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 41a317f2..04990669 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; @@ -242,7 +244,8 @@ pub(crate) struct RegistryInputs<'env> { /// Convert a Cloudflare Worker request into an `EdgeZero` core request. /// /// # Errors -/// Returns [`EdgeError::bad_request`] if the URL or URI cannot be parsed, +/// Returns [`EdgeError::bad_request`] for invalid runtime methods, header values, +/// URLs or URIs, /// and [`EdgeError::internal`] if the body cannot be read or the core /// request cannot be built. #[inline] @@ -251,19 +254,36 @@ pub async fn into_core_request( env: Env, ctx: Context, ) -> Result { - let method = into_core_method(&req.method()); - let url = req - .url() - .map_err(|err| EdgeError::bad_request(format!("invalid URL: {err}")))?; + let method = CoreMethod::from_bytes(req.inner().method().as_bytes()) + .map_err(|_error| EdgeError::bad_request("invalid runtime method"))?; + let runtime_url = req.inner().url(); + let target = CapturedTarget::capture( + &runtime_url, + TargetSource::RuntimeUrl, + Preservation::Unknown, + ); + let url: worker::Url = runtime_url + .parse() + .map_err(|_error| EdgeError::bad_request("invalid runtime URL"))?; let uri: Uri = url .as_str() .parse() - .map_err(|err| EdgeError::bad_request(format!("invalid URI: {err}")))?; + .map_err(|_error| EdgeError::bad_request("invalid runtime URI"))?; + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let fidelity = HeaderFidelity::RUNTIME_HEADER_MAP; + let ingress = RequestIngress::new(target, origin, fidelity); let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); for (name, value) in headers.entries() { - builder = builder.header(name.as_str(), value); + let header_value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_error| EdgeError::bad_request("invalid runtime header value"))?; + builder = builder.header(name.as_str(), header_value); } let bytes = req.bytes().await.map_err(EdgeError::internal)?; @@ -272,6 +292,7 @@ pub async fn into_core_request( .body(Body::from(bytes)) .map_err(EdgeError::internal)?; + request.extensions_mut().insert(ingress); CloudflareRequestContext::insert(&mut request, env, ctx); request .extensions_mut() @@ -475,17 +496,6 @@ fn edge_error_to_worker(err: &EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } -fn into_core_method(method: &Method) -> CoreMethod { - let bytes = method.as_ref().as_bytes(); - CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { - log::warn!( - "unknown HTTP method {:?}, defaulting to GET", - method.as_ref() - ); - CoreMethod::GET - }) -} - fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { match CloudflareConfigStore::from_env(env, binding_name) { Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), @@ -574,26 +584,6 @@ fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { } } -#[cfg(test)] -mod tests { - use super::*; - use wasm_bindgen_test::wasm_bindgen_test; - - #[wasm_bindgen_test] - fn into_http_method_defaults_unknown_to_get() { - let method = Method::from("FOO".to_owned()); - assert_eq!(into_core_method(&method), CoreMethod::GET); - } - - #[wasm_bindgen_test] - fn into_http_method_maps_known_methods() { - assert_eq!(into_core_method(&Method::Get), CoreMethod::GET); - assert_eq!(into_core_method(&Method::Post), CoreMethod::POST); - assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); - assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); - } -} - #[cfg(test)] mod synthesis_tests { use std::collections::BTreeMap; diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 4097601e..07408b1f 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -29,8 +29,12 @@ mod tests { use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{Method, Response, StatusCode, response_builder}; - use edgezero_core::router::RouterService; + use edgezero_core::http::{Method, Request, Response, StatusCode, response_builder}; + use edgezero_core::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; @@ -40,6 +44,10 @@ mod tests { wasm_bindgen_test_configure!(run_in_browser); + struct IngressHook { + continue_routing: bool, + } + struct FixedConfigStore(&'static str); #[async_trait::async_trait(?Send)] @@ -49,6 +57,195 @@ 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_invalid_runtime_header_value_is_a_bad_request() { + let raw = web_sys::Request::new_with_str("https://example.com/reserved") + .expect("should construct Web Request"); + raw.headers() + .append("x-bad", "a\u{1}b") + .expect("Fetch should accept SOH inside a header value"); + let (env, ctx) = test_env_ctx(); + let error = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect_err("core headers should reject SOH"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert_eq!(error.to_string(), "invalid runtime header value"); + } + + #[wasm_bindgen_test] + async fn ingress_preserves_runtime_extension_methods_and_header_strings() { + for token in [ + "EXAMPLE-METHOD", + "example-method", + "GET", + "POST", + "PUT", + "DELETE", + ] { + let init = web_sys::RequestInit::new(); + init.set_method(token); + let headers = web_sys::Headers::new().expect("should create headers"); + headers + .append("x-octets", "\u{ff}\u{80}\u{c3}\u{a9}") + .expect("should append ByteString header"); + init.set_headers_headers(&headers); + let raw = + web_sys::Request::new_with_str_and_init("https://example.com/reserved", &init) + .expect("should create raw Web Request"); + let expected_method = raw.method(); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert raw request"); + assert_eq!(converted.method().as_str(), expected_method); + assert_eq!( + converted.headers()["x-octets"].as_bytes(), + "\u{ff}\u{80}\u{c3}\u{a9}".as_bytes() + ); + } + } + + #[wasm_bindgen_test] + async fn ingress_metadata_tracks_runtime_url_origin_and_coalescing() { + let oversized = format!("https://example.com/{}", "a".repeat(MAX_TARGET_BYTES)); + for url in [ + "https://example.com/reserved?q=a%2Fb", + "https://example.com/reserved/../ordinary?q=a%2Fb", + "https://example.com/reserved/%2e%2e/ordinary", + "https://example.com/reserved//%2F", + oversized.as_str(), + ] { + let raw = web_sys::Request::new_with_str(url).expect("should construct Web Request"); + raw.headers() + .append("x-repeat", "first") + .expect("should append header"); + raw.headers() + .append("x-repeat", "second") + .expect("should append header"); + raw.headers() + .set("origin", "https://spoof.example.com") + .expect("should set spoofed origin"); + raw.headers() + .set("forwarded", "proto=http;host=spoof.example.com") + .expect("should set spoofed forwarding header"); + let exposed = raw.url(); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert Web Request"); + let ingress = converted + .extensions() + .get::() + .expect("should insert metadata"); + match ingress.target() { + CapturedTarget::Complete(target) => { + assert_eq!(target.value(), exposed); + assert_eq!(target.fidelity(), Preservation::Unknown); + } + CapturedTarget::Unavailable(reason) => { + assert_eq!(*reason, TargetUnavailable::TooLarge); + } + } + assert_eq!( + matches!(ingress.target(), CapturedTarget::Complete(_)), + exposed.len() <= MAX_TARGET_BYTES + ); + let origin = ingress.origin().expect("should expose runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!(converted.headers().get_all("x-repeat").iter().count(), 1); + assert_eq!(converted.headers()["x-repeat"], "first, second"); + assert_eq!( + ingress + .header_fidelity(&"x-repeat".parse().expect("should parse name")) + .field_multiplicity(), + Preservation::Unknown + ); + } + } + + #[wasm_bindgen_test] + async fn ingress_non_http_runtime_url_has_no_origin() { + let raw = web_sys::Request::new_with_str("ftp://example.com/reserved") + .expect("should construct runtime URL"); + let (env, ctx) = test_env_ctx(); + let request = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert exposed URI"); + assert!( + request + .extensions() + .get::() + .expect("should insert metadata") + .origin() + .is_none() + ); + } + + #[wasm_bindgen_test] + async fn ingress_hook_intercepts_and_continues_through_service() { + for continue_routing in [false, true] { + let app = App::new( + RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(IngressHook { continue_routing })) + .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/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index bbaf6c23..ad96aa7d 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,15 +1,20 @@ 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, RequestIngress, TargetUnavailable, +}; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -99,6 +104,7 @@ pub struct FastlyService<'app> { app: &'app App, config: ConfigSource, kv: Option, + request_ingress: Option, secrets: SecretSource, } @@ -122,6 +128,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 +159,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + ingress, |_req, _extensions| {}, ) } @@ -163,6 +173,7 @@ impl<'app> FastlyService<'app> { app, config: ConfigSource::None, kv: None, + request_ingress: None, secrets: SecretSource::Off, } } @@ -240,6 +251,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 @@ -310,6 +331,7 @@ fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, + ingress: RequestIngress, extend: F, ) -> Result where @@ -317,8 +339,9 @@ where { // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. let scratch = apply_request_extend(&req, extend); - let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + let mut core_request = convert_request(req).map_err(|err| map_edge_error(&err))?; core_request.extensions_mut().extend(scratch); + core_request.extensions_mut().insert(ingress); dispatch_core_request(app, core_request, stores) } @@ -354,7 +377,34 @@ pub fn dispatch_with_registries( where F: FnOnce(&FastlyRequest, &mut Extensions), { - let core_request = into_core_request_with_registries(req, stores, extend)?; + let ingress = capture_request_ingress(&req); + dispatch_with_registries_and_ingress(app, req, stores, ingress, extend) +} + +/// Dispatch with logical store bindings and an already captured ingress snapshot. +/// +/// The snapshot is inserted after the extension callback, preventing scratch +/// metadata from replacing the supplied snapshot. Store semantics match +/// [`dispatch_with_registries`]. +/// +/// The caller must supply a snapshot captured from this same native request +/// before mutation. Metadata construction and syntax validation do not establish +/// its provenance or authenticate caller assertions. +/// +/// # Errors +/// Returns an error if a declared KV store cannot be opened or dispatch fails. +#[inline] +pub fn dispatch_with_registries_and_ingress( + app: &App, + req: FastlyRequest, + stores: StoresMetadata, + ingress: RequestIngress, + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let core_request = into_core_request_with_registries_and_ingress(req, stores, ingress, extend)?; let response = executor::block_on(app.router().oneshot(core_request)) .map_err(|err| map_edge_error(&err))?; from_core_response(response).map_err(|err| map_edge_error(&err)) @@ -378,6 +428,33 @@ pub fn into_core_request_with_registries( stores: StoresMetadata, extend: F, ) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let ingress = capture_request_ingress(&req); + into_core_request_with_registries_and_ingress(req, stores, ingress, extend) +} + +/// Convert with store registries and an already captured ingress snapshot. +/// +/// The snapshot is inserted after the extension callback, preventing scratch +/// metadata from replacing the supplied snapshot. Store semantics match +/// [`into_core_request_with_registries`]. +/// +/// The caller must supply a snapshot captured from this same native request +/// before mutation. Metadata construction and syntax validation do not establish +/// its provenance or authenticate caller assertions. +/// +/// # Errors +/// Returns an error if a declared KV store cannot be opened or the request +/// cannot be converted, including an inbound body read failure. +#[inline] +pub fn into_core_request_with_registries_and_ingress( + req: FastlyRequest, + stores: StoresMetadata, + ingress: RequestIngress, + extend: F, +) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { @@ -385,8 +462,9 @@ where let config_registry = build_config_registry(stores.config); let secret_registry = build_secret_registry(stores.secrets); let scratch = apply_request_extend(&req, extend); - let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + let mut core_request = convert_request(req).map_err(|err| map_edge_error(&err))?; core_request.extensions_mut().extend(scratch); + core_request.extensions_mut().insert(ingress); insert_store_registries( &mut core_request, Stores { @@ -511,10 +589,87 @@ fn build_secret_registry(secret_meta: Option) -> Option RequestIngress { + let target = CapturedTarget::Unavailable(TargetUnavailable::NotExposed); + let origin = if req.is_from_client() { + parse_uri(req.get_url_str()).ok().and_then(|uri| { + origin_from_runtime_uri(&uri, req.get_header_all(HOST).map(HeaderValue::as_bytes)) + }) + } else { + None + }; + RequestIngress::new(target, origin, HeaderFidelity::default()) +} + +fn origin_from_runtime_uri<'header>( + uri: &Uri, + mut hosts: impl Iterator, +) -> Option { + let host = from_utf8(hosts.next()?).ok()?; + if hosts.next().is_some() { + return None; + } + let scheme = uri.scheme_str()?; + let runtime_origin = + InboundOrigin::parse(scheme, uri.authority()?.as_str(), OriginSource::RuntimeUri).ok()?; + let origin = InboundOrigin::parse(scheme, host, OriginSource::RuntimeUri).ok()?; + if !runtime_origin.same_authority(&origin) { + return None; + } + Some(runtime_origin) +} + +/// Convert a native request, capturing ingress before native mutation. +/// /// # Errors -/// Returns [`EdgeError::Internal`] if the Fastly request cannot be reconstituted into a core request (e.g., method or URI conversion failure). +/// Returns an error if method, URI or body conversion fails. #[inline] -pub fn into_core_request(mut req: FastlyRequest) -> Result { +pub fn into_core_request(req: FastlyRequest) -> Result { + let ingress = capture_request_ingress(&req); + into_core_request_with_ingress(req, ingress) +} + +/// Convert a native request while retaining the supplied bounded snapshot. +/// +/// The caller must establish the supplied snapshot's provenance and fidelity. +/// Conversion retains its facts even if the native request has since changed. +/// +/// # Errors +/// Returns an error if method, URI or body conversion fails. +#[inline] +pub fn into_core_request_with_ingress( + req: FastlyRequest, + ingress: RequestIngress, +) -> Result { + let mut request = convert_request(req)?; + request.extensions_mut().insert(ingress); + Ok(request) +} + +fn convert_request(mut req: FastlyRequest) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -806,3 +961,226 @@ 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::header::HOST; + 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(), + uri.authority() + .expect("should have runtime authority") + .as_str(), + "should retain runtime authority rather than Host spelling" + ); + assert_eq!( + origin.source(), + OriginSource::RuntimeUri, + "should identify runtime provenance" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_missing_duplicate_and_conflicting_hosts() { + let uri: Uri = "https://example.com/reserved" + .parse() + .expect("should parse URI"); + for hosts in [ + vec![], + vec![b"example.com".as_slice(), b"example.com".as_slice()], + vec![b"other.example.com".as_slice()], + vec![b"example.com:80".as_slice()], + vec![b"example.com, other.example.com".as_slice()], + vec![b"example.com:invalid".as_slice()], + vec![b"a=\xff".as_slice()], + ] { + assert!( + origin_from_runtime_uri(&uri, hosts.into_iter()).is_none(), + "should not invent an origin from unavailable or inconsistent authority" + ); + } + } + + #[test] + fn ingress_runtime_origin_rejects_invalid_runtime_authorities() { + for url in [ + "https://user@example.com/reserved", + "https://example.com:invalid/reserved", + "https://example.com:65536/reserved", + ] { + let uri: Uri = url + .parse() + .expect("should represent invalid runtime authority"); + assert!( + origin_from_runtime_uri(&uri, [b"example.com".as_slice()].into_iter()).is_none(), + "should validate the complete runtime authority before comparing host or default port" + ); + } + } + + fn snapshot() -> RequestIngress { + RequestIngress::new( + CapturedTarget::capture( + "/reserved/%2e", + TargetSource::TransportRequestTarget, + Preservation::Unknown, + ), + None, + HeaderFidelity::default(), + ) + } + + #[test] + fn ingress_current_sdk_reports_missing_target_and_no_synthetic_origin() { + let native = FastlyRequest::get("https://example.com/reserved"); + let ingress = capture_request_ingress(&native); + assert_eq!( + ingress.header_fidelity(&HOST).global_field_order(), + Preservation::Unknown + ); + assert!(matches!( + ingress.target(), + CapturedTarget::Unavailable(TargetUnavailable::NotExposed) + )); + assert!(ingress.origin().is_none()); + } + + #[test] + fn ingress_snapshot_and_header_bytes_survive_conversion() { + let mut native = FastlyRequest::new("EXAMPLE-METHOD", "https://example.com/later"); + native.append_header("cookie", b"a=\xff".as_slice()); + native.append_header("cookie", "b=2"); + let client_ip = native.get_client_ip_addr(); + let core = + into_core_request_with_ingress(native, snapshot()).expect("should convert snapshot"); + assert_eq!( + FastlyRequestContext::get(&core) + .expect("should preserve context") + .client_ip, + client_ip + ); + assert!(core.extensions().get::().is_some()); + assert_eq!(core.method().as_str(), "EXAMPLE-METHOD"); + let values: Vec<_> = core + .headers() + .get_all("cookie") + .iter() + .map(HeaderValue::as_bytes) + .collect(); + assert_eq!(values, [b"a=\xff".as_slice(), b"b=2".as_slice()]); + let CapturedTarget::Complete(target) = core + .extensions() + .get::() + .expect("should carry snapshot") + .target() + else { + panic!("should retain target") + }; + assert_eq!(target.value(), "/reserved/%2e"); + } + + #[test] + fn ingress_registry_dispatch_wins_scratch_collision() { + let app = App::new( + RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(VerifySnapshot { + scratch_expected: true, + })) + .build(), + ); + let response = dispatch_with_registries_and_ingress( + &app, + FastlyRequest::get("https://example.com/later"), + StoresMetadata::default(), + snapshot(), + |native, scratch| { + scratch.insert(capture_request_ingress(native)); + scratch.insert(ScratchMarker); + }, + ) + .expect("should dispatch with chosen snapshot"); + assert_eq!(response.get_status().as_u16(), 204); + let service_app = App::new( + RouterService::builder() + .pre_dispatch_hook_arc(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/ingress.rs b/crates/edgezero-adapter-spin/src/ingress.rs new file mode 100644 index 00000000..c335e816 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/ingress.rs @@ -0,0 +1,65 @@ +use edgezero_core::http::Uri; +use edgezero_core::request::{ + CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, + TargetSource, +}; + +pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { + let origin = uri + .scheme_str() + .zip(uri.authority()) + .and_then(|(scheme, authority)| { + InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() + }); + let source = if uri.scheme().is_some() { + TargetSource::RuntimeUrl + } else { + TargetSource::RuntimePathAndQuery + }; + let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); + let headers = HeaderFidelity::RUNTIME_HEADER_MAP; + RequestIngress::new(target, origin, headers) +} + +#[cfg(test)] +mod ingress_tests { + use super::capture_request_ingress; + use edgezero_core::http::Uri; + use edgezero_core::request::{CapturedTarget, OriginSource, Preservation}; + + #[test] + fn ingress_captures_runtime_uri_and_origin_only() { + let uri: Uri = "https://example.com/reserved/%2e//%2F?q=a%2Fb" + .parse() + .expect("should parse URI"); + let ingress = capture_request_ingress(&uri); + let CapturedTarget::Complete(target) = ingress.target() else { + panic!("should capture target") + }; + assert_eq!(target.value(), uri.to_string()); + let origin = ingress.origin().expect("should capture runtime origin"); + assert_eq!(origin.scheme(), "https"); + assert_eq!(origin.authority(), "example.com"); + assert_eq!(origin.source(), OriginSource::RuntimeUri); + assert_eq!( + ingress + .header_fidelity(&"cookie".parse().expect("should parse name")) + .global_field_order(), + Preservation::Unavailable + ); + assert!( + capture_request_ingress(&"/reserved".parse().expect("should parse path")) + .origin() + .is_none() + ); + assert!( + capture_request_ingress( + &"ftp://example.com/reserved" + .parse() + .expect("should parse URI") + ) + .origin() + .is_none() + ); + } +} diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index f1d5426b..e67817b5 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -7,6 +7,8 @@ pub mod cli; pub mod config_store; pub mod context; mod decompress; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod ingress; #[cfg(all(feature = "spin", target_arch = "wasm32"))] pub mod key_value_store; // `kv_pagination` is the pure paging logic for `SpinKvStore::list_keys_page`. diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index bf9543bf..9c08e1e3 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use crate::SpinFullResponse; use crate::config_store::SpinConfigStore; use crate::context::{SpinRequestContext, parse_client_addr}; +use crate::ingress::capture_request_ingress; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; use crate::proxy::SpinProxyClient; use crate::response::from_core_response; @@ -50,6 +51,7 @@ pub(crate) struct Stores { #[inline] pub async fn into_core_request(req: SpinRequest) -> Result { let (parts, body) = req.into_parts(); + let ingress = capture_request_ingress(&parts.uri); let client_addr = parts .headers @@ -80,6 +82,7 @@ pub async fn into_core_request(req: SpinRequest) -> Result { .body(Body::from(body_bytes.to_vec())) .map_err(|err| EdgeError::bad_request(format!("failed to build request: {err}")))?; + request.extensions_mut().insert(ingress); SpinRequestContext::insert( &mut request, SpinRequestContext { diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa8545..58bb9187 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod manifest; pub mod middleware; pub mod params; pub mod proxy; +pub mod request; pub mod responder; pub mod response; pub mod router; diff --git a/crates/edgezero-core/src/request.rs b/crates/edgezero-core/src/request.rs new file mode 100644 index 00000000..3e71e3f9 --- /dev/null +++ b/crates/edgezero-core/src/request.rs @@ -0,0 +1,620 @@ +//! Confidential adapter-owned ingress facts and explicit preservation limits. +//! +//! Metadata describes what a runtime exposes; it cannot recover information +//! erased before conversion. Captured values are not serialized, and Debug +//! implementations omit targets and authorities because they can contain private data. + +use std::fmt; +use std::io::Error as IoError; +use std::net::Ipv6Addr; +use std::sync::Arc; + +use http::uri::Authority; + +use crate::error::EdgeError; +use crate::http::HeaderName; + +/// Maximum complete target metadata, measured in UTF-8 bytes. +pub const MAX_TARGET_BYTES: usize = 16 * 1024; + +/// Maximum inbound authority metadata, measured in UTF-8 bytes. +pub const MAX_AUTHORITY_BYTES: usize = 1024; + +/// A complete bounded target or an explicit acquisition failure. +#[derive(Clone)] +pub enum CapturedTarget { + /// A complete value created only by the bounded capture constructor. + Complete(CompleteTarget), + /// No complete target is available. + Unavailable(TargetUnavailable), +} + +impl CapturedTarget { + /// Capture a whole runtime target without retaining an oversized prefix. + #[inline] + #[must_use] + pub fn capture(value: &str, source: TargetSource, fidelity: Preservation) -> Self { + if value.len() > MAX_TARGET_BYTES { + return Self::Unavailable(TargetUnavailable::TooLarge); + } + Self::Complete(CompleteTarget { + fidelity, + source, + value: Arc::from(value), + }) + } +} + +/// A complete bounded target with explicit provenance. +/// +/// Fields remain private so callers cannot bypass the capture bound. +/// +/// ```compile_fail +/// use edgezero_core::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. +/// `Default` leaves all axes unknown; `RUNTIME_HEADER_MAP` additionally states +/// that the converted map cannot supply global field order. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct HeaderFidelity { + field_multiplicity: Preservation, + global_field_order: Preservation, + octets: Preservation, + same_name_order: Preservation, +} + +impl HeaderFidelity { + /// Conservative baseline for copied runtime headers: original octets, + /// multiplicity and same-name order are unknown; global order is unavailable. + pub const RUNTIME_HEADER_MAP: Self = Self::new( + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unknown, + Preservation::Unavailable, + ); + + /// Return original field-count preservation for this header. + #[inline] + #[must_use] + pub const fn field_multiplicity(&self) -> Preservation { + self.field_multiplicity + } + + /// Return original ordering across different field names. + #[inline] + #[must_use] + pub const fn global_field_order(&self) -> Preservation { + self.global_field_order + } + + /// Construct four independent fidelity guarantees. + #[inline] + #[must_use] + pub const fn new( + octets: Preservation, + field_multiplicity: Preservation, + same_name_order: Preservation, + global_field_order: Preservation, + ) -> Self { + Self { + field_multiplicity, + global_field_order, + octets, + same_name_order, + } + } + + /// Return original field-value byte preservation. + #[inline] + #[must_use] + pub const fn octets(&self) -> Preservation { + self.octets + } + + /// Return original value ordering within a single field name. + #[inline] + #[must_use] + pub const fn same_name_order(&self) -> Preservation { + self.same_name_order + } +} + +/// Bounded validated origin facts established by the adapter. +/// +/// Syntax validation does not make a forwarded header trustworthy. Adapters +/// must establish provenance before calling the constructor. +#[derive(Clone)] +pub struct InboundOrigin { + authority: Authority, + scheme: &'static str, + source: OriginSource, +} + +impl InboundOrigin { + /// Borrow the validated authority in its source-provided spelling. + /// + /// Host case and explicit default ports are retained when the source exposes + /// them, including leading zeros in explicit ports. Port zero is accepted + /// syntactically; this does not make it a usable destination. Consumers must + /// normalize authorities before comparing origins. + #[inline] + #[must_use] + pub fn authority(&self) -> &str { + self.authority.as_str() + } + + /// Validate a bounded HTTP(S) scheme and authority. + /// + /// # Errors + /// + /// Returns a bounded validation error for invalid or oversized input. + #[inline] + pub fn parse(scheme: &str, authority: &str, source: OriginSource) -> Result { + let normalized_scheme = if scheme.eq_ignore_ascii_case("http") { + "http" + } else if scheme.eq_ignore_ascii_case("https") { + "https" + } else { + return Err(EdgeError::bad_request("invalid ingress origin")); + }; + if authority.is_empty() + || authority.len() > MAX_AUTHORITY_BYTES + || authority + .chars() + .any(|character| matches!(character, '@' | '/' | '?' | '#' | '\\')) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let parsed: Authority = authority + .parse() + .map_err(|_error| EdgeError::bad_request("invalid ingress origin"))?; + let host = parsed.host(); + if host.chars().any(|character| matches!(character, '[' | ']')) + && host + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + .is_none_or(|inner| inner.parse::().is_err()) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + let suffix = authority + .strip_prefix(parsed.host()) + .ok_or_else(|| EdgeError::bad_request("invalid ingress origin"))?; + if parsed.host().is_empty() + || (!suffix.is_empty() + && suffix.strip_prefix(':').is_none_or(|port| { + port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err() + })) + { + return Err(EdgeError::bad_request("invalid ingress origin")); + } + Ok(Self { + authority: parsed, + scheme: normalized_scheme, + source, + }) + } + + /// Compare hosts case-insensitively and ports using each scheme's default. + /// + /// This compares authorities, not full origins: the schemes may differ. + #[inline] + #[must_use] + pub fn same_authority(&self, other: &Self) -> bool { + let port = |origin: &Self| { + origin + .authority + .port_u16() + .unwrap_or(if origin.scheme == "https" { 443 } else { 80 }) + }; + self.authority + .host() + .eq_ignore_ascii_case(other.authority.host()) + && port(self) == port(other) + } + + /// Return the normalized HTTP(S) scheme. + #[inline] + #[must_use] + pub const fn scheme(&self) -> &str { + self.scheme + } + + /// Return the adapter-owned origin acquisition source. + #[inline] + #[must_use] + pub const fn source(&self) -> OriginSource { + self.source + } +} + +/// Provenance of a validated inbound origin. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OriginSource { + /// Scheme and authority supplied by the runtime request URI. + /// + /// Runtimes typically derive authority from the client's Host or :authority. + /// It identifies the requested origin; it does not establish that an operator + /// approves or owns that host. + RuntimeUri, + /// A transport scheme established by the adapter's listener, combined with + /// one validated inbound request authority. + /// + /// The authority identifies the requested origin; it does not establish + /// that an operator approves or owns that host. + TransportBinding, +} + +/// Degree of preservation established for a particular fact. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[non_exhaustive] +pub enum Preservation { + /// Pinned source and transport evidence establish equality. + Preserved, + /// A known transformation changed the original information. + Transformed, + /// The runtime does not expose the required information. + Unavailable, + /// Preservation has not been established. + #[default] + Unknown, +} + +/// Confidential adapter-owned metadata stored in request extensions. +/// +/// Absence means unknown capability, not complete preservation. Existing +/// headers remain in the request `HeaderMap` and are never duplicated here. +#[derive(Clone)] +pub struct RequestIngress { + common_header_fidelity: HeaderFidelity, + origin: Option, + overrides: Vec<(HeaderName, HeaderFidelity)>, + target: CapturedTarget, +} + +impl RequestIngress { + /// Return a header-specific guarantee or the common conservative default. + #[inline] + #[must_use] + pub fn header_fidelity(&self, name: &HeaderName) -> HeaderFidelity { + self.overrides + .iter() + .find_map(|(candidate, fidelity)| (candidate == name).then_some(*fidelity)) + .unwrap_or(self.common_header_fidelity) + } + + /// Construct metadata without header-specific overrides. + /// + /// Sources and preservation guarantees are caller assertions, not validated + /// provenance or fidelity of captured facts. + #[inline] + #[must_use] + pub fn new( + target: CapturedTarget, + origin: Option, + common_header_fidelity: HeaderFidelity, + ) -> Self { + Self { + common_header_fidelity, + origin, + overrides: Vec::new(), + target, + } + } + + /// Borrow the validated origin, when an adapter-established source is available. + #[inline] + #[must_use] + pub const fn origin(&self) -> Option<&InboundOrigin> { + self.origin.as_ref() + } + + /// Borrow complete target metadata or its explicit unavailability reason. + #[inline] + #[must_use] + pub const fn target(&self) -> &CapturedTarget { + &self.target + } + + /// Set unambiguous per-header fidelity overrides. + /// + /// # Errors + /// Returns an internal error for duplicate override names, a caller mistake. + #[inline] + pub fn with_header_overrides( + mut self, + mut overrides: Vec<(HeaderName, HeaderFidelity)>, + ) -> Result { + overrides.sort_unstable_by(|left, right| left.0.as_str().cmp(right.0.as_str())); + if overrides + .windows(2) + .any(|pair| matches!(pair, [left, right] if left.0 == right.0)) + { + return Err(EdgeError::internal(IoError::other( + "duplicate ingress header override", + ))); + } + self.overrides = overrides; + Ok(self) + } +} + +impl fmt::Debug for CapturedTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Complete(target) => f.debug_tuple("Complete").field(target).finish(), + Self::Unavailable(reason) => f.debug_tuple("Unavailable").field(reason).finish(), + } + } +} + +impl fmt::Debug for CompleteTarget { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CompleteTarget") + .field("source", &self.source) + .field("fidelity", &self.fidelity) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for InboundOrigin { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InboundOrigin") + .field("source", &self.source) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for RequestIngress { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RequestIngress") + .field("common_header_fidelity", &self.common_header_fidelity) + .finish_non_exhaustive() + } +} + +/// Source of captured target information, independent of fidelity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TargetSource { + /// A runtime path/query before additional `EdgeZero` parsing. + RuntimePathAndQuery, + /// A runtime-exposed URL; earlier normalization may have occurred. + RuntimeUrl, + /// A transport-exposed request target with evidence of its provenance. + TransportRequestTarget, +} + +/// Why no complete target was retained. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TargetUnavailable { + /// The runtime offers no safe acquisition API. + NotExposed, + /// Acquisition failed without retaining error text. + ReadFailed, + /// The target exceeded the metadata bound. + TooLarge, +} + +#[cfg(test)] +mod tests { + use crate::http::StatusCode; + use crate::http::header::{CONTENT_LENGTH, COOKIE}; + use crate::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, + ) + .with_header_overrides(vec![(CONTENT_LENGTH, folded)]) + .expect("should accept distinct header overrides"); + assert_eq!(metadata.header_fidelity(&CONTENT_LENGTH), folded); + assert_eq!(metadata.header_fidelity(&COOKIE), common); + assert!(metadata.origin().is_none()); + } + + #[test] + fn ingress_origin_accepts_bounded_http_authorities() { + for (scheme, authority) in [ + ("HTTP", "example.com"), + ("https", "example.com:443"), + ("http", "[::1]:8787"), + ] { + let origin = InboundOrigin::parse(scheme, authority, OriginSource::TransportBinding) + .expect("should accept a validated HTTP origin"); + assert_eq!(origin.scheme(), scheme.to_ascii_lowercase()); + assert_eq!(origin.authority(), authority); + assert_eq!(origin.source(), OriginSource::TransportBinding); + } + } + + #[test] + fn ingress_origin_rejects_untrusted_or_invalid_input() { + for (scheme, authority) in [ + ("ftp", "example.com"), + ("https", "user@example.com"), + ("https", "example.com/path"), + ("https", "example.com?secret=1"), + ("https", "example.com#fragment"), + ("https", "example.com:invalid"), + ("https", "example.com:65536"), + ("https", "example.com:+443"), + ("https", "[::1]:+443"), + ("https", "[]"), + ("https", "[not-an-ip]"), + ("https", "example[bad]"), + ("https", ""), + ("https", "example.com\\evil"), + ] { + assert!( + InboundOrigin::parse(scheme, authority, OriginSource::RuntimeUri) + .err() + .is_some(), + "should reject malformed authority without retaining it", + ); + } + assert!( + InboundOrigin::parse("https", &"a".repeat(1025), OriginSource::RuntimeUri) + .err() + .is_some(), + "should bound origin metadata", + ); + } + + #[test] + fn ingress_rejects_duplicate_overrides() { + let result = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::ReadFailed), + None, + HeaderFidelity::default(), + ) + .with_header_overrides(vec![ + (COOKIE, HeaderFidelity::default()), + (COOKIE, HeaderFidelity::default()), + ]); + assert_eq!( + result + .expect_err("should reject ambiguous overrides") + .status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + } + + #[test] + fn ingress_debug_never_prints_captured_values() { + let target = CapturedTarget::capture( + "/private-target?secret=1", + TargetSource::RuntimeUrl, + Preservation::Unknown, + ); + let CapturedTarget::Complete(complete) = &target else { + panic!("should capture target") + }; + let origin = + InboundOrigin::parse("https", "private-host.example", OriginSource::RuntimeUri) + .expect("should parse origin"); + let values_output = format!("{target:?} {complete:?} {origin:?}"); + let ingress = RequestIngress::new(target, Some(origin), HeaderFidelity::default()); + let output = format!("{values_output} {ingress:?}"); + assert!(!output.contains("private-target")); + assert!(!output.contains("secret=1")); + assert!(!output.contains("private-host")); + } + + #[test] + fn ingress_authorities_compare_host_and_effective_port() { + for (scheme, left, right, same) in [ + ("http", "Example.COM", "example.com:00080", true), + ("https", "Example.COM", "example.com:443", true), + ("https", "example.com", "example.com:80", false), + ("http", "[::1]", "[::1]:80", true), + ("http", "example.com", "other.example", false), + ("http", "example.com:0", "example.com:80", false), + ] { + let left_origin = InboundOrigin::parse(scheme, left, OriginSource::RuntimeUri) + .expect("should parse origin"); + let right_origin = InboundOrigin::parse(scheme, right, OriginSource::TransportBinding) + .expect("should parse origin"); + assert_eq!(left_origin.same_authority(&right_origin), same); + } + } + + #[test] + fn ingress_target_bound_is_atomic() { + let mut value = "a".repeat(16 * 1024); + let target = + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Transformed); + assert!( + matches!(&target, CapturedTarget::Complete(complete) if complete.value() == value), + "should preserve the entire value at the bound", + ); + value.push('b'); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should retain no truncated prefix", + ); + } + + #[test] + fn ingress_target_counts_utf8_bytes() { + let value = "\u{e9}".repeat(8193); + assert!( + matches!( + CapturedTarget::capture(&value, TargetSource::RuntimeUrl, Preservation::Unknown,), + CapturedTarget::Unavailable(TargetUnavailable::TooLarge), + ), + "should bound bytes rather than Unicode characters", + ); + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index ae77619a..37af34c3 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, @@ -74,6 +94,7 @@ enum RouteMatch<'route> { pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_info: Vec, routes: HashMap>, /// App state registered via [`RouterBuilder::with_state`], keyed by type. @@ -122,6 +143,7 @@ impl RouterBuilder { route_index, self.manifest_json, self.state_extensions, + self.pre_dispatch_hook, ) } @@ -175,6 +197,37 @@ impl RouterBuilder { self.route(path, Method::POST, handler) } + /// Install one hook before route lookup, state injection and middleware. + /// + /// Router clones share the same instance. None of the normal route lifecycle + /// runs after a terminal response; outer adapter/application finalizers may run. + /// + /// # Panics + /// Panics if a hook is already registered. + #[must_use] + #[inline] + pub fn pre_dispatch_hook(self, hook: H) -> Self + where + H: PreDispatchHook, + { + self.pre_dispatch_hook_arc(Arc::new(hook)) + } + + /// Install a shared hook with the same guarantees as [`Self::pre_dispatch_hook`]. + /// + /// # Panics + /// Panics if a hook is already registered. + #[must_use] + #[inline] + pub fn pre_dispatch_hook_arc(mut self, hook: BoxPreDispatchHook) -> Self { + assert!( + self.pre_dispatch_hook.is_none(), + "pre-dispatch hook already registered" + ); + self.pre_dispatch_hook = Some(hook); + self + } + #[must_use] #[inline] pub fn put(self, path: &str, handler: H) -> Self @@ -227,6 +280,7 @@ impl RouterBuilder { struct RouterInner { manifest_json: Option>, middlewares: Vec, + pre_dispatch_hook: Option, route_index: Arc<[RouteInfo]>, routes: HashMap>, state_extensions: Extensions, @@ -234,6 +288,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(); @@ -336,11 +395,13 @@ impl RouterService { route_index: Arc<[RouteInfo]>, manifest_json: Option>, state_extensions: Extensions, + pre_dispatch_hook: Option, ) -> Self { Self { inner: Arc::new(RouterInner { manifest_json, middlewares, + pre_dispatch_hook, route_index, routes, state_extensions, @@ -1092,3 +1153,294 @@ 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(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(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: true, + })) + .post("/after", after_handler) + .build(); + let error = block_on( + router + .clone() + .call(input(Method::POST, "/after", Body::empty())), + ) + .expect_err("should propagate hook error from Service"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + let response = block_on(router.oneshot(input(Method::POST, "/after", Body::empty()))) + .expect("should render hook error from oneshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_hook_is_shared_across_router_clones() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .build(); + for service in [router.clone(), router] { + let response = block_on(service.oneshot(input(Method::GET, "/missing", Body::empty()))) + .expect("should reuse shared hook"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn pre_dispatch_inspects_empty_stream_without_send_bound() { + let router = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::new()), Ok(Bytes::new())]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should inspect clean streamed EOF"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[test] + #[should_panic(expected = "pre-dispatch hook already registered")] + fn pre_dispatch_registration_rejects_duplicate_hook() { + let _builder = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(InspectHook)) + .pre_dispatch_hook(RewriteHook); + } + + #[test] + fn pre_dispatch_rejects_nonempty_or_failed_streams() { + let router = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(InspectHook)) + .build(); + let chunks = stream::iter([Ok::(Bytes::from_static(b"x"))]); + let response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(chunks)))) + .expect("should render nonempty body rejection"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let failure = stream::iter([Err::(IoError::other("fixture failure"))]); + let failure_response = + block_on(router.oneshot(input(Method::POST, "/unknown", Body::from_stream(failure)))) + .expect("should render stream failure"); + assert_eq!(failure_response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn pre_dispatch_terminal_response_precedes_method_path_and_lifecycle() { + let calls = Arc::new(AtomicUsize::new(0)); + let clones = Arc::new(AtomicUsize::new(0)); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(CountedState { + clones: Arc::clone(&clones), + }) + .with_manifest_json("{}") + .middleware(CountingMiddleware { + calls: Arc::clone(&middleware_calls), + }) + .pre_dispatch_hook_arc(Arc::new(TerminalHook { + calls: Arc::clone(&calls), + fail: false, + })) + .post("/after", unreachable_handler) + .build(); + for (method, path) in [ + (Method::POST, "/after"), + (Method::GET, "/after"), + ( + Method::from_bytes(b"EXAMPLE-METHOD").expect("should parse extension method"), + "/after", + ), + (Method::GET, "/missing"), + ] { + let response = block_on(router.oneshot(input(method, path, Body::empty()))) + .expect("should intercept before method/path lookup"); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(response.headers()["allow"], "POST"); + assert_eq!(response.headers()["www-authenticate"], "Basic"); + assert_eq!(response.body().as_bytes(), Some(b"local".as_slice())); + } + assert_eq!(calls.load(Ordering::SeqCst), 4); + assert_eq!(clones.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } +} diff --git a/crates/edgezero-macros/tests/fixtures/hooked_app.toml b/crates/edgezero-macros/tests/fixtures/hooked_app.toml new file mode 100644 index 00000000..2058229d --- /dev/null +++ b/crates/edgezero-macros/tests/fixtures/hooked_app.toml @@ -0,0 +1,19 @@ +[app] +name = "hooked-manifest-app" +middleware = ["crate::tests::MarkMiddleware"] + +[[triggers.http]] +path = "/ordinary" +methods = ["GET"] +handler = "crate::tests::ordinary" + +[stores.kv] +ids = ["sessions"] +[stores.config] +ids = ["settings"] +[stores.secrets] +ids = ["tokens"] + +[adapters.fastly.logging] +endpoint = "fixture_logs" +level = "debug" diff --git a/crates/edgezero-macros/tests/hooked_app.rs b/crates/edgezero-macros/tests/hooked_app.rs new file mode 100644 index 00000000..3fec3263 --- /dev/null +++ b/crates/edgezero-macros/tests/hooked_app.rs @@ -0,0 +1,182 @@ +//! Executable counterpart of the routing guide's manual `Hooks` workaround. + +#[cfg(test)] +mod tests { + mod manifest_app { + edgezero_core::app!( + "tests/fixtures/hooked_app.toml", + ManifestApp, + owns_logging = true, + state = 42_u32 + ); + } + + use async_trait::async_trait; + use edgezero_core::action; + use edgezero_core::app::{App, Hooks, StoresMetadata}; + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::State; + use edgezero_core::http::{Request, Response, request_builder, response_builder}; + use edgezero_core::introspection::ManifestJson; + use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::router::{PreDispatchHook, RouterService}; + use futures::executor::block_on; + use std::sync::{Arc, LazyLock}; + + use manifest_app::ManifestApp; + + static MANIFEST_JSON: LazyLock> = LazyLock::new(|| { + let manifest = ManifestLoader::load_from_str(include_str!("fixtures/hooked_app.toml")); + serde_json::to_string(manifest.manifest()) + .expect("valid manifest") + .into() + }); + + struct HookedApp; + impl Hooks for HookedApp { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + fn configure(app: &mut App) { + ManifestApp::configure(app); + } + fn logging_for(adapter: &str) -> ResolvedLoggingConfig { + ManifestApp::logging_for(adapter) + } + fn name() -> &'static str { + ManifestApp::name() + } + fn owns_logging() -> bool { + ManifestApp::owns_logging() + } + fn routes() -> RouterService { + RouterService::builder() + .pre_dispatch_hook(ReservedGate) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) + .with_state(42_u32) + .middleware(MarkMiddleware) + .get("/ordinary", ordinary) + .build() + } + fn stores() -> StoresMetadata { + ManifestApp::stores() + } + } + + struct ReservedGate; + #[async_trait(?Send)] + impl PreDispatchHook for ReservedGate { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + assert!( + request.extensions().get::().is_none(), + "hook precedes state" + ); + assert!( + request.extensions().get::().is_none(), + "hook precedes introspection" + ); + if request.uri().path() == "/reserved" { + return response_builder() + .status(204) + .body(Body::empty()) + .map(Some) + .map_err(EdgeError::internal); + } + Ok(None) + } + } + + pub struct MarkMiddleware; + #[async_trait(?Send)] + impl Middleware for MarkMiddleware { + #[inline] + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let mut response = next.run(ctx).await?; + response + .headers_mut() + .insert("x-middleware", "present".parse().expect("header")); + Ok(response) + } + } + + #[action(manifest)] + async fn ordinary(State(value): State, ManifestJson(json): ManifestJson) -> String { + assert_eq!(value, 42); + json.to_string() + } + + #[test] + fn manual_hook_wrapper_preserves_every_manifest_metadata_surface() { + assert_eq!(HookedApp::stores(), ManifestApp::stores()); + assert_eq!(HookedApp::stores().kv.expect("kv").ids, &["sessions"]); + assert_eq!( + HookedApp::stores().config.expect("config").ids, + &["settings"] + ); + assert_eq!( + HookedApp::stores().secrets.expect("secrets").ids, + &["tokens"] + ); + assert_eq!(HookedApp::name(), "hooked-manifest-app"); + assert!(HookedApp::owns_logging()); + let actual = HookedApp::logging_for("fastly"); + let expected = ManifestApp::logging_for("fastly"); + assert_eq!(actual.endpoint, expected.endpoint); + assert_eq!(actual.level, expected.level); + assert_eq!(actual.echo_stdout, expected.echo_stdout); + } + + #[test] + fn manual_router_preserves_continuation_and_terminal_hook_boundaries() { + block_on(async { + let app = HookedApp::build_app(); + assert_eq!(app.name(), "hooked-manifest-app"); + let router = app.router(); + let continued = router + .oneshot( + request_builder() + .uri("/ordinary") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(continued.status(), 200); + assert_eq!(continued.headers()["x-middleware"], "present"); + let generated = ManifestApp::build_app() + .into_router() + .oneshot( + request_builder() + .uri("/ordinary") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("macro response"); + assert_eq!(generated.status(), 200); + assert_eq!(generated.headers()["x-middleware"], "present"); + assert_eq!( + continued.body().as_bytes().expect("buffered manifest"), + generated.body().as_bytes().expect("macro manifest"), + "manifest retained byte for byte" + ); + let terminal = router + .oneshot( + request_builder() + .method("EXAMPLE-METHOD") + .uri("/reserved") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(terminal.status(), 204); + assert!(terminal.headers().get("x-middleware").is_none()); + }); + } +} diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 80b3f3e0..fff6da6d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -284,6 +284,26 @@ extensions, and body. Restarting the server creates a fresh app. Native Axum measurements are a useful ownership reference but do not establish WASM runtime performance or resource lifetimes. +## Ingress provenance + +Conversion captures runtime URI parts and retains the original HeaderMap and +extensions. JSON buffering and non-JSON streaming are unchanged. The standard +plain HTTP listener supplies a private transport marker. Origin is available +only with that marker and one valid Host consistent with any URI authority and +scheme; forwarded headers do not supply a scheme. Generic conversion without +the marker leaves origin unavailable. + +Raw TCP tests retain extension methods, tested path spelling, same-name +duplicates and FF values. Hyper folds identical Content-Length values and +rejects conflicting lengths before the hook. Common field multiplicity remains +`Unknown`, including Content-Length: the runtime binding does not reveal whether +the parser folded original fields. Global wire order remains unavailable. +See [the capability matrix](./overview#inbound-request-fidelity). + +Origin validation checks HTTP(S) URI authority syntax, including registered +names and trailing-dot hosts. It does not establish DNS resolution or ownership. +Consumers that require DNS-label syntax must apply that additional policy. + ## Next Steps - Deploy to [Fastly Compute](/guide/adapters/fastly) for production diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index dad084e8..c31cf367 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -348,6 +348,39 @@ in flight in the same instance. A serialized test or two separate instances does not establish this property. Restore the existing `run_app` entry point to remove explicit retention. The generated default remains unchanged. +## Runtime request fidelity + +Inbound conversion reads `req.inner().method()` rather than the closed Workers +method enum. Valid runtime extension tokens retain their spelling. +`Headers.entries()` values follow workerd's UTF-8 string convention and are copied through `HeaderValue::from_bytes(value.as_bytes())`. Invalid runtime HeaderValues produce bounded conversion errors. Conversion never comma-splits combined fields. Browser ByteString construction is not evidence of Worker wire-byte preservation. + +Ingress captures `req.inner().url()` before additional parsing. It records a +runtime URL and validated runtime origin. Fetch can already have normalized dot +segments and combined repeated fields; those changes cannot be reversed by +EdgeZero. Header fidelity remains conservative. + +Browser converter tests cover custom methods, high bytes, metadata and service +hook dispatch. Locked local workerd transport tests additionally distinguish +pre-Worker rejections: its HTTP parser returns 501 for extension methods. +Real Worker tests assert valid UTF-8 values C3 A9 and C4 80 survive conversion. Local wire FF becomes U+FFFD in Web Headers before Rust, so its runtime UTF-8 copy EF BF BD cannot prove original bytes. Common octet and multiplicity fidelity is `Unknown`: the observed transformations do not prove every particular field changed. Local absolute-form and duplicate Content-Length requests return 500 without a hook verdict. These transport/runtime limits remain separate from runtime-to-core conversion correctness. +See [the capability matrix](./overview#inbound-request-fidelity). + +Captured runtime-target preservation is `Unknown`. Fetch URL normalization is +a known runtime capability, but the original request target is unavailable, so +a transformation cannot be established for each individual incoming request. + +### Conversion compatibility + +Unknown method tokens are retained rather than coerced to GET, so they no longer +match a GET route. Invalid runtime header values fail conversion with a bounded +`EdgeError::bad_request`. Direct converter callers receive that error; +`run_app`, `dispatch_app` and `CloudflareService` surface it as `worker::Error`, +which the standard fetch wrapper returns as HTTP 500. +Method, URL, URI and header conversion errors use bounded messages without parse +details. Primary routing still uses the existing SDK URL conversion, including +Unicode percent encoding and backslash normalization; ingress target metadata is +captured separately before that conversion. + ## Next Steps - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 8e77876f..ea3880ea 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -435,16 +435,16 @@ from an unavailable first snapshot. Warnings through the log facade may be lost before a logger is installed; absence of a warning does not establish a healthy configuration read. -SDK 0.12.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and +SDK 0.13.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and `with_max_memory`. A value of zero disables the request-count or memory limit; it does not request zero callbacks or zero memory use. The lifetime limit is measured from `Serve` construction, including time before the first callback. `with_timeout` bounds only the idle wait for the next request; it does not bound handler or backend time. If your service is billed by memory × wall-clock time, idle waiting is billed too. Limits do not guarantee reuse; any request may start a -fresh sandbox. The standalone Viceroy 0.17.0 pinned for the fixtures admits at most -six requests per guest even with a larger SDK request limit. `edgezero serve` runs -`fastly compute serve`, whose bundled Viceroy can differ. Neither local ceiling +fresh sandbox. The fixtures pin standalone Viceroy 0.21.0. Local request ceilings depend on +the runtime version and must be checked when pins change. `edgezero serve` runs +`fastly compute serve`, whose bundled Viceroy can differ. Neither local runtime establishes deployed behavior. Lifetime and memory checks occur between callbacks and cannot interrupt blocked application work. Before using a local memory limit, verify that the @@ -601,6 +601,47 @@ resource accounting, or performance. Named endpoint delivery must be verified separately from echoed stdout. Roll back by restoring the original single-request entry point and removing retained state, not merely setting a request limit of one. +## Early ingress snapshots + +Call `request::capture_request_ingress(&req)` immediately after acquiring the +native request and before URL-based shortcuts or native mutation. It borrows +the same request without round-tripping handles. With Fastly SDK 0.13.1, +safe raw-target acquisition is unavailable: the snapshot records `NotExposed` +first, then uses the SDK runtime URL only to recover the canonical origin. +This URL access can normalize the path; it never establishes original-target +preservation. Client requests need one valid Host consistent with the runtime +authority, including equivalent default ports. Origin provenance is +`RuntimeUri`; absent TLS metadata does not establish an HTTP scheme. Synthetic +requests and missing, duplicate, invalid or inconsistent Host have no origin. + +Use `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). + +Fastly exposes `get_original_header_names()` and `get_original_header_count()`. +These provide original-name ordering and a total count, but cannot associate +original fields with converted values. Local Viceroy can lowercase and deduplicate +names, so global order is `Unknown`, pending production verification. Aggregate +count equality cannot prove per-header multiplicity; no per-header guarantees +are inferred from it. + ## Next Steps - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 4bb4ac36..d8bb5421 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -13,7 +13,7 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in ## Request Conversion -Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion must: +Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion preserves the runtime-visible request subject to the fidelity limits below. It must: - **Preserve the HTTP method** exactly (`GET`, `POST`, etc.) - **Parse the full URI** (path and query string) into an `http::Uri`. Reject invalid URIs with `EdgeError::bad_request` @@ -155,6 +155,76 @@ Fastly is the only adapter implementing `gc_config_entries` and the staging life actions (`DeployStaged`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return an unsupported error for those. +## Inbound request fidelity + +Adapters insert `edgezero_core::request::RequestIngress` into request extensions. +It records runtime-visible target and adapter-supplied origin facts separately from the +primary URI and headers. Missing metadata means unknown capability. + +`CapturedTarget::Complete` holds the whole captured string, its acquisition +source and preservation status. Capture has a 16 KiB UTF-8 byte bound; oversize +input becomes `Unavailable(TooLarge)`, without retaining a prefix. Fastly's +current safe API reports `Unavailable(NotExposed)`. Metadata has borrowed +getters and intentionally has no value-bearing `Debug`, `Display` or +serialization implementation. Do not log or serialize captured values. + +`InboundOrigin` validates an HTTP(S) scheme and an authority of at most 1 KiB, +including IPv6 and port syntax. Parsing validates syntax; the adapter still +establishes provenance. Runtime URI facts use `RuntimeUri`; Axum's plain HTTP +listener uses `TransportBinding` plus one consistent Host. Origin-form Axum +conversion without that binding has no established origin. Ordinary Origin, +Forwarded and Spin special headers do not establish ingress origin. +Authorities retain source-provided spelling, including host case and explicit +default ports and leading-zero ports when exposed. Syntax validation accepts +port zero without establishing a usable destination. Runtime authority usually +reflects the client's Host or :authority; adapter provenance does not establish +operator approval, ownership or an allowlist. Compare configured origins after +normalizing host case and effective ports. + +Fastly records target unavailability before reading its SDK runtime URL for +origin, and requires a client request with one consistent validated Host. +This recovers the validated runtime origin without claiming an original path. + +`HeaderFidelity` has separate octet, field-multiplicity, same-name-order and +global-order axes, with per-name overrides. They concern original received +fields, excluding HTTP framing and header-name spelling. Faithful copying from +a runtime HeaderMap does not establish original-wire preservation. Unproved +axes remain `Unknown`. Axum, Cloudflare and Spin report +`HeaderFidelity::RUNTIME_HEADER_MAP`: octets, multiplicity and same-name order +are `Unknown`, and global order is `Unavailable`. Fastly reports all four axes +as `Unknown`, with no per-name overrides. Its original-header names/count APIs +expose partial evidence, but do not associate original fields with converted +values. Aggregate count equality cannot prove per-name multiplicity, and full +global order is not established. Production verification remains pending. +The matrix below shows observed runtime behavior, not `RequestIngress` guarantees. + +The table reflects the checked-in `observations.json`; +`./scripts/test-request-fidelity.sh all` compares against it exactly. +These results describe the tested local runtimes; they do not establish +production-edge behavior. + +| Surface | Fastly 0.13.1 / Viceroy 0.21.0 | worker 0.8.5 / workerd 1.20260415.1 | Axum / Hyper 1.11.1 | Spin SDK 7.0.0 / Spin 4.1.0 | +| ----------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Pre-dispatch hook | Executed | Executed for supported incoming methods | Executed | Executed | +| Runtime extension method conversion | Byte method preserved | Web Request converter preserves tokens; local wire parser returns 501 for unknown methods | Preserved in tested TCP fixture | Preserved in tested HTTP fixture | +| Target metadata | Not exposed by safe SDK API | Runtime URL; literal and encoded dot spellings normalize | Runtime URI; tested spelling retained | Runtime URI; tested spelling retained | +| High-byte Cookie | FF retained in tested fixture | Local wire FF becomes U+FFFD before Rust and is copied as runtime UTF-8 EF BF BD | FF retained in tested TCP fixture | Runtime returns 500 before component for invalid UTF-8 | +| Repeated control fields | Tested ordinary duplicates retained; identical Content-Length folded | Fetch combines fields; original counts cannot be recovered; common fidelity Unknown | Tested ordinary duplicates retained; identical Content-Length folded | Tested ordinary duplicates retained; identical Content-Length folded | +| Global wire order | Not established | Unavailable | Unavailable | Unavailable | + +Run `./scripts/test-request-fidelity.sh all` for local transport probes. They +assert pinned observations, including unsupported outcomes; a green regression +run does not turn an unsupported capability into a preservation guarantee. +Fastly raw-target support requires a safe borrowed SDK accessor and new ingress +evidence. Consumers that require original dot spellings or duplicate fields +must separately resolve Fetch normalization/coalescing and platform rejection +limits. Consumer authentication and cookie policy remain application responsibilities. + +The [pre-dispatch hook](../routing#intercepting-before-dispatch) runs after +adapter conversion. Fastly, Cloudflare and Spin still buffer bodies; Axum still +buffers JSON and streams other bodies. Existing store/capability bootstrap and +outer finalizers retain their behavior. + ## Opt-in application retention Use retention when measurements show that constructing the app is expensive. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 38c5129e..578115c5 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -318,7 +318,7 @@ export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract ``` -The tests execute the adapter's real `wasm32-wasip2` request path under Wasmtime. The +These contracts execute under Wasmtime. Incoming conversion is additionally exercised by the actual Spin HTTP fixture. The `wasmtime` version CI uses is pinned in `.tool-versions`. ## Manifest Configuration @@ -361,6 +361,30 @@ request/response bodies and pending operations invocation-local. Existing response buffering and stream collection limits still apply. Restore `run_app` and remove the cache to roll back; default generated entry points are unchanged. +## Runtime ingress metadata + +Conversion captures SDK URI parts before rebuilding the core request. Origin +comes from HTTP(S) URI scheme/authority, with runtime provenance. +`spin-full-url`, `spin-client-addr`, Origin and forwarding headers do not become +adapter-supplied ingress-origin facts. Runtime URI authority still identifies the client-requested host; it does not establish operator approval or ownership. Existing context fields and store/body behavior +remain unchanged. + +The Spin 4.1 HTTP component fixture verifies methods, tested target spelling, +duplicates and hook ordering through the actual converter. Spin 4.1's request +preparation requires UTF-8 header values; a Cookie containing FF returns 500 +before the component starts. This needs an upstream runtime fix and cannot be +restored inside EdgeZero. Same-name runtime values are copied byte-for-byte, +while original-wire fidelity remains conservative and global order unavailable. +Spin 4.1 consumes chunked framing and removes `Transfer-Encoding` before +component invocation: the `chunked` and `te-cl` probes expose no such field to +the hook. The component cannot reconstruct that transport framing. + +Pure metadata tests execute on the host. The WASM contract suite runs with +`wasmtime run`. The full WASM library test artifact +also imports Spin-specific host interfaces, so plain Wasmtime cannot execute +that artifact. The real Spin HTTP fixture supplies runtime conversion evidence. +See [the capability matrix](./overview#inbound-request-fidelity). + ## Next Steps - [Migration guide](/guide/manifest-store-migration) — moving from the diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index efefe3d3..4837b6e6 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -58,7 +58,8 @@ middleware = [ ] ``` -Middleware are applied in order before routes are matched. +Middleware runs in registration order after a route matches, before its handler. +Use a pre-dispatch hook for work that must happen before route lookup. ### Programmatically @@ -228,6 +229,14 @@ EdgeZero provides these middleware out of the box: If you already hold an `Arc` (`BoxMiddleware`), register it with `.middleware_arc(...)` instead of `.middleware(...)`. +## Work before route lookup + +Ordinary middleware runs after route selection and router-owned extension setup. +For method-independent interception before 404/405, use the +[pre-dispatch hook](./routing#intercepting-before-dispatch). A terminal hook +response or error bypasses ordinary middleware and handlers. A continuing hook +passes its request mutations into the existing middleware chain. + ## Next Steps - Learn about [Streaming](/guide/streaming) for progressive responses diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 9c58c81d..b1fa1b50 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -182,6 +182,152 @@ surfaces before any traffic, but the Fastly, Cloudflare, and Spin entry points c per request, so a conflicting route deploys successfully and then panics on the first request. Run the app under the Axum dev server before deploying to an edge target. +## Intercepting before dispatch + +Use an optional `PreDispatchHook` when handling a request must precede method +and path lookup, including router-generated 404/405 responses. The hook receives +the converted request before router state, introspection, request context, +middleware or handlers. Its future can be non-`Send`. + +```rust +use async_trait::async_trait; +use edgezero_core::body::Body; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Request, Response, response_builder}; +use edgezero_core::router::{PreDispatchHook, RouterService}; + +struct ExampleHook; + +#[async_trait(?Send)] +impl PreDispatchHook for ExampleHook { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + if request.headers().contains_key("x-example-stop") { + return Ok(Some( + response_builder() + .status(204) + .body(Body::empty()) + .map_err(EdgeError::internal)?, + )); + } + Ok(None) + } +} + +let router = RouterService::builder() + .pre_dispatch_hook(ExampleHook) + .build(); +``` + +`Ok(None)` continues with 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. + +Register a value with `pre_dispatch_hook`, or an existing shared hook with +`pre_dispatch_hook_arc`. A second registration panics during router construction. +Router clones share the same hook instance. +Without a hook, existing routing is unchanged. Adapter buffering, capability +bootstrap and outer response finalizers can still run before or after this +router boundary; the hook does not replace those layers. + +The hook value must be `Send + Sync + 'static`; it cannot hold `Rc` state, +although its returned future may be non-`Send`. Adapter extensions such as +`RequestIngress`, platform context and store registries are available. Router +state, introspection and `RequestContext` have not been injected yet: + +```rust +let ingress = request.extensions() + .get::(); +``` + +If the hook consumes the body and returns `Ok(None)`, the handler receives only +what remains unless the hook replaces the body. Generated `app!` applications +cannot install a hook through the macro or modify the built router. A +hand-written `Hooks` implementation does not inherit the generated metadata. +Move your existing `app!` invocation **unchanged, including all arguments** +(`owns_logging = …`, `state = …`), into a separate module. Delegate stores, +logging, name and application configuration. For example, an application whose +existing invocation uses `owns_logging = true, state = 42_u32` would keep them: + +```rust +use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; +use edgezero_core::router::RouterService; +use std::sync::{Arc, LazyLock}; + +mod manifest_app { + edgezero_core::app!( + "edgezero.toml", ManifestApp, + owns_logging = true, state = 42_u32 + ); +} +use manifest_app::ManifestApp; + +static MANIFEST_JSON: LazyLock> = LazyLock::new(|| { + let manifest = ManifestLoader::load_from_str(include_str!("../edgezero.toml")); + serde_json::to_string(manifest.manifest()).expect("valid manifest").into() +}); + +pub struct HookedApp; + +impl Hooks for HookedApp { + fn build_app() -> App { + let mut app = App::with_name(Self::routes(), Self::name()); + Self::configure(&mut app); + app + } + + fn routes() -> RouterService { + RouterService::builder() + .pre_dispatch_hook(ExampleHook) + .with_manifest_json(Arc::clone(&MANIFEST_JSON)) + .with_state(42_u32) + // Re-register every manifest route and middleware here, in order. + .build() + } + + fn stores() -> StoresMetadata { ManifestApp::stores() } + fn logging_for(adapter: &str) -> ResolvedLoggingConfig { + ManifestApp::logging_for(adapter) + } + fn owns_logging() -> bool { ManifestApp::owns_logging() } + fn name() -> &'static str { ManifestApp::name() } + fn configure(app: &mut App) { ManifestApp::configure(app); } +} +``` + +This outline uses `ExampleHook` from above, with code in `src/lib.rs` and +`edgezero.toml` at the crate root. Add direct `async-trait` and `serde_json` +dependencies: core does not re-export those crates. The `app!` path is relative +to the crate root; the `include_str!` path is relative to the source file. +Import any names used by the original arguments into the nested module (for +example, `use super::app_state;` for `state = app_state()`). +Use `HookedApp` as the adapter entry point. Its `routes()` must explicitly +re-register **all routes, manifest middleware and configured state**; delegating +metadata alone does not preserve them. `with_manifest_json` above preserves +manifest introspection. The lazy static parses and serializes the manifest once +per runtime instance. Standard Fastly, Cloudflare and Spin runners construct the +application on every request. Persistent instances (Cloudflare isolates, Axum, +or Fastly `serve_app`) reuse this cached JSON. Classic Fastly Compute starts a +fresh instance per request, so it still pays the initialization cost on each +request. Spin's [instance reuse depends on the component and runtime](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse): +WASI P3 HTTP components can reuse the cache, while fresh instances always pay +the initialization cost. Do not assume any minimum degree of reuse. +The macro embeds its JSON at compile time; exposing that literal +or registering hooks directly through `app!` is tracked in +[issue #434](https://github.com/IABTechLab/edgezero/issues/434). +This manual duplication must stay synchronized with `app!` and the manifest, +including matching `.with_state(...)` to the original macro's state argument. +The complete executable example and regression tests are in +`crates/edgezero-macros/tests/hooked_app.rs`. The store-free ingress fixture is +not a metadata-preserving example for manifest applications. + +Consult [adapter ingress capabilities](./adapters/overview#inbound-request-fidelity) +before classifying a request using its target or duplicate headers. + ## Next Steps - Learn about [Handlers & Extractors](/guide/handlers) for processing requests diff --git a/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md new file mode 100644 index 00000000..9e083ddf --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md @@ -0,0 +1,109 @@ +# Pre-dispatch interception and inbound request fidelity implementation plan + +**Goal:** Method-independent interception before router dispatch, with bounded, +confidential metadata that accurately describes runtime-visible request facts. + +**Status:** Core feature implemented. This document records the implemented +surfaces, maintenance requirements and verification procedure. + +**Contract:** [Design](../specs/2026-10-05-pre-dispatch-request-fidelity-design.md). + +## Implemented surfaces + +- `crates/edgezero-core/src/router.rs`: optional `PreDispatchHook` before lookup, + state, introspection, context and middleware. Register a value with + `pre_dispatch_hook` or an Arc with `pre_dispatch_hook_arc`; duplicate + registration panics. Futures may be non-Send; hook values are Send + Sync. +- `crates/edgezero-core/src/request.rs`: bounded target/origin metadata, + infallible `RequestIngress::new`, fallible `with_header_overrides` with an + internal error for duplicate names, redacted Debug, public authority/target + limits and shared host/effective-port comparison. The four metadata enums are + non-exhaustive so acquisition sources and guarantees can evolve. Authorities are requested + hosts, not operator approval; port spelling is retained. +- Fastly `src/request.rs`: capture before native mutation, explicit snapshot + conversion and `dispatch_with_registries_and_ingress(app, req, stores, +ingress, extend)`. Capture reports target NotExposed before reading the SDK + URL for origin. Ordinary runners use the same registry dispatcher. +- Cloudflare `src/request.rs`: original runtime method token and one runtime URL + read; parse the captured copy with `worker::Url`; preserve runtime UTF-8 + header bytes. Bounded conversion errors become Worker errors through standard + runners and the fetch wrapper returns 500. +- Axum `src/request.rs`: preserve incoming URI/headers, listener-established plain-HTTP + transport binding and one consistent Host. Existing JSON buffering and + non-JSON streaming remain in place. +- Spin `src/ingress.rs`: pure metadata helper shared by WASM conversion and host + tests, separate from the WASM-only `src/request.rs`. +- `tests/fixtures/request-fidelity`: store-free runtime application and fixed + boolean/count observations. `request-fidelity-runner` is an isolated native + Rust supervisor; its Tokio dependency does not enter core or WASM adapters. + +## Fidelity decisions + +Axum, Cloudflare and Spin use `HeaderFidelity::RUNTIME_HEADER_MAP`: octets, +multiplicity and same-name order Unknown, global order Unavailable. Fastly uses +all Unknown. Its original-header names/count APIs provide partial ordering and +aggregate count but do not associate original fields with converted values. +Viceroy can lowercase/deduplicate names; production behavior needs verification. +No per-header preservation is inferred from aggregate count equality. + +The hook cannot recover erased wire data, intercept an earlier native shortcut, +remove buffering or bypass outer response finalizers. Metadata capture remains +eager; changing it to lazy/opt-in requires separate lifecycle/performance work. +Generated `app!` routers cannot install a hook. A manual `Hooks` wrapper must +delegate manifest metadata and explicitly preserve routes, middleware, state and +manifest introspection; see the routing guide. + +## Dependency refresh + +The isolated fixture pins Fastly 0.13.1, Worker 0.8.5, wasm-bindgen 0.2.128 and +Spin SDK 7.0.0. Its lockfile must be regenerated with these pins together. +CI and the runner require worker-build 0.8.5 and Spin CLI 4.1.0. Rust, Node, +Viceroy and Wasmtime versions come from `.tool-versions`. The shared Spin pin +check includes this fixture. CI resolves fixture wasm-bindgen from its own lock, +and worker-build uses the npm-integrity-locked esbuild executable. Its version +is declared once beside Spin CLI in fixture metadata. SDK drift checks compare +exact Fastly, Worker, wasm-bindgen and Spin SDK pins against both lockfiles. +CI caches the actual native-runner and WASM-fixture target directories with +versioned cache keys; debug lint and release probe builds keep their profiles. + +## Verification checklist + +- Run core, affected adapter and native runner tests after code changes. +- Run workspace fmt, strict all-feature clippy and full workspace tests. +- Check workspace `fastly cloudflare spin` features and Spin wasm32-wasip2. +- Format/lint both isolated fixture workspaces; lint WASM fixture on each of + wasm32-wasip1/Fastly, wasm32-unknown-unknown/Cloudflare and + wasm32-wasip2/Spin with `--locked`. +- Execute browser Cloudflare, Viceroy Fastly and Wasmtime Spin contracts. + Spin uses plain `wasmtime run`; full Spin lib-test artifacts additionally + need Spin-specific host interfaces and are not plain-Wasmtime contracts. +- Run `./scripts/test-request-fidelity.sh all --record-observations` into a + temporary file, inspect per-case differences, then replace the baseline. + Run the ordinary comparison afterward. See the fixture README commands. +- Run docs lint, format and build; review the final diff independently. + +## Baseline maintenance + +The checked-in `tests/fixtures/request-fidelity/observations.json` records local +runtime observations. `./scripts/test-request-fidelity.sh all` compares them +exactly. Inspect differences when updating SDK or runtime pins; baseline results +are not production preservation guarantees. Record run-specific validation in +the PR description. + +## Decision log + +- Worker runtime UTF-8 strings must be copied as UTF-8; browser ByteString + construction is not evidence of Worker wire bytes. Exact C3 A9/C4 80 assertions + guard against corruption from a ByteString/Latin-1 conversion. +- Origin availability and hook dispatch prerequisites must pass before the + UTF-8 regression can prove anything; assert the specific encoding error and + include a passing valid verdict. +- Baseline failures print changed/added/removed cases. Child stdin is null, and + direct child termination backs up external process-group signals before wait. +- The consumer owns visible-path classification, authentication, cookie policy, + native shortcut ordering and terminal finalization. Its integration requires + an immutable reviewed toolkit revision and tests on its own dependency graph. +- The worker-build binary uses a version-specific CI cache; cold installs build + from source while the pinned release has no Ubuntu prebuilt. Opt-in capture + remains separate work. + No production capability or performance claim follows from local fixtures. diff --git a/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md new file mode 100644 index 00000000..ce784e59 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md @@ -0,0 +1,242 @@ +# Pre-dispatch interception and inbound request fidelity + +**Status:** Implemented API contract; current verification requirements and dependency pins are recorded in the matching plan. +**Date:** 2026-10-05. +**Baseline:** EdgeZero main `98930917d96cb665c7255f36ca8bbd61fc7539e1`. +**Implementation plan:** [Matching plan](../plans/2026-10-05-pre-dispatch-request-fidelity.md). + +## 1. Problem and outcome + +Applications need to intercept a reserved namespace independently of the registered method/path routes, before router-owned state, introspection, request context and middleware run. A consumer must be able to authenticate a reserved request and return its own complete response even when its method has no registered handler. + +At the historical planning baseline the router performed `find_route` first. Middleware therefore cannot reliably authenticate a reserved request before router-generated 404/405 errors. Adapter conversion also changes information that a strict consumer may need: Cloudflare previously reduced extension methods to GET; header values were already copied as UTF-8 strings, with invalid values failing as internal errors; some runtimes normalize URLs or combine repeated fields before EdgeZero sees them. + +Provide one optional generic pre-dispatch hook, preserve runtime-visible method/target/header data without additional avoidable loss, and expose explicit fidelity information. Preserve the concrete `RouterService` and existing routes when no hook is installed. This is toolkit infrastructure, not an implementation of Trusted Server tracing. + +## 2. Consumer requirements and references + +The motivating consumer is approved [Trusted Server PR #1107](https://github.com/IABTechLab/trusted-server/pull/1107), whose [design at the planning baseline](https://github.com/IABTechLab/trusted-server/blob/20f4a0cc0139eac842d1d6ff1410a1af6ee8eba8/docs/superpowers/specs/2026-09-01-mobile-ad-render-trace-endpoint-design.md) defines: + +- Section 8: authentication and local handling across reserved spellings and methods before ordinary application lifecycle work. +- Section 9.2: bounded cookie inspection, duplicate detection and non-UTF-8 rejection. +- Sections 12 and 14: hardened local responses and actual adapter ordering/conversion tests. + +Primary interoperability references: [Fetch header sorting/combining](https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine), [URL parsing](https://url.spec.whatwg.org/#url-parsing), and [Web IDL ByteString](https://webidl.spec.whatwg.org/#idl-ByteString). These define platform semantics, not proof of a particular deployed runtime. + +## 3. Scope and alternatives + +Recommended: an optional hook on `RouterBuilder` plus adapter-populated ingress metadata. It keeps manual `Hooks::routes()` and all existing adapter runners on the same concrete router and provides a single method-independent interception point. + +Alternatives considered: + +1. Ordinary middleware or standard-method route registration: smaller apparent edit, but it runs after lookup and cannot cover arbitrary extension methods or route-generated errors. Rejected. +2. Separate wrappers/entry-point bypasses per platform: can intercept native requests but duplicates policy and does not fit existing concrete `RouterService` seams consistently. Consumer-native shortcuts still require native integration; they do not replace the router hook. +3. Replace routing/body/bootstrap wholesale: unnecessary compatibility risk. Existing buffering is explicitly accepted by consumer section 8; no lazy-body or store-initialization redesign is included. + +Included: core hook, bounded ingress metadata, lossless runtime method/header conversion, adapter-specific target capture and fidelity descriptions, native/WASM/runtime contract tests, and docs. + +Excluded: trace routes/cookies/schema/CSP/authentication policy, EC/EID processing, auction evidence, telemetry, browser UI, generic hook chains, macro grammar additions, forwarding-header trust, transport deadlines/size protection, deployment, and unrelated proxy/response refactors. + +## 4. Historical planning baseline and limits + +This table describes the pre-implementation checkout, not current dependencies. +The matching plan records current pins and verification requirements. + +| Surface | Planning source | Consequence | +| ---------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Router | `crates/edgezero-core/src/router.rs`, `RouterInner::dispatch` | Lookup precedes state/introspection/context/middleware; no interception hook exists. | +| Bootstrap | `crates/edgezero-core/src/app.rs`; `crates/edgezero-macros/src/app.rs` | `Hooks::routes`, `App` and generated routers use `RouterService`; retain that type. | +| Fastly | `crates/edgezero-adapter-fastly/src/request.rs`, `into_core_request` | SDK URL accessor has already parsed with `url::Url`. Runtime header bytes and same-name values pass through byte-oriented construction. Body is buffered. | +| Cloudflare | `crates/edgezero-adapter-cloudflare/src/request.rs`, `into_core_request` | `req.method()` is a closed Workers enum; extension methods become GET. `req.url()` parses the runtime URL again. Header entries become Rust strings. Body is buffered. | +| Axum | `crates/edgezero-adapter-axum/src/request.rs` | `Request::from_parts` preserves the incoming `http::Uri` and `HeaderMap`. Hyper may already reject/fold fields; JSON bodies are buffered. | +| Spin | `crates/edgezero-adapter-spin/src/request.rs` | SDK request parts contain runtime URI and byte-oriented headers. Original wire interleaving is already unavailable in `HeaderMap`. Body is buffered. | +| Pins | `Cargo.lock` | The planning checkout pinned Worker 0.8.3, Fastly 0.12.1 and wasm-bindgen 0.2.122. The matching plan supersedes these pins with the dependency refresh. | + +Fastly SDK `RequestHandle::get_url` calls `uri_get` then `Url::parse`. Its request handle getter is private; the public consuming handle round trip rebuilds the request without original client provenance, changing IP/TLS/original-header accessors. It is not an acceptable raw-target workaround. A safe borrowed SDK accessor is an external prerequisite for an original-target claim. + +Fetch `Headers` may combine/sort fields. Pinned workerd uses UTF-8 header strings, unlike browser ByteString construction; copying the runtime string cannot recover invalid UTF-8 bytes replaced earlier or missing field boundaries. Hyper's HTTP/1 parser can fold identical repeated Content-Length and reject conflicting lengths before application dispatch. Therefore `HeaderMap` fidelity is not a blanket wire-multiplicity guarantee. + +Existing Fastly/Cloudflare/Spin buffering and Axum JSON buffering occur before the hook. The hook guarantees ordering relative to the router/application, not relative to network receipt, SDK parsing, body buffering, logger setup or capability-handle construction. Consumer section 8 explicitly accepts pre-buffering and offers no transport deadline. Do not claim that the hook removes those limits. + +## 5. Core pre-dispatch API + +Add `PreDispatchHook` and `BoxPreDispatchHook` in `crates/edgezero-core/src/router.rs`: + +```rust +#[async_trait::async_trait(?Send)] +pub trait PreDispatchHook: Send + Sync + 'static { + async fn handle( + &self, + request: &mut Request, + ) -> Result, EdgeError>; +} + +pub type BoxPreDispatchHook = Arc; +``` + +`Request`/`Response` are existing `edgezero_core::http` aliases; the hook future has no Send requirement. Use the existing async-trait dependency. New public items receive the repository's normal documentation. + +Builder methods `pre_dispatch_hook(hook: H)` and `pre_dispatch_hook_arc(hook: BoxPreDispatchHook)` install one optional hook. Default is None. Repeated registration panics during router construction, so another registration cannot silently remove an authentication gate. `RouterService` clones share the same Arc-backed instance. + +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 provides the hook without changing `Hooks` or the +`app!` grammar. Applications using `app!` need the +[routing guide's manual `Hooks` delegation](../../guide/routing.md#intercepting-before-dispatch); +direct macro registration is tracked in [#434](https://github.com/IABTechLab/edgezero/issues/434). + +## 6. Ingress metadata contract + +Create `crates/edgezero-core/src/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, normally derived from client Host or :authority; TransportBinding records a scheme established by the adapter's listener combined with a single validated inbound request authority. Request authority is not operator approval or host ownership. Invalid, multiple or contradictory authority stays unavailable. +- `InboundOrigin`: validated HTTP(S) scheme and authority, bounded to 1 KiB authority bytes, captured from adapter-established transport binding or runtime URI information with `OriginSource`. Getters borrow those values. No user-info/path/query/fragment; reject invalid authority or expose unavailable origin. An Origin request header or arbitrary forwarded header cannot populate it. +- `HeaderFidelity`: independent `Preservation` values for octets, field multiplicity, same-name value order and global field order. +- `RequestIngress`: captured target, optional inbound origin, common header fidelity and small per-header fidelity overrides. `header_fidelity(&HeaderName)` returns an override or the common default; this handles Content-Length folding without implying identical behavior for Cookie. + +`Preservation`, `OriginSource`, `TargetSource` and `TargetUnavailable` are +non-exhaustive public enums; downstream matches must handle future variants. + +Choose these names and semantics for implementation; constructors/accessors may follow repository naming conventions without adding speculative APIs. `RequestIngress::new(target, origin, fidelity)` is infallible; `with_header_overrides` rejects duplicate names with an internal error. `InboundOrigin::same_authority` compares host case and effective ports without asserting scheme equality. `MAX_AUTHORITY_BYTES` exposes the 1 KiB bound; source port spelling is retained, including leading zeros and syntactic port zero. Do not duplicate method metadata: `Request::method()` is authoritative after lossless conversion. + +HeaderFidelity axes describe original received HTTP field-value octets, field multiplicity and ordering compared with the resulting core headers, not merely a successful runtime-to-core copy. Octets mean field-value bytes, not header-name case, delimiters, HTTP framing or complete request serialization. Trimming/combining or parser/runtime loss must be reflected as Transformed, Unknown or Unavailable; Preserved requires pinned runtime/parser evidence and actual transport tests. Document and test runtime-to-core copying separately when it is all that can be established. + +Capture target strings only up to 16 KiB UTF-8 bytes. A larger or failed target capture stores Unavailable without a partial string. Raw octets that cannot be represented by the verified platform string API are unavailable, not replacement-decoded. This toolkit bound is a metadata allocation bound, not a server URI acceptance limit. + +Populate metadata before EdgeZero's own URL parse/normalization where possible. It preserves what the runtime actually supplies. Report stronger wire guarantees only with direct transport evidence. Missing metadata means unknown capability; consumers cannot infer full preservation from its absence. + +Do not turn optional target-capture failure into an unrelated new ordinary-request rejection. Retain the current conversion/error behavior for the primary core URI; record optional metadata failure separately. Invalid original method or invalid runtime HeaderValues are conversion errors, never coerced values. + +## 7. Adapter requirements + +### 7.1 Fastly + +- Preserve `req.get_method()` and existing byte-oriented repeated header conversion. +- Add a reusable `capture_request_ingress(&fastly::Request) -> RequestIngress` helper in the Fastly request module. A consumer calls it before native health/debug shortcuts or mutations. Record the raw-target capture result before reading the SDK URL. With the current missing accessor, retain `NotExposed`, then recover canonical origin separately from the runtime URL plus exactly one valid, consistent Host on a native client request. URL normalization is acceptable for origin acquisition only; it never proves original pathname preservation. Use `RuntimeUri` provenance and do not infer HTTP from missing TLS metadata. Supplied snapshots remain caller assertions and are retained through subsequent mutation. +- With current SDK, mark original target unavailable or runtime-normalized/unknown; do not call `get_url_str` “raw”. +- Future work: once a reviewed safe SDK accessor exists that borrows the original request, reads the runtime target before `Url::parse` and preserves client provenance, pin its compatible SDK revision/version and add evidence. Do not fabricate one or add a local Cargo-cache patch. +- Do not use consuming handle round trips, clone_without_body, raw pointers or a new unsafe fastly-sys wrapper to bypass the SDK gate. +- After the accessor exists, parse scheme/authority without normalizing the captured path. Prove encoded separators/dot segments/repeated separators survive in metadata and that client IP/TLS accessors retain behavior. +- Runtime header bytes and same-name order must be tested. `get_original_header_names()` and `get_original_header_count()` expose name order and total count, not original field/value association. Local Viceroy lowercases and deduplicates names. Report global order Unknown pending production verification; aggregate count equality cannot prove per-name multiplicity. Do not reconstruct fields from mismatched/FIFO guesses. +- Add `into_core_request_with_ingress` and a public `dispatch_with_registries_and_ingress` overload that retains the existing App/StoresMetadata/extension-callback semantics. Add a metadata-aware conversion path for an already captured snapshot: retain that same snapshot through later native mutation/dispatch. Ordinary converters capture once and delegate. Insert adapter-captured ingress after generic scratch extension merging so it cannot be accidentally overwritten. +- Existing request-extensions callbacks can carry native facts. They are not the new router policy hook and cannot return local responses. + +### 7.2 Cloudflare + +Use `req.inner().method()` and parse with core `Method::from_bytes`. Preserve any valid extension token exposed by the runtime; malformed values produce a bounded conversion error. Do not pass through Workers' closed method enum for inbound conversion. + +Capture `req.inner().url()` once before parsing that string with `worker::Url`, then preserve it separately with runtime-visible provenance. This does not recover a wire target normalized by the Fetch runtime. + +For each `Headers.entries()` value, preserve the pinned Worker runtime's UTF-8 string representation with `HeaderValue::from_bytes(value.as_bytes())`. Header names retain validated construction; invalid HeaderValues produce bounded conversion errors rather than silent omission. Pinned workerd represents headers as UTF-8, which differs from browser ByteString expectations. Do not cast Unicode scalars to single bytes: that changes valid wire C3 A9 to E9 and rejects valid C4 80. Browser-created high-byte strings are not evidence of original Worker wire bytes. + +Real workerd probes must assert exact valid UTF-8 preservation for C3 A9 and C4 80. A raw invalid FF is already represented by U+FFFD before Rust; its runtime UTF-8 copy EF BF BD is not the original FF. Expose Unknown octet and multiplicity fidelity: runtime replacement capability does not establish transformation of every field. Never invent lost bytes or reject an otherwise valid runtime string solely for that capability. + +Web Headers has already normalized/combined entries: original wire multiplicity remains Unknown and global order is Unavailable. Per-name value order and octet provenance must also be stated precisely. Never comma-split Origin/Cookie/control values to invent original fields. + +Keep existing body buffering and Env/Context/proxy wiring intact. Distinguish browser Fetch construction constraints from actual incoming Workers HTTP behavior in tests. + +### 7.3 Axum + +Capture `parts.uri` before any added URL parsing and preserve `parts.headers` through `from_parts`. Source is runtime path/query or URI, not an original TCP request line. 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 matrix records observed behavior and unresolved limits. Unknown is a valid conservative metadata value; it does not prove preservation or satisfy a consumer requirement for original-wire equality. + +Two completion claims are distinct: + +1. **EdgeZero infrastructure complete:** hook/API, concrete conversion fixes, metadata that honestly describes remaining limits, tests and docs pass. +2. **Consumer integration satisfied across all platforms:** consumer-required path/cookie/control properties are proved, or the consumer spec receives explicit approval for a supported runtime contract. EdgeZero cannot grant that approval. + +If a runtime irreversibly hides required path spellings or original duplicate control fields, record the exact limitation and a proposed consumer resolution. Do not silently weaken the approved consumer spec, advertise universal original-wire preservation, disable a consumer platform without approval or report consumer integration complete solely because a hook exists. + +Trusted Server owns early Fastly native shortcut exclusion, classification, authentication, lifecycle gates and terminal response finalization. The ingress helper must be callable before those shortcuts; changing Trusted Server's `main.rs` is outside 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. Decision log and consumer boundary + +- Worker header conversion copies UTF-8 runtime strings. A ByteString/Latin-1 + conversion would corrupt C3 A9 and reject C4 80; the fixture asserts both exact + byte sequences. Invalid UTF-8 replacement remains a runtime limitation. +- Fidelity is per observation. Runtime normalization or folding capability does + not establish that a particular request changed; use Unknown when unproved. +- Fastly original-name/count APIs provide partial information, so global order + is Unknown rather than Unavailable. Original-target acquisition still needs a + safe borrowed SDK accessor; no consuming-handle workaround is used. +- The consumer's runtime-boundary amendment classifies application-visible URI + paths and applies its own conservative cookie ambiguity policy. Toolkit metadata + does not impose that policy on other applications. Earlier original-wire + consumer requirements are historical context, not toolkit acceptance gates. +- Consumer integration still requires an immutable reviewed toolkit revision, + checks against the consumer's resolved graph, early native shortcut ordering, + authentication and terminal response finalization on all supported adapters. + Toolkit tests alone do not establish those outcomes. + +The hook runs after adapter conversion and eager ingress capture. It does not +avoid current buffering, logger/store bootstrap, transport rejection or outer +finalizers. Lazy capture and installer/cache redesign are separate performance +work; they are not needed to correct the fidelity contract. diff --git a/scripts/check_shared_dep_pins.sh b/scripts/check_shared_dep_pins.sh index 1b7a70fc..588b90e7 100755 --- a/scripts/check_shared_dep_pins.sh +++ b/scripts/check_shared_dep_pins.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# check_shared_dep_pins.sh — keep every `validator` pin on one major. +# check_shared_dep_pins.sh — keep shared manifest and fixture SDK pins in sync. # # `edgezero-core` re-exports the `Validate` derive, so any crate that # derives `AppConfig` links against the exact `validator` the core crate @@ -74,7 +74,8 @@ elif [[ "$got" != "$WANT" ]]; then VIOLATIONS=$((VIOLATIONS + 1)) fi -# `spin-sdk` is pinned in four places and shares no lockfile across them. +# `spin-sdk` pins span isolated workspaces and the scaffold; they do not +# share one lockfile. # A mismatch does not fail at the pin: the fixture resolves a second copy # of the SDK and the build dies on `expected IncomingBody, found # a different IncomingBody`, naming neither crate nor version. @@ -95,6 +96,7 @@ fi for file in \ examples/app-demo/Cargo.toml \ tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml \ + tests/fixtures/request-fidelity/Cargo.toml \ crates/edgezero-cli/src/generator.rs; do got="$(spin_major_in "$file")" if [[ -z "$got" ]]; then @@ -106,8 +108,67 @@ for file in \ fi done +# Fixture SDKs must use the same resolved packages as the workspace. Check +# both the exact manifest pins and the isolated lockfile, not just SDK majors. +# SDK pins intentionally accept only spaced inline tables (`name = { ... }`); +# other Cargo spellings fail closed rather than bypassing this guard. +locked_version_in() { + awk -v package="$2" ' + $0 == "[[package]]" { name=""; next } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { + version=$3; gsub(/"/, "", version); count++; found=version + } + END { if (count == 1) print found } + ' "$1" +} + +fixture_manifest="tests/fixtures/request-fidelity/Cargo.toml" +fixture_lock="tests/fixtures/request-fidelity/Cargo.lock" +for package in fastly worker wasm-bindgen spin-sdk; do + want="$(locked_version_in Cargo.lock "$package")" + pin="$(awk -v package="$package" ' + { sub(/[[:space:]]*#.*/, "") } + /^\[/ { in_deps=($0 == "[dependencies]") } + in_deps && $1 == package && $2 == "=" { + count++ + if (match($0, /[,{][[:space:]]*version[[:space:]]*=[[:space:]]*"=[^"]+"/)) { + version=substr($0, RSTART, RLENGTH) + sub(/^[^"]*"=/, "", version); sub(/"$/, "", version) + sub(/^[[:space:]]+/, "", version) + found=version + } + } + END { if (count == 1) print found } + ' "$fixture_manifest")" + locked="$(locked_version_in "$fixture_lock" "$package")" + if [[ -z "$want" || -z "$pin" || -z "$locked" ]]; then + echo "$fixture_manifest / $fixture_lock: violation: $package requires one exact pin and one resolved version in each lock" + VIOLATIONS=$((VIOLATIONS + 1)) + elif [[ "$pin" != "$want" || "$locked" != "$want" ]]; then + echo "$fixture_manifest / $fixture_lock: violation: $package pin=$pin lock=$locked, workspace lock has $want" + VIOLATIONS=$((VIOLATIONS + 1)) + fi +done + +# worker-build's release must match the fixture's Worker SDK, but its pin is +# declared once in metadata and consumed by CI and the native runner. +worker_build="$(awk ' + { sub(/[[:space:]]*#.*/, "") } + /^\[/ { in_pins=($0 == "[package.metadata.request-fidelity]") } + in_pins && $1 == "worker-build" && $2 == "=" { + value=$3; gsub(/"/, "", value); count++; found=value + } + END { if (count == 1) print found } +' "$fixture_manifest")" +worker_version="$(locked_version_in Cargo.lock worker)" +if [[ -z "$worker_build" || "$worker_build" != "$worker_version" ]]; then + echo "$fixture_manifest: violation: worker-build $worker_build, workspace worker is $worker_version" + VIOLATIONS=$((VIOLATIONS + 1)) +fi + if ((VIOLATIONS > 0)); then echo "check_shared_dep_pins: $VIOLATIONS pin(s) out of sync" >&2 exit 1 fi -echo "check_shared_dep_pins: validator pins agree ($WANT); spin-sdk majors agree ($SPIN_WANT)" +echo "check_shared_dep_pins: validator pins agree ($WANT); spin-sdk majors agree ($SPIN_WANT); fixture SDK locks and worker-build agree" diff --git a/scripts/test-request-fidelity.sh b/scripts/test-request-fidelity.sh new file mode 100755 index 00000000..8bdd2c46 --- /dev/null +++ b/scripts/test-request-fidelity.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +host_triple="$(rustc -vV | awk '$1 == "host:" { print $2 }')" +test -n "$host_triple" + +# Job control gives the bootstrap build its own process group on Linux and macOS. +set -m +bootstrap_pid="" +cleanup_bootstrap() { + if [[ -n "$bootstrap_pid" ]]; then + kill -TERM -- "-$bootstrap_pid" 2>/dev/null || true + kill -KILL -- "-$bootstrap_pid" 2>/dev/null || true + wait "$bootstrap_pid" 2>/dev/null || true + fi +} +trap cleanup_bootstrap EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +cargo build --manifest-path "$repo_root/tests/fixtures/request-fidelity-runner/Cargo.toml" \ + --locked --target "$host_triple" --target-dir "$repo_root/target/request-fidelity-runner" --quiet & +bootstrap_pid=$! +wait "$bootstrap_pid" +cleanup_bootstrap +bootstrap_pid="" +trap - EXIT INT TERM HUP +set +m +exec "$repo_root/target/request-fidelity-runner/$host_triple/debug/request-fidelity-runner" \ + "$@" --repo-root "$repo_root" diff --git a/scripts/test_shared_dep_pins.sh b/scripts/test_shared_dep_pins.sh new file mode 100644 index 00000000..7be673ce --- /dev/null +++ b/scripts/test_shared_dep_pins.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Exercise the real guard in isolated repositories, including malformed pins. +set -euo pipefail +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-pin-guard.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +FILES=( + scripts/check_shared_dep_pins.sh Cargo.toml Cargo.lock + examples/app-demo/Cargo.toml + .github/actions/deploy-fastly/tests/make-smoke-fixture.sh + crates/edgezero-cli/src/generator.rs + tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml + tests/fixtures/request-fidelity/Cargo.toml + tests/fixtures/request-fidelity/Cargo.lock +) +MANIFEST=tests/fixtures/request-fidelity/Cargo.toml +LOCK=tests/fixtures/request-fidelity/Cargo.lock + +reset_fixture() { + local file + rm -rf -- "$WORK_DIR/repo" + mkdir -p "$WORK_DIR/repo" + for file in "${FILES[@]}"; do + mkdir -p "$WORK_DIR/repo/$(dirname -- "$file")" + cp "$ROOT/$file" "$WORK_DIR/repo/$file" + done +} + +check_case() { + local name="$1" expected="$2" diagnostic="${3:-check_shared_dep_pins: validator pins agree}" status=0 + bash "$WORK_DIR/repo/scripts/check_shared_dep_pins.sh" >"$WORK_DIR/output" 2>&1 || status=$? + if [[ "$status" != "$expected" ]] || ! grep -Fq -- "$diagnostic" "$WORK_DIR/output"; then + printf 'FAIL %s: expected exit %s and diagnostic %s, got exit %s\n' "$name" "$expected" "$diagnostic" "$status" >&2 + cat "$WORK_DIR/output" >&2 + exit 1 + fi + printf 'PASS %s\n' "$name" +} + +mutate_manifest() { + local package="$1" mode="$2" + awk -v package="$package" -v mode="$mode" ' + $1 == package && /version[[:space:]]*=/ { + if (mode == "missing") next + if (mode == "duplicate") print + if (mode == "drift") sub(/version = "=[^"]+"/, "version = \"=99.0.0\"") + if (mode == "caret") sub(/version = "=/, "version = \"^") + if (mode == "misleading-key") { + match($0, /version = "=[^"]+"/) + exact=substr($0, RSTART, RLENGTH) + sub(/version = "=/, "version = \"^") + sub(/ }/, ", test_" exact " }") + } + if (mode == "comment") { + match($0, /version = "=[^"]+"/) + exact=substr($0, RSTART, RLENGTH) + sub(/version = "=/, "version = \"^") + $0=$0 " # " exact + } + if (mode == "duplicate-caret") { + print + sub(/version = "=/, "version = \"^") + } + if (mode == "space") sub(/version = "=/, "version = \"= ") + if (mode == "reordered") { + match($0, /version = "=[^"]+", /) + version=substr($0, RSTART, RLENGTH) + sub(/version = "=[^"]+", /, "") + sub(/ }/, ", " version "}") + $0=$0 " # trailing comment" + } + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" +} + +mutate_lock() { + local file="$1" package="$2" mode="$3" + awk -v package="$package" -v mode="$mode" ' + $0 == "[[package]]" { name="" } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { + if (mode == "missing") next + if (mode == "drift") $0="version = \"99.0.0\"" + if (mode == "duplicate") print "version = \"99.0.0\"\n\n[[package]]\nname = \"" package "\"" + } + { print } + ' "$WORK_DIR/repo/$file" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$file" +} + +mutate_shared_pin() { + local file="$1" package="$2" mode="$3" + awk -v package="$package" -v mode="$mode" ' + $0 ~ "^[[:space:]]*\"?" package "[[:space:]]*=" { + if (mode == "missing") next + sub(/[0-9]+(\.[0-9]+)*/, "99.0.0") + } + { print } + ' "$WORK_DIR/repo/$file" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$file" +} + +reset_fixture +check_case unchanged 0 +for package in validator spin-sdk; do + reset_fixture + mutate_shared_pin Cargo.toml "$package" missing + check_case "missing root $package" 1 "check_shared_dep_pins: no $package pin in root Cargo.toml" +done +for file in examples/app-demo/Cargo.toml .github/actions/deploy-fastly/tests/make-smoke-fixture.sh crates/edgezero-cli/src/generator.rs; do + for mode in missing drift; do + reset_fixture + mutate_shared_pin "$file" validator "$mode" + diagnostic="$file: violation: no validator pin found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: validator 99.0.0"; fi + if [[ "$file" == crates/edgezero-cli/src/generator.rs ]]; then + diagnostic="$file: violation: no validator seed found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: seeds validator 99.0.0"; fi + fi + check_case "validator $file $mode" 1 "$diagnostic" + done +done +for file in examples/app-demo/Cargo.toml tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml "$MANIFEST" crates/edgezero-cli/src/generator.rs; do + for mode in missing drift; do + reset_fixture + mutate_shared_pin "$file" spin-sdk "$mode" + diagnostic="$file: violation: no spin-sdk pin found" + if [[ "$mode" == drift ]]; then diagnostic="$file: violation: spin-sdk major 99"; fi + check_case "spin-sdk $file $mode" 1 "$diagnostic" + done +done +for package in fastly worker wasm-bindgen spin-sdk; do + version=$(awk -v package="$package" ' + $0 == "[[package]]" { name="" } + $1 == "name" { name=$3; gsub(/"/, "", name) } + name == package && $1 == "version" { gsub(/"/, "", $3); print $3 } + ' "$ROOT/Cargo.lock") + for mode in drift caret missing duplicate reordered space comment duplicate-caret misleading-key; do + reset_fixture + mutate_manifest "$package" "$mode" + expected=1 + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=99.0.0"; fi + if [[ "$mode" == reordered || "$mode" == space ]]; then expected=0; fi + if [[ "$expected" == 0 ]]; then diagnostic="check_shared_dep_pins: validator pins agree"; fi + check_case "$package $mode" "$expected" "$diagnostic" + done + for mode in drift duplicate missing; do + reset_fixture + mutate_lock "$LOCK" "$package" "$mode" + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=$version lock=99.0.0"; fi + check_case "$package fixture lock $mode" 1 "$diagnostic" + done + for mode in drift duplicate missing; do + reset_fixture + mutate_lock Cargo.lock "$package" "$mode" + diagnostic="violation: $package requires one exact pin" + if [[ "$mode" == drift ]]; then diagnostic="violation: $package pin=$version lock=$version, workspace lock has 99.0.0"; fi + check_case "$package workspace lock $mode" 1 "$diagnostic" + done +done +for mode in missing wrong duplicate misplaced; do + reset_fixture + awk -v mode="$mode" ' + $0 == "[package.metadata.request-fidelity]" && mode == "misplaced" { + print "[package.metadata.unrelated]"; next + } + $1 == "worker-build" { + if (mode == "missing") next + if (mode == "wrong") $0="worker-build = \"0.8.4\"" + if (mode == "duplicate") print + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" + check_case "worker-build $mode" 1 "violation: worker-build" +done + +for mode in section-comment attached-comment spaced-comment unrelated-section; do + reset_fixture + awk -v mode="$mode" ' + $0 == "[package.metadata.request-fidelity]" { + if (mode == "section-comment") $0=$0 " # tools" + if (mode == "unrelated-section") print "[package.metadata.unrelated]\nworker-build = \"99.0.0\"\n" + } + $1 == "worker-build" { + if (mode == "attached-comment") $0=$0 "# tools" + if (mode == "spaced-comment") $0=$0 " # tools" + } + { print } + ' "$WORK_DIR/repo/$MANIFEST" >"$WORK_DIR/changed" + mv "$WORK_DIR/changed" "$WORK_DIR/repo/$MANIFEST" + check_case "worker-build $mode" 0 +done diff --git a/tests/fixtures/request-fidelity-runner/Cargo.lock b/tests/fixtures/request-fidelity-runner/Cargo.lock new file mode 100644 index 00000000..4d105aa3 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/Cargo.lock @@ -0,0 +1,346 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "request-fidelity-runner" +version = "0.1.0" +dependencies = [ + "serde_json", + "tempfile", + "tokio", + "toml", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/request-fidelity-runner/Cargo.toml b/tests/fixtures/request-fidelity-runner/Cargo.toml new file mode 100644 index 00000000..90899968 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "request-fidelity-runner" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +serde_json = "=1.0.150" +tempfile = "=3.27.0" +tokio = { version = "=1.52.3", features = ["macros", "rt-multi-thread", "signal"] } +toml = "=1.1.2" + +[lints.rust] +unsafe_code = "deny" diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs new file mode 100644 index 00000000..5a3f5a68 --- /dev/null +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -0,0 +1,1724 @@ +//! Fixed, loopback-only HTTP ingress probes for the three WASM adapters. + +use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::env; +use std::fs::{self, File}; +use std::io::Seek as _; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; +use tokio::signal::unix::{SignalKind, signal}; + +const RESPONSE_LIMIT: usize = 1024 * 1024; +const HEADER_LIMIT: usize = 64 * 1024; +const POLL_INTERVAL: Duration = Duration::from_millis(100); +type Cancelled = Arc; + +struct Options { + platforms: Vec<&'static str>, + skip_build: bool, + readiness_timeout: Duration, + record_observations: bool, + root: PathBuf, +} + +impl Options { + fn parse() -> io::Result { + let mut args = env::args().skip(1); + let first = args.next(); + let (root, platform) = if first.as_deref() == Some("--repo-root") { + let root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + (root, args.next()) + } else { + ( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize()?, + first, + ) + }; + let platforms = match platform.as_deref() { + Some("all") => vec!["fastly", "cloudflare", "spin"], + Some("fastly") => vec!["fastly"], + Some("cloudflare") => vec!["cloudflare"], + Some("spin") => vec!["spin"], + _ => { + return Err(io::Error::other( + "usage: request-fidelity-runner [--skip-build] [--readiness-timeout SECONDS] [--record-observations]", + )); + } + }; + let mut options = Self { + platforms, + skip_build: false, + readiness_timeout: Duration::from_secs(40), + record_observations: false, + root, + }; + while let Some(argument) = args.next() { + match argument.as_str() { + "--repo-root" => { + options.root = PathBuf::from( + args.next() + .ok_or_else(|| io::Error::other("missing repository root"))?, + ) + .canonicalize()?; + } + "--skip-build" => options.skip_build = true, + "--record-observations" => options.record_observations = true, + "--readiness-timeout" => { + let seconds = args + .next() + .ok_or_else(|| io::Error::other("missing readiness timeout"))? + .parse::() + .map_err(io::Error::other)?; + options.readiness_timeout = + Duration::try_from_secs_f64(seconds).map_err(io::Error::other)?; + if options.readiness_timeout.is_zero() { + return Err(io::Error::other("readiness timeout must be positive")); + } + } + _ => return Err(io::Error::other(format!("unknown argument: {argument}"))), + } + } + Ok(options) + } +} + +#[tokio::main] +async fn main() -> ExitCode { + match execute().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let _result = writeln!(io::stderr(), "request fidelity: {error}"); + ExitCode::FAILURE + } + } +} + +async fn execute() -> io::Result<()> { + let options = Options::parse()?; + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; + let mut worker = tokio::task::spawn_blocking(move || run(options, &worker_cancelled)); + tokio::select! { + result = &mut worker => result.map_err(io::Error::other)?, + _ = interrupt.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGINT: owned children stopped")) + } + _ = terminate.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGTERM: owned children stopped")) + } + _ = hangup.recv() => { + cancelled.store(true, Ordering::SeqCst); + let _result = worker.await; + Err(io::Error::new(io::ErrorKind::Interrupted, "SIGHUP: owned children stopped")) + } + } +} + +fn check_cancelled(cancelled: &Cancelled) -> io::Result<()> { + if cancelled.load(Ordering::SeqCst) { + Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")) + } else { + Ok(()) + } +} + +struct OwnedProcess { + child: Child, + group: u32, +} + +impl OwnedProcess { + fn spawn(command: &mut Command) -> io::Result { + command.process_group(0).stdin(Stdio::null()); + let child = command.spawn().map_err(|error| { + io::Error::new( + error.kind(), + format!("{}: {error}", command.get_program().to_string_lossy()), + ) + })?; + let group = child.id(); + Ok(Self { child, group }) + } + + fn wait(&mut self, cancelled: &Cancelled) -> io::Result<()> { + loop { + check_cancelled(cancelled)?; + if let Some(status) = self.child.try_wait()? { + return if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "fixture command exits with {status}" + ))) + }; + } + thread::sleep(POLL_INTERVAL); + } + } +} + +fn signal_group(group: u32, name: &str) { + let _result = Command::new("/bin/kill") + .args([name, "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + // Signal the owned group even if its direct wrapper already exited. + signal_group(self.group, "-TERM"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if self.child.try_wait().is_ok_and(|status| status.is_some()) + || Instant::now() >= deadline + { + break; + } + thread::sleep(POLL_INTERVAL); + } + signal_group(self.group, "-KILL"); + // Also stop the direct child if the external group signal failed. + let _result = self.child.kill(); + let _result = self.child.wait(); + } +} + +fn command(fixture: &Path, program: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(program); + command + .args(arguments) + .current_dir(fixture) + .env("WRANGLER_SEND_METRICS", "false"); + command +} + +fn configure_worker_build( + command: &mut Command, + fixture: &Path, + bindgen: &Path, + cancelled: &Cancelled, +) -> io::Result<()> { + let lock: toml::Value = toml::from_str(&fs::read_to_string(fixture.join("Cargo.lock"))?) + .map_err(io::Error::other)?; + let versions: Vec<_> = lock + .get("package") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + .filter(|package| package.get("name").and_then(toml::Value::as_str) == Some("wasm-bindgen")) + .filter_map(|package| package.get("version").and_then(toml::Value::as_str)) + .collect(); + let [version] = versions.as_slice() else { + return Err(io::Error::other( + "fixture lock requires one wasm-bindgen version", + )); + }; + let bindgen = bindgen.canonicalize().map_err(|error| { + io::Error::new( + error.kind(), + format!("wasm-bindgen {}: {error}", bindgen.display()), + ) + })?; + verify_version(fixture, &bindgen.to_string_lossy(), version, cancelled)?; + let esbuild = fixture.join("node_modules/.bin/esbuild"); + let esbuild = esbuild.canonicalize().map_err(|error| { + io::Error::new( + error.kind(), + format!( + "npm-locked esbuild {}: {error}; run npm ci", + esbuild.display() + ), + ) + })?; + let npm_lock: Value = serde_json::from_slice(&fs::read(fixture.join("package-lock.json"))?) + .map_err(io::Error::other)?; + let version = npm_lock + .pointer("/packages/node_modules~1esbuild/version") + .and_then(Value::as_str) + .ok_or_else(|| io::Error::other("missing npm-locked esbuild version"))?; + let actual = run_command( + fixture, + &esbuild.to_string_lossy(), + &["--version"], + cancelled, + true, + )?; + if actual.trim() != version { + return Err(io::Error::other(format!( + "esbuild requires version {version}; found {:?}", + actual.trim() + ))); + } + command + .env("WASM_BINDGEN_BIN", bindgen) + .env("ESBUILD_BIN", esbuild); + Ok(()) +} + +fn run_command( + fixture: &Path, + program: &str, + arguments: &[&str], + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + let mut command = command(fixture, program, arguments); + run_prepared_command(&mut command, cancelled, capture) +} + +fn run_prepared_command( + command: &mut Command, + cancelled: &Cancelled, + capture: bool, +) -> io::Result { + let description = std::iter::once(command.get_program()) + .chain(command.get_args()) + .map(|value| value.to_string_lossy()) + .collect::>() + .join(" "); + let describe = + |error: io::Error| io::Error::new(error.kind(), format!("{description}: {error}")); + check_cancelled(cancelled).map_err(describe)?; + // A file avoids pipe backpressure while waiting interruptibly for build tools. + let mut output = tempfile::tempfile().map_err(describe)?; + let mut stderr = tempfile::tempfile().map_err(describe)?; + command.stdout(output.try_clone().map_err(describe)?); + command.stderr(if capture { + Stdio::from(stderr.try_clone().map_err(describe)?) + } else { + Stdio::inherit() + }); + let result = OwnedProcess::spawn(command) + .map_err(describe)? + .wait(cancelled); + output.rewind().map_err(describe)?; + if capture { + let mut bytes = Vec::new(); + output.read_to_end(&mut bytes).map_err(describe)?; + stderr.rewind().map_err(describe)?; + let mut diagnostics = Vec::new(); + stderr.read_to_end(&mut diagnostics).map_err(describe)?; + result.map_err(|error| { + describe(io::Error::new( + error.kind(), + format!( + "{error}; stdout: {}; stderr: {}", + String::from_utf8_lossy(&bytes), + String::from_utf8_lossy(&diagnostics) + ), + )) + })?; + io::stderr().write_all(&diagnostics).map_err(describe)?; + String::from_utf8(bytes).map_err(|error| { + describe(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{error}; output: {}", + String::from_utf8_lossy(error.as_bytes()) + ), + )) + }) + } else { + io::copy(&mut output, &mut io::stderr()).map_err(describe)?; + result.map_err(describe)?; + Ok(String::new()) + } +} + +fn verify_version( + fixture: &Path, + program: &str, + expected: &str, + cancelled: &Cancelled, +) -> io::Result<()> { + let output = run_command(fixture, program, &["--version"], cancelled, true)?; + let actual = if program == "worker-build" { + output.trim() + } else { + output.split_whitespace().nth(1).unwrap_or("") + }; + if actual != expected { + return Err(io::Error::other(format!( + "{program} requires version {expected}; found {actual:?} (output: {:?})", + output.trim() + ))); + } + Ok(()) +} + +fn fixture_tool_pin(fixture: &Path, tool: &str) -> io::Result { + let manifest: toml::Value = toml::from_str(&fs::read_to_string(fixture.join("Cargo.toml"))?) + .map_err(io::Error::other)?; + manifest + .get("package") + .and_then(|package| package.get("metadata")) + .and_then(|metadata| metadata.get("request-fidelity")) + .and_then(|pins| pins.get(tool)) + .and_then(toml::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| io::Error::other(format!("missing fixture tool pin: {tool}"))) +} + +fn resolve_wasm_bindgen( + override_path: Option, + path: &std::ffi::OsStr, +) -> io::Result { + use std::os::unix::fs::PermissionsExt as _; + let executable = + |metadata: &fs::Metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0; + if let Some(path) = override_path { + let path = PathBuf::from(path); + let metadata = path.metadata().map_err(|error| { + io::Error::new( + error.kind(), + format!("WASM_BINDGEN_BIN {}: {error}", path.display()), + ) + })?; + if !executable(&metadata) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "WASM_BINDGEN_BIN {} must be an executable file", + path.display() + ), + )); + } + return Ok(path); + } + env::split_paths(path) + .map(|directory| directory.join("wasm-bindgen")) + .find(|path| path.metadata().is_ok_and(|metadata| executable(&metadata))) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, + "wasm-bindgen not found on PATH; install the fixture-locked CLI or set WASM_BINDGEN_BIN")) +} + +fn build(platform: &str, fixture: &Path, cancelled: &Cancelled) -> io::Result<()> { + if platform == "cloudflare" { + let pin = fixture_tool_pin(fixture, "worker-build")?; + verify_version(fixture, "worker-build", &pin, cancelled)?; + run_command(fixture, "npm", &["ci"], cancelled, false)?; + let lock = fs::read(fixture.join("Cargo.lock"))?; + let mut command = command( + fixture, + "worker-build", + &[ + ".", + "--release", + "--no-opt", + "--out-dir", + "build/cloudflare", + "--", + "--features", + "cloudflare", + "--locked", + "--target-dir", + "target/cloudflare", + ], + ); + let bindgen = resolve_wasm_bindgen( + env::var_os("WASM_BINDGEN_BIN"), + &env::var_os("PATH").unwrap_or_default(), + )?; + configure_worker_build(&mut command, fixture, &bindgen, cancelled)?; + run_prepared_command(&mut command, cancelled, false)?; + if fs::read(fixture.join("Cargo.lock"))? != lock { + return Err(io::Error::other( + "worker-build changes the locked fixture dependencies", + )); + } + } else { + let mut arguments = vec![ + "build", + "--locked", + "--release", + "--features", + platform, + "--target-dir", + ]; + arguments.extend(if platform == "fastly" { + [ + "target/fastly", + "--target", + "wasm32-wasip1", + "--bin", + "request-fidelity-fastly", + ] + .as_slice() + } else { + ["target/spin", "--target", "wasm32-wasip2", "--lib"].as_slice() + }); + run_command(fixture, "cargo", &arguments, cancelled, false)?; + } + Ok(()) +} + +fn with_runtime_log( + result: io::Result, + log_path: &Path, + output: &mut impl Write, +) -> io::Result { + if result.is_err() + && let Ok(log) = fs::read(log_path) + { + let log = String::from_utf8_lossy(&log); + let _result = writeln!(output, "{log}"); + } + result +} + +fn report_observation_diff( + output: &mut impl Write, + platform: &str, + expected: &Value, + observed: &Value, +) -> io::Result<()> { + let cases: BTreeSet<_> = expected + .as_object() + .into_iter() + .flat_map(|cases| cases.keys()) + .chain( + observed + .as_object() + .into_iter() + .flat_map(|cases| cases.keys()), + ) + .collect(); + for case in cases { + if expected.get(case) != observed.get(case) { + writeln!( + output, + "{platform}/{case}: expected={} observed={}", + expected.get(case).unwrap_or(&Value::Null), + observed.get(case).unwrap_or(&Value::Null) + )?; + } + } + Ok(()) +} + +fn run(options: Options, cancelled: &Cancelled) -> io::Result<()> { + let fixture = options.root.join("tests/fixtures/request-fidelity"); + let mut observations = json!({}); + for platform in &options.platforms { + check_cancelled(cancelled)?; + if *platform == "spin" { + let pin = fixture_tool_pin(&fixture, "spin-cli")?; + verify_version(&fixture, "spin", &pin, cancelled)?; + } else if *platform == "fastly" { + let versions = fs::read_to_string(options.root.join(".tool-versions"))?; + let pin = versions + .lines() + .find_map(|line| line.strip_prefix("viceroy ")) + .ok_or_else(|| io::Error::other("missing Viceroy pin"))?; + verify_version(&fixture, "viceroy", pin.trim(), cancelled)?; + } + if !options.skip_build { + build(platform, &fixture, cancelled)?; + } + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + writeln!(io::stderr(), "{platform} loopback fixture uses port {port}")?; + let address = format!("127.0.0.1:{port}"); + let port_string = port.to_string(); + let scratch_string = scratch.path().to_string_lossy(); + let log_dir = scratch.path().join("logs"); + let log_dir_string = log_dir.to_string_lossy(); + let mut runtime = match *platform { + "fastly" => command( + &fixture, + "viceroy", + &[ + "serve", + "-C", + "fastly.toml", + "--addr", + &address, + "target/fastly/wasm32-wasip1/release/request-fidelity-fastly.wasm", + ], + ), + "spin" => command( + &fixture, + "spin", + &[ + "up", + "--from", + "spin.toml", + "--listen", + &address, + "--state-dir", + &scratch_string, + "--log-dir", + &log_dir_string, + ], + ), + _ => command( + &fixture, + "node_modules/.bin/wrangler", + &[ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + &port_string, + ], + ), + }; + let log_path = scratch.path().join("runtime.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + options.readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() { + return Err(io::Error::other("runtime exits before readiness")); + } + if Instant::now() >= deadline { + return Err(io::Error::other("runtime readiness deadline exceeded")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let mut observed = probes(platform, port, cancelled)?; + if *platform == "cloudflare" { + let routing = + cloudflare_routing_probes(&fixture, cancelled, options.readiness_timeout)?; + observed + .as_object_mut() + .expect("probe results are objects") + .extend(routing); + } + if !options.record_observations { + let expected: Value = + serde_json::from_slice(&fs::read(fixture.join("observations.json"))?) + .map_err(io::Error::other)?; + if expected.get(*platform) != Some(&observed) { + report_observation_diff( + &mut io::stderr(), + platform, + &expected[*platform], + &observed, + )?; + return Err(io::Error::other(format!( + "{platform}: full runtime observations changed; inspect before updating baseline" + ))); + } + } + Ok(observed) + })(); + drop(process); + observations[*platform] = with_runtime_log(result, &log_path, &mut io::stderr())?; + } + serde_json::to_writer_pretty(io::stdout(), &observations).map_err(io::Error::other)?; + writeln!(io::stdout()) +} + +fn request_packet(case: &Case) -> Vec { + let mut packet = Vec::new(); + packet.extend_from_slice(case.method); + packet.push(b' '); + packet.extend_from_slice(case.target); + packet.extend_from_slice(b" HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: "); + packet.extend_from_slice(case.name.as_bytes()); + packet.extend_from_slice(b"\r\n"); + for (name, value) in &case.headers { + packet.extend_from_slice(name); + packet.extend_from_slice(b": "); + packet.extend_from_slice(value); + packet.extend_from_slice(b"\r\n"); + } + packet.extend_from_slice(b"\r\n"); + packet.extend_from_slice(case.body); + packet +} + +struct TimedSocket<'a> { + socket: TcpStream, + cancelled: &'a Cancelled, + deadline: Instant, +} + +impl Read for TimedSocket<'_> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + loop { + // Buffered/exact reads retry Interrupted, so cancellation must be terminal. + if self.cancelled.load(Ordering::SeqCst) { + return Err(io::Error::other("cancelled")); + } + if Instant::now() >= self.deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP response deadline exceeded", + )); + } + match self.socket.read(bytes) { + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + result => return result, + } + } + } +} + +fn exchange( + port: u16, + case: &Case, + cancelled: &Cancelled, + deadline: Instant, +) -> io::Result<(u16, Value)> { + check_cancelled(cancelled)?; + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let timeout = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(5)); + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "HTTP connection deadline exceeded", + )); + } + let mut socket = TcpStream::connect_timeout(&address, timeout)?; + socket.set_write_timeout(Some(timeout))?; + socket.set_read_timeout(Some(POLL_INTERVAL))?; + socket.write_all(&request_packet(case))?; + let mut reader = BufReader::new(TimedSocket { + socket, + cancelled, + deadline, + }); + let (status, payload) = read_response(&mut reader)?; + Ok(( + status, + serde_json::from_slice(&payload).unwrap_or(Value::Null), + )) +} + +fn http_line(reader: &mut impl BufRead, remaining: &mut usize) -> io::Result { + let mut bytes = Vec::new(); + reader + .take(u64::try_from(*remaining).map_err(io::Error::other)?) + .read_until(b'\n', &mut bytes)?; + *remaining -= bytes.len(); + if !bytes.ends_with(b"\r\n") { + return Err(io::Error::other("missing CRLF or oversized HTTP header")); + } + bytes.truncate(bytes.len() - 2); + String::from_utf8(bytes).map_err(io::Error::other) +} + +fn read_response(reader: &mut impl BufRead) -> io::Result<(u16, Vec)> { + let mut remaining = HEADER_LIMIT; + loop { + let status_line = http_line(reader, &mut remaining)?; + let mut fields = status_line.split_whitespace(); + if !matches!(fields.next(), Some("HTTP/1.1" | "HTTP/1.0")) { + return Err(io::Error::other("invalid HTTP response status line")); + } + let status = fields + .next() + .ok_or_else(|| io::Error::other("missing response status"))? + .parse::() + .map_err(io::Error::other)?; + let mut length = None; + let mut chunked = false; + loop { + let line = http_line(reader, &mut remaining)?; + if line.is_empty() { + break; + } + let (name, value) = line + .split_once(':') + .ok_or_else(|| io::Error::other("invalid HTTP response header"))?; + if name.eq_ignore_ascii_case("content-length") { + let next = value.trim().parse::().map_err(io::Error::other)?; + if length.is_some_and(|previous| previous != next) { + return Err(io::Error::other("conflicting response lengths")); + } + length = Some(next); + } + if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value + .split(',') + .any(|item| item.trim().eq_ignore_ascii_case("chunked")); + } + } + if (100..200).contains(&status) { + continue; + } + let mut body = Vec::new(); + if chunked { + loop { + let line = http_line(reader, &mut remaining)?; + let size = usize::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16) + .map_err(io::Error::other)?; + if size == 0 { + while !http_line(reader, &mut remaining)?.is_empty() {} + break; + } + append_body(reader, &mut body, size)?; + let mut delimiter = [0; 2]; + reader.read_exact(&mut delimiter)?; + if delimiter != *b"\r\n" { + return Err(io::Error::other("invalid chunk delimiter")); + } + } + } else if let Some(length) = length { + append_body(reader, &mut body, length)?; + } else { + reader + .take(u64::try_from(RESPONSE_LIMIT + 1).map_err(io::Error::other)?) + .read_to_end(&mut body)?; + if body.len() > RESPONSE_LIMIT { + return Err(io::Error::other("oversized HTTP response body")); + } + } + return Ok((status, body)); + } +} + +fn append_body(reader: &mut impl Read, body: &mut Vec, length: usize) -> io::Result<()> { + let end = body + .len() + .checked_add(length) + .filter(|end| *end <= RESPONSE_LIMIT) + .ok_or_else(|| io::Error::other("oversized HTTP response body"))?; + let start = body.len(); + body.resize(end, 0); + reader.read_exact(&mut body[start..]) +} + +fn ready_case() -> Case { + Case { + name: "ready", + method: b"GET", + target: b"/reserved", + headers: Vec::new(), + body: b"", + } +} + +fn cases() -> Vec { + let mut cases: Vec<_> = [ + "extension", + "dot", + "encoded-dot", + "slashes", + "absolute", + "high-byte", + "utf8", + "unicode", + "repeat", + "compound-invalid", + "compound-valid", + "spoof", + "length-identical", + "length-conflict", + "te-cl", + "chunked", + "continue", + ] + .into_iter() + .map(|name| Case { + name, + ..ready_case() + }) + .collect(); + for case in &mut cases { + match case.name { + "extension" => case.method = b"EXAMPLE-METHOD", + "dot" => case.target = b"/reserved/../ordinary", + "encoded-dot" => case.target = b"/reserved/%2e%2e/ordinary", + "slashes" => case.target = b"/reserved//%2F?q=a%2Fb", + "absolute" => case.target = b"http://example.com/reserved?fixed=1", + "high-byte" => case.headers = vec![(b"Cookie", b"a=\xff")], + "utf8" => case.headers = vec![(b"Cookie", b"a=\xc3\xa9")], + "unicode" => case.headers = vec![(b"Cookie", b"a=\xc4\x80")], + "repeat" => { + case.headers = vec![ + (b"Cookie", b"a=1"), + (b"Origin", b"https://example.com"), + (b"X-Control", b"first"), + (b"COOKIE", b"b=2"), + (b"Origin", b"https://other.example.com"), + (b"x-control", b"second"), + ] + } + "compound-invalid" | "compound-valid" => { + case.headers = vec![ + (b"Cookie", b"diagnostics=1"), + ( + b"Cookie", + if case.name == "compound-invalid" { + b"a=\xff" + } else { + b"a=\xef\xbf\xbd" + }, + ), + ]; + } + "spoof" => { + case.headers = vec![ + (b"Origin", b"https://spoof.example.com"), + (b"Forwarded", b"proto=https;host=spoof.example.com"), + (b"spin-full-url", b"https://spoof.example.com/reserved"), + (b"spin-client-addr", b"192.0.2.1:1"), + ] + } + "length-identical" | "length-conflict" => { + case.method = b"POST"; + case.headers = vec![ + (b"Content-Length", b"0"), + ( + b"Content-Length", + if case.name == "length-identical" { + b"0" + } else { + b"1" + }, + ), + ]; + } + "te-cl" | "chunked" => { + case.method = b"POST"; + case.headers = vec![(b"Transfer-Encoding", b"chunked")]; + if case.name == "te-cl" { + case.headers.push((b"Content-Length", b"0")); + } + case.body = b"0\r\n\r\n"; + } + "continue" => { + case.method = b"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"] == (platform != "fastly") + && verdict["spoofed_origin_ignored"] == true + && verdict["origin_available"] == true + && verdict["origin_matches_expected"] == true, + platform, + case, + "order capability and origin provenance must remain explicit", + )?; + require( + verdict["target_available"] == (platform != "fastly"), + platform, + case, + "unexpected target availability", + )?; + if platform != "fastly" { + require( + verdict["target_matches_runtime"] == true, + platform, + case, + "target must match runtime", + )?; + } + match case { + "extension" => require( + verdict["extension_method"] == true, + platform, + case, + "extension token lost", + )?, + "high-byte" if platform == "cloudflare" => { + require( + verdict["cookie_replacement"] == true && verdict["cookie_octets"] == false, + platform, + case, + "expected documented UTF-8 replacement, not original FF preservation", + )?; + writeln!( + io::stderr(), + "UNSUPPORTED local workerd replaces invalid UTF-8 FF before Rust; hook sees UTF-8 replacement bytes." + )?; + } + "high-byte" => require( + verdict["cookie_octets"] == true, + platform, + case, + "original cookie FF lost", + )?, + "utf8" => require( + verdict["cookie_utf8"] == true && verdict["cookie_latin1"] == false, + platform, + case, + "valid UTF-8 C3 A9 must remain exact", + )?, + "unicode" => require( + verdict["cookie_unicode"] == true, + platform, + case, + "valid UTF-8 C4 80 must remain exact", + )?, + "repeat" if platform != "cloudflare" => require( + verdict["cookie_count"] == 2 + && verdict["same_name_order"] == true + && verdict["origin_count"] == 2 + && verdict["control_count"] == 2, + platform, + case, + "ordinary repeated fields or same-name order lost", + )?, + "repeat" => require( + verdict["cookie_count"] == 1 + && verdict["cookie_repeat_comma_joined"] == true + && verdict["cookie_repeat_semicolon_joined"] == false, + platform, + case, + "must establish exact comma joining rather than infer cookie semantics from counts", + )?, + "compound-invalid" | "compound-valid" if platform == "cloudflare" => require( + verdict["cookie_count"] == 1 && verdict["cookie_compound_comma_joined"] == true, + platform, + case, + "invalid FF and original UTF-8 replacement must have the same documented runtime representation", + )?, + "compound-invalid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_original"] == true, + platform, + case, + "must retain valid-looking diagnostics alongside unrelated original FF", + )?, + "compound-valid" => require( + verdict["cookie_count"] == 2 && verdict["cookie_compound_runtime_utf8"] == true, + platform, + case, + "must retain the client's original valid UTF-8 replacement bytes", + )?, + "te-cl" | "chunked" => { + let count = verdict["transfer_encoding_count"].as_u64(); + let chunked = verdict["transfer_encoding_chunked"].as_bool(); + require( + matches!(count, Some(0 | 1)) && chunked == count.map(|count| count == 1), + platform, + case, + "must record whether chunked Transfer-Encoding survives conversion", + )?; + } + _ => {} + } + return Ok(()); + } + match (platform, case, status) { + ("cloudflare", "length-identical" | "length-conflict", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler duplicate Content-Length ingress (500); no hook verdict." + )?, + ("cloudflare", "absolute", 500) => writeln!( + io::stderr(), + "UNSUPPORTED local Wrangler absolute-form ingress (500); no hook verdict." + )?, + ("cloudflare", "extension", 501) => writeln!( + io::stderr(), + "PENDING local workerd rejects extension methods before Worker invocation (501)." + )?, + ("spin", "high-byte" | "compound-invalid", 500) => writeln!( + io::stderr(), + "PENDING Spin rejects non-UTF-8 header bytes before component invocation (500)." + )?, + (_, "length-identical" | "length-conflict" | "te-cl" | "absolute", 400) => {} + _ => { + return Err(io::Error::other(format!( + "{platform}/{case}: unexpected HTTP status {status}" + ))); + } + } + require( + verdict.is_null(), + platform, + case, + "transport rejection must have no hook verdict", + ) +} + +fn cloudflare_routing_probes( + fixture: &Path, + cancelled: &Cancelled, + readiness_timeout: Duration, +) -> io::Result> { + let scratch = tempfile::tempdir()?; + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + let address = format!("http=127.0.0.1:{port}"); + let mut runtime = command( + fixture, + "node_modules/.bin/workerd", + &["serve", "workerd.capnp", "--socket-addr", &address], + ); + let log_path = scratch.path().join("workerd.log"); + let log = File::create(&log_path)?; + runtime.stdout(log.try_clone()?).stderr(log); + let mut process = OwnedProcess::spawn(&mut runtime)?; + let result = (|| { + let deadline = Instant::now() + readiness_timeout; + loop { + check_cancelled(cancelled)?; + if process.child.try_wait()?.is_some() || Instant::now() >= deadline { + return Err(io::Error::other("direct workerd readiness failed")); + } + if exchange(port, &ready_case(), cancelled, deadline) + .is_ok_and(|(status, _)| status == 200) + { + break; + } + thread::sleep(POLL_INTERVAL); + } + let mut observed = serde_json::Map::new(); + let mut failures = Vec::new(); + for (name, target) in [ + ("route-unicode", b"/caf\xc3\xa9".as_slice()), + ("route-backslash", b"/a\\b".as_slice()), + ] { + let case = Case { + name, + target, + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + if status != 200 + || verdict + != json!({ + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true, + }) + { + failures.push(format!("{name}: HTTP {status}, verdict {verdict}")); + } + observed.insert( + name.to_owned(), + json!({"status": status, "verdict": verdict}), + ); + } + require( + failures.is_empty(), + "cloudflare", + "no-hook routing", + &failures.join("; "), + )?; + Ok(observed) + })(); + drop(process); + with_runtime_log(result, &log_path, &mut io::stderr()) +} + +fn probes(platform: &str, port: u16, cancelled: &Cancelled) -> io::Result { + let mut observed = json!({}); + let cases = cases(); + require( + cases.len() == 17, + platform, + "fixture", + "must execute all 17 fixed ingress cases", + )?; + for case in cases { + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + verify_observation(platform, case.name, status, &verdict)?; + observed[case.name] = json!({ "status": status, "verdict": verdict }); + } + if platform == "fastly" { + let case = Case { + name: "snapshot-origin", + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + verify_observation(platform, case.name, status, &verdict)?; + observed[case.name] = json!({"status": status, "verdict": verdict}); + for (name, target) in [ + ("native", b"/native".as_slice()), + ("native-dot", b"/reserved/../native".as_slice()), + ] { + let case = Case { + name, + target, + ..ready_case() + }; + let (status, verdict) = exchange( + port, + &case, + cancelled, + Instant::now() + Duration::from_secs(5), + )?; + require( + status == 200 && verdict == json!({"native": true, "raw_target_supported": false}), + platform, + name, + "native shortcut must report unavailable raw-target support", + )?; + observed[name] = json!({"status": status, "verdict": verdict}); + } + writeln!( + io::stderr(), + "PENDING Fastly original-target and native-shortcut exclusion: safe SDK accessor required." + )?; + } + Ok(observed) +} + +struct Case { + name: &'static str, + method: &'static [u8], + target: &'static [u8], + headers: Vec<(&'static [u8], &'static [u8])>, + body: &'static [u8], +} + +#[cfg(test)] +mod tests { + use super::{ + Case, HEADER_LIMIT, OwnedProcess, TimedSocket, cases, cloudflare_routing_probes, + read_response, request_packet, verify_observation, + }; + use serde_json::json; + use std::io::{self, BufRead as _, BufReader, Cursor, Write as _}; + use std::net::{TcpListener, TcpStream}; + use std::path::Path; + use std::process::{Command, Stdio}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn missing_program_diagnostic_names_the_executable() { + let program = "/missing-edgezero-runtime"; + let error = OwnedProcess::spawn(&mut Command::new(program)) + .err() + .expect("missing program must fail"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains(program)); + } + + #[test] + fn command_failures_include_arguments_and_captured_diagnostics() { + let directory = tempfile::tempdir().expect("command directory"); + let cancelled = Arc::new(AtomicBool::new(false)); + for (script, kind, diagnostics) in [ + ( + "printf 'probe stdout'; printf 'probe stderr' >&2; exit 3", + io::ErrorKind::Other, + "probe stdout", + ), + ("printf '\\377'", io::ErrorKind::InvalidData, "�"), + ] { + let error = super::run_command( + directory.path(), + "/bin/sh", + &["-c", script], + &cancelled, + true, + ) + .expect_err("command must fail"); + assert_eq!(error.kind(), kind); + let message = error.to_string(); + assert!(message.contains("/bin/sh -c"), "{message}"); + assert!(message.contains(diagnostics), "{message}"); + if kind == io::ErrorKind::Other { + assert!(message.contains("probe stderr"), "{message}"); + assert!(message.contains('3'), "{message}"); + } + } + } + + #[test] + fn captured_success_keeps_stdout_separate_from_stderr() { + let directory = tempfile::tempdir().expect("command directory"); + let output = super::run_command( + directory.path(), + "/bin/sh", + &["-c", "printf 'tool 1.2.3'; printf 'tool warning' >&2"], + &Arc::new(AtomicBool::new(false)), + true, + ) + .expect("successful command"); + assert_eq!(output, "tool 1.2.3"); + } + + #[test] + fn bindgen_resolution_skips_nonexecutables_and_honors_overrides() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("tool directories"); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir_all(&first).expect("first directory"); + std::fs::create_dir_all(&second).expect("second directory"); + let shadow = first.join("wasm-bindgen"); + let executable = second.join("wasm-bindgen"); + for (file, mode) in [(&shadow, 0o644), (&executable, 0o755)] { + std::fs::write(file, "#!/bin/sh\nexit 0\n").expect("tool"); + std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)) + .expect("permissions"); + } + let path = std::env::join_paths([&first, &second]).expect("PATH"); + assert_eq!( + super::resolve_wasm_bindgen(None, &path).expect("executable lookup"), + executable + ); + let override_tool = directory.path().join("override-wasm-bindgen"); + std::fs::copy(&executable, &override_tool).expect("distinct override outside PATH"); + assert_eq!( + super::resolve_wasm_bindgen(Some(override_tool.clone().into_os_string()), &path) + .expect("override"), + override_tool + ); + for invalid in [shadow, directory.path().join("missing"), first] { + let error = super::resolve_wasm_bindgen(Some(invalid.into_os_string()), &path) + .expect_err("invalid override must not fall back to PATH"); + assert!(error.to_string().contains("WASM_BINDGEN_BIN"), "{error}"); + } + std::fs::remove_file(&executable).expect("remove executable"); + let error = super::resolve_wasm_bindgen(None, &path).expect_err("no executable"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn version_diagnostic_includes_expected_and_actual() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("tool directory"); + let tool = directory.path().join("fixture-tool"); + std::fs::write(&tool, "#!/bin/sh\necho 'fixture-tool 9.9.9'\n").expect("binary"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + let cancelled = Arc::new(AtomicBool::new(false)); + let error = super::verify_version( + directory.path(), + &tool.to_string_lossy(), + "expected-pin", + &cancelled, + ) + .expect_err("wrong tool version"); + assert!(error.to_string().contains("expected-pin")); + assert!(error.to_string().contains("9.9.9")); + } + + #[test] + fn missing_tool_pin_names_the_tool() { + let directory = tempfile::tempdir().expect("temporary fixture"); + std::fs::write( + directory.path().join("Cargo.toml"), + "[package]\nname = 'fixture'\n", + ) + .expect("manifest"); + let error = + super::fixture_tool_pin(directory.path(), "worker-build").expect_err("missing pin"); + assert!(error.to_string().contains("worker-build")); + } + + #[test] + fn non_utf8_runtime_logs_are_printed_lossily() { + let directory = tempfile::tempdir().expect("temporary log"); + let path = directory.path().join("runtime.log"); + std::fs::write(&path, b"before\xffafter").expect("log bytes"); + let mut output = Vec::new(); + let error = super::with_runtime_log( + Err::<(), _>(io::Error::other("original failure")), + &path, + &mut output, + ) + .expect_err("original error"); + assert_eq!(error.to_string(), "original failure"); + assert_eq!( + String::from_utf8(output).expect("lossy log"), + "before\u{fffd}after\n" + ); + } + + #[test] + fn worker_build_requires_locked_local_tools() { + use std::os::unix::fs::PermissionsExt as _; + let directory = tempfile::tempdir().expect("fixture directory"); + let fixture = directory.path(); + std::fs::write( + fixture.join("Cargo.lock"), + "[[package]]\nname = 'wasm-bindgen'\nversion = '0.2.128'\n", + ) + .expect("lock"); + std::fs::write( + fixture.join("package-lock.json"), + r#"{"packages":{"node_modules/esbuild":{"version":"0.27.3"}}}"#, + ) + .expect("npm lock"); + let bindgen = fixture.join("wasm-bindgen"); + std::fs::write(&bindgen, "#!/bin/sh\necho 'wasm-bindgen 0.2.128'\n").expect("binary"); + std::fs::set_permissions(&bindgen, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("worker-build"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("missing npm-locked esbuild must not trigger a download"); + assert!(error.to_string().contains("esbuild")); + let esbuild = fixture.join("node_modules/.bin/esbuild"); + std::fs::create_dir_all(esbuild.parent().expect("parent")).expect("bin directory"); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.27.3'\n").expect("binary"); + std::fs::set_permissions(&esbuild, std::fs::Permissions::from_mode(0o755)) + .expect("executable"); + super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect("pinned tools"); + let envs: std::collections::BTreeMap<_, _> = command.get_envs().collect(); + assert_eq!( + envs[std::ffi::OsStr::new("WASM_BINDGEN_BIN")], + Some( + bindgen + .canonicalize() + .expect("canonical binary") + .as_os_str() + ) + ); + assert_eq!( + envs[std::ffi::OsStr::new("ESBUILD_BIN")], + Some( + esbuild + .canonicalize() + .expect("canonical binary") + .as_os_str() + ) + ); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.28.1'\n").expect("wrong esbuild"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("reject unlocked esbuild"); + assert!(error.to_string().contains("0.28.1")); + std::fs::write(&esbuild, "#!/bin/sh\necho '0.27.3'\n").expect("restore esbuild"); + std::fs::write(&bindgen, "#!/bin/sh\necho 'wasm-bindgen 0.2.127'\n").expect("wrong tool"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("reject wrong bindgen"); + assert!(error.to_string().contains("0.2.127")); + std::fs::remove_file(&bindgen).expect("remove tool"); + let error = super::configure_worker_build(&mut command, fixture, &bindgen, &cancelled) + .expect_err("missing configured tool must fail instead of downloading"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(error.to_string().contains("wasm-bindgen")); + } + + #[test] + fn diagnostic_failures_preserve_the_original_runtime_error() { + let directory = tempfile::tempdir().expect("should create log directory"); + for (name, contents) in [ + ("missing", None), + ("invalid-utf8", Some(b"\xff".as_slice())), + ("failed-output", Some(b"runtime diagnostics".as_slice())), + ] { + let path = directory.path().join(name); + if let Some(contents) = contents { + std::fs::write(&path, contents).expect("should create diagnostic log"); + } + let mut output = Cursor::new([].as_mut_slice()); + let result = Err::<(), _>(io::Error::new( + io::ErrorKind::TimedOut, + "original readiness failure", + )); + let error = super::with_runtime_log(result, &path, &mut output) + .expect_err("should preserve runtime failure"); + assert_eq!(error.kind(), io::ErrorKind::TimedOut, "{name}"); + assert_eq!(error.to_string(), "original readiness failure", "{name}"); + } + } + + #[test] + #[ignore = "requires a freshly built Cloudflare fixture and pinned workerd"] + fn cloudflare_primary_uri_preserves_no_hook_routing() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("runner has a fixture parent") + .join("request-fidelity"); + let cancelled = Arc::new(AtomicBool::new(false)); + cloudflare_routing_probes(&fixture, &cancelled, Duration::from_secs(60)) + .expect("primary URI normalization and raw ingress capture must remain independent"); + } + + #[test] + fn cancelled_response_reads_do_not_retry_until_deadline() { + for prefix in [ + b"".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n".as_slice(), + ] { + let listener = + TcpListener::bind("127.0.0.1:0").expect("should bind a silent loopback server"); + let socket = TcpStream::connect(listener.local_addr().expect("should get port")) + .expect("should connect to silent server"); + let (mut server, _) = listener.accept().expect("should accept test client"); + server + .write_all(prefix) + .expect("should write partial response"); + socket + .set_read_timeout(Some(Duration::from_millis(10))) + .expect("should bound individual reads"); + let cancelled = Arc::new(AtomicBool::new(false)); + let mut reader = BufReader::new(TimedSocket { + socket, + cancelled: &cancelled, + deadline: Instant::now() + Duration::from_millis(150), + }); + if !prefix.is_empty() { + let _buffer = reader.fill_buf().expect("should buffer response headers"); + } + cancelled.store(true, Ordering::SeqCst); + // Bound the old retry loop as well, so a regression fails rather than hangs. + let fallback_cancelled = Arc::clone(&cancelled); + let fallback = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + fallback_cancelled.store(false, Ordering::SeqCst); + }); + let error = read_response(&mut reader) + .expect_err("should cancel both header and exact-body reads"); + fallback.join().expect("should join retry-loop fallback"); + assert_eq!( + error.kind(), + io::ErrorKind::Other, + "should return a terminal cancellation error instead of a retryable interrupt" + ); + assert_eq!( + error.to_string(), + "cancelled", + "should preserve cancellation reason" + ); + } + } + + #[test] + fn baseline_diff_reports_changed_added_and_removed_cases_once() { + let expected = json!({"same": {"hook": true}, "changed": {"count": 2}, "removed": true}); + let observed = json!({"same": {"hook": true}, "changed": {"count": 1}, "added": false}); + let mut output = Vec::new(); + super::report_observation_diff(&mut output, "spin", &expected, &observed) + .expect("should report changes"); + let output = String::from_utf8(output).expect("should write UTF-8"); + assert_eq!(output.lines().count(), 3); + assert!(output.contains("spin/changed: expected={\"count\":2} observed={\"count\":1}")); + assert!(output.contains("spin/added: expected=null observed=false")); + assert!(output.contains("spin/removed: expected=true observed=null")); + assert!(!output.contains("spin/same")); + } + + #[test] + fn fixed_registry_contains_unicode_and_standalone_chunked_probes() { + let cases = cases(); + assert_eq!(cases.len(), 17, "should include every fixed ingress case"); + let unicode = cases + .iter() + .find(|case| case.name == "unicode") + .expect("should include unicode case"); + assert_eq!( + unicode.headers, + [(b"Cookie".as_slice(), b"a=\xc4\x80".as_slice())], + "should send exact UTF-8 C4 80" + ); + let chunked = cases + .iter() + .find(|case| case.name == "chunked") + .expect("should include chunked case"); + assert_eq!( + chunked.headers, + [(b"Transfer-Encoding".as_slice(), b"chunked".as_slice())], + "should probe Transfer-Encoding without Content-Length" + ); + assert_eq!( + chunked.body, b"0\r\n\r\n", + "should terminate the empty chunked body" + ); + } + + #[test] + fn observation_verification_rejects_utf8_corruption_even_during_collection() { + let mut verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "origin_available": true, "origin_matches_expected": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); + let error = verify_observation("cloudflare", "utf8", 200, &verdict) + .expect_err("should reject the former C3 A9 to E9 defect"); + assert!( + error.to_string().contains("C3 A9"), + "should reach the UTF-8 check: {error}" + ); + verdict["cookie_utf8"] = json!(true); + verdict["cookie_latin1"] = json!(false); + verify_observation("cloudflare", "utf8", 200, &verdict).expect("should accept exact UTF-8"); + let _error = verify_observation("cloudflare", "unicode", 500, &serde_json::Value::Null) + .expect_err("should reject the former C4 80 conversion error"); + } + + #[test] + fn cancellation_stops_and_reaps_the_owned_process_group() { + let cancelled = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("/bin/sleep"); + command + .arg("30") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut process = OwnedProcess::spawn(&mut command).expect("should spawn owned group"); + let group = process.group; + let worker_cancelled = Arc::clone(&cancelled); + let stopper = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + worker_cancelled.store(true, Ordering::SeqCst); + }); + let start = Instant::now(); + let error = process + .wait(&cancelled) + .expect_err("should stop waiting on interruption"); + assert_eq!( + error.kind(), + io::ErrorKind::Interrupted, + "should preserve interruption reason" + ); + drop(process); + stopper.join().expect("should join cancellation trigger"); + assert!( + start.elapsed() < Duration::from_secs(6), + "should bound cleanup even when interrupted" + ); + let status = Command::new("/bin/kill") + .args(["-0", "--", &format!("-{group}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("should inspect owned group"); + assert!( + !status.success(), + "should leave no live owned process group" + ); + } + + #[test] + fn packet_preserves_case_order_high_bytes_and_chunk_body() { + let case = Case { + name: "fixed", + method: b"EXAMPLE-METHOD", + target: b"/reserved//%2F?q=a%2Fb", + headers: vec![(b"Cookie", b"a=\xff"), (b"COOKIE", b"b=2")], + body: b"0\r\n\r\n", + }; + assert_eq!( + request_packet(&case), + b"EXAMPLE-METHOD /reserved//%2F?q=a%2Fb HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-Fixture: fixed\r\nCookie: a=\xff\r\nCOOKIE: b=2\r\n\r\n0\r\n\r\n", + "should preserve the exact raw request bytes" + ); + } + + #[test] + fn response_decodes_chunks_extensions_and_trailers() { + let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4;fixed=yes\r\n{\"a\"\r\n3\r\n:1}\r\n0\r\nX-Fixture: fixed\r\n\r\n"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse chunks"); + assert_eq!(status, 200, "should preserve status"); + assert_eq!(body, b"{\"a\":1}", "should decode the chunk framing"); + } + + #[test] + fn response_handles_interim_status_and_content_length() { + let raw = + b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 418 Fixed\r\nContent-Length: 2\r\n\r\n{}extra"; + let (status, body) = read_response(&mut Cursor::new(raw)).expect("should parse response"); + assert_eq!(status, 418, "should use final response status"); + assert_eq!(body, b"{}", "should read exactly Content-Length"); + } + + #[test] + fn response_rejects_truncation_and_oversized_body() { + for raw in [ + b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n{}".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}XX".as_slice(), + ] { + let _error = read_response(&mut Cursor::new(raw)) + .expect_err("should reject incomplete or oversized responses"); + } + } + + #[test] + fn response_reads_eof_framing_and_rejects_excessive_headers() { + let (status, body) = read_response(&mut Cursor::new(b"HTTP/1.0 200 OK\r\n\r\n{}")) + .expect("should parse EOF-framed response"); + assert_eq!(status, 200, "should retain status"); + assert_eq!(body, b"{}", "should read the EOF-framed body"); + let oversized = format!( + "HTTP/1.1 200 OK\r\nX-Fixture: {}\r\n\r\n", + "a".repeat(HEADER_LIMIT) + ); + let _error = read_response(&mut Cursor::new(oversized)) + .expect_err("should enforce a total header budget"); + } +} diff --git a/tests/fixtures/request-fidelity/.gitignore b/tests/fixtures/request-fidelity/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/tests/fixtures/request-fidelity/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/tests/fixtures/request-fidelity/Cargo.lock b/tests/fixtures/request-fidelity/Cargo.lock new file mode 100644 index 00000000..ab5f9993 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.lock @@ -0,0 +1,2154 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", + "brotli-decompressor 5.0.3", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "brotli 9.0.0", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli 8.0.4", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + +[[package]] +name = "edgezero-adapter-fastly" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base16ct", + "brotli 8.0.4", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli 8.0.4", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.21", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "base16ct", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit 0.9.2", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.11.0", + "thiserror 2.0.21", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.6", + "toml", + "validator", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastly" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "mime", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "sha2 0.9.9", + "smallvec", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "fastly-macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "log-fastly" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" +dependencies = [ + "fastly", + "log", + "regex", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "request-fidelity" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "fastly", + "serde_json", + "spin-sdk", + "wasm-bindgen", + "worker", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spin-macro" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.21", + "wasip3", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" +dependencies = [ + "idna", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.7.1+wasi-0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.21", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.13.2", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.13.2", + "futures", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worker" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/request-fidelity/Cargo.toml b/tests/fixtures/request-fidelity/Cargo.toml new file mode 100644 index 00000000..8779a140 --- /dev/null +++ b/tests/fixtures/request-fidelity/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "request-fidelity" +version = "0.1.0" +edition = "2024" +publish = false + +[package.metadata.request-fidelity] +spin-cli = "4.1.0" +worker-build = "0.8.5" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "request-fidelity-fastly" +path = "src/bin/fastly.rs" +required-features = ["fastly"] + +[features] +default = [] +fastly = ["dep:edgezero-adapter-fastly", "dep:fastly"] +cloudflare = ["dep:edgezero-adapter-cloudflare", "dep:worker", "dep:wasm-bindgen"] +spin = ["dep:edgezero-adapter-spin", "dep:spin-sdk", "dep:anyhow"] + +[dependencies] +async-trait = "=0.1.89" +edgezero-core = { path = "../../../crates/edgezero-core" } +edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"], optional = true } +edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"], optional = true } +edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"], optional = true } +fastly = { version = "=0.13.1", optional = true } +worker = { version = "=0.8.5", default-features = false, features = ["http"], optional = true } +wasm-bindgen = { version = "=0.2.128", optional = true } +spin-sdk = { version = "=7.0.0", default-features = false, features = ["http"], optional = true } +anyhow = { version = "=1.0.103", optional = true } +serde_json = "=1.0.150" diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md new file mode 100644 index 00000000..9473a31b --- /dev/null +++ b/tests/fixtures/request-fidelity/README.md @@ -0,0 +1,81 @@ +# Request ingress runtime fixtures + +Run from the repository root with the Rust and Node versions in `.tool-versions`: + +```sh +./scripts/test-request-fidelity.sh all +./scripts/test-request-fidelity.sh fastly --skip-build +``` + +`--skip-build` intentionally reuses existing fixture artifacts; it does not +prove source freshness. Use the default locked build for review evidence. + +The isolated Cargo workspace and npm lock pin fixture dependencies. Install +Viceroy at the version in `.tool-versions`, Spin CLI and worker-build at the +versions in the fixture manifest's `[package.metadata.request-fidelity]`, and +wasm-bindgen CLI at the `wasm-bindgen` version in the fixture `Cargo.lock`. +The runner and CI read these authoritative pins. +The runner uses `WASM_BINDGEN_BIN` when set, otherwise resolves `wasm-bindgen` +from `PATH`, checks it against the fixture Cargo lock, and passes its absolute +path to worker-build. An invalid override fails instead of falling back to a +download. After `npm ci`, the runner requires the local esbuild binary, checks +its npm-locked version, and passes it as `ESBUILD_BIN`. +Wrangler 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 isolated Tokio/serde_json/tempfile/TOML pins. Tokio is confined to this host test helper; it adds no core or WASM runtime dependency. Responses contain fixed booleans/counts rather than unrestricted request values. +`observations.json` records the pinned local runtimes' observed outcomes, and +subsequent runs compare them exactly and report each changed case. Inspect +changes before updating it. Record all platforms to a temporary file first: + +```sh +./scripts/test-request-fidelity.sh all --record-observations > /tmp/request-fidelity-observations.json +diff -u tests/fixtures/request-fidelity/observations.json /tmp/request-fidelity-observations.json +# After a successful run and inspection: +cp /tmp/request-fidelity-observations.json tests/fixtures/request-fidelity/observations.json +``` + +Recording a single platform emits only that platform; replacing the baseline +with that output would discard the other platforms. A failed run may leave an +empty output file. +A passing regression run includes explicitly reported unsupported cases; it +does not establish consumer compliance or production wire preservation. +Axum's real TCP ingress cases live alongside its request converter tests. + +Known gates: + +- Two Cloudflare routing regressions send raw UTF-8 `/café` and `/a\b` + directly to the pinned workerd, bypassing Wrangler's development proxy, + which normalizes these targets before Worker invocation. A no-hook + application must match `/caf%C3%A9` and `/a/b` while ingress metadata + independently retains the raw runtime URL with unknown wire preservation. +- Fastly 0.13.1 lacks a safe borrowed original-target getter. Ingress reports + `NotExposed`; a preceding native path shortcut can see a normalized target. + Its canonical origin is recovered separately from the client runtime URI + and one validated consistent Host, before native mutation. + A snapshot probe changes the native URL/Host after capture and proves the + original origin remains available through explicit-snapshot dispatch. +- Local workerd rejects extension methods before Worker invocation, normalizes + dot segments and coalesces repeated fields. Raw FF becomes U+FFFD before Rust + and is copied as runtime UTF-8 EF BF BD. Common header fidelity remains + `Unknown`, since a transforming runtime does not prove any particular field + changed. Valid wire UTF-8 C3 A9 and C4 80 are asserted byte-for-byte. + Repeated Cookie values are checked for exact comma joining versus semicolon + joining. Compound probes compare a valid-looking diagnostics value plus + unrelated invalid FF against original valid EF BF BD: their Worker verdicts + are identical. They do not implement or prove Trusted Server cookie parsing. + Absolute-form and duplicate Content-Length requests return local 500 errors. +- Spin 4.1.0 rejects non-UTF-8 header values before component invocation. + It consumes chunked framing and removes Transfer-Encoding before the hook in + `chunked` and `te-cl` cases. +- Global original field order is unavailable on Axum, Cloudflare and Spin. + Fastly reports Unknown: original-name/count APIs provide partial evidence, + without associating original fields with converted values. Local evidence + cannot certify production runtime or TLS/client provenance guarantees. + +The single [spec](../../../docs/superpowers/specs/2026-10-05-pre-dispatch-request-fidelity-design.md) +and [plan](../../../docs/superpowers/plans/2026-10-05-pre-dispatch-request-fidelity.md) +record the complete contract, verification and remaining upstream/consumer gates. diff --git a/tests/fixtures/request-fidelity/fastly.toml b/tests/fixtures/request-fidelity/fastly.toml new file mode 100644 index 00000000..13b31829 --- /dev/null +++ b/tests/fixtures/request-fidelity/fastly.toml @@ -0,0 +1,4 @@ +manifest_version = 3 +name = "request-fidelity" +language = "rust" +[local_server] diff --git a/tests/fixtures/request-fidelity/observations.json b/tests/fixtures/request-fidelity/observations.json new file mode 100644 index 00000000..4fcfb02d --- /dev/null +++ b/tests/fixtures/request-fidelity/observations.json @@ -0,0 +1,1441 @@ +{ + "cloudflare": { + "absolute": { + "status": 500, + "verdict": null + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": true, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": true, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": false, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": false, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 501, + "verdict": null + }, + "high-byte": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": true, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "length-conflict": { + "status": 500, + "verdict": null + }, + "length-identical": { + "status": 500, + "verdict": null + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 1, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": true, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "route-backslash": { + "status": 200, + "verdict": { + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true + } + }, + "route-unicode": { + "status": 200, + "verdict": { + "ordinary": true, + "primary_uri_normalized": true, + "raw_target_preserved": true + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + }, + "fastly": { + "absolute": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "compound-invalid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": true, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": true, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "native": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "native-dot": { + "status": 200, + "verdict": { + "native": true, + "raw_target_supported": false + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "snapshot-origin": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": true, + "transfer_encoding_count": 1 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": false, + "hook": true, + "native_client_preserved": true, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": false, + "target_matches_original": false, + "target_matches_runtime": false, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + }, + "spin": { + "absolute": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "chunked": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "compound-invalid": { + "status": 500, + "verdict": null + }, + "compound-valid": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": true, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "continue": { + "status": 200, + "verdict": { + "ordinary": true + } + }, + "dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "encoded-dot": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "extension": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": true, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "high-byte": { + "status": 500, + "verdict": null + }, + "length-conflict": { + "status": 400, + "verdict": null + }, + "length-identical": { + "status": 200, + "verdict": { + "content_length_count": 1, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "repeat": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 2, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 2, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 2, + "origin_matches_expected": true, + "same_name_order": true, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "slashes": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "spoof": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 1, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "te-cl": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 0, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "unicode": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": true, + "cookie_utf8": false, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + }, + "utf8": { + "status": 200, + "verdict": { + "content_length_count": 0, + "control_count": 0, + "cookie_compound_comma_joined": false, + "cookie_compound_original": false, + "cookie_compound_runtime_utf8": false, + "cookie_count": 1, + "cookie_latin1": false, + "cookie_octets": false, + "cookie_repeat_comma_joined": false, + "cookie_repeat_semicolon_joined": false, + "cookie_replacement": false, + "cookie_unicode": false, + "cookie_utf8": true, + "extension_method": false, + "global_order_unavailable": true, + "hook": true, + "native_client_preserved": null, + "ordinary": false, + "origin_available": true, + "origin_count": 0, + "origin_matches_expected": true, + "same_name_order": false, + "spoofed_origin_ignored": true, + "target_available": true, + "target_matches_original": true, + "target_matches_runtime": true, + "transfer_encoding_chunked": false, + "transfer_encoding_count": 0 + } + } + } +} diff --git a/tests/fixtures/request-fidelity/package-lock.json b/tests/fixtures/request-fidelity/package-lock.json new file mode 100644 index 00000000..f8cc7069 --- /dev/null +++ b/tests/fixtures/request-fidelity/package-lock.json @@ -0,0 +1,1503 @@ +{ + "name": "request-fidelity-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "request-fidelity-fixture", + "devDependencies": { + "wrangler": "4.83.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260415.1.tgz", + "integrity": "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260415.1.tgz", + "integrity": "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260415.1.tgz", + "integrity": "sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260415.1.tgz", + "integrity": "sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260415.1.tgz", + "integrity": "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260415.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260415.0.tgz", + "integrity": "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260415.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260415.1.tgz", + "integrity": "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260415.1", + "@cloudflare/workerd-darwin-arm64": "1.20260415.1", + "@cloudflare/workerd-linux-64": "1.20260415.1", + "@cloudflare/workerd-linux-arm64": "1.20260415.1", + "@cloudflare/workerd-windows-64": "1.20260415.1" + } + }, + "node_modules/wrangler": { + "version": "4.83.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.83.0.tgz", + "integrity": "sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260415.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260415.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260415.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/tests/fixtures/request-fidelity/package.json b/tests/fixtures/request-fidelity/package.json new file mode 100644 index 00000000..a944fcdf --- /dev/null +++ b/tests/fixtures/request-fidelity/package.json @@ -0,0 +1,7 @@ +{ + "name": "request-fidelity-fixture", + "private": true, + "devDependencies": { + "wrangler": "4.83.0" + } +} diff --git a/tests/fixtures/request-fidelity/spin.toml b/tests/fixtures/request-fidelity/spin.toml new file mode 100644 index 00000000..d06c2e53 --- /dev/null +++ b/tests/fixtures/request-fidelity/spin.toml @@ -0,0 +1,10 @@ +spin_manifest_version = 2 +[application] +name = "request-fidelity" +version = "0.1.0" +[[trigger.http]] +route = "/..." +component = "request-fidelity" +[component.request-fidelity] +source = "target/spin/wasm32-wasip2/release/request_fidelity.wasm" +allowed_outbound_hosts = [] diff --git a/tests/fixtures/request-fidelity/src/bin/fastly.rs b/tests/fixtures/request-fidelity/src/bin/fastly.rs new file mode 100644 index 00000000..3fe12104 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/bin/fastly.rs @@ -0,0 +1,32 @@ +use edgezero_adapter_fastly::request::{ + capture_request_ingress, dispatch_with_registries_and_ingress, +}; +use edgezero_core::app::{Hooks, StoresMetadata}; +use fastly::{Error, Request, Response}; +use request_fidelity::{Fixture, NativeClient}; + +#[fastly::main] +fn main(mut req: Request) -> Result { + let ingress = capture_request_ingress(&req); + let client_ip = req.get_client_ip_addr(); + // This generic shortcut deliberately demonstrates the SDK normalization gate. + if req.get_path() == "/native" { + return Ok(Response::from_body( + r#"{"native":true,"raw_target_supported":false}"#, + )); + } + // The snapshot must keep the original origin through later native mutation. + if req.get_header_str("x-fixture") == Some("snapshot-origin") { + req.set_url("https://spoof.example.com/changed"); + req.set_header("host", "spoof.example.com"); + } + dispatch_with_registries_and_ingress( + &Fixture::build_app(), + req, + StoresMetadata::default(), + ingress, + |_req, extensions| { + extensions.insert(NativeClient(client_ip)); + }, + ) +} diff --git a/tests/fixtures/request-fidelity/src/cloudflare.rs b/tests/fixtures/request-fidelity/src/cloudflare.rs new file mode 100644 index 00000000..4d830a87 --- /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 00000000..4c3da19a --- /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(Arc::new(Probe { + ordinary_calls: Arc::clone(&ordinary_calls), + })) + .middleware(CountOrdinary(ordinary_calls)) + .get("/ordinary", ordinary) + .build() + } +} + +struct CountOrdinary(Arc); +struct Probe { + ordinary_calls: Arc, +} + +#[async_trait(?Send)] +impl Middleware for CountOrdinary { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } +} + +#[action] +async fn ordinary() -> &'static str { + r#"{"ordinary":true}"# +} + +#[async_trait(?Send)] +impl PreDispatchHook for Probe { + async fn handle(&self, request: &mut Request) -> Result, EdgeError> { + let case = request + .headers() + .get("x-fixture") + .and_then(|value| value.to_str().ok()) + .unwrap_or("ready"); + if case == "continue" { + *request.method_mut() = Method::GET; + *request.uri_mut() = "/ordinary".parse().map_err(EdgeError::internal)?; + return Ok(None); + } + let expected_target = match case { + "dot" => "/reserved/../ordinary", + "encoded-dot" => "/reserved/%2e%2e/ordinary", + "slashes" => "/reserved//%2F?q=a%2Fb", + "absolute" => "/reserved?fixed=1", + _ => "/reserved", + }; + let ingress = request + .extensions() + .get::() + .ok_or_else(|| EdgeError::bad_request("missing ingress fixture metadata"))?; + let (available, matches_original, matches_runtime) = match ingress.target() { + CapturedTarget::Complete(target) => { + let original = target.value() == expected_target + || target.value() == format!("http://example.com{expected_target}"); + (true, original, target.value() == *request.uri()) + } + CapturedTarget::Unavailable(_) => (false, false, false), + }; + let cookies: Vec<_> = request + .headers() + .get_all("cookie") + .iter() + .map(|value| value.as_bytes()) + .collect(); + let cookie_octets = cookies == [b"a=\xff".as_slice()]; + let same_name_order = cookies == [b"a=1".as_slice(), b"b=2".as_slice()]; + let cookie_name = "cookie".parse().map_err(EdgeError::internal)?; + let fidelity = ingress.header_fidelity(&cookie_name); + #[cfg(feature = "fastly")] + let native_client_preserved = + request + .extensions() + .get::() + .is_some_and(|native| { + native.0.is_some() + && edgezero_adapter_fastly::context::FastlyRequestContext::get(request) + .is_some_and(|context| context.client_ip == native.0) + }); + #[cfg(not(feature = "fastly"))] + let native_client_preserved = None::; + let verdict = json!({ + "hook": true, + "native_client_preserved": native_client_preserved, + "ordinary": self.ordinary_calls.load(Ordering::SeqCst) != 0, + "extension_method": request.method().as_str() == "EXAMPLE-METHOD", + "cookie_octets": cookie_octets, + "cookie_utf8": cookies == [b"a=\xc3\xa9".as_slice()], + "cookie_latin1": cookies == [b"a=\xe9".as_slice()], + "cookie_unicode": cookies == [b"a=\xc4\x80".as_slice()], + "cookie_replacement": cookies == [b"a=\xef\xbf\xbd".as_slice()], + "cookie_repeat_comma_joined": cookies == [b"a=1, b=2".as_slice()], + "cookie_repeat_semicolon_joined": cookies == [b"a=1; b=2".as_slice()], + "cookie_compound_original": cookies == [b"diagnostics=1".as_slice(), b"a=\xff".as_slice()], + "cookie_compound_runtime_utf8": cookies == [b"diagnostics=1".as_slice(), b"a=\xef\xbf\xbd".as_slice()], + "cookie_compound_comma_joined": cookies == [b"diagnostics=1, a=\xef\xbf\xbd".as_slice()], + "cookie_count": cookies.len(), + "same_name_order": same_name_order, + "origin_count": request.headers().get_all("origin").iter().count(), + "control_count": request.headers().get_all("x-control").iter().count(), + "content_length_count": request.headers().get_all("content-length").iter().count(), + "transfer_encoding_count": request.headers().get_all("transfer-encoding").iter().count(), + "transfer_encoding_chunked": request.headers().get("transfer-encoding").is_some_and(|value| value.as_bytes() == b"chunked"), + "target_available": available, + "target_matches_original": matches_original, + "target_matches_runtime": matches_runtime, + "origin_available": ingress.origin().is_some(), + "origin_matches_expected": ingress.origin().is_some_and(|origin| origin.scheme() == "http" && origin.authority() == "example.com"), + "spoofed_origin_ignored": ingress.origin().is_none_or(|origin| !origin.authority().contains("spoof")), + "global_order_unavailable": fidelity.global_field_order() == Preservation::Unavailable, + }); + Ok(Some( + response_builder() + .header("content-type", "application/json") + .body(Body::text(verdict.to_string())) + .map_err(EdgeError::internal)?, + )) + } +} diff --git a/tests/fixtures/request-fidelity/src/spin.rs b/tests/fixtures/request-fidelity/src/spin.rs new file mode 100644 index 00000000..7da12d16 --- /dev/null +++ b/tests/fixtures/request-fidelity/src/spin.rs @@ -0,0 +1,13 @@ +use crate::Fixture; +use edgezero_adapter_spin::request::into_core_request; +use edgezero_adapter_spin::response::from_core_response; +use edgezero_core::app::Hooks; +use spin_sdk::http::{IntoResponse, Request}; +use spin_sdk::http_service; + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + let request = into_core_request(req).await?; + let response = Fixture::build_app().router().oneshot(request).await?; + Ok(from_core_response(response).await?) +} diff --git a/tests/fixtures/request-fidelity/workerd.capnp b/tests/fixtures/request-fidelity/workerd.capnp new file mode 100644 index 00000000..a8a3eb52 --- /dev/null +++ b/tests/fixtures/request-fidelity/workerd.capnp @@ -0,0 +1,13 @@ +using Workerd = import "/workerd/workerd.capnp"; + +# Direct ingress avoids Wrangler's development proxy URL normalization. +const config :Workerd.Config = ( + services = [(name = "fixture", worker = ( + compatibilityDate = "2026-04-01", + modules = [ + (name = "index.js", esModule = embed "build/cloudflare/index.js"), + (name = "index_bg.wasm", wasm = embed "build/cloudflare/index_bg.wasm") + ] + ))], + sockets = [(name = "http", address = "127.0.0.1:8080", http = (), service = "fixture")] +); diff --git a/tests/fixtures/request-fidelity/wrangler.toml b/tests/fixtures/request-fidelity/wrangler.toml new file mode 100644 index 00000000..b133beff --- /dev/null +++ b/tests/fixtures/request-fidelity/wrangler.toml @@ -0,0 +1,6 @@ +name = "request-fidelity" +main = "build/cloudflare/worker/shim.mjs" +compatibility_date = "2026-04-01" +[dev] +ip = "127.0.0.1" +local_protocol = "http" diff --git a/tests/fixtures/reusable-app/README.md b/tests/fixtures/reusable-app/README.md index 4b03747a..ab734c36 100644 --- a/tests/fixtures/reusable-app/README.md +++ b/tests/fixtures/reusable-app/README.md @@ -148,8 +148,9 @@ logger fails on its second installation. ## Runtime and measurement limits -Raising `--max-requests` cannot extend the pinned Viceroy's per-guest request -ceiling (see the Fastly guide). Verify deployed eviction and long-lived memory +Local request ceilings depend on the Viceroy version; check them when pins change +(see the [Fastly guide](../../../docs/guide/adapters/fastly.md#reusing-a-sandbox-and-retaining-an-app)). +Verify deployed eviction and long-lived memory behavior separately. The runner checks the Viceroy version against `.tool-versions` and reports a mismatch as unverified.