From 590dff9e337c6a70c32efc1f344bff2b5560912b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 14:02:27 -0600 Subject: [PATCH 1/3] feat(geo): resolve portable providers from checked native ingress --- .github/workflows/test.yml | 34 +- crates/edgezero-adapter-axum/Cargo.toml | 3 + .../edgezero-adapter-axum/src/dev_server.rs | 90 ++- crates/edgezero-adapter-axum/src/geo.rs | 623 ++++++++++++++++++ .../src/geo/cloudfront.rs | 140 ++++ crates/edgezero-adapter-axum/src/lib.rs | 4 + .../src/peer_networks.rs | 143 ++++ crates/edgezero-adapter-axum/src/proxy.rs | 126 +--- .../edgezero-adapter-axum/src/run_options.rs | 44 ++ crates/edgezero-adapter-axum/src/service.rs | 350 +++++++++- .../tests/fixtures/production_host.rs | 245 ++++++- .../tests/production_hosting.rs | 207 ++++++ .../tests/contract.rs | 20 +- .../edgezero-adapter-fastly/tests/contract.rs | 19 +- .../edgezero-adapter-spin/tests/contract.rs | 27 +- crates/edgezero-core/src/app.rs | 145 +++- crates/edgezero-core/src/context.rs | 23 +- crates/edgezero-core/src/geo.rs | 582 ++++++++++++++++ crates/edgezero-core/src/geo/country_codes.rs | 42 ++ crates/edgezero-core/src/ingress.rs | 27 + crates/edgezero-core/src/lib.rs | 1 + .../fixtures/reusable-app/Cargo.lock | 2 + docs/.vitepress/config.mts | 1 + docs/guide/adapters/axum.md | 23 + docs/guide/native-geo.md | 317 +++++++++ .../2026-10-22-pluggable-geo-providers.md | 498 ++++++++++++++ ...26-10-22-pluggable-geo-providers-design.md | 217 ++++++ examples/app-demo/Cargo.lock | 2 + scripts/check_geo_dependency_contract.sh | 98 +++ scripts/check_outbound_legacy_api.sh | 6 +- 30 files changed, 3895 insertions(+), 164 deletions(-) create mode 100644 crates/edgezero-adapter-axum/src/geo.rs create mode 100644 crates/edgezero-adapter-axum/src/geo/cloudfront.rs create mode 100644 crates/edgezero-adapter-axum/src/peer_networks.rs create mode 100644 crates/edgezero-core/src/geo.rs create mode 100644 crates/edgezero-core/src/geo/country_codes.rs create mode 100644 docs/guide/native-geo.md create mode 100644 docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md create mode 100644 docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md create mode 100755 scripts/check_geo_dependency_contract.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b64c7a5c7..8c51a8b02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,6 @@ on: push: branches: [main] pull_request: - # Native-store PRs are currently stacked on the outbound HTTP specification. - branches: [main, docs/outbound-http-spec] permissions: contents: read @@ -188,6 +186,38 @@ jobs: if: matrix.suite == 'workspace' run: bash scripts/check_native_store_dependency_contract.sh + - name: Geo dependency isolation + run: bash scripts/check_geo_dependency_contract.sh + + - name: Immutable core Geo outcome + run: scripts/run_test_nonzero.sh geo_owned_outcome_survives_public_request_mutation cargo test --offline --locked -p edgezero-core --lib geo_ + + - name: Native Geo disabled implementation and policy unit contracts + run: | + scripts/run_test_nonzero.sh geo_protected_policy_reuses_network_bounds cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib geo:: + scripts/run_test_nonzero.sh geo_unavailable_selection_fails_before_runner_resources cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib geo_ + + - name: Native Geo supplied-provider unit contract without built-in + run: scripts/run_test_nonzero.sh source_admission_precedes_header_inspection_and_provider_calls cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib source_admission_precedes_header_inspection_and_provider_calls + + - name: Native Geo enabled implementation and managed dispatch unit contracts + run: | + scripts/run_test_nonzero.sh geo_cloudfront_parsing_matches_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront --lib geo:: + scripts/run_test_nonzero.sh geo_managed_resolution_runs_once_from_owned_ingress cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront --lib geo_ + cargo test --offline --locked -p edgezero-adapter-axum --features geo-cloudfront --lib service::tests + + - name: Native Geo disabled built-in hosting (loopback only) + run: scripts/run_test_nonzero.sh geo_hosting_rejects_unavailable_cloudfront cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test production_hosting geo_hosting_rejects_unavailable_cloudfront + + - name: Native Geo supplied provider without built-in (loopback only) + run: scripts/run_test_nonzero.sh geo_hosting_supplied_provider_without_builtin cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test production_hosting geo_hosting_supplied_provider_without_builtin + + - name: Native Geo real-peer hosting (loopback only) + run: scripts/run_test_nonzero.sh geo_hosting_proves_real_peer_and_shared_outcome cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront,test-utils --test production_hosting geo_hosting_proves_real_peer_and_shared_outcome + + - name: Native Geo public examples + run: cargo test --offline --locked -p edgezero-adapter-axum --features geo-cloudfront --doc + - name: Native AWS provider and startup contracts (loopback only) if: matrix.suite == 'workspace' run: | diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index d42d30567..d9eeffa10 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -19,6 +19,8 @@ axum = [ "dep:tokio", "dep:tower", "dep:futures-util", + "dep:getrandom", + "dep:ipnet", "dep:http-body", "dep:hyper", "dep:hyper-util", @@ -35,6 +37,7 @@ aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] aws-dynamodb = ["axum", "edgezero-store-aws/dynamodb-kv"] celld-config = ["axum", "edgezero-store-celld/config"] celld-kv = ["axum", "edgezero-store-celld/kv"] +geo-cloudfront = ["axum"] cli = [ "native-bindings", "dep:edgezero-adapter", diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 03882320a..97735f80c 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -64,6 +64,7 @@ use tokio::time::{Instant as NativeInstant, sleep_until}; use crate::config_store::AxumConfigStore; use crate::config_store_limits::ConfigStoreLimits; use crate::connection::{ConnectionExit, serve_http1_with_shutdown}; +use crate::geo::NativeGeo; use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; use crate::native_stores::{self, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; @@ -509,9 +510,10 @@ fn serve_local( ) } -#[derive(Clone, Debug)] +#[derive(Clone)] struct NativeHosting { diagnostics: NativeDiagnosticsHandle, + geo: NativeGeo, policy: TrustedProxyPolicy, request_records: bool, } @@ -522,6 +524,7 @@ impl Default for NativeHosting { diagnostics: NativeDiagnosticsHandle::new(), policy: TrustedProxyPolicy::no_trust(), request_records: true, + geo: NativeGeo::default(), } } } @@ -745,9 +748,15 @@ where log::warn!(target: BOOT_LOG_TARGET, "application head limits exceed Axum raw ingress limits; the parser rejects first"); } let session = NativeSession::new(native.diagnostics, native.request_records)?; - let mut service = - AxumServiceState::with_transport(app, transport, json_body_limit, session, native.policy) - .with_phase(reader.clone()); + let mut service = AxumServiceState::with_native_transport( + app, + transport, + json_body_limit, + session, + native.policy, + native.geo, + ) + .with_phase(reader.clone()); if let Some(registry) = stores.config { service = service.with_config_registry(registry); } @@ -1108,6 +1117,7 @@ where policy, request_records, diagnostics: NativeDiagnosticsHandle::new(), + geo: NativeGeo::default(), }, )) } @@ -1284,6 +1294,13 @@ where &'startup PreparedStores, ) -> LocalStartupFuture<'startup>, { + let geo = options.native_geo()?; + let native = NativeHosting { + diagnostics: options.diagnostics(), + policy: options.trusted_proxy_policy(), + request_records: options.request_records(), + geo, + }; run_local( ListenerSource::Bind(options.addr()), signals, @@ -1306,17 +1323,7 @@ where ); failure("initializer", "application", &category) })?; - Ok(( - app, - stores, - transport, - options.json_body_limit(), - NativeHosting { - diagnostics: options.diagnostics(), - policy: options.trusted_proxy_policy(), - request_records: options.request_records(), - }, - )) + Ok((app, stores, transport, options.json_body_limit(), native)) }, ) } @@ -2371,7 +2378,8 @@ mod tests { OsString::from("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES"), binary, )]) - .expect_err("recognized binary setting"); + .err() + .expect("recognized binary setting"); assert!(error.to_string().contains("JSON_BODY_LIMIT_BYTES")); assert_eq!(error.chain().count(), 1); for (path, category) in [ @@ -2382,7 +2390,8 @@ mod tests { OsString::from(format!("EDGEZERO__ADAPTER__{path}")), OsString::from_vec(b"PRIVATE_SENTINEL\xff".to_vec()), )]) - .expect_err("owned binary limit must fail"); + .err() + .expect("owned binary limit must fail"); assert!(owned_error.to_string().contains(category)); assert!(!format!("{owned_error:?}").contains("PRIVATE_SENTINEL")); assert_eq!(owned_error.chain().count(), 1); @@ -4219,6 +4228,53 @@ mod native_store_tests { Ok(format!("{}:{state}", value.as_str())) } + #[cfg(not(feature = "geo-cloudfront"))] + #[tokio::test(flavor = "current_thread")] + async fn geo_unavailable_selection_fails_before_runner_resources() { + use crate::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; + let root = tempfile::tempdir().unwrap(); + let options = options() + .with_config_dir(root.path()) + .unwrap() + .with_data_dir(root.path()) + .unwrap() + .with_geo(NativeGeoSelection::CloudFront { + policy: ProtectedCloudFrontAlb::new(["127.0.0.1/32"], "x-origin-verify-fixture") + .unwrap(), + }); + let address = options.addr(); + let prepared = Rc::new(Cell::new(false)); + let initialized = Rc::new(Cell::new(false)); + let prepare_observed = Rc::clone(&prepared); + let initialize_observed = Rc::clone(&initialized); + let mut overrides = NativeStoreOverrides::default(); + overrides + .prepare_config("first", async move { + prepare_observed.set(true); + Err(NativeStorePreparationError::Unavailable) + }) + .unwrap(); + // Existing Tokio runtime would reject nested runner construction if validation were late. + let error = run_app_with_native_stores::( + options, + overrides, + future::pending::<()>(), + move |_app, _stores| { + Box::pin(async move { + initialize_observed.set(true); + Ok(()) + }) + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("unavailable")); + assert!(!prepared.get()); + assert!(!initialized.get()); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0); + let listener = StdTcpListener::bind(address).expect("runner never bound listener"); + drop(listener); + } + #[test] fn ready_and_rc_deferred_inputs_share_completed_registries_with_requests() { let options = options(); diff --git a/crates/edgezero-adapter-axum/src/geo.rs b/crates/edgezero-adapter-axum/src/geo.rs new file mode 100644 index 000000000..4d3f86557 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/geo.rs @@ -0,0 +1,623 @@ +//! Explicit native Geo composition for an operator-protected CloudFront/ALB path. +//! +//! This policy declares external infrastructure protection. A matching socket peer +//! is not proof of an ALB credential rule or a particular `CloudFront` distribution. +//! See the native Geo guide for the required network, header and cache controls. +//! +//! Off is valid and is the default, with no ambient Geo selector: +//! ``` +//! use edgezero_adapter_axum::dev_server::AxumRunOptions; +//! use edgezero_adapter_axum::geo::{NativeGeoCapability, NativeGeoSelection}; +//! let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? +//! .with_geo(NativeGeoSelection::Off); +//! assert_eq!(options.geo_capability()?, NativeGeoCapability::Off); +//! # Ok::<(), anyhow::Error>(()) +//! ``` +//! +//! Capture capability from finalized options in the existing local initializer. +//! The selector compiles without `geo-cloudfront`, but startup then rejects it: +//! ```no_run +//! use edgezero_adapter_axum::dev_server::{AxumRunOptions, run_app_with_options_and_initializer}; +//! use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +//! use edgezero_core::app::Hooks; +//! +//! fn run() -> anyhow::Result<()> { +//! let policy = ProtectedCloudFrontAlb::new( +//! ["10.42.1.0/24", "10.42.2.0/24"], "x-origin-verify-randomsuffix", +//! )?; +//! let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)? +//! .with_geo(NativeGeoSelection::CloudFront { policy }); +//! let capability = options.geo_capability()?; +//! run_app_with_options_and_initializer::( +//! options, std::future::pending::<()>(), move |_app, _stores| { +//! Box::pin(async move { +//! let _configured_capability = capability; +//! // Validate application requirements here, without inferring +//! // that every request will have geographic metadata. +//! Ok(()) +//! }) +//! }, +//! ) +//! } +//! ``` +//! +//! Supplied providers work without the built-in feature and use the same bounded, +//! synchronous, local-only contract. Construction belongs to the application: +//! ``` +//! use std::sync::Arc; +//! use edgezero_adapter_axum::dev_server::AxumRunOptions; +//! use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +//! use edgezero_core::geo::{GeoInput, GeoProvider, GeoProviderId, GeoResolution}; +//! +//! struct ExampleProvider; +//! impl GeoProvider for ExampleProvider { +//! fn resolve(&self, _input: &GeoInput) -> GeoResolution { +//! GeoResolution::MissingMetadata +//! } +//! } +//! let policy = ProtectedCloudFrontAlb::new( +//! ["10.42.1.0/24"], "x-origin-verify-randomsuffix", +//! )?; +//! let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? +//! .with_geo(NativeGeoSelection::Supplied { +//! policy, +//! id: GeoProviderId::new("example.local")?, +//! provider: Arc::new(ExampleProvider), +//! }); +//! let _capability = options.geo_capability()?; +//! # Ok::<(), anyhow::Error>(()) +//! ``` +//! +//! Consumers distinguish every outcome. Even a located country/subdivision pair +//! is an estimate, and subdivision membership is not verified: +//! ``` +//! use edgezero_core::geo::{GeoError, GeoOutcome, GeoStatus, GeoUnavailableReason}; +//! fn describe(outcome: &GeoOutcome) -> &'static str { +//! match outcome { +//! GeoOutcome::NoSelection => "not configured", +//! GeoOutcome::Selected { status, .. } => match status { +//! GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress) => "source not admitted", +//! GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) => "metadata missing", +//! GeoStatus::Error(GeoError::InvalidAssertion) => "assertion rejected", +//! GeoStatus::Error(GeoError::ProviderFailure) => "provider failed", +//! GeoStatus::Located { location, .. } if location.subdivision().is_none() => "country only", +//! GeoStatus::Located { .. } => "country and unverified subdivision assertion", +//! }, +//! } +//! } +//! ``` + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "public composition types precede private request-bound resolution and tests" +)] +#![cfg_attr( + feature = "geo-cloudfront", + expect( + clippy::self_named_module_files, + reason = "repo convention uses geo.rs with submodules under geo/, not mod.rs" + ) +)] + +#[cfg(feature = "geo-cloudfront")] +mod cloudfront; + +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; + +use edgezero_core::geo::{ + GeoAssertion, GeoInput, GeoOutcome, GeoProvider, GeoProviderId, GeoResolution, GeoSource, + GeoStatus, GeoTrustBasis, GeoUnavailableReason, +}; +use edgezero_core::http::{HeaderMap, HeaderName}; + +use crate::peer_networks::PeerNetworks; + +/// Bounded startup configuration categories. No policy inputs are retained. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum NativeGeoConfigError { + #[error("native_geo: invalid protected source policy")] + InvalidProtectedPolicy, + #[error("native_geo: invalid credential removal policy")] + InvalidCredentialHeader, + #[error("native_geo: cloudfront implementation unavailable")] + UnavailableCloudFront, + #[error("native_geo: reserved supplied provider identity")] + ReservedProviderId, +} + +/// An operator declaration of a credential-gated, isolated CloudFront/ALB path. +/// +/// This is distinct from ordinary proxy trust. The policy contains no credential +/// value. The ALB must validate the credential before forwarding to the target. +#[derive(Clone)] +pub struct ProtectedCloudFrontAlb { + networks: PeerNetworks, + credential_header: HeaderName, +} + +impl ProtectedCloudFrontAlb { + /// Validate a nonempty bounded literal peer allowlist and dedicated header name. + /// + /// Names must be literal HTTP tokens of at most 128 ASCII bytes beginning with + /// `x-`. Forwarding, AWS, `EdgeZero`, real-IP and correlation names are reserved. + /// Neither malformed names nor network inputs are repaired or logged. + /// + /// # Errors + /// Returns a fixed category for invalid networks or an unsafe header name. + #[inline] + pub fn new(raw_networks: I, credential_name: &str) -> Result + where + I: IntoIterator, + S: AsRef, + { + let networks = PeerNetworks::new(raw_networks) + .map_err(|_error| NativeGeoConfigError::InvalidProtectedPolicy)?; + if networks.is_empty() { + return Err(NativeGeoConfigError::InvalidProtectedPolicy); + } + if credential_name.len() > 128 || !credential_name.is_ascii() { + return Err(NativeGeoConfigError::InvalidCredentialHeader); + } + let normalized = credential_name.to_ascii_lowercase(); + if !normalized.starts_with("x-") + || ["x-forwarded-", "x-amz-", "x-amzn-", "x-edge-"] + .iter() + .any(|prefix| normalized.starts_with(prefix)) + || ["x-real-ip", "x-request-id", "x-correlation-id"].contains(&normalized.as_str()) + { + return Err(NativeGeoConfigError::InvalidCredentialHeader); + } + let credential_header = HeaderName::from_bytes(normalized.as_bytes()) + .map_err(|_error| NativeGeoConfigError::InvalidCredentialHeader)?; + Ok(Self { + networks, + credential_header, + }) + } + + fn admit<'request>( + &self, + peer: Option, + headers: &'request HeaderMap, + ) -> Option> { + peer.filter(|actual_peer| self.networks.trusts(actual_peer.ip())) + .map(|_peer| ProtectedAssertions { headers }) + } +} + +impl fmt::Debug for ProtectedCloudFrontAlb { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProtectedCloudFrontAlb") + .field("network_count", &self.networks.len()) + .finish_non_exhaustive() + } +} + +/// One complete deployment selection. Repeated `with_geo` calls replace it. +#[derive(Clone, Default)] +pub enum NativeGeoSelection { + #[default] + Off, + CloudFront { + policy: ProtectedCloudFrontAlb, + }, + Supplied { + policy: ProtectedCloudFrontAlb, + id: GeoProviderId, + provider: Arc, + }, +} + +impl fmt::Debug for NativeGeoSelection { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Off => f.write_str("Off"), + Self::CloudFront { policy } => f + .debug_struct("CloudFront") + .field("policy", policy) + .finish(), + Self::Supplied { policy, id, .. } => f + .debug_struct("Supplied") + .field("policy", policy) + .field("id", id) + .finish_non_exhaustive(), + } + } +} + +/// Owned startup capability, not proof of per-request coverage or infrastructure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NativeGeoCapability { + Off, + Selected { + provider: GeoProviderId, + source: GeoSource, + trust_basis: GeoTrustBasis, + }, +} + +/// Validated runner-owned composition, never installed in public extensions. +#[derive(Clone, Default)] +pub(crate) enum NativeGeo { + #[default] + Off, + Selected { + policy: ProtectedCloudFrontAlb, + id: GeoProviderId, + provider: Arc, + }, +} + +impl NativeGeo { + pub(crate) fn validate(selection: &NativeGeoSelection) -> Result { + match selection { + NativeGeoSelection::Off => Ok(Self::Off), + #[cfg(feature = "geo-cloudfront")] + NativeGeoSelection::CloudFront { policy } => Ok(Self::Selected { + policy: policy.clone(), + id: GeoProviderId::cloudfront(), + provider: Arc::new(cloudfront::CloudFrontProvider), + }), + #[cfg(not(feature = "geo-cloudfront"))] + NativeGeoSelection::CloudFront { .. } => { + Err(NativeGeoConfigError::UnavailableCloudFront) + } + NativeGeoSelection::Supplied { + policy, + id, + provider, + } => { + if id == &GeoProviderId::cloudfront() { + return Err(NativeGeoConfigError::ReservedProviderId); + } + Ok(Self::Selected { + policy: policy.clone(), + id: id.clone(), + provider: Arc::clone(provider), + }) + } + } + } + + pub(crate) fn capability(&self) -> NativeGeoCapability { + match self { + Self::Off => NativeGeoCapability::Off, + Self::Selected { id, .. } => NativeGeoCapability::Selected { + provider: id.clone(), + source: GeoSource::CloudFront, + trust_basis: GeoTrustBasis::DeclaredProtectedAlb, + }, + } + } + + pub(crate) fn strip_credential(&self, headers: &mut HeaderMap) { + if let Self::Selected { policy, .. } = self { + let _removed = headers.remove(&policy.credential_header); + } + } + + pub(crate) fn resolve(&self, peer: Option, headers: &HeaderMap) -> GeoOutcome { + let Self::Selected { + policy, + id, + provider, + } = self + else { + return GeoOutcome::NoSelection; + }; + let status = match policy.admit(peer, headers) { + None => GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress), + Some(admitted) => match provider.resolve(&admitted.input()) { + GeoResolution::MissingMetadata => { + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) + } + GeoResolution::Error(error) => GeoStatus::Error(error), + GeoResolution::Located(location) => GeoStatus::Located { + location, + source: GeoSource::CloudFront, + trust_basis: GeoTrustBasis::DeclaredProtectedAlb, + }, + }, + }; + GeoOutcome::Selected { + provider: id.clone(), + status, + } + } +} + +/// Private witness tied to the admitted request's still-owned header snapshot. +struct ProtectedAssertions<'request> { + headers: &'request HeaderMap, +} + +impl ProtectedAssertions<'_> { + fn input(&self) -> GeoInput { + GeoInput { + country: assertion(self.headers, "cloudfront-viewer-country"), + subdivision: assertion(self.headers, "cloudfront-viewer-country-region"), + } + } +} + +fn assertion(headers: &HeaderMap, name: &'static str) -> GeoAssertion { + let mut values = headers.get_all(name).iter(); + let Some(first) = values.next() else { + return GeoAssertion::absent(); + }; + if values.next().is_some() { + return GeoAssertion::invalid(); + } + GeoAssertion::from_bytes(first.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::run_options::AxumRunOptions; + use edgezero_core::geo::{CountryCode, GeoError, GeoLocation}; + use edgezero_core::http::HeaderValue; + use std::iter::empty; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn policy() -> ProtectedCloudFrontAlb { + ProtectedCloudFrontAlb::new(["127.0.0.1/32"], "x-origin-verify-fixture").unwrap() + } + + #[test] + fn geo_protected_policy_reuses_network_bounds() { + assert_eq!( + ProtectedCloudFrontAlb::new(empty::<&str>(), "x-origin-verify-fixture").unwrap_err(), + NativeGeoConfigError::InvalidProtectedPolicy + ); + ProtectedCloudFrontAlb::new(["127.0.0.1"; 256], "x-origin-verify-fixture").unwrap(); + assert_eq!( + ProtectedCloudFrontAlb::new(["127.0.0.1"; 257], "x-origin-verify-fixture").unwrap_err(), + NativeGeoConfigError::InvalidProtectedPolicy + ); + let exact = format!("127.0.0.1{}", " ".repeat(0x4000 - 9)); + ProtectedCloudFrontAlb::new([exact.as_str()], "x-origin-verify-fixture").unwrap(); + assert_eq!( + ProtectedCloudFrontAlb::new([format!("{exact} ")], "x-origin-verify-fixture") + .unwrap_err(), + NativeGeoConfigError::InvalidProtectedPolicy + ); + for ranges in [ + vec!["0.0.0.0/0"], + vec!["::/0"], + vec!["0.0.0.0/1", "128.0.0.0/1"], + vec!["::/1", "8000::/1"], + vec!["::ffff:0:0/96"], + vec!["not-a-network"], + ] { + assert_eq!( + ProtectedCloudFrontAlb::new(ranges, "x-origin-verify-fixture").unwrap_err(), + NativeGeoConfigError::InvalidProtectedPolicy + ); + } + let selected = ProtectedCloudFrontAlb::new( + ["192.0.2.0/24", "2001:db8::/32"], + "x-origin-verify-fixture", + ) + .unwrap(); + let headers = HeaderMap::new(); + for peer in ["192.0.2.1:80", "[2001:db8::1]:80", "[::ffff:192.0.2.1]:80"] { + assert!( + selected + .admit(Some(peer.parse().unwrap()), &headers) + .is_some() + ); + } + assert!(selected.admit(None, &headers).is_none()); + assert!( + selected + .admit(Some("192.0.3.1:80".parse().unwrap()), &headers) + .is_none() + ); + ProtectedCloudFrontAlb::new(["192.0.2.0/24"; 256], "x-origin-verify-fixture").unwrap(); + } + + #[test] + fn credential_names_are_dedicated_bounded_and_redacted() { + for name in [ + "authorization", + "cookie", + "host", + "forwarded", + "CloudFront-Viewer-Country", + "CloudFront-Viewer-Country-Region", + "X-FORWARDED-FOR", + "x-forwarded-host", + "X-Real-IP", + "X-Amz-Token", + "x-amzn-trace-id", + "x-edge-request", + "x-request-id", + "X-Correlation-ID", + "x-bad name", + "x-bad\r\nname", + " x-secret", + "x-secret ", + "x-\u{e9}", + "", + ] { + assert!( + ProtectedCloudFrontAlb::new(["127.0.0.1"], name).is_err(), + "{name:?}" + ); + } + let exact = format!("x-{}", "a".repeat(126)); + ProtectedCloudFrontAlb::new(["127.0.0.1"], &exact).unwrap(); + assert_eq!( + ProtectedCloudFrontAlb::new(["127.0.0.1"], &format!("{exact}a")).unwrap_err(), + NativeGeoConfigError::InvalidCredentialHeader + ); + let policy = + ProtectedCloudFrontAlb::new(["192.0.2.0/24"], "X-Origin-Private-Sentinel").unwrap(); + let debug = format!("{policy:?}"); + assert!(!debug.contains("192.0.2")); + assert!(!debug.to_ascii_lowercase().contains("sentinel")); + let error = + ProtectedCloudFrontAlb::new(["not-a-network-secret"], "x-private-secret").unwrap_err(); + assert!(!format!("{error:?} {error}").contains("secret")); + } + + struct CountingProvider { + calls: Arc, + result: GeoResolution, + } + impl GeoProvider for CountingProvider { + fn resolve(&self, _input: &GeoInput) -> GeoResolution { + self.calls.fetch_add(1, Ordering::SeqCst); + self.result.clone() + } + } + + #[test] + fn source_admission_precedes_header_inspection_and_provider_calls() { + let calls = Arc::new(AtomicUsize::new(0)); + let selected = NativeGeo::validate(&NativeGeoSelection::Supplied { + policy: policy(), + id: GeoProviderId::new("counting").unwrap(), + provider: Arc::new(CountingProvider { + calls: Arc::clone(&calls), + result: GeoResolution::Error(GeoError::ProviderFailure), + }), + }) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.append("cloudfront-viewer-country", HeaderValue::from_static("US")); + headers.append("cloudfront-viewer-country", HeaderValue::from_static("CA")); + for peer in [None, Some("192.0.2.1:80".parse().unwrap())] { + assert_eq!( + selected.resolve(peer, &headers), + GeoOutcome::Selected { + provider: GeoProviderId::new("counting").unwrap(), + status: GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress) + } + ); + } + assert_eq!( + NativeGeo::Off.resolve(Some("127.0.0.1:80".parse().unwrap()), &headers), + GeoOutcome::NoSelection + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + for values in [&headers, &HeaderMap::new()] { + assert_eq!( + selected.resolve(Some("127.0.0.1:80".parse().unwrap()), values), + GeoOutcome::Selected { + provider: GeoProviderId::new("counting").unwrap(), + status: GeoStatus::Error(GeoError::ProviderFailure) + } + ); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn duplicate_and_oversize_assertions_are_invalid_before_copying() { + let mut headers = HeaderMap::new(); + headers.append("cloudfront-viewer-country", HeaderValue::from_static("US")); + headers.append("cloudfront-viewer-country", HeaderValue::from_static("US")); + assert_eq!( + assertion::<2>(&headers, "cloudfront-viewer-country").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + headers.remove("cloudfront-viewer-country"); + headers.insert("cloudfront-viewer-country", HeaderValue::from_static("USA")); + assert_eq!( + assertion::<2>(&headers, "cloudfront-viewer-country").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + headers.insert( + "cloudfront-viewer-country-region", + HeaderValue::from_static("ABCD"), + ); + assert_eq!( + assertion::<3>(&headers, "cloudfront-viewer-country-region").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + } + + #[test] + fn credential_removal_strips_all_instances_and_preserves_geography() { + let selected = NativeGeo::validate(&NativeGeoSelection::Supplied { + policy: policy(), + id: GeoProviderId::new("custom").unwrap(), + provider: Arc::new(CountingProvider { + calls: Arc::default(), + result: GeoResolution::MissingMetadata, + }), + }) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.append("x-origin-verify-fixture", HeaderValue::from_static("first")); + headers.append( + "x-origin-verify-fixture", + HeaderValue::from_static("second"), + ); + headers.insert("cloudfront-viewer-country", HeaderValue::from_static("US")); + selected.strip_credential(&mut headers); + assert_eq!(headers.get_all("x-origin-verify-fixture").iter().count(), 0); + assert_eq!(headers["cloudfront-viewer-country"], "US"); + } + + #[test] + fn supplied_identity_cannot_claim_builtin_and_options_keep_thread_bounds() { + fn assert_bounds() + where + T: Clone + Send + Sync, + { + } + assert_bounds::(); + let error = NativeGeo::validate(&NativeGeoSelection::Supplied { + policy: policy(), + id: GeoProviderId::cloudfront(), + provider: Arc::new(CountingProvider { + calls: Arc::default(), + result: GeoResolution::Located(GeoLocation::new( + CountryCode::new("US").unwrap(), + None, + )), + }), + }) + .err() + .unwrap(); + assert_eq!(error, NativeGeoConfigError::ReservedProviderId); + let options = AxumRunOptions::new("127.0.0.1:8080".parse().unwrap()).unwrap(); + assert_eq!(options.geo_capability().unwrap(), NativeGeoCapability::Off); + let supplied = NativeGeoSelection::Supplied { + policy: policy(), + id: GeoProviderId::new("custom").unwrap(), + provider: Arc::new(CountingProvider { + calls: Arc::default(), + result: GeoResolution::MissingMetadata, + }), + }; + let selected_options = options.with_geo(supplied); + assert!( + matches!(selected_options.geo_capability().unwrap(), NativeGeoCapability::Selected { provider, .. } if provider.as_str() == "custom") + ); + assert_eq!( + selected_options + .with_geo(NativeGeoSelection::Off) + .geo_capability() + .unwrap(), + NativeGeoCapability::Off + ); + } + + #[cfg(not(feature = "geo-cloudfront"))] + #[test] + fn compiled_out_builtin_is_an_error_not_off() { + let selected = NativeGeoSelection::CloudFront { policy: policy() }; + assert_eq!( + NativeGeo::validate(&selected).err(), + Some(NativeGeoConfigError::UnavailableCloudFront) + ); + } +} diff --git a/crates/edgezero-adapter-axum/src/geo/cloudfront.rs b/crates/edgezero-adapter-axum/src/geo/cloudfront.rs new file mode 100644 index 000000000..ae633ba0d --- /dev/null +++ b/crates/edgezero-adapter-axum/src/geo/cloudfront.rs @@ -0,0 +1,140 @@ +//! Local normalization of source-admitted `CloudFront` geographic assertions. + +use std::str; + +use edgezero_core::geo::{ + CountryCode, GeoError, GeoInput, GeoLocation, GeoProvider, GeoResolution, SubdivisionCode, +}; + +pub(super) struct CloudFrontProvider; + +impl GeoProvider for CloudFrontProvider { + #[inline] + fn resolve(&self, input: &GeoInput) -> GeoResolution { + let (Ok(country_slot), Ok(subdivision_slot)) = + (input.country.as_bytes(), input.subdivision.as_bytes()) + else { + return GeoResolution::Error(GeoError::InvalidAssertion); + }; + let Some(country_bytes) = country_slot else { + return if subdivision_slot.is_none() { + GeoResolution::MissingMetadata + } else { + GeoResolution::Error(GeoError::InvalidAssertion) + }; + }; + let parsed_country = str::from_utf8(country_bytes) + .ok() + .and_then(|value| CountryCode::new(value).ok()); + let Some(country_code) = parsed_country else { + return GeoResolution::Error(GeoError::InvalidAssertion); + }; + let subdivision_code = match subdivision_slot { + None => None, + Some(bytes) => { + let parsed_subdivision = str::from_utf8(bytes) + .ok() + .and_then(|value| SubdivisionCode::new(value).ok()); + let Some(component) = parsed_subdivision else { + return GeoResolution::Error(GeoError::InvalidAssertion); + }; + Some(component) + } + }; + GeoResolution::Located(GeoLocation::new(country_code, subdivision_code)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::geo::GeoAssertion; + + fn input(country: Option<&[u8]>, subdivision: Option<&[u8]>) -> GeoInput { + GeoInput { + country: country.map_or_else(GeoAssertion::absent, GeoAssertion::from_bytes), + subdivision: subdivision.map_or_else(GeoAssertion::absent, GeoAssertion::from_bytes), + } + } + + fn located(country: &str, subdivision: Option<&str>) -> GeoResolution { + GeoResolution::Located(GeoLocation::new( + CountryCode::new(country).unwrap(), + subdivision.map(|value| SubdivisionCode::new(value).unwrap()), + )) + } + + #[test] + fn geo_cloudfront_parsing_matches_spec() { + let invalid = GeoResolution::Error(GeoError::InvalidAssertion); + let cases = [ + (input(None, None), GeoResolution::MissingMetadata), + (input(Some(b"us"), Some(b"ca")), located("US", Some("CA"))), + (input(Some(b"US"), None), located("US", None)), + (input(Some(b"US"), Some(b"ZZ")), located("US", Some("ZZ"))), + (input(Some(b"GB"), Some(b"eng")), located("GB", Some("ENG"))), + (input(Some(b"JP"), Some(b"01")), located("JP", Some("01"))), + (input(Some(b"BR"), Some(b"1")), located("BR", Some("1"))), + (input(Some(b"AU"), Some(b"001")), located("AU", Some("001"))), + (input(Some(b"US"), Some(b"a1B")), located("US", Some("A1B"))), + (input(None, Some(b"CA")), invalid.clone()), + (input(Some(b""), None), invalid.clone()), + (input(Some(b"U"), None), invalid.clone()), + (input(Some(b"USA"), None), invalid.clone()), + (input(Some(b"USAA"), None), invalid.clone()), + (input(Some(b"XX"), None), invalid.clone()), + (input(Some(b"ZZ"), None), invalid.clone()), + (input(Some(b"UK"), None), invalid.clone()), + (input(Some(b"XK"), None), invalid.clone()), + (input(Some(b"U1"), None), invalid.clone()), + (input(Some(b" US"), None), invalid.clone()), + (input(Some(b"US "), None), invalid.clone()), + (input(Some(b" U"), None), invalid.clone()), + (input(Some(b"U\t"), None), invalid.clone()), + (input(Some(b"U\0"), None), invalid.clone()), + (input(Some(b"\xc3\xa9"), None), invalid.clone()), + (input(Some(b"\xff\xfe"), None), invalid.clone()), + (input(Some(b"US,CA"), None), invalid.clone()), + (input(Some(b"%55S"), None), invalid.clone()), + (input(Some(b"US"), Some(b"")), invalid.clone()), + (input(Some(b"US"), Some(b"ABCD")), invalid.clone()), + (input(Some(b"US"), Some(b"US-CA")), invalid.clone()), + (input(Some(b"US"), Some(b" CA")), invalid.clone()), + (input(Some(b"US"), Some(b"CA ")), invalid.clone()), + (input(Some(b"US"), Some(b"A B")), invalid.clone()), + (input(Some(b"US"), Some(b"A\t")), invalid.clone()), + (input(Some(b"US"), Some(b"C\0")), invalid.clone()), + (input(Some(b"US"), Some(b"\xc3\xa9")), invalid.clone()), + (input(Some(b"US"), Some(b"\xff")), invalid.clone()), + (input(Some(b"US"), Some(b"A,B")), invalid.clone()), + (input(Some(b"US"), Some(b"A-B")), invalid.clone()), + (input(Some(b"US"), Some(b"A_B")), invalid.clone()), + (input(Some(b"US"), Some(b"%41")), invalid.clone()), + ( + GeoInput { + country: GeoAssertion::invalid(), + subdivision: GeoAssertion::absent(), + }, + invalid.clone(), + ), + ( + GeoInput { + country: GeoAssertion::from_bytes(b"US"), + subdivision: GeoAssertion::invalid(), + }, + invalid.clone(), + ), + ( + GeoInput { + country: GeoAssertion::absent(), + subdivision: GeoAssertion::invalid(), + }, + invalid, + ), + ]; + let provider = CloudFrontProvider; + for (index, (input, expected)) in cases.iter().enumerate() { + assert_eq!(&provider.resolve(input), expected, "case {index}"); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 5b428ae2f..c9fb4aba8 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -14,6 +14,8 @@ pub mod dev_server; #[cfg(feature = "axum")] pub mod diagnostics; #[cfg(feature = "axum")] +pub mod geo; +#[cfg(feature = "axum")] mod header_deadline; pub mod ingress_config; #[cfg(feature = "axum")] @@ -27,6 +29,8 @@ pub mod native_stores; #[cfg(feature = "axum")] pub mod outbound; #[cfg(feature = "axum")] +mod peer_networks; +#[cfg(feature = "axum")] pub mod proxy; #[cfg(feature = "axum")] pub mod request; diff --git a/crates/edgezero-adapter-axum/src/peer_networks.rs b/crates/edgezero-adapter-axum/src/peer_networks.rs new file mode 100644 index 000000000..dd2fec164 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/peer_networks.rs @@ -0,0 +1,143 @@ +//! Shared bounded socket-peer networks for forwarding and protected Geo admission. +use crate::proxy::ProxyTrustConfigError; +use ipnet::{IpNet, Ipv4Net}; +use std::net::{IpAddr, Ipv6Addr}; +const MAX_BYTES: usize = 0x4000; +pub(crate) const MAX_NETWORKS: usize = 256; + +#[derive(Clone, Default, Eq, PartialEq)] +pub(crate) struct PeerNetworks(Vec); +impl PeerNetworks { + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub(crate) fn len(&self) -> usize { + self.0.len() + } + pub(crate) fn new(networks: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut normalized = Vec::new(); + let mut bytes = 0_usize; + for (index, entry) in networks.into_iter().enumerate() { + if index >= MAX_NETWORKS { + return Err(ProxyTrustConfigError); + } + let raw = entry.as_ref(); + bytes = bytes + .checked_add(raw.len()) + .and_then(|size| size.checked_add(usize::from(index != 0))) + .filter(|size| *size <= MAX_BYTES) + .ok_or(ProxyTrustConfigError)?; + if raw + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(ProxyTrustConfigError); + } + let literal = raw.trim_matches([' ', '\t']); + let parsed = literal + .parse::() + .or_else(|_error| literal.parse::().map(IpNet::from)) + .map_err(|_error| ProxyTrustConfigError)?; + let network = normalize_network(parsed)?; + if !normalized.contains(&network) { + normalized.push(network); + } + } + if covers_family(&normalized, false) || covers_family(&normalized, true) { + return Err(ProxyTrustConfigError); + } + Ok(Self(normalized)) + } + pub(crate) fn trusts(&self, address: IpAddr) -> bool { + let canonical = canonical_ip(address); + self.0.iter().any(|network| network.contains(&canonical)) + } +} +pub(crate) fn canonical_ip(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4), + IpAddr::V4(v4) => IpAddr::V4(v4), + } +} + +fn normalize_network(network: IpNet) -> Result { + let truncated = network.trunc(); + if truncated.prefix_len() == 0 { + return Err(ProxyTrustConfigError); + } + if let IpNet::V6(v6) = truncated { + let mapped_start = Ipv6Addr::from(0xffff_u128 << 32_u32); + if v6.prefix_len() < 96 && v6.contains(&mapped_start) { + // A shorter overlapping prefix contains every mapped IPv4 address. + return Err(ProxyTrustConfigError); + } + if let Some(v4) = v6.network().to_ipv4_mapped() { + let prefix = v6 + .prefix_len() + .checked_sub(96) + .ok_or(ProxyTrustConfigError)?; + if prefix == 0 { + return Err(ProxyTrustConfigError); + } + return Ipv4Net::new(v4, prefix) + .map(IpNet::V4) + .map_err(|_error| ProxyTrustConfigError); + } + } + Ok(truncated) +} + +#[expect( + clippy::arithmetic_side_effects, + reason = "the maximum endpoint returns before end + 1; IPv4 endpoints fit u128 and the IPv6 maximum is handled explicitly" +)] +fn covers_family(networks: &[IpNet], ipv6: bool) -> bool { + let mut intervals: Vec<(u128, u128)> = networks + .iter() + .filter_map(|network| match network { + IpNet::V4(v4) if !ipv6 => Some(( + u128::from(u32::from(v4.network())), + u128::from(u32::from(v4.broadcast())), + )), + IpNet::V6(v6) if ipv6 => Some((u128::from(v6.network()), u128::from(v6.broadcast()))), + IpNet::V4(_) | IpNet::V6(_) => None, + }) + .collect(); + intervals.sort_unstable(); + let maximum = if ipv6 { + u128::MAX + } else { + u128::from(u32::MAX) + }; + let mut next = 0_u128; + for (start, end) in intervals { + if start > next { + return false; + } + if end == maximum { + return true; + } + next = next.max(end + 1); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn family_interval_merging_checks_maximum_endpoints() { + // Exercise interval merging's maximum endpoint without enumerating addresses. + let v6 = ["::/1".parse().unwrap(), "8000::/1".parse().unwrap()]; + assert!(covers_family(&v6, true)); + assert!(!covers_family(&["ffff::/16".parse().unwrap()], true)); + assert!(!covers_family( + &["0.0.0.0/1".parse().unwrap(), "192.0.0.0/2".parse().unwrap()], + false + )); + } +} diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/proxy.rs index c77ae013f..b5a58d28f 100644 --- a/crates/edgezero-adapter-axum/src/proxy.rs +++ b/crates/edgezero-adapter-axum/src/proxy.rs @@ -1,5 +1,8 @@ //! Checked incoming-proxy assertions for the managed native socket boundary. +#[cfg(test)] +use crate::peer_networks::MAX_NETWORKS; +use crate::peer_networks::{PeerNetworks, canonical_ip}; use std::error::Error; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -7,11 +10,9 @@ use std::str::from_utf8; use edgezero_core::http::{Authority, HeaderMap, HeaderName, RequestParts}; use edgezero_core::ingress::{CheckedEffectiveHost, CheckedHostSource}; -use ipnet::{IpNet, Ipv4Net}; const MAX_BYTES: usize = 0x4000; const MAX_CHAIN_SLOTS: usize = 64; -const MAX_NETWORKS: usize = 256; const MAX_PARAMETER_SLOTS: usize = 16; const X_HEADERS: [&str; 3] = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"]; @@ -50,7 +51,7 @@ impl Error for ProxyTrustConfigError {} #[derive(Clone, Eq, PartialEq)] pub struct TrustedProxyPolicy { family: Option, - networks: Vec, + networks: PeerNetworks, } impl fmt::Debug for TrustedProxyPolicy { @@ -98,7 +99,7 @@ impl TrustedProxyPolicy { if raw.trim_matches([' ', '\t']).is_empty() { return Ok(Self { family: selected_family, - networks: Vec::new(), + networks: PeerNetworks::default(), }); } Self::new( @@ -120,40 +121,9 @@ impl TrustedProxyPolicy { I: IntoIterator, S: AsRef, { - let mut normalized = Vec::new(); - let mut bytes = 0_usize; - for (index, entry) in networks.into_iter().enumerate() { - if index >= MAX_NETWORKS { - return Err(ProxyTrustConfigError); - } - let raw = entry.as_ref(); - bytes = bytes - .checked_add(raw.len()) - .and_then(|size| size.checked_add(usize::from(index != 0))) - .filter(|size| *size <= MAX_BYTES) - .ok_or(ProxyTrustConfigError)?; - if raw - .bytes() - .any(|byte| byte.is_ascii_control() && byte != b'\t') - { - return Err(ProxyTrustConfigError); - } - let literal = raw.trim_matches([' ', '\t']); - let parsed = literal - .parse::() - .or_else(|_error| literal.parse::().map(IpNet::from)) - .map_err(|_error| ProxyTrustConfigError)?; - let network = normalize_network(parsed)?; - if !normalized.contains(&network) { - normalized.push(network); - } - } - if covers_family(&normalized, false) || covers_family(&normalized, true) { - return Err(ProxyTrustConfigError); - } Ok(Self { family: Some(family), - networks: normalized, + networks: PeerNetworks::new(networks)?, }) } @@ -162,15 +132,12 @@ impl TrustedProxyPolicy { pub fn no_trust() -> Self { Self { family: None, - networks: Vec::new(), + networks: PeerNetworks::default(), } } fn trusts(&self, address: IpAddr) -> bool { - let canonical = canonical_ip(address); - self.networks - .iter() - .any(|network| network.contains(&canonical)) + self.networks.trusts(address) } } @@ -290,75 +257,6 @@ struct ParsedAssertion { scheme: Option, } -fn canonical_ip(address: IpAddr) -> IpAddr { - match address { - IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4), - IpAddr::V4(v4) => IpAddr::V4(v4), - } -} - -fn normalize_network(network: IpNet) -> Result { - let truncated = network.trunc(); - if truncated.prefix_len() == 0 { - return Err(ProxyTrustConfigError); - } - if let IpNet::V6(v6) = truncated { - let mapped_start = Ipv6Addr::from(0xffff_u128 << 32_u32); - if v6.prefix_len() < 96 && v6.contains(&mapped_start) { - // A shorter overlapping prefix contains every mapped IPv4 address. - return Err(ProxyTrustConfigError); - } - if let Some(v4) = v6.network().to_ipv4_mapped() { - let prefix = v6 - .prefix_len() - .checked_sub(96) - .ok_or(ProxyTrustConfigError)?; - if prefix == 0 { - return Err(ProxyTrustConfigError); - } - return Ipv4Net::new(v4, prefix) - .map(IpNet::V4) - .map_err(|_error| ProxyTrustConfigError); - } - } - Ok(truncated) -} - -#[expect( - clippy::arithmetic_side_effects, - reason = "the maximum endpoint returns before end + 1; IPv4 endpoints fit u128 and the IPv6 maximum is handled explicitly" -)] -fn covers_family(networks: &[IpNet], ipv6: bool) -> bool { - let mut intervals: Vec<(u128, u128)> = networks - .iter() - .filter_map(|network| match network { - IpNet::V4(v4) if !ipv6 => Some(( - u128::from(u32::from(v4.network())), - u128::from(u32::from(v4.broadcast())), - )), - IpNet::V6(v6) if ipv6 => Some((u128::from(v6.network()), u128::from(v6.broadcast()))), - IpNet::V4(_) | IpNet::V6(_) => None, - }) - .collect(); - intervals.sort_unstable(); - let maximum = if ipv6 { - u128::MAX - } else { - u128::from(u32::MAX) - }; - let mut next = 0_u128; - for (start, end) in intervals { - if start > next { - return false; - } - if end == maximum { - return true; - } - next = next.max(end + 1); - } - false -} - pub(crate) fn normalize( policy: &TrustedProxyPolicy, peer: Option, @@ -1041,14 +939,6 @@ mod tests { ] { TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, entries).unwrap_err(); } - // Exercise interval merging's maximum endpoint without enumerating addresses. - let v6 = ["::/1".parse().unwrap(), "8000::/1".parse().unwrap()]; - assert!(covers_family(&v6, true)); - assert!(!covers_family(&["ffff::/16".parse().unwrap()], true)); - assert!(!covers_family( - &["0.0.0.0/1".parse().unwrap(), "192.0.0.0/2".parse().unwrap()], - false - )); TrustedProxyPolicy::new( ForwardingHeaderFamily::Forwarded, ["::fffd:0:0/96", "ffff::/16"], diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index 2fb4584a0..cde39336c 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -15,6 +15,7 @@ use edgezero_store_celld::settings::{CelldAccessToken, CelldPreparationLimits}; use log::LevelFilter; use crate::diagnostics::NativeDiagnosticsHandle; +use crate::geo::{NativeGeo, NativeGeoCapability, NativeGeoConfigError, NativeGeoSelection}; use crate::native_bindings::{ NativeBindingsLimits, NativeConfigBinding, NativeKvBinding, NativePreparationPolicySources, NativeStoreBindings, NativeStoreBindingsInput, @@ -73,6 +74,7 @@ pub struct AxumRunOptions { data_dir: Option, diagnostics: NativeDiagnosticsHandle, env: EnvConfig, + geo: NativeGeoSelection, grace: Duration, json_body_limit: JsonBodyLimit, level: LevelFilter, @@ -362,6 +364,7 @@ impl AxumRunOptions { data_dir: None, diagnostics: NativeDiagnosticsHandle::new(), env: EnvConfig::default(), + geo: NativeGeoSelection::Off, grace: Duration::from_secs(10), json_body_limit: JsonBodyLimit::DEFAULT, level: LevelFilter::Info, @@ -371,6 +374,27 @@ impl AxumRunOptions { }) } + /// Select geographic resolution explicitly. Validation happens before runner resources. + #[must_use] + #[inline] + pub fn with_geo(mut self, selection: NativeGeoSelection) -> Self { + self.geo = selection; + self + } + + /// Validate the finalized selection and capture its startup capability. + /// + /// # Errors + /// Rejects unavailable implementations and invalid protected-source policies. + #[inline] + pub fn geo_capability(&self) -> Result { + self.native_geo().map(|geo| geo.capability()) + } + + pub(crate) fn native_geo(&self) -> Result { + NativeGeo::validate(&self.geo) + } + pub(crate) fn request_records(&self) -> bool { self.request_records } @@ -1523,6 +1547,26 @@ mod tests { assert!(defaults.with_json_body_limit_bytes(usize::MAX).is_err()); } + #[test] + fn geo_selection_replacement_preserves_clone_bounds() { + use crate::geo::ProtectedCloudFrontAlb; + let options = + AxumRunOptions::new("127.0.0.1:8787".parse().expect("address")).expect("options"); + let _thread_safe_clone: Box = Box::new(options.clone()); + assert!(matches!( + options.geo_capability(), + Ok(NativeGeoCapability::Off) + )); + let selected = options.with_geo(NativeGeoSelection::CloudFront { + policy: ProtectedCloudFrontAlb::new(["127.0.0.1/32"], "x-origin-verify-fixture") + .expect("policy"), + }); + assert!(matches!( + selected.with_geo(NativeGeoSelection::Off).geo_capability(), + Ok(NativeGeoCapability::Off) + )); + } + #[test] fn defaults_and_invalid_supplied_scalars_are_distinct() { let options = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index f59850c5d..abe2b66d3 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -13,10 +13,12 @@ use edgezero_core::OutboundScopeOwner; use edgezero_core::app::{App, FrameworkErrorDetail}; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; +use edgezero_core::geo::GeoOutcome; use edgezero_core::http::{Method, RequestParts}; use edgezero_core::ingress::{ IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, - IngressHeadParts, PreparedIngress, validate_normalized_ingress_parts_with_summary, + IngressHeadParts, NormalizedIngressHeadSummary, PreparedIngress, + validate_normalized_ingress_parts_with_summary, }; use edgezero_core::key_value_store::KvHandle; use edgezero_core::outbound::HttpClient; @@ -43,6 +45,7 @@ use crate::diagnostics::{ ConnectionActivity, ConnectionGuard, IngressDisposition, NativeIngressMetadata, NativeSession, RequestGuard, }; +use crate::geo::NativeGeo; #[cfg(test)] use crate::ingress_config::AxumIngressConfig; use crate::outbound::{AxumOutboundClient, native_time}; @@ -63,6 +66,7 @@ pub(crate) struct AxumServiceState { app: Arc, config_registry: Option, config_store_handle: Option, + geo: NativeGeo, json_body_limit: JsonBodyLimit, kv_handle: Option, kv_registry: Option, @@ -120,6 +124,7 @@ impl AxumServiceState { config_registry: None, config_store_handle: None, json_body_limit: JsonBodyLimit::DEFAULT, + geo: NativeGeo::default(), kv_handle: None, kv_registry: None, native, @@ -155,6 +160,19 @@ impl AxumServiceState { Self::from_app(App::new(router)) } + pub(crate) fn with_native_transport( + app: App, + transport: Arc, + json_body_limit: JsonBodyLimit, + native: NativeSession, + policy: TrustedProxyPolicy, + geo: NativeGeo, + ) -> Self { + let mut state = Self::with_transport(app, transport, json_body_limit, native, policy); + state.geo = geo; + state + } + /// Attach an id-keyed config-store registry to this service. #[must_use] #[inline] @@ -285,6 +303,16 @@ impl AxumServiceState { .is_none_or(|phase| *phase.borrow() == LifecyclePhase::Ready) } + fn sanitize_received_head( + &self, + parts: &mut RequestParts, + ) -> Result { + let received = + validate_normalized_ingress_parts_with_summary(parts, self.app.ingress_head_limits()); + self.geo.strip_credential(&mut parts.headers); + received + } + async fn dispatch_prepared( &self, prepared: PreparedIngress, @@ -292,6 +320,7 @@ impl AxumServiceState { native_body: AxumBody, connection: &EgressConnection, guard: RequestGuard, + geo: GeoOutcome, ) -> Response { let app = &self.app; let read_deadline = prepared.read_deadline(); @@ -329,10 +358,11 @@ impl AxumServiceState { core_request.extensions_mut().insert(registry); } let egress = app - .dispatch_admitted_with_framework_error_detail( + .dispatch_admitted_with_geo( prepared, core_request, FrameworkErrorDetail::CategoryOnly, + geo, ) .await; prepare_native_egress(egress, connection, guard).await @@ -377,22 +407,22 @@ impl AxumServiceState { let app = Arc::clone(&self.app); let (mut parts, native_body) = request.into_parts(); let request_method = parts.method.clone(); - let received = - match validate_normalized_ingress_parts_with_summary(&parts, app.ingress_head_limits()) - { - Ok(summary) => summary, - Err(error) => { - return prepare_detached_ingress_error( - &app, - error, - request_method.clone(), - request_start, - connection, - guard, - ) - .await; - } - }; + // Account for the original received head, but remove credentials before any error + // renderer or admission callback. Sanitization must also cover rejected ingress. + let received = match self.sanitize_received_head(&mut parts) { + Ok(summary) => summary, + Err(error) => { + return prepare_detached_ingress_error( + &app, + error, + request_method.clone(), + request_start, + connection, + guard, + ) + .await; + } + }; let checked = normalize(&self.trusted_proxy_policy, remote_addr, &parts); guard.set_forwarding_result(checked.forwarding_result()); strip_forwarding_headers(&mut parts.headers); @@ -440,8 +470,9 @@ impl AxumServiceState { .await; } }; + let geo = self.geo.resolve(remote_addr, &parts.headers); Ok(self - .dispatch_prepared(prepared, parts, native_body, connection, guard) + .dispatch_prepared(prepared, parts, native_body, connection, guard, geo) .await) } } @@ -1065,6 +1096,287 @@ mod tests { assert_eq!(drops.load(Ordering::SeqCst), 1, "source releases once"); } + #[expect( + clippy::too_many_lines, + reason = "one counted-provider scenario verifies admission and terminal boundaries without resetting shared evidence" + )] + #[tokio::test(flavor = "current_thread")] + async fn geo_managed_resolution_runs_once_from_owned_ingress() { + use crate::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; + use edgezero_core::geo::{ + GeoError, GeoInput, GeoOutcome, GeoProvider, GeoProviderId, GeoResolution, GeoStatus, + GeoUnavailableReason, + }; + use futures_util::FutureExt as _; + use futures_util::stream::iter as result_stream; + use std::future::pending; + struct Counted(Arc>>); + impl GeoProvider for Counted { + fn resolve(&self, input: &GeoInput) -> GeoResolution { + self.0.lock().unwrap().push(input.clone()); + if input.country.as_bytes().is_err() { + GeoResolution::Error(GeoError::InvalidAssertion) + } else { + GeoResolution::MissingMetadata + } + } + } + struct ObserveAndMutate; + #[async_trait::async_trait(?Send)] + impl Middleware for ObserveAndMutate { + async fn handle( + &self, + mut ctx: RequestContext, + next: Next<'_>, + ) -> Result { + assert!(!ctx.headers().contains_key("x-origin-verify-fixture")); + let owned = ctx.geo().clone(); + ctx.extensions_mut().insert(GeoOutcome::NoSelection); + ctx.headers_mut() + .insert("cloudfront-viewer-country", HeaderValue::from_static("DE")); + assert_eq!(ctx.geo(), &owned); + next.run(ctx).await + } + } + let calls = Arc::new(Mutex::new(Vec::new())); + let selection = NativeGeoSelection::Supplied { + policy: ProtectedCloudFrontAlb::new(["127.0.0.1/32"], "x-origin-verify-fixture") + .unwrap(), + id: GeoProviderId::new("counted").unwrap(), + provider: Arc::new(Counted(Arc::clone(&calls))), + }; + let geo = NativeGeo::validate(&selection).unwrap(); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let handler_calls = Arc::new(AtomicUsize::new(0)); + let observed_handler = Arc::clone(&handler_calls); + let router = RouterService::builder() + .middleware(ObserveAndMutate) + .get("/{mode}", move |mut ctx: RequestContext| { + let handler = Arc::clone(&observed_handler); + async move { + handler.fetch_add(1, Ordering::SeqCst); + assert!(!ctx.headers().contains_key("x-origin-verify-fixture")); + let owned = ctx.geo().clone(); + assert!(matches!(owned, GeoOutcome::Selected { .. })); + ctx.extensions_mut().remove::(); + assert_eq!(ctx.geo(), &owned); + if ctx.uri().path() == "/handler-cancel" { + pending::<()>().await; + } + let label = match ctx.geo() { + GeoOutcome::Selected { + status: GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + .. + } => "admitted", + GeoOutcome::Selected { + status: GeoStatus::Error(_), + .. + } => "error", + GeoOutcome::NoSelection | GeoOutcome::Selected { .. } => "untrusted", + }; + let body = if ctx.uri().path() == "/egress-fail" { + Body::from_stream(result_stream([Err(EdgeError::bad_request( + "fixture egress failure", + ))])) + } else { + Body::from(label) + }; + response_builder() + .body(body) + .map_err(|error| EdgeError::internal(anyhow::Error::new(error))) + } + }) + .build(); + let mut app = App::new(router); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_header_bytes(128) + .unwrap(), + ); + let observed_admission = Arc::clone(&admission_calls); + app.set_ingress_admission_policy(move |head| { + observed_admission.fetch_add(1, Ordering::SeqCst); + assert!(!head.headers().contains_key("x-origin-verify-fixture")); + match head.target().path() { + "/refuse" => AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::empty()) + .unwrap(), + }, + "/abort" => AdmissionDecision::Abort, + _ => AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }, + } + }); + let mut service = AxumServiceState::from_app(app); + service.geo = geo; + let trusted: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + for (peer, country, path, expected) in [ + (Some(trusted), Some("US"), "/good", "admitted"), + (None, Some("US"), "/forged", "untrusted"), + ( + Some("127.0.0.2:1234".parse().unwrap()), + Some("US"), + "/forged", + "untrusted", + ), + (Some(trusted), None, "/missing", "admitted"), + ] { + let mut request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .unwrap(); + request.extensions_mut().insert(ConnectInfo(trusted)); + request.extensions_mut().insert(AxumRequestContext { + remote_addr: Some(trusted), + }); + request.extensions_mut().insert(GeoOutcome::NoSelection); + request.headers_mut().append( + "x-origin-verify-fixture", + HeaderValue::from_static("secret-one"), + ); + request.headers_mut().append( + "x-origin-verify-fixture", + HeaderValue::from_static("secret-two"), + ); + if let Some(assertion) = country { + request.headers_mut().insert( + "cloudfront-viewer-country", + HeaderValue::from_static(assertion), + ); + } + let response = service + .dispatch_request(request, &EgressConnection::default(), peer) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(to_bytes(response.into_body(), 128).await.unwrap(), expected); + } + assert_eq!(calls.lock().unwrap().len(), 2); + assert_eq!( + calls.lock().unwrap()[0].country.as_bytes().unwrap(), + Some(b"US".as_slice()) + ); + assert_eq!(calls.lock().unwrap()[1].country.as_bytes().unwrap(), None); + for path in ["/invalid", "/egress-drop", "/egress-fail"] { + let expected_calls = calls.lock().unwrap().len().checked_add(1).unwrap(); + let mut request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .unwrap(); + if path == "/invalid" { + request + .headers_mut() + .append("cloudfront-viewer-country", HeaderValue::from_static("US")); + request + .headers_mut() + .append("cloudfront-viewer-country", HeaderValue::from_static("US")); + } + let response = service + .dispatch_request(request, &EgressConnection::default(), Some(trusted)) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "Geo errors remain outcomes, not HTTP errors" + ); + assert_eq!(calls.lock().unwrap().len(), expected_calls); + match path { + "/invalid" => { + assert_eq!(to_bytes(response.into_body(), 128).await.unwrap(), "error"); + } + "/egress-drop" => drop(response), + _ => { + to_bytes(response.into_body(), 128).await.unwrap_err(); + } + } + assert_eq!( + calls.lock().unwrap().len(), + expected_calls, + "terminal work does not resolve Geo again" + ); + } + calls.lock().unwrap()[2].country.as_bytes().unwrap_err(); + for path in ["/refuse", "/abort"] { + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .unwrap(); + let outcome = service + .dispatch_request(request, &EgressConnection::default(), Some(trusted)) + .await; + if path == "/refuse" { + assert_eq!(outcome.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE); + } else { + assert!(outcome.is_err()); + } + } + let before = admission_calls.load(Ordering::SeqCst); + let invalid = Request::builder() + .uri("/oversized") + .header("x-origin-verify-fixture", "x".repeat(256)) + .body(AxumBody::empty()) + .unwrap(); + let (mut parts, _) = invalid.into_parts(); + parts.headers.append( + "x-origin-verify-fixture", + HeaderValue::from_static("duplicate-secret"), + ); + service.sanitize_received_head(&mut parts).unwrap_err(); + assert!(!parts.headers.contains_key("x-origin-verify-fixture")); + let request = Request::builder() + .uri("/oversized") + .header("x-origin-verify-fixture", "x".repeat(256)) + .body(AxumBody::empty()) + .unwrap(); + let response = service + .dispatch_request(request, &EgressConnection::default(), Some(trusted)) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + assert_eq!(admission_calls.load(Ordering::SeqCst), before); + drop(response); + let cancelled = Request::builder() + .uri("/handler-cancel") + .body(AxumBody::empty()) + .unwrap(); + assert!( + service + .dispatch_request(cancelled, &EgressConnection::default(), Some(trusted)) + .now_or_never() + .is_none() + ); + assert_eq!( + calls.lock().unwrap().len(), + 6, + "cancellation does not resolve again" + ); + let (phase_owner, reader) = watch::channel(LifecyclePhase::Draining); + service.phase = Some(reader); + let draining = Request::builder() + .uri("/draining") + .body(AxumBody::empty()) + .unwrap(); + assert!( + service + .dispatch_request(draining, &EgressConnection::default(), Some(trusted)) + .await + .is_err() + ); + drop(phase_owner); + assert_eq!(handler_calls.load(Ordering::SeqCst), 8); + assert_eq!(calls.lock().unwrap().len(), 6); + } + async fn to_bytes(mut body: AxumEgressBody, limit: usize) -> Result { let mut buffered = BytesMut::new(); loop { diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs index 08ba8cca3..8ae942637 100644 --- a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -10,7 +10,7 @@ use std::future; use std::net::SocketAddr; use std::panic::set_hook; use std::rc::Rc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; @@ -25,9 +25,14 @@ use edgezero_adapter_axum::diagnostics::{ IngressDisposition, NativeDiagnosticsHandle, NativeDiagnosticsSnapshot, NativeIngressMetadata, NativeRequestFailure, NativeRequestObserver, NativeRequestOutcome, NativeRequestRecord, }; +use edgezero_adapter_axum::geo::{NativeGeoCapability, NativeGeoSelection, ProtectedCloudFrontAlb}; use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::extractor::{AppConfig, ForwardedHost, FromRequest as _, Json, State}; +use edgezero_core::geo::{ + CountryCode, GeoError, GeoInput, GeoLocation, GeoOutcome, GeoProvider, GeoProviderId, + GeoResolution, GeoStatus, GeoUnavailableReason, SubdivisionCode, +}; use edgezero_core::http::{HeaderMap, HeaderValue, Response, StatusCode, Uri}; use edgezero_core::ingress::{ AdmissionDecision, CheckedEffectiveHost, CheckedHostSource, IngressGrant, @@ -54,6 +59,7 @@ use tokio::sync::oneshot; use tokio::time::sleep; static ADMISSION_FACTS: Mutex> = Mutex::new(None); +static GEO_RESOLUTIONS: AtomicUsize = AtomicUsize::new(0); static MIDDLEWARE_FACTS: Mutex> = Mutex::new(None); static PANIC_COMPLETION: AtomicBool = AtomicBool::new(false); static LOGGER_FAULT_REPORTED: AtomicBool = AtomicBool::new(false); @@ -163,6 +169,80 @@ impl Middleware for NativeFactsMiddleware { struct FixtureApp; +#[derive(Clone)] +struct GeoObserved(GeoOutcome); + +struct GeoMutationMiddleware(bool); + +struct FixtureGeoProvider(bool); + +#[async_trait(?Send)] +#[expect( + clippy::expect_used, + reason = "fixture assertions require the first middleware observation and known-valid provider ID" +)] +impl Middleware for GeoMutationMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + if ctx.uri().path() != "/geo" { + return next.run(ctx).await; + } + assert!( + !ctx.headers().contains_key("x-origin-verify-fixture"), + "origin credential must be absent before middleware" + ); + let original = if self.0 { + ctx.extensions() + .get::() + .expect("first Geo middleware") + .0 + .clone() + } else { + ctx.geo().clone() + }; + assert_eq!( + ctx.geo(), + &original, + "middleware must share the owned outcome" + ); + if self.0 { + ctx.extensions_mut().clear(); + } else { + ctx.parts_mut() + .headers + .insert("cloudfront-viewer-country", HeaderValue::from_static("GB")); + ctx.parts_mut().headers.insert( + "cloudfront-viewer-country-region", + HeaderValue::from_static("LND"), + ); + } + ctx.extensions_mut().insert(GeoOutcome::Selected { + provider: GeoProviderId::new("forged-extension").expect("fixture provider ID"), + status: GeoStatus::Error(GeoError::ProviderFailure), + }); + ctx.extensions_mut().insert(GeoObserved(original.clone())); + assert_eq!(ctx.geo(), &original, "public mutation cannot replace Geo"); + next.run(ctx).await + } +} + +#[expect( + clippy::expect_used, + reason = "supplied fixture provider returns fixed validated country and subdivision values" +)] +impl GeoProvider for FixtureGeoProvider { + fn resolve(&self, _input: &GeoInput) -> GeoResolution { + GEO_RESOLUTIONS.fetch_add(1, Ordering::SeqCst); + if self.0 { + GeoResolution::Error(GeoError::ProviderFailure) + } else { + GeoResolution::Located(GeoLocation::new( + CountryCode::new("US").expect("fixture country"), + Some(SubdivisionCode::new("CA").expect("fixture subdivision")), + )) + } + } +} + struct WorkGuard(&'static str); #[expect( @@ -182,8 +262,25 @@ struct StopState(Mutex>>); reason = "fixture retains unused Hooks defaults" )] impl Hooks for FixtureApp { + #[expect( + clippy::panic_in_result_fn, + reason = "fixture assertion proves credential removal before application admission callbacks" + )] fn configure(app: &mut App) -> Result<(), EdgeError> { println!("HOOK_ENTERED"); + if mode().starts_with("geo-") { + app.set_ingress_admission_policy(|head| { + assert!( + !head.headers().contains_key("x-origin-verify-fixture"), + "origin credential removed before admission" + ); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + } if mode() == "hook-error" { return Err(EdgeError::internal(anyhow::anyhow!("HOOK_SENTINEL"))); } @@ -224,6 +321,7 @@ impl Hooks for FixtureApp { .get("/distinct", distinct) .get("/snapshot", snapshot) .get("/stop", stop) + .get("/geo", geo_observation) .get("/native-metadata", native_metadata) .get("/native-error", native_error) .get("/native-upstream", native_upstream) @@ -239,10 +337,15 @@ impl Hooks for FixtureApp { .get("/native-refuse", native_status503) .get("/native-abort", native_status503) .middleware(NativeFactsMiddleware) + .middleware(GeoMutationMiddleware(false)) + .middleware(GeoMutationMiddleware(true)) .build()) } fn stores() -> StoresMetadata { + if mode().starts_with("geo-") { + return StoresMetadata::default(); + } if matches!( mode().as_str(), "bare" @@ -814,6 +917,145 @@ async fn stop(State(state): State>) -> Result<&'static str, EdgeE Ok("stopping") } +fn geo_json(outcome: &GeoOutcome) -> Value { + match outcome { + GeoOutcome::NoSelection => json!({"status": "no_selection"}), + GeoOutcome::Selected { provider, status } => match status { + GeoStatus::Unavailable(reason) => { + json!({"provider": provider.as_str(), "status": match reason { + GeoUnavailableReason::MissingMetadata => "missing_metadata", + GeoUnavailableReason::UntrustedIngress => "untrusted_ingress", + }}) + } + GeoStatus::Error(error) => { + json!({"provider": provider.as_str(), "status": match error { + GeoError::InvalidAssertion => "invalid_assertion", + GeoError::ProviderFailure => "provider_failure", + }}) + } + GeoStatus::Located { + location, + source, + trust_basis, + } => json!({ + "provider": provider.as_str(), + "status": "located", + "country": location.country().as_str(), + "subdivision": location.subdivision().map(SubdivisionCode::as_str), + "source": format!("{source:?}"), + "trust_basis": format!("{trust_basis:?}"), + }), + }, + } +} + +#[action] +async fn geo_observation(mut ctx: RequestContext) -> Result { + let original = ctx + .extensions() + .get::() + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "fixture Geo middleware observation missing" + )) + })? + .0 + .clone(); + assert_eq!( + ctx.geo(), + &original, + "handler must share middleware outcome" + ); + ctx.extensions_mut().remove::(); + assert_eq!( + ctx.geo(), + &original, + "removing public outcome cannot affect Geo" + ); + let credential_absent = !ctx.headers().contains_key("x-origin-verify-fixture"); + let mutated_country = ctx + .headers() + .get("cloudfront-viewer-country") + .and_then(|value| value.to_str().ok()); + json_response(&json!({ + "geo": geo_json(ctx.geo()), + "credential_absent": credential_absent, + "immutable": ctx.geo() == &original, + "mutated_country": mutated_country, + "resolutions": GEO_RESOLUTIONS.load(Ordering::SeqCst), + })) +} + +#[expect( + clippy::panic_in_result_fn, + clippy::expect_used, + reason = "test-only initializer asserts root-free startup, capability capture and known-valid forged state" +)] +fn run_geo_fixture(selected: &str) -> anyhow::Result<()> { + let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?; + let peer_network = if selected.ends_with("untrusted") { + "192.0.2.1/32" + } else { + "127.0.0.1/32" + }; + let policy = ProtectedCloudFrontAlb::new( + if selected == "geo-invalid-policy" { + Vec::new() + } else { + vec![peer_network] + }, + "x-origin-verify-fixture", + )?; + let selection = if selected == "geo-off" { + NativeGeoSelection::Off + } else if selected.starts_with("geo-supplied") || selected == "geo-provider-failure" { + NativeGeoSelection::Supplied { + policy, + id: GeoProviderId::new("fixture-local")?, + provider: Arc::new(FixtureGeoProvider(selected == "geo-provider-failure")), + } + } else { + NativeGeoSelection::CloudFront { policy } + }; + let options = AxumRunOptions::new(address)?.with_geo(selection); + // Capture from the same finalized options, without bypassing the runner's validation. + let capability = options.geo_capability(); + let is_off = selected == "geo-off"; + let (sender, receiver) = oneshot::channel(); + run_app_with_options_and_initializer::( + options, + async move { + let _closed = receiver.await; + }, + move |app, stores| { + Box::pin(async move { + assert!( + stores.config().is_none(), + "Geo fixture declares no config stores" + ); + assert!(stores.kv().is_none(), "Geo fixture declares no KV stores"); + assert!( + stores.secrets().is_none(), + "Geo fixture declares no secret stores" + ); + let captured_capability = capability.map_err(EdgeError::internal)?; + assert_eq!( + matches!(captured_capability, NativeGeoCapability::Off), + is_off, + "initializer sees the finalized selection capability" + ); + app.insert_state(GeoOutcome::Selected { + provider: GeoProviderId::new("forged-app-state").expect("fixture provider ID"), + status: GeoStatus::Error(GeoError::ProviderFailure), + }); + app.insert_state(Arc::new(StopState(Mutex::new(Some(sender))))); + println!("INITIALIZED"); + Ok(()) + }) + }, + ) +} + fn run_explicit_initializer_fixture() -> anyhow::Result<()> { let options = AxumRunOptions::new(env::var("FIXTURE_BIND")?.parse()?)? .with_config_dir(env::var("FIXTURE_CONFIG_ROOT")?)? @@ -853,6 +1095,7 @@ fn main() -> anyhow::Result<()> { return run_native_fixture(&selected); } match selected.as_str() { + geo_mode if geo_mode.starts_with("geo-") => return run_geo_fixture(geo_mode), "development" | "bare-dev" => return run_app::(), "framework" | "bare" | "hook-error" => return run_production_app::(), "explicit-initializer" => return run_explicit_initializer_fixture(), diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs index 5cf651ccd..c74a86c0f 100644 --- a/crates/edgezero-adapter-axum/tests/production_hosting.rs +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -2225,6 +2225,213 @@ mod tests { assert!(!server.log().contains("SENTINEL")); } + fn geo_command(mode: &str, cwd: &TempDir, addr: SocketAddr) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero-hosting-fixture")); + command + .env_clear() + .current_dir(cwd.path()) + .env("FIXTURE_MODE", mode) + .env("FIXTURE_BIND", addr.to_string()); + command + } + + fn geo_response(server: &Server, headers: &str) -> Value { + let response = raw_request( + server.addr, + &format!("GET /geo HTTP/1.1\r\nHost: fixture\r\n{headers}Connection: close\r\n\r\n"), + ); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(!response.contains("ORIGIN_CREDENTIAL_SENTINEL")); + assert!(!response.contains("x-origin-verify-fixture")); + let value = response_json(&response); + assert_eq!(value["immutable"], true); + assert_eq!(value["credential_absent"], true); + assert_eq!(value["mutated_country"], "GB"); + value + } + + fn finish_geo(server: &mut Server, cwd: &TempDir) { + assert!( + server + .request("/stop") + .expect("Geo caller stop") + .ends_with("stopping") + ); + assert!(server.wait().success(), "{}", server.log()); + let log = server.log(); + assert_eq!(log.matches("INITIALIZED").count(), 1); + assert!(!log.contains("ORIGIN_CREDENTIAL_SENTINEL")); + assert!(!log.contains("x-origin-verify-fixture")); + assert_eq!( + fs::read_dir(cwd.path()).expect("unrelated cwd").count(), + 0, + "Geo-only startup must not create store roots or local databases" + ); + } + + #[test] + #[cfg(not(feature = "geo-cloudfront"))] + fn geo_hosting_rejects_unavailable_cloudfront() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut server = Server::spawn(geo_command("geo-cloudfront", &cwd, addr), addr); + assert!(!server.wait().success()); + let log = server.log(); + assert!(!log.is_empty(), "bounded startup error must be reported"); + assert!(!log.contains("INITIALIZED")); + assert!(!log.contains("HOOK_ENTERED")); + assert!(!log.contains("ready")); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("unavailable implementation never binds"); + assert_eq!(fs::read_dir(cwd.path()).expect("unrelated cwd").count(), 0); + } + + #[test] + #[cfg(not(feature = "geo-cloudfront"))] + fn geo_hosting_supplied_provider_without_builtin() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut server = Server::spawn(geo_command("geo-supplied", &cwd, addr), addr); + server.ready(); + let headers = "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\nx-origin-verify-fixture: ORIGIN_CREDENTIAL_SENTINEL\r\nx-origin-verify-fixture: SECOND_ORIGIN_CREDENTIAL_SENTINEL\r\n"; + let first = geo_response(&server, headers); + assert_eq!(first["geo"]["status"], "located"); + assert_eq!(first["geo"]["provider"], "fixture-local"); + assert_eq!(first["geo"]["country"], "US"); + assert_eq!(first["geo"]["subdivision"], "CA"); + assert_eq!(first["geo"]["source"], "CloudFront"); + assert_eq!(first["geo"]["trust_basis"], "DeclaredProtectedAlb"); + let second = geo_response(&server, ""); + assert_eq!(second["geo"]["status"], "located"); + assert_eq!( + second["resolutions"].as_u64().expect("count"), + first["resolutions"].as_u64().expect("count") + 1 + ); + finish_geo(&mut server, &cwd); + } + + #[test] + fn geo_hosting_default_off_untrusted_supplied_and_failure_stay_outcomes() { + for (mode, expected, provider) in [ + ("geo-off", "no_selection", None), + ( + "geo-supplied-untrusted", + "untrusted_ingress", + Some("fixture-local"), + ), + ( + "geo-provider-failure", + "provider_failure", + Some("fixture-local"), + ), + ] { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut server = Server::spawn(geo_command(mode, &cwd, addr), addr); + server.ready(); + let headers = if mode == "geo-off" { + "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\n" + } else { + "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\nx-origin-verify-fixture: ORIGIN_CREDENTIAL_SENTINEL\r\n" + }; + let value = geo_response(&server, headers); + assert_eq!(value["geo"]["status"], expected); + assert_eq!(value["geo"]["provider"].as_str(), provider); + if mode == "geo-provider-failure" { + assert!(value["resolutions"].as_u64().expect("provider invoked") >= 1); + } else { + assert_eq!(value["resolutions"], 0_u64); + } + finish_geo(&mut server, &cwd); + } + } + + #[test] + fn geo_hosting_invalid_policy_never_initializes_or_listens() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut server = Server::spawn(geo_command("geo-invalid-policy", &cwd, addr), addr); + assert!(!server.wait().success()); + assert!(!server.log().contains("INITIALIZED")); + assert!(!server.log().contains("HOOK_ENTERED")); + assert!(!server.log().contains("x-origin-verify-fixture")); + TcpStream::connect_timeout(&addr, Duration::from_millis(50)) + .expect_err("invalid policy never binds"); + assert_eq!(fs::read_dir(cwd.path()).expect("unrelated cwd").count(), 0); + } + + #[test] + #[cfg(feature = "geo-cloudfront")] + fn geo_hosting_proves_real_peer_and_shared_outcome() { + let cwd = tempfile::tempdir().expect("unrelated cwd"); + let addr = address(); + let mut server = Server::spawn(geo_command("geo-cloudfront", &cwd, addr), addr); + server.ready(); + for (headers, expected, subdivision) in [ + ( + "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\n", + "located", + Some("CA"), + ), + ("CloudFront-Viewer-Country: US\r\n", "located", None), + ( + "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country-Region: ZZ\r\n", + "located", + Some("ZZ"), + ), + ("", "missing_metadata", None), + ( + "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country: US\r\n", + "invalid_assertion", + None, + ), + ( + "CloudFront-Viewer-Country: XX\r\n", + "invalid_assertion", + None, + ), + ( + "CloudFront-Viewer-Country-Region: CA\r\n", + "invalid_assertion", + None, + ), + ( + "CloudFront-Viewer-Country: US,CA\r\n", + "invalid_assertion", + None, + ), + ] { + let value = geo_response( + &server, + &format!( + "{headers}x-origin-verify-fixture: ORIGIN_CREDENTIAL_SENTINEL\r\nx-origin-verify-fixture: SECOND_ORIGIN_CREDENTIAL_SENTINEL\r\n" + ), + ); + assert_eq!(value["geo"]["status"], expected); + assert_eq!(value["geo"]["provider"], "cloudfront"); + if expected == "located" { + assert_eq!(value["geo"]["country"], "US"); + assert_eq!(value["geo"]["subdivision"].as_str(), subdivision); + assert_eq!(value["geo"]["source"], "CloudFront"); + assert_eq!(value["geo"]["trust_basis"], "DeclaredProtectedAlb"); + } + } + finish_geo(&mut server, &cwd); + let untrusted_addr = address(); + let mut untrusted = Server::spawn( + geo_command("geo-cloudfront-untrusted", &cwd, untrusted_addr), + untrusted_addr, + ); + untrusted.ready(); + let value = geo_response( + &untrusted, + "CloudFront-Viewer-Country: US\r\nx-origin-verify-fixture: ORIGIN_CREDENTIAL_SENTINEL\r\n", + ); + assert_eq!(value["geo"]["status"], "untrusted_ingress"); + assert_eq!(value["geo"]["provider"], "cloudfront"); + finish_geo(&mut untrusted, &cwd); + } + #[test] fn embedding_does_not_install_process_signal_handlers() { let roots = Roots::new(); diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index b80e34dec..48786e7a3 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -59,6 +59,7 @@ mod tests { }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; use edgezero_core::http::{Method, Response, StatusCode, response_builder}; use edgezero_core::outbound::{ HttpClient, OutboundCollectedSlot, OutboundRequest, OutboundSlotResult, OutboundTime, @@ -137,6 +138,13 @@ mod tests { } } + fn forged_geo() -> GeoOutcome { + GeoOutcome::Selected { + provider: GeoProviderId::new("forged-portable").expect("fixture provider ID"), + status: GeoStatus::Error(GeoError::ProviderFailure), + } + } + fn resolved_slot(slot: &OutboundCollectedSlot) -> &OutboundSlotResult { match slot { OutboundCollectedSlot::Resolved(result) => Some(result), @@ -210,6 +218,8 @@ mod tests { fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { + assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) @@ -284,7 +294,9 @@ mod tests { .get("/config-value", config_value) .build(); - App::new(router) + let mut app = App::new(router); + app.insert_state(forged_geo()); + app } fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { @@ -295,6 +307,12 @@ mod tests { let headers = worker::Headers::new(); headers.set("host", "example.com").expect("host header"); + headers + .set("cloudfront-viewer-country", "US") + .expect("country header"); + headers + .set("cloudfront-viewer-country-region", "CA") + .expect("subdivision header"); headers.set("x-edgezero-test", "1").expect("custom header"); init.with_headers(headers); diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 1c9bb1fa3..a1921b6ec 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -42,6 +42,7 @@ mod tests { }; use edgezero_core::context::{BodyKind, RequestContext}; use edgezero_core::error::EdgeError; + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, response_builder}; use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; use edgezero_core::outbound::{ @@ -140,8 +141,17 @@ mod tests { } } + fn forged_geo() -> GeoOutcome { + GeoOutcome::Selected { + provider: GeoProviderId::new("forged-portable").expect("fixture provider ID"), + status: GeoStatus::Error(GeoError::ProviderFailure), + } + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { + assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) @@ -199,7 +209,9 @@ mod tests { .get("/config", config_value) .build(); - App::new(router) + let mut app = App::new(router); + app.insert_state(forged_geo()); + app } fn fastly_request(method: FastlyMethod, path: &str, body: Option<&[u8]>) -> FastlyRequest { @@ -208,6 +220,8 @@ mod tests { let mut req = FastlyRequest::new(method, format!("http://example.com{path}")); req.set_header("host", "example.com"); req.set_header("x-edgezero-test", "1"); + req.set_header("cloudfront-viewer-country", "US"); + req.set_header("cloudfront-viewer-country-region", "CA"); if let Some(bytes) = body { req.set_body(bytes.to_vec()); } @@ -587,6 +601,8 @@ mod tests { let request_events = Arc::clone(&handler_events); async move { request_events.lock().expect("events").push("handler"); + assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let value = ctx .extensions() .get::() @@ -607,6 +623,7 @@ mod tests { prepare_events.lock().expect("events").push("prepare"); raw.set_url("http://example.com/hook"); extensions.insert(HookValue("visible")); + extensions.insert(forged_geo()); }, move |response| { finalize_events.lock().expect("events").push("finalize"); diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 2503b266a..2b7e879fa 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -52,6 +52,7 @@ mod tests { }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; use edgezero_core::http::{Response, StatusCode, request_builder, response_builder}; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; use edgezero_core::router::RouterService; @@ -224,8 +225,17 @@ mod tests { } } + fn forged_geo() -> GeoOutcome { + GeoOutcome::Selected { + provider: GeoProviderId::new("forged-portable").expect("fixture provider ID"), + status: GeoStatus::Error(GeoError::ProviderFailure), + } + } + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { + assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + assert_eq!(ctx.extensions().get::(), Some(&forged_geo())); let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) @@ -327,7 +337,9 @@ mod tests { .get("/secret-value", secret_value) .build(); - App::new(router) + let mut app = App::new(router); + app.insert_state(forged_geo()); + app } #[test] @@ -595,6 +607,19 @@ mod tests { use super::*; + #[test] + fn geo_managed_dispatch_ignores_forged_extensions() { + let app = build_test_app(); + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::GET, + "http://example.com/uri".parse().expect("URI"), + empty::>(), + )) + .expect("Spin admitted dispatch"); + assert_eq!(captured_body(response).as_ref(), b"http://example.com/uri"); + } + #[test] fn admission_abort() { let source_calls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index cae473d4e..a2ea3020e 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -7,6 +7,7 @@ use crate::error::{ EdgeError, RequestBodyOverflowResponse, mark_body_overflow_response, suppress_overflow_retry_after, }; +use crate::geo::GeoOutcome; use crate::http::header::CONTENT_TYPE; use crate::http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode}; use crate::ingress::{ @@ -309,6 +310,20 @@ impl App { prepared: PreparedIngress, request: Request, detail: FrameworkErrorDetail, + ) -> ResponseEgressEnvelope { + self.dispatch_admitted_with_geo(prepared, request, detail, GeoOutcome::NoSelection) + .await + } + + /// Dispatches with an adapter-owned outcome, independent of public request extensions. + #[doc(hidden)] + #[inline] + pub async fn dispatch_admitted_with_geo( + &self, + prepared: PreparedIngress, + request: Request, + detail: FrameworkErrorDetail, + geo: GeoOutcome, ) -> ResponseEgressEnvelope { let request_method = request.method().clone(); let request_start = prepared.request_start(); @@ -316,7 +331,11 @@ impl App { let (resolved, admitted, completion) = prepared.into_parts(); let (result, route_error, mut failure) = self .router - .dispatch_resolved_outcome(resolved, request, admitted) + .dispatch_resolved_outcome( + resolved, + request, + admitted.with_request_context(geo, detail == FrameworkErrorDetail::CategoryOnly), + ) .await .into_parts(); let synthesized = result.is_err() && route.is_none(); @@ -2566,6 +2585,130 @@ mod tests { ); } + #[test] + fn geo_owned_outcome_survives_public_request_mutation() { + use crate::geo::{GeoOutcome, GeoProviderId, GeoStatus, GeoUnavailableReason}; + use crate::params::PathParams; + fn selected() -> GeoOutcome { + GeoOutcome::Selected { + provider: GeoProviderId::new("owned").unwrap(), + status: GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + } + } + struct MutatePublic; + #[async_trait(?Send)] + impl Middleware for MutatePublic { + async fn handle( + &self, + mut ctx: RequestContext, + next: Next<'_>, + ) -> Result { + assert_eq!(ctx.geo(), &selected()); + ctx.extensions_mut().insert(GeoOutcome::NoSelection); + ctx.headers_mut() + .insert("cloudfront-viewer-country", HeaderValue::from_static("DE")); + assert_eq!(ctx.geo(), &selected()); + next.run(ctx).await + } + } + let router = RouterService::builder() + .middleware(MutatePublic) + .post("/geo", |mut ctx: RequestContext| async move { + assert_eq!(ctx.geo(), &selected()); + ctx.extensions_mut().remove::(); + ctx.parts_mut().extensions.insert(GeoOutcome::NoSelection); + assert_eq!(ctx.geo(), &selected()); + let error = ctx + .json_within::(64) + .await + .expect_err("wrong JSON type"); + assert_eq!(error.message(), "invalid JSON payload"); + ctx.extensions_mut().insert(selected()); + let reconstructed = + RequestContext::new(ctx.into_request().unwrap(), PathParams::default()); + assert_eq!(reconstructed.geo(), &GeoOutcome::NoSelection); + Err::<&str, _>(error) + }) + .build(); + let mut app = App::new(router); + app.set_error_response_renderer(|error| { + assert_eq!(error.message(), "invalid JSON payload"); + Response::new(Body::from("private parse failure")) + }); + let mut request = request_builder() + .method(Method::POST) + .uri("/geo") + .header("content-type", "application/json") + .body(Body::from("\"PAYLOAD_SENTINEL\"")) + .unwrap(); + request.extensions_mut().insert(GeoOutcome::NoSelection); + let head = IngressHeadParts::from_request( + &request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, app.monotonic_now()).unwrap() + else { + panic!("admission") + }; + let response = complete_envelope(block_on(app.dispatch_admitted_with_geo( + prepared, + request, + FrameworkErrorDetail::CategoryOnly, + selected(), + ))); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.body().as_bytes().unwrap(), + b"private parse failure" + ); + } + + #[test] + fn geo_legacy_dispatch_ignores_forged_extensions() { + use crate::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + use crate::params::PathParams; + let forged = GeoOutcome::Selected { + provider: GeoProviderId::new("forged").unwrap(), + status: GeoStatus::Error(GeoError::ProviderFailure), + }; + let mut unmanaged = request_builder().body(Body::empty()).unwrap(); + unmanaged.extensions_mut().insert(forged.clone()); + assert_eq!( + RequestContext::new(unmanaged, PathParams::default()).geo(), + &GeoOutcome::NoSelection + ); + let legacy = App::new( + RouterService::builder() + .get("/legacy", |ctx: RequestContext| async move { + assert_eq!(ctx.geo(), &GeoOutcome::NoSelection); + Ok::<_, EdgeError>("legacy") + }) + .build(), + ); + let mut legacy_request = request_builder() + .uri("/legacy") + .body(Body::empty()) + .unwrap(); + legacy_request.extensions_mut().insert(forged.clone()); + let legacy_head = IngressHeadParts::from_request( + &legacy_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressBeginOutcome::Admitted(legacy_prepared) = legacy + .begin_ingress(legacy_head, legacy.monotonic_now()) + .unwrap() + else { + panic!("admission") + }; + let legacy_response = complete_envelope(block_on( + legacy.dispatch_admitted(legacy_prepared, legacy_request), + )); + assert_eq!(legacy_response.status(), StatusCode::OK); + } + fn managed_dispatch( app: &App, method: Method, diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 2e451ee5e..7be77ed4a 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -13,6 +13,7 @@ use crate::error::{ StoreExtractionReason, }; use crate::error_detail::ProviderFailureDetail; +use crate::geo::GeoOutcome; use crate::http::header::{CONTENT_LENGTH, TRANSFER_ENCODING}; use crate::http::{ Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Uri, Version, @@ -299,7 +300,9 @@ impl StoredError { pub struct RequestContext { body: RefCell, body_policy: Option, + category_only_json_errors: bool, config_extraction_limits: ConfigExtractionLimits, + geo: GeoOutcome, ingress_grant: RefCell>, input_guard: Option>, monotonic_clock: MonotonicClock, @@ -569,7 +572,8 @@ impl RequestContext { ) -> Self { self.path_params = params; self.route_metadata = route_metadata; - if let Some(admitted) = ingress { + if let Some(mut admitted) = ingress { + (self.geo, self.category_only_json_errors) = admitted.take_request_context(); let (request_start, read_deadline, grant, limits, clock) = admitted.into_parts(); self.config_extraction_limits = limits; *self.ingress_grant.get_mut() = Some(grant); @@ -599,6 +603,13 @@ impl RequestContext { .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))) } + /// Returns this request's immutable geographic outcome. Public extensions do not replace it. + #[must_use] + #[inline] + pub fn geo(&self) -> &GeoOutcome { + &self.geo + } + #[must_use] #[inline] pub fn headers(&self) -> &HeaderMap { @@ -663,7 +674,7 @@ impl RequestContext { { let bytes = self.buffered_bytes(max, true).await?; serde_json::from_slice(bytes.as_ref()).map_err(|err| { - if self.body_policy.is_some() { + if self.category_only_json_errors || self.body_policy.is_some() { EdgeError::bad_request("invalid JSON payload") } else { EdgeError::bad_request(format!("invalid JSON payload: {err}")) @@ -755,6 +766,8 @@ impl RequestContext { Self { body: RefCell::new(BodyState::from_transfer(transfer)), body_policy, + geo: GeoOutcome::NoSelection, + category_only_json_errors: false, config_extraction_limits: ConfigExtractionLimits::default(), ingress_grant: RefCell::new(None), input_guard: None, @@ -767,6 +780,10 @@ impl RequestContext { } } pub(crate) fn new_pre_dispatch(request: Request, ingress: Option<&AdmittedIngress>) -> Self { + let (geo, category_only_json_errors) = ingress.map_or( + (GeoOutcome::NoSelection, false), + AdmittedIngress::request_context, + ); let clock = ingress.map_or_else(MonotonicClock::default, AdmittedIngress::monotonic_clock); let deadline = ingress.map(AdmittedIngress::read_deadline); let guard = Rc::new(InputGuard { @@ -794,6 +811,8 @@ impl RequestContext { ingress_grant: RefCell::new(None), input_guard: Some(guard), monotonic_clock: clock, + geo, + category_only_json_errors, parts, path_params: PathParams::default(), read_deadline: deadline, diff --git a/crates/edgezero-core/src/geo.rs b/crates/edgezero-core/src/geo.rs new file mode 100644 index 000000000..6d6007c00 --- /dev/null +++ b/crates/edgezero-core/src/geo.rs @@ -0,0 +1,582 @@ +//! Portable geographic assertions and synchronous, local-only providers. +//! +//! Geography is an estimate, not proof of residence, identity or consent. +//! Country-only results and unrecognized subdivision assertions remain partial. +//! Applications own jurisdiction recognition and permission policy. +//! +//! ``` +//! use edgezero_core::geo::{CountryCode, GeoLocation, SubdivisionCode}; +//! +//! let country_only = GeoLocation::new(CountryCode::new("us")?, None); +//! assert_eq!(country_only.country().as_str(), "US"); +//! assert!(country_only.subdivision().is_none()); +//! +//! // Syntax-valid does not mean an assigned or recognized US subdivision. +//! let assertion = SubdivisionCode::new("zz")?; +//! let location = GeoLocation::new(CountryCode::new("us")?, Some(assertion)); +//! assert_eq!(location.subdivision().map(SubdivisionCode::as_str), Some("ZZ")); +//! # Ok::<(), edgezero_core::geo::GeoValueError>(()) +//! ``` + +#![expect( + clippy::self_named_module_files, + reason = "repo convention uses geo.rs with submodules under geo/, not mod.rs" +)] + +mod country_codes; + +use std::fmt; + +/// A bounded validation category. Rejected source values are never retained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum GeoValueError { + #[error("invalid country code")] + InvalidCountry, + #[error("invalid geographic provider ID")] + InvalidProviderId, + #[error("invalid subdivision code")] + InvalidSubdivision, + #[error("reserved geographic provider ID")] + ReservedProviderId, +} + +/// A public diagnostic label, never a credential. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GeoProviderId(String); + +impl GeoProviderId { + /// Return the validated public label. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Identity reserved for the built-in `CloudFront` provider. + /// + /// Adapters must reject this ID for application-supplied selections. + #[inline] + #[must_use] + pub fn cloudfront() -> Self { + Self("cloudfront".to_owned()) + } + + /// Validate an application provider label matching `[a-z][a-z0-9._-]{0,63}`. + /// + /// # Errors + /// Returns a bounded category for invalid labels or the reserved `cloudfront` ID. + #[inline] + pub fn new(value: &str) -> Result { + if value.is_empty() + || value.len() > 64 + || !value.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte) + }) + { + return Err(GeoValueError::InvalidProviderId); + } + if value == "cloudfront" { + return Err(GeoValueError::ReservedProviderId); + } + Ok(Self(value.to_owned())) + } +} + +/// An officially assigned ISO 3166-1 alpha-2 code from the pinned snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CountryCode(String); + +impl CountryCode { + /// Return the normalized code. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Validate two ASCII letters and normalize them to uppercase. + /// + /// # Errors + /// Rejects unassigned, reserved and unsupported codes without alias substitution. + #[inline] + pub fn new(value: &str) -> Result { + if value.len() != 2 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) { + return Err(GeoValueError::InvalidCountry); + } + let code = value.to_ascii_uppercase(); + if country_codes::ASSIGNED + .binary_search(&code.as_str()) + .is_err() + { + return Err(GeoValueError::InvalidCountry); + } + Ok(Self(code)) + } +} + +/// A syntax-checked ISO 3166-2 component assertion, without a country prefix. +/// +/// This does not verify membership in a country's subdivision catalogue. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubdivisionCode(String); + +impl SubdivisionCode { + /// Return the normalized component. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Validate one to three ASCII alphanumerics, preserving leading zeroes. + /// + /// # Errors + /// Rejects malformed components. No trimming, decoding or prefix removal occurs. + #[inline] + pub fn new(value: &str) -> Result { + if !(1..=3).contains(&value.len()) + || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err(GeoValueError::InvalidSubdivision); + } + Ok(Self(value.to_ascii_uppercase())) + } +} + +/// A country estimate with an optional syntax-checked subdivision assertion. +/// +/// A subdivision-only location cannot be constructed: +/// ```compile_fail +/// use edgezero_core::geo::{GeoLocation, SubdivisionCode}; +/// let subdivision = SubdivisionCode::new("CA").unwrap(); +/// let location = GeoLocation::new(None, Some(subdivision)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeoLocation { + country: CountryCode, + subdivision: Option, +} + +impl GeoLocation { + /// Return the country estimate. + #[inline] + #[must_use] + pub fn country(&self) -> &CountryCode { + &self.country + } + + /// Build a location from validated codes. Country is always required. + #[inline] + #[must_use] + pub fn new(country: CountryCode, subdivision: Option) -> Self { + Self { + country, + subdivision, + } + } + + /// Return the asserted subdivision, if present. Absence remains unknown. + #[inline] + #[must_use] + pub fn subdivision(&self) -> Option<&SubdivisionCode> { + self.subdivision.as_ref() + } +} + +/// Bounded request-resolution errors, with no raw error chain or source data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum GeoError { + #[error("invalid geographic assertion")] + InvalidAssertion, + #[error("geographic provider failure")] + ProviderFailure, +} + +/// An absent, bounded or rejected source assertion. +/// +/// Ingress owns duplicate detection and must use [`Self::invalid`] for duplicates, +/// including identical duplicates. A bounded value is not yet a validated code. +#[derive(Clone, PartialEq, Eq)] +pub struct GeoAssertion { + state: AssertionState, +} + +#[derive(Clone, PartialEq, Eq)] +enum AssertionState { + Absent, + Invalid, + Value { bytes: [u8; N], len: usize }, +} + +impl GeoAssertion { + /// Record a missing assertion. + #[inline] + #[must_use] + pub const fn absent() -> Self { + Self { + state: AssertionState::Absent, + } + } + + /// Inspect the bounded assertion, preserving missing versus rejected input. + /// + /// # Errors + /// Returns [`GeoError::InvalidAssertion`] for an ingress-rejected assertion. + #[inline] + pub fn as_bytes(&self) -> Result, GeoError> { + match &self.state { + AssertionState::Absent => Ok(None), + AssertionState::Value { bytes, len } => Ok(Some(&bytes[..*len])), + AssertionState::Invalid => Err(GeoError::InvalidAssertion), + } + } + + /// Copy at most `N` bytes. Oversize values become invalid before copying. + /// + /// Empty, malformed and non-UTF-8 bounded values remain values for the + /// provider to reject; they never become absence or truncated valid input. + #[inline] + #[must_use] + pub fn from_bytes(value: &[u8]) -> Self { + if value.len() > N { + return Self::invalid(); + } + let mut bytes = [0; N]; + bytes[..value.len()].copy_from_slice(value); + Self { + state: AssertionState::Value { + bytes, + len: value.len(), + }, + } + } + + /// Record a rejected assertion without retaining its bytes. + #[inline] + #[must_use] + pub const fn invalid() -> Self { + Self { + state: AssertionState::Invalid, + } + } +} + +impl fmt::Debug for GeoAssertion { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self.state { + AssertionState::Absent => "GeoAssertion(Absent)", + AssertionState::Value { .. } => "GeoAssertion(Value([redacted]))", + AssertionState::Invalid => "GeoAssertion(Invalid)", + }) + } +} + +/// The complete v1 provider input, admitted by the adapter's protected source. +/// +/// No request, reader IP, service handle or arbitrary headers are available. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeoInput { + pub country: GeoAssertion<2>, + pub subdivision: GeoAssertion<3>, +} + +/// Provider-owned resolution, without framework selection or provenance choices. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeoResolution { + Error(GeoError), + Located(GeoLocation), + MissingMetadata, +} + +/// A synchronous provider performing only bounded local computation. +/// +/// Implementations must not perform network, filesystem or other blocking I/O, +/// launch background work or resolve geography again during response finalization. +/// They return owned results without futures. The portable trait intentionally +/// imposes no `Send` or `Sync` bound; native composition adds those bounds. +/// +/// Application Rust is trusted code. This narrow input prevents accidental +/// coupling, but does not sandbox a malicious implementation. +pub trait GeoProvider { + /// Resolve source-admitted assertions, including absent or rejected slots. + fn resolve(&self, input: &GeoInput) -> GeoResolution; +} + +/// Why a selected provider could not supply geography. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GeoUnavailableReason { + MissingMetadata, + UntrustedIngress, +} + +/// The source of a located assertion, not a distribution identity attestation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GeoSource { + CloudFront, +} + +/// The declared infrastructure protection used for source admission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GeoTrustBasis { + /// Operator-declared credential-gated, network-isolated ALB path. + /// This does not prove the external listener or network configuration. + DeclaredProtectedAlb, +} + +/// Framework-owned status for a selected provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeoStatus { + Error(GeoError), + Located { + location: GeoLocation, + source: GeoSource, + trust_basis: GeoTrustBasis, + }, + Unavailable(GeoUnavailableReason), +} + +/// One owned request outcome. It neither sets HTTP status nor grants permission. +/// +/// Every selected status retains the deployment-selected provider identity, +/// including source rejection before a provider call. No fallback is implied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeoOutcome { + NoSelection, + Selected { + provider: GeoProviderId, + status: GeoStatus, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use base16ct::lower::encode_string; + use std::cell::Cell; + use std::rc::Rc; + use std::str; + + #[test] + fn geo_country_codes_normalize_and_reject_unsupported() { + assert_eq!(CountryCode::new("us").unwrap().as_str(), "US"); + assert_eq!(CountryCode::new("gB").unwrap().as_str(), "GB"); + assert_eq!(CountryCode::new("NA").unwrap().as_str(), "NA"); + for value in [ + "", "U", "USA", " US", "US ", "U S", "\u{e9}", "U1", "US,CA", "\0U", + ] { + assert_eq!(CountryCode::new(value), Err(GeoValueError::InvalidCountry)); + } + // Reserved, transitional and user-assigned codes are not aliases. + for value in [ + "XX", "ZZ", "UK", "XK", "AA", "QM", "QZ", "XA", "XZ", "EU", "SU", "AN", "TP", + ] { + assert_eq!(CountryCode::new(value), Err(GeoValueError::InvalidCountry)); + } + for (value, expected) in [ + ("ca", "CA"), + ("1", "1"), + ("01", "01"), + ("001", "001"), + ("a1b", "A1B"), + ("ZZ", "ZZ"), + ] { + assert_eq!(SubdivisionCode::new(value).unwrap().as_str(), expected); + } + for value in [ + "", "ABCD", "US-CA", " CA", "CA ", "A B", "\u{e9}", "A_", "1,2", "\0", + ] { + assert_eq!( + SubdivisionCode::new(value), + Err(GeoValueError::InvalidSubdivision) + ); + } + let country = CountryCode::new("US").unwrap(); + let unknown = GeoLocation::new(country.clone(), Some(SubdivisionCode::new("ZZ").unwrap())); + assert_eq!(unknown.country(), &country); + assert_eq!(unknown.subdivision().unwrap().as_str(), "ZZ"); + assert!(GeoLocation::new(country, None).subdivision().is_none()); + } + + #[test] + fn country_snapshot_is_ordered_unique_and_complete() { + use sha2::{Digest as _, Sha256}; + + assert_eq!(country_codes::ASSIGNED.len(), 249); + assert!( + country_codes::ASSIGNED + .windows(2) + .all(|pair| matches!(pair, [first, second] if first < second)) + ); + let mut snapshot = String::new(); + for code in country_codes::ASSIGNED { + assert_eq!(code.len(), 2); + assert!(code.bytes().all(|byte| byte.is_ascii_uppercase())); + assert_eq!(CountryCode::new(code).unwrap().as_str(), *code); + snapshot.push_str(code); + snapshot.push('\n'); + } + assert_eq!( + encode_string(&Sha256::digest(snapshot.as_bytes())), + country_codes::SNAPSHOT_SHA256 + ); + let mut accepted = 0; + for first in b'A'..=b'Z' { + for second in b'A'..=b'Z' { + let bytes = [first, second]; + let value = str::from_utf8(&bytes).unwrap(); + let valid = country_codes::ASSIGNED.contains(&value); + assert_eq!(CountryCode::new(value).is_ok(), valid); + assert_eq!(CountryCode::new(&value.to_ascii_lowercase()).is_ok(), valid); + accepted += usize::from(valid); + } + } + assert_eq!(accepted, 249); + } + + #[test] + fn provider_ids_are_bounded_public_labels_with_a_reserved_builtin() { + for value in [ + "a", + "local", + "test.provider-1_v2", + &format!("a{}", "9".repeat(63)), + ] { + assert_eq!(GeoProviderId::new(value).unwrap().as_str(), value); + } + for value in [ + "", + "A", + "1a", + "_a", + "a b", + "\u{e9}", + "a/b", + "a\n", + &"a".repeat(65), + ] { + assert_eq!( + GeoProviderId::new(value), + Err(GeoValueError::InvalidProviderId) + ); + } + assert_eq!( + GeoProviderId::new("cloudfront"), + Err(GeoValueError::ReservedProviderId) + ); + assert_eq!(GeoProviderId::cloudfront().as_str(), "cloudfront"); + } + + #[test] + fn assertions_are_bounded_owned_and_redacted() { + assert_eq!(GeoAssertion::<2>::absent().as_bytes(), Ok(None)); + assert_eq!( + GeoAssertion::<2>::invalid().as_bytes(), + Err(GeoError::InvalidAssertion) + ); + for bytes in [b"".as_slice(), b"U", b"US", &[0xff, 0xfe]] { + let assertion = GeoAssertion::<2>::from_bytes(bytes); + assert_eq!(assertion.as_bytes(), Ok(Some(bytes))); + assert_eq!(format!("{assertion:?}"), "GeoAssertion(Value([redacted]))"); + } + assert_eq!( + GeoAssertion::<2>::from_bytes(b"USA").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + assert_eq!( + GeoAssertion::<3>::from_bytes(b"123").as_bytes(), + Ok(Some(b"123".as_slice())) + ); + assert_eq!( + GeoAssertion::<3>::from_bytes(b"1234").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + assert_eq!( + GeoAssertion::<0>::from_bytes(b"X").as_bytes(), + Err(GeoError::InvalidAssertion) + ); + let mut source = *b"us"; + let assertion = GeoAssertion::<2>::from_bytes(&source); + source.fill(b'X'); + assert_eq!(assertion.as_bytes(), Ok(Some(b"us".as_slice()))); + let input = GeoInput { + country: assertion, + subdivision: GeoAssertion::from_bytes(b"ca"), + }; + let debug = format!("{input:?}"); + assert!(!debug.contains("us")); + assert!(!debug.contains("ca")); + assert!(debug.contains("[redacted]")); + } + + #[test] + fn validated_values_and_outcomes_have_equality_and_safe_diagnostics() { + let location = GeoLocation::new( + CountryCode::new("us").unwrap(), + Some(SubdivisionCode::new("ca").unwrap()), + ); + assert_eq!( + location, + GeoLocation::new( + CountryCode::new("US").unwrap(), + Some(SubdivisionCode::new("CA").unwrap()) + ) + ); + let outcome = GeoOutcome::Selected { + provider: GeoProviderId::cloudfront(), + status: GeoStatus::Located { + location, + source: GeoSource::CloudFront, + trust_basis: GeoTrustBasis::DeclaredProtectedAlb, + }, + }; + assert_eq!(outcome.clone(), outcome); + let debug = format!("{outcome:?}"); + assert!(debug.contains("US")); + assert!(debug.contains("CA")); + assert!(debug.contains("cloudfront")); + assert_ne!(GeoOutcome::NoSelection, outcome); + for reason in [ + GeoUnavailableReason::MissingMetadata, + GeoUnavailableReason::UntrustedIngress, + ] { + let selected = GeoOutcome::Selected { + provider: GeoProviderId::new("custom").unwrap(), + status: GeoStatus::Unavailable(reason), + }; + assert!(format!("{selected:?}").contains("custom")); + } + assert_eq!( + GeoError::InvalidAssertion.to_string(), + "invalid geographic assertion" + ); + assert_eq!( + GeoError::ProviderFailure.to_string(), + "geographic provider failure" + ); + assert_eq!( + GeoValueError::InvalidCountry.to_string(), + "invalid country code" + ); + } + + #[test] + fn portable_provider_is_synchronous_and_need_not_be_send_or_sync() { + struct LocalProvider(Rc>); + impl GeoProvider for LocalProvider { + fn resolve(&self, _input: &GeoInput) -> GeoResolution { + self.0.set(self.0.get() + 1); + GeoResolution::MissingMetadata + } + } + let calls = Rc::new(Cell::new(0)); + let provider: Box = Box::new(LocalProvider(Rc::clone(&calls))); + let input = GeoInput { + country: GeoAssertion::absent(), + subdivision: GeoAssertion::absent(), + }; + assert_eq!(provider.resolve(&input), GeoResolution::MissingMetadata); + assert_eq!(calls.get(), 1); + } +} diff --git a/crates/edgezero-core/src/geo/country_codes.rs b/crates/edgezero-core/src/geo/country_codes.rs new file mode 100644 index 000000000..6fe08e4a5 --- /dev/null +++ b/crates/edgezero-core/src/geo/country_codes.rs @@ -0,0 +1,42 @@ +//! Assigned ISO 3166-1 alpha-2 country codes, captured 2026-10-22. +//! +//! Authority: ISO 3166 Maintenance Agency, +//! and . +//! Imported from datasets/country-list revision +//! ca393dfa16db21ce07be77902c1c129d63c86d9f, data.csv: +//! +//! Source CSV SHA-256: 3187a6850a6d2d8f03c099ac6eb2477d38fe7d2a85a3a5c1394eaf12e6e03d76. +//! +//! Only the 249 code elements are used, sorted by ASCII code. No country names, +//! software, or original arrangement are reproduced. The upstream list derives +//! from pycountry and Debian iso-codes; its maintainers dedicate their own work +//! under ODC-PDDL-1.0. See that revision's README.md and ATTRIBUTION.txt. +//! ISO permits free-of-charge use of the codes: +//! . +//! This table does not reproduce the ISO standard and is not endorsed by ISO. +//! +//! Updates require an explicit source revision, checksum and exclusion review. +//! The snapshot checksum covers sorted codes, each followed by one LF byte. + +#[cfg(test)] +pub(super) const SNAPSHOT_SHA256: &str = + "801ef127f0b3e6b4e971c239c9b8475caedb65c17573d84ca1b57eed72523a0e"; + +pub(super) const ASSIGNED: &[&str] = &[ + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", + "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", + "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", + "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", + "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", + "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", + "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", + "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", + "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", + "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", + "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", + "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", + "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", + "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", + "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", +]; diff --git a/crates/edgezero-core/src/ingress.rs b/crates/edgezero-core/src/ingress.rs index bda5e451a..4a78f7758 100644 --- a/crates/edgezero-core/src/ingress.rs +++ b/crates/edgezero-core/src/ingress.rs @@ -14,6 +14,7 @@ pub use fidelity::{ use std::any::Any; use std::fmt; +use std::mem; use std::sync::Arc; use std::time::Duration; @@ -22,6 +23,7 @@ use bytes::Bytes; use crate::body::Body; use crate::config_store::ConfigExtractionLimits; use crate::error::EdgeError; +use crate::geo::GeoOutcome; use crate::http::{ Authority, Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Response, StatusCode, Uri, Version, @@ -229,12 +231,14 @@ pub enum IngressDispatchOutcome { /// Proof that the application selected one request disposition with a finite read deadline. pub struct AdmittedIngress { + category_only_json_errors: bool, config_extraction_limits: ConfigExtractionLimits, dispatch_disposition: IngressDispatchDisposition, grant: IngressGrant, head_limits: IngressHeadLimits, monotonic_clock: MonotonicClock, read_deadline: Deadline, + request_geo: GeoOutcome, request_start: MonotonicInstant, } @@ -308,12 +312,23 @@ impl AdmittedIngress { self.read_deadline } + pub(crate) fn request_context(&self) -> (GeoOutcome, bool) { + (self.request_geo.clone(), self.category_only_json_errors) + } + #[must_use] #[inline] pub fn request_start(&self) -> MonotonicInstant { self.request_start } + pub(crate) fn take_request_context(&mut self) -> (GeoOutcome, bool) { + ( + mem::replace(&mut self.request_geo, GeoOutcome::NoSelection), + self.category_only_json_errors, + ) + } + pub(crate) fn with_config_extraction_limits(mut self, limits: ConfigExtractionLimits) -> Self { self.config_extraction_limits = limits; self @@ -323,6 +338,16 @@ impl AdmittedIngress { self.head_limits = limits; self } + + pub(crate) fn with_request_context( + mut self, + geo: GeoOutcome, + category_only_json_errors: bool, + ) -> Self { + self.request_geo = geo; + self.category_only_json_errors = category_only_json_errors; + self + } } /// Provenance of one independently checked effective proxy field. @@ -868,6 +893,8 @@ pub(crate) fn apply_admission_policy( Ok(IngressAdmissionOutcome::Admitted { completion, ingress: AdmittedIngress { + request_geo: GeoOutcome::NoSelection, + category_only_json_errors: false, config_extraction_limits: ConfigExtractionLimits::default(), dispatch_disposition, grant, diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 3f0903627..303a3ab12 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod env_config; pub mod error; pub mod error_detail; pub mod extractor; +pub mod geo; pub mod handler; pub mod http; pub mod ingress; diff --git a/crates/edgezero-test-harness/fixtures/reusable-app/Cargo.lock b/crates/edgezero-test-harness/fixtures/reusable-app/Cargo.lock index 8be039781..6a23dd199 100644 --- a/crates/edgezero-test-harness/fixtures/reusable-app/Cargo.lock +++ b/crates/edgezero-test-harness/fixtures/reusable-app/Cargo.lock @@ -507,10 +507,12 @@ dependencies = [ "edgezero-store-celld", "futures", "futures-util", + "getrandom 0.4.2", "http", "http-body", "hyper", "hyper-util", + "ipnet", "libc", "log", "redb", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 90148daa1..894c351c2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -54,6 +54,7 @@ export default defineConfig({ text: 'Axum diagnostics and trusted proxies', link: '/guide/axum-diagnostics', }, + { text: 'Native Geo providers', link: '/guide/native-geo' }, { text: 'Native AWS stores', link: '/guide/native-aws-stores' }, { text: 'Native celld stores', link: '/guide/native-celld-stores' }, { text: 'DynamoDB KV', link: '/guide/dynamodb-kv' }, diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index a84a9d210..fb05ecabe 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -278,6 +278,29 @@ field-section accounting or raw framing-rejection policy. Admission still receiv `IngressHeadAccounting::HostManaged` and `IngressFraming::HostManaged`; both raw-ingress capabilities remain `Unsupported`. See [Capabilities](/guide/capabilities). +## Checked ingress and diagnostics + +Managed hosting validates the original normalized request head, removes raw forwarding fields, +and exposes checked metadata from the actual socket peer. Proxy trust is off by default. +Configure a bounded `TrustedProxyPolicy` only for deployment-owned peers. Native request records +omit raw headers, addresses, paths/queries, and source error strings; application-owned loggers +and observers remain responsible for their own output. See +[Axum diagnostics and trusted proxies](../axum-diagnostics.md) for configuration, metadata, +request identity, counters, and deployment examples. + +## Native Geo providers + +Geo is off by default and selected only through `AxumRunOptions::with_geo`. The opt-in +`geo-cloudfront` feature adds a bounded local CloudFront parser, not AWS SDKs or store setup. +Both built-in and supplied providers require a separate `ProtectedCloudFrontAlb` policy using +the actual socket peer. Ordinary trusted-proxy configuration does not admit geographic assertions. + +Read the immutable outcome through `RequestContext::geo()`. Missing metadata, untrusted ingress, +invalid assertions, and provider failures remain distinct; none grants permission or chooses an +HTTP status. The [native Geo guide](../native-geo.md) covers the Rust API, mandatory HTTPS origin +credential gate and network isolation, header generation, disabled shared caching, rotation, +and the separate cloud/downstream acceptance checks. + ## Outbound HTTP The Axum adapter uses a `HttpClient` registered in the assembled app's router state when present, diff --git a/docs/guide/native-geo.md b/docs/guide/native-geo.md new file mode 100644 index 000000000..7a1b1f05e --- /dev/null +++ b/docs/guide/native-geo.md @@ -0,0 +1,317 @@ +# Native Geo providers + +Native Axum hosting can resolve CloudFront country and subdivision assertions once per admitted +request. Geo is off by default. There is no database, reader-IP lookup, remote call, fallback +provider, CLI option, environment selector, or manifest selector. + +Geography is an estimate, not proof of residence, identity, consent, or permission. Applications +own jurisdiction recognition and policy. A missing subdivision stays unknown, and a +syntax-valid subdivision is not necessarily assigned or recognized. + +## Select a provider in Rust + +The public selection API lives in `edgezero_adapter_axum::geo` with the `axum` feature. +Only the built-in implementation needs the default-off `geo-cloudfront` feature: + +```toml +[dependencies] +edgezero-adapter-axum = { version = "", default-features = false, features = ["geo-cloudfront"] } +``` + +Use the same pinned EdgeZero release as your application. This feature enables `axum`, not +AWS stores or an AWS SDK. A Geo-only application needs no AWS credentials, config/data roots, +or declared stores. Existing declared stores keep their own startup requirements. + +### Off + +Every options constructor defaults to `Off`, including `from_env` and `from_vars`. Selection +is always programmatic, even when other hosting settings come from the environment. + +```rust +use edgezero_adapter_axum::dev_server::AxumRunOptions; +use edgezero_adapter_axum::geo::{NativeGeoCapability, NativeGeoSelection}; +let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? + .with_geo(NativeGeoSelection::Off); +assert_eq!(options.geo_capability()?, NativeGeoCapability::Off); +``` + +### CloudFront + +First deploy and verify the [protected origin](#protect-the-cloudfront-origin). Selecting the +policy below declares that those infrastructure controls are enforced. It does not configure +or inspect AWS. + +```rust +use edgezero_adapter_axum::dev_server::{AxumRunOptions, run_app_with_options_and_initializer}; +use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +use edgezero_core::app::Hooks; + +fn run() -> anyhow::Result<()> { + let policy = ProtectedCloudFrontAlb::new( + ["10.42.1.0/24", "10.42.2.0/24"], "x-origin-verify-randomsuffix", + )?; + let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)? + .with_geo(NativeGeoSelection::CloudFront { policy }); + let capability = options.geo_capability()?; + run_app_with_options_and_initializer::( + options, std::future::pending::<()>(), move |_app, _stores| { + Box::pin(async move { + let _configured_capability = capability; + // Validate application requirements here, without inferring + // that every request will have geographic metadata. + Ok(()) + }) + }, + ) +} +``` + +The constructor accepts literal IPv4/IPv6 addresses or CIDRs, not DNS names or provider aliases. +The list must be nonempty, with at most 256 supplied entries and 16,384 input bytes including +separators. Duplicates count before normalization. Whole-address-family coverage, including +unions and unrestricted mapped-IPv4 ranges, is rejected. IPv4-mapped IPv6 peers match their +IPv4 equivalents. Use the narrowest actual ALB peer ranges and enforce their ownership through +security groups. A matching private address alone does not authenticate a distribution. + +The dedicated credential-header name must be a valid ASCII HTTP token of at most 128 bytes. +After case normalization it must start with `x-`. These names are forbidden: + +- Every `x-forwarded-*`, `x-amz-*`, `x-amzn-*`, and `x-edge-*` name. +- `x-real-ip`, `x-request-id`, and `x-correlation-id`. + +The `x-` rule also excludes standard authorization, cookie, transport, and CloudFront geographic +headers. Do not include a credential value in this policy. EdgeZero never compares that value. +Policy debug output and configuration errors omit both the name and network literals. + +Selecting `CloudFront` without compiling `geo-cloudfront` is a startup error, not `Off`. +The selector remains available so a missing implementation cannot silently disable a required +configuration. Invalid selections fail before runner resources, deferred store setup, the +application initializer, and listener binding. + +### Application-supplied provider + +Supply a local implementation of `edgezero_core::geo::GeoProvider`. It works with only `axum` +enabled, without the built-in `geo-cloudfront` implementation. This composition-only example +always returns missing metadata; replace `ExampleProvider` with your own bounded implementation: + +```rust +use std::sync::Arc; +use edgezero_adapter_axum::dev_server::AxumRunOptions; +use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +use edgezero_core::geo::{GeoInput, GeoProvider, GeoProviderId, GeoResolution}; + +struct ExampleProvider; +impl GeoProvider for ExampleProvider { + fn resolve(&self, _input: &GeoInput) -> GeoResolution { + GeoResolution::MissingMetadata + } +} +let policy = ProtectedCloudFrontAlb::new( + ["10.42.1.0/24"], "x-origin-verify-randomsuffix", +)?; +let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? + .with_geo(NativeGeoSelection::Supplied { + policy, + id: GeoProviderId::new("example.local")?, + provider: Arc::new(ExampleProvider), + }); +let _capability = options.geo_capability()?; +``` + +Provider IDs are public diagnostic labels, never secrets. They match +`[a-z][a-z0-9._-]{0,63}`. The built-in `cloudfront` identity is reserved and cannot label a +supplied provider, including when obtained through `GeoProviderId::cloudfront()`. + +`GeoProvider::resolve(&self, input: &GeoInput) -> GeoResolution` is synchronous, bounded local +computation. The input has only a country slot of at most two bytes and a subdivision slot of +at most three bytes. Each slot is absent, bounded, or marked invalid. Preserve invalid markers; +do not turn them into missing data. Results use validated `CountryCode`, `SubdivisionCode`, +and `GeoLocation` types or bounded `GeoError` categories. + +Do not perform blocking I/O, remote requests, filesystem access, or background preparation. +The portable trait has no `Send`/`Sync` supertraits; native ownership adds these bounds to the +supplied object. Application Rust is trusted code, not a sandbox. In v1 the supplied provider +uses the same protected CloudFront/ALB source contract, not an alternative raw-header source. + +There is exactly one selection. A later `with_geo` call replaces the entire earlier selection; +it does not merge policies or create a fallback chain. Provider failures do not switch providers. + +### Inspect startup capability + +Capture capability from the final options, then pass those same options to the existing +initializer-based runner. No extra initializer argument or App registry is needed. + +Capability is `NativeGeoCapability::Off` or `NativeGeoCapability::Selected { provider, source, +trust_basis }`. The CloudFront example above captures it in the initializer closure. + +This embedding example waits indefinitely; supply your own stop future in a real host. +Explicit-options runners install no process-wide signal handlers. Capability inspection returns +`Result`. It confirms validated selection, not +per-request coverage, infrastructure protection, or readiness of a downstream policy. + +## Read the immutable request outcome + +Middleware and handlers read `RequestContext::geo()`. An outcome does not set an HTTP status +or grant access. Call `describe(ctx.geo())` with the following classifier. Its labels describe +data availability, not permission. Applications must separately recognize each subdivision, +preserving unknown values rather than treating them as known non-regulated jurisdictions: + +```rust +use edgezero_core::geo::{GeoError, GeoOutcome, GeoStatus, GeoUnavailableReason}; +fn describe(outcome: &GeoOutcome) -> &'static str { + match outcome { + GeoOutcome::NoSelection => "not configured", + GeoOutcome::Selected { status, .. } => match status { + GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress) => "source not admitted", + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) => "metadata missing", + GeoStatus::Error(GeoError::InvalidAssertion) => "assertion rejected", + GeoStatus::Error(GeoError::ProviderFailure) => "provider failed", + GeoStatus::Located { location, .. } if location.subdivision().is_none() => "country only", + GeoStatus::Located { .. } => "country and unverified subdivision assertion", + }, + } +} +``` + +Every selected outcome retains the selected provider identity, even when source admission +fails before invocation. Located results carry `GeoSource::CloudFront` and +`GeoTrustBasis::DeclaredProtectedAlb`. These describe the source and operator-declared path, +not a cryptographic attestation of distribution identity. + +```mermaid +flowchart TD + Head[Account original request head] --> Strip[Remove every configured credential field] + Strip --> Admission[Validate and admit ingress] + Admission -->|Refused or aborted| Stop[No provider invocation] + Admission -->|Admitted| Selection{Provider selected?} + Selection -->|No| Off[NoSelection] + Selection -->|Yes| Peer{Actual socket peer matches protected policy?} + Peer -->|No or missing| Untrusted[UntrustedIngress, no invocation] + Peer -->|Yes| Resolve[Resolve bounded assertion slots exactly once] + Resolve --> Context[Store private outcome before middleware and handlers] +``` + +Ordinary trusted-proxy configuration, effective reader IP, forwarding headers, public +extensions, and caller-supplied `ConnectInfo` cannot create Geo source admission. Admission +callbacks run before Geo exists. Header changes or extension insertion/removal by application +code cannot replace the managed outcome. Response finalization never resolves it again. + +The unmanaged request converter, legacy dispatch, Fastly, Cloudflare, and Spin expose +`NoSelection`. Returning a context to an ordinary request and constructing a new unmanaged +context does not preserve its private Geo result. + +## CloudFront assertion rules + +CloudFront must generate both `CloudFront-Viewer-Country` and +`CloudFront-Viewer-Country-Region` through an explicit origin request policy. Generic viewer +header forwarding is not enough. Never install static custom geographic values or an edge +function that rewrites the generated geography. + +| Received assertions from an admitted source | Outcome | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Neither present | `MissingMetadata` | +| Country only | `Located`, subdivision unknown | +| `us` and `ca` | `Located` with `US` and `CA` | +| `US` and `01` | `Located` with syntax-checked numeric subdivision | +| `US` and `ZZ` | `Located` with a syntax-valid assertion, not verified subdivision membership | +| Subdivision without country | `InvalidAssertion` | +| Unsupported/reserved country, including `XX`, `ZZ`, `UK`, or `XK` | `InvalidAssertion`, no aliases | +| Duplicate field instances, even identical, or comma-joined values | `InvalidAssertion` | +| Empty, whitespace, non-ASCII, malformed, or oversized value in either field | `InvalidAssertion` for the whole result | + +Country is exactly two ASCII letters, normalized to uppercase and checked against the pinned, +checked-in list of 249 officially assigned ISO 3166-1 alpha-2 codes. The source revision and +checksum live beside `edgezero-core/src/geo/country_codes.rs`; updates are reviewed code changes, +not runtime downloads. Subdivision is one to three ASCII letters/digits, normalized to uppercase, +without a country prefix or separator. There is no subdivision catalogue. No trimming, decoding, +truncation into validity, or guessed geography occurs. These rules apply to values exposed by +the HTTP parser; upstream parser-stripped whitespace cannot be detected retroactively. + +## Protect the CloudFront origin + +Use AWS's [origin-secret ALB pattern](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html). +All of these controls are required together: + +1. Give the intended CloudFront distribution a dedicated high-entropy custom origin credential. + Do not share it with unrelated distributions. Keep both header name and value out of request + logs, responses, diagnostic exports, and test artifacts. +2. Require viewer HTTPS. Set the CloudFront origin protocol to HTTPS-only. For AWS's HTTPS ALB + recipe, include `Host` in the origin request policy and configure the ALB certificate to match + the routed hostname. Restrict served public hostnames; a viewer's arbitrary host is not an + authorization decision. +3. Forward from the ALB listener to the application target group only when that credential + matches an explicit non-wildcard value. The default action denies access. No alternate + listener or rule may forward unchecked traffic to this target group. +4. Restrict the ALB security group to CloudFront origin-facing managed prefix lists for every + enabled IP family. These ranges admit CloudFront infrastructure, not one distribution; + they do not replace the credential gate. +5. Keep the Axum port private and reachable only from the intended ALB security group. Keep + the ALB-to-Axum HTTP hop inside the isolated VPC. No public target, alternate proxy, listener, + or network route may bypass the gate. This feature adds no native TLS server. + +AWS documents that [custom origin headers replace viewer-supplied values](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html). +The ALB validates the credential. EdgeZero stores only its removal name, strips every occurrence +before application callbacks, and never receives the expected value as runtime configuration. +A correctly matched socket peer with an incorrectly configured ALB is still unsafe. EdgeZero +cannot inspect listener rules or security groups. + +### Generated headers and missing subdivision + +AWS documents [generated-header replacement after the viewer-request event](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html#lambda-at-edge-restrictions-cloudfront-headers). +No Lambda@Edge function is required. The [header reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html) +says subdivision is omitted for requests originating from the AWS network. Test spoofed viewer +subdivision in that case. Do not assume that omission of generated data safely removes a +viewer-supplied value. + +### Disable shared caching + +Apply CloudFront's managed [CachingDisabled policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html) +to these application routes, with minimum/default/maximum TTL zero. Set +[Error Caching Minimum TTL](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/custom-error-pages-expiration.html) +to zero for cacheable application-error statuses too; the default is ten seconds. Prevent any +other shared cache from reusing these responses. Forwarding geographic headers does not partition +cached responses by geography or permission. A custom Geo-aware cache-key recipe and automatic +response/cache-header mutation are outside this feature. + +### Rotate the credential value + +Keep the header name fixed during live rotation: + +1. Generate a new high-entropy value through your secret-management process, without printing it. +2. Temporarily configure the ALB gate to accept old and new exact values. +3. Update the intended distribution's custom origin value and wait for deployment completion. +4. Verify intended traffic, denial of unrelated traffic, and application-side header removal. +5. Remove the old accepted value and verify it no longer passes the gate. + +Renaming the credential header requires coordinated stop/reconfigure/restart so no in-flight +configuration leaves an unremoved credential field. V1 has no dual-name removal state. + +## Acceptance boundaries + +Local EdgeZero tests prove bounded parsing, selection failures, credential removal, real-socket +peer matching, one invocation, immutable outcomes, lifecycle preservation, and portable dependency +isolation. They do not prove AWS configuration or application permission policy. + +Before production use, record separate evidence against the actual distribution, ALB, and target: + +- Intended distribution traffic reaches the app. Direct ALB traffic and a different distribution + without the credential cannot. A network denial is valid when prefix lists block connection. +- Direct target access, alternate listeners/proxies, and unintended security-group sources cannot + bypass the credential gate. +- Forged viewer country/subdivision does not become accepted geography. Include the AWS-network + missing-subdivision case. +- Credential replacement, validation, removal, and value rotation work without exposing name or + value to application code, responses, or logs. +- Missing subdivision stays country-only and absent metadata stays missing, without fallback. +- Successful and cacheable error responses are not reused across requests by any shared cache. + +A failed deployment check blocks production use of the declared policy. It does not justify +broader header trust or a fallback. Downstream policy-path tests remain another acceptance gate. +Trusted Server adoption, jurisdiction lists, consent interpretation, and Edge Cookie/identity +behavior are separate work; this feature does not relax their fail-closed requirements. + +[AWS VPC origins](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html), +other adapters/sources, GeoIP databases, reader-IP inputs, remote providers, hot reload, and +subdivision catalogues are deferred. VPC origins are not a second supported trust mode here. +See [Axum diagnostics and trusted proxies](./axum-diagnostics.md) for ordinary forwarding policy, +which remains independent from Geo source admission. diff --git a/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md b/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md new file mode 100644 index 000000000..6ac078a5d --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md @@ -0,0 +1,498 @@ +# Pluggable Geo providers implementation plan + +- Status: full local implementation and verification complete. Draft publication is authorized on top of native celld #422. Cloud deployment, local GeoIP #438 and downstream adoption remain separate. +- Governing spec: [Pluggable Geo providers](../specs/2026-10-22-pluggable-geo-providers-design.md). +- Planning baseline: `5426ea65f642beafc59e6b4a4d198220d3c20e57`, branch `feature/pluggable-geo-providers`. +- Reviewed design SHA-256: `12eeada53b62663923531fa43e27aac08386df1c97dbc3b66a032b504c7f9f2b`. It matched before implementation. Final spec SHA-256: `7de048a373628d552a7e0e450e0b2f97d8927cf52ce88345348cd7722dcc6502`. Final edits update status, prerequisite reconciliation and the #438 scope link without expanding the provider contract. +- Ingress reference: [#409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6). Its checked-ingress contract is reconciled in `5da2000f`, not merged as an old tree. The distinct #407 body feature is excluded explicitly below. +- Authority: the user authorized full implementation, commits, push and a draft pull request stacked on native celld on 2026-10-22. AWS deployment and downstream adoption remain separate acceptance work. + +## 1. Outcome and limits + +Deliver one native CloudFront provider with explicit selection, a synchronous portable contract and one immutable outcome per managed request. Accept geographic assertions only through the declared protected-ALB path. Preserve the existing native lifecycle, checked forwarding interpretation and application ownership of policy. + +The implementation must not add GeoIP databases, reader-IP inputs, remote lookups, asynchronous provider preparation, environment/manifest selectors, generated CLI configuration, alternative trust modes or Trusted Server changes. CloudFront and ALB provisioning remain operator work. Runtime code must not call AWS to inspect or configure infrastructure. + +Two safety assumptions govern the proof: + +1. Only the operator-protected CloudFront path can supply accepted assertions. Local tests prove policy/real-peer enforcement. Deployed tests must prove the ALB credential gate, network isolation and CloudFront header behavior. +2. Missing, partial, malformed or unrecognized geography does not imply permission. Core preserves these distinctions; downstream policy tests must prove their interpretation. This implementation does not certify downstream policy. + +## 2. Decisions fixed by this plan + +These are proposed APIs to implement, not APIs already present in the repository. + +| Decision | Implementation | +| --- | --- | +| Provider placement | Core types and synchronous trait in `edgezero-core::geo`; CloudFront implementation inside the native Axum adapter. No new crate. | +| Feature | `edgezero-adapter-axum/geo-cloudfront`, default-off, enabling `axum` but no AWS service feature. The selector remains available under `axum` when the implementation feature is absent. | +| Provider ownership | Native selection/service state holds an `Arc`. The portable trait itself has no `Send`/`Sync` supertraits and no future. | +| Compatibility | Preserve `AxumRunOptions: Clone + Send + Sync`, `App: Send + Sync`, existing initializer signatures and local/non-`Send` startup futures. Do not add a provider factory to support non-`Send` native provider objects. | +| Selection | One `NativeGeoSelection` value: `Off`, `CloudFront { policy }`, or `Supplied { policy, id, provider }`. Repeated consuming `with_geo` calls explicitly replace the whole selection before startup; there is no merge or precedence between independent inputs. | +| Capability inspection | `AxumRunOptions::geo_capability()` returns a validated, owned description. The caller captures it from finalized options in the existing initializer closure. No new `App` capability field, state registry or `PreparedStores` field. | +| Trust | One opaque `ProtectedCloudFrontAlb` policy, distinct from ordinary proxy trust. Source admission uses the actual connection peer and an adapter-private request-bound witness. | +| CIDRs | Extract and reuse #409's internal bounded network set and matcher. No second parser and no dummy forwarding-family policy. | +| Request storage | Private outcome field in `AdmittedIngress`, transferred to a private `RequestContext` field. No import from public extensions and no live setter. | +| Cross-crate handoff | One explicit `App::dispatch_admitted_with_geo` operation consumes an owned outcome alongside the existing admission token. Existing dispatch APIs supply `NoSelection`. | +| Geographic validation | Checked-in ISO country-code table with source attribution and pinned snapshot; syntax-only subdivision validation. No new runtime dependency or subdivision catalogue. | +| Request preparers | Neither the inspected native baseline nor pinned #409 has a request-preparer callback. Do not add one. Preserve existing Fastly preparer APIs and test that their scratch extensions cannot supply Geo. | + +The native-only provider bounds preserve existing options auto-traits. Portable provider implementations may still use `Rc` or `Cell`; they simply cannot be supplied to the native options API without satisfying its bounds. No application handler, startup future or portable interface gains a new future `Send` requirement. + +The core dispatch operation is a trusted Rust integration API, not proof of origin authentication. Rust code can deliberately start another core dispatch with an explicit outcome. Managed Axum never obtains its outcome from such caller-controlled metadata. The adapter-private witness establishes source admission for that managed path. + +## 3. Existing integration points and file map + +All unqualified anchors below refer to the planning baseline. Anchors marked #409 refer to the pinned commit and must be reconciled before implementation. + +| Existing location | Relevant behavior | +| --- | --- | +| `crates/edgezero-adapter-axum/src/run_options.rs`, `AxumRunOptions` | Cloneable explicit native settings. Constructors currently default Geo implicitly to absent because no Geo field exists. | +| `crates/edgezero-adapter-axum/src/dev_server.rs`, `run_production_selected` | Validates native store sources, builds the app/stores/transport and invokes the existing initializer. Geo validation must precede its call to `run_local`. | +| `dev_server.rs`, `PreparedApp`, `run_owned` | Private prepared resources flow into service construction before listener adoption/readiness. Carry Geo separately from stores. | +| #409 `crates/edgezero-adapter-axum/src/service.rs:329-418` | Actual-peer cleanup, original normalized-head summary, forwarding normalization/removal and admission. Insert protected-source resolution after successful admission. | +| #409 `service.rs:265-327`, `dispatch_prepared` | Converts the admitted body, installs stores and dispatches with `FrameworkErrorDetail::CategoryOnly`. Preserve this behavior. | +| #409 `crates/edgezero-adapter-axum/src/proxy.rs:115-174,293-359` | Bounded CIDR validation, IPv4-mapped normalization, whole-family union rejection and containment matching. | +| `crates/edgezero-core/src/ingress.rs`, `AdmittedIngress`, `PreparedIngress` | Private request-lifetime data already passes from admission to routed context. Do not expose a public Geo setter on either token. | +| #409 `crates/edgezero-core/src/app.rs:247-299` | Admitted dispatch and fixed framework error detail. Add an owned-outcome overload without losing failure classification. | +| `crates/edgezero-core/src/router.rs`, `dispatch_found` | App state overwrites public extension types before constructing the context. Geo must travel outside that extension map. | +| `crates/edgezero-core/src/context.rs`, `new`, `new_routed`, `parts_mut`, `extensions_mut` | Add private Geo storage; unmanaged construction ignores any forged Geo extension. | +| `crates/edgezero-adapter-axum/tests/production_hosting.rs` and `tests/fixtures/production_host.rs` | Existing child-process, readiness, raw-socket, initializer and shutdown fixtures. Extend these rather than building another server harness. | + +New files: + +```text +crates/edgezero-core/src/geo.rs +crates/edgezero-core/src/geo/country_codes.rs +crates/edgezero-adapter-axum/src/geo.rs +crates/edgezero-adapter-axum/src/geo/cloudfront.rs +crates/edgezero-adapter-axum/src/peer_networks.rs +scripts/check_geo_dependency_contract.sh +docs/guide/native-geo.md +``` + +Existing files expected to change narrowly: + +- Core `src/lib.rs`, `src/ingress.rs`, `src/context.rs` and `src/app.rs`. Keep the existing router production interface; add router tests only if needed for state-extension overwrite coverage. +- Axum `Cargo.toml`, `src/lib.rs`, `src/run_options.rs`, `src/dev_server.rs`, `src/service.rs` and reconciled `src/proxy.rs`. +- Axum `tests/fixtures/production_host.rs` and `tests/production_hosting.rs`. +- Existing adapter contract tests for portable no-selection behavior, including the Fastly preparer fixture where available. +- `.github/workflows/test.yml`, `docs/.vitepress/config.mts` and `docs/guide/adapters/axum.md`. + +No workspace member, production binary, CLI template or new third-party dependency is planned for Geo itself. #409 reconciliation may have its own manifest/lockfile changes; record those separately. Do not churn lockfiles or fold unrelated provider refactoring into this work. + +## 4. Execution order + +```mermaid +flowchart TD + Base[Stage 0: reconcile checked ingress and establish baseline] --> Types[Stage 1: portable values and provider contract] + Base --> Policy[Stage 2: shared peer validation and protected policy] + Types --> Context[Stage 3: immutable core dispatch handoff] + Types --> Selection[Stage 4: native selection and early validation] + Policy --> Selection + Types --> Parser[Stage 5: CloudFront resolution] + Context --> Dispatch[Stage 6: managed request integration] + Selection --> Dispatch + Parser --> Dispatch + Dispatch --> Proof[Stage 7: real hosting and compatibility proof] + Proof --> Docs[Stage 8: deployment guide and CI coverage] + Docs --> Final[Stage 9: final integration checks] + Final --> Deploy[Separate authorized deployment acceptance] +``` + +Work in coherent, testable units. Add a failing regression first, implement the behavior, run its focused checks, then continue. Record test names and commands before relying on filters. Use `scripts/run_test_nonzero.sh` for named sentinels so a renamed or feature-gated test cannot produce a false pass. + +One implementation owner should handle core/native dispatch integration. Independent policy/type research may overlap, but serialize shared Cargo targets, manifest edits and global formatting. Reuse passing checks while their relevant inputs remain unchanged. Do not run every workspace gate after every small edit. + +## 5. Stage 0: reconcile prerequisites and record the baseline + +### Work + +- [x] Read repository instructions, record HEAD/status and recompute the governing spec hash. Preserve uncommitted spec/plan and unrelated work. HEAD is still `5426ea65f642beafc59e6b4a4d198220d3c20e57`; the spec hash matches the reviewed hash above. Initially only the untracked spec and plan were present. +- [x] Split the local GeoIP/database/input-contract work into #438 and record the narrowed initial scope on #432. CloudFront does not satisfy that follow-up. +- [x] Reconcile the pinned #409 checked-ingress delta with explicit preservation of the newer native-store, async EOF and Connections owners. The narrower reconciliation is reviewed below; only the separate #407 buffered JSON-cap/body-carrier feature is excluded. No old whole-file replacement or second forwarding parser was used. +- [x] Preserve current `NativeSourcePlan`, native store overrides, asynchronous store preparation, current logging behavior and the reconciled body/lifecycle/egress ownership. The pinned branch and HEAD diverge beyond Geo; whole-file replacement would discard newer work. +- [x] Confirm original head accounting, missing-peer cleanup, checked proxy normalization, forwarding-header removal, native diagnostics and category-only framework error behavior survive the reconciliation. +- [x] Record the resulting integration revision and the exact baseline checks below. If integration uses a later #409 revision, inspect its changed contract rather than relying on this plan's old line numbers. + +Useful inspection commands, not merge instructions: + +```sh +git status --short +git rev-parse HEAD +sha256sum docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md +git merge-base HEAD 66a05e25bee674ced69387467acec3c87b06c8b6 +git diff --stat HEAD 66a05e25bee674ced69387467acec3c87b06c8b6 -- crates/edgezero-core crates/edgezero-adapter-axum +git show 66a05e25bee674ced69387467acec3c87b06c8b6:crates/edgezero-adapter-axum/src/service.rs +``` + +### Baseline checks after reconciliation + +```sh +cargo test --locked -p edgezero-core --lib ingress:: +cargo test --locked -p edgezero-core --lib router:: +cargo test --locked -p edgezero-adapter-axum --lib proxy::tests +cargo test --locked -p edgezero-adapter-axum --lib run_options::tests +cargo test --locked -p edgezero-adapter-axum --lib dev_server::native_store_tests +cargo test --locked -p edgezero-adapter-axum --features test-utils --test production_hosting +``` + +Use the pinned sentinels `owned_peer_checked_metadata_precedes_all_managed_consumers` and `original_forwarding_head_overages_reject_before_stripping_or_body_poll` to confirm the relevant #409 tests survived. Record any renamed equivalents. These tests do not exist at the current planning baseline. + +### Exit gate + +A reviewed integrated ingress baseline with passing neighboring checks, or documented pre-existing failures isolated once. No second forwarding parser, no loss of current store startup behavior, and no unresolved ownership conflict with #409. Native Geo work does not bypass this gate. + +## 6. Stage 1: portable values and synchronous provider contract + +Files: new core `geo.rs` and `geo/country_codes.rs`; core `lib.rs` exports. + +### Work + +- [x] Add bounded `GeoProviderId`, `CountryCode`, `SubdivisionCode` and `GeoLocation` values with private fields and validated constructors. `GeoLocation` requires a country and accepts an optional subdivision. Avoid unchecked conversions or deserializers that bypass validation. +- [x] Give provider IDs the ASCII format `[a-z][a-z0-9._-]{0,63}`. Reserve `cloudfront` for the built-in provider. They are public diagnostic labels, never credentials. Native supplied selection receives the validated ID explicitly, so provider identity cannot vary per request. +- [x] Add bounded assertion slots, such as `GeoAssertion` with absent/value/invalid states. A private bytes/length representation prevents out-of-bounds access. `GeoInput` contains exactly a country slot of capacity two and a subdivision slot of capacity three, with no request/service/IP fields. +- [x] Keep provider results separate from framework outcomes. `GeoResolution` allows missing metadata, a validated location or a bounded provider error. Providers do not choose `NoSelection`, `UntrustedIngress`, selected identity or source provenance. +- [x] Add `GeoOutcome::NoSelection` and a selected outcome carrying a validated provider ID plus unavailable/located/error status. Located data includes `CloudFront` source and the declared protected-ALB trust basis. Errors contain only `InvalidAssertion` or `ProviderFailure`, not an arbitrary error chain. +- [x] Add the synchronous `GeoProvider::resolve(&self, input: &GeoInput) -> GeoResolution` trait. No async method, I/O context, mutable request, future or portable `Send`/`Sync` bound. Document bounded local computation and the trusted-Rust limitation. +- [x] Check in the officially assigned ISO 3166-1 alpha-2 codes. Record the authoritative ISO source URL, captured revision/date, imported-data checksum and applicable attribution in the table file. Review the exact imported list and any licence obligations before committing it. No runtime downloads, country-name database or dependency added just for two-letter membership. +- [x] Normalize ASCII case once in the value constructors. Country is two letters and must be assigned in the pinned list. Subdivision is one to three ASCII alphanumerics and preserves leading zeroes. It is not checked against a subdivision-membership database. +- [x] Make raw assertion debug output redacted. Outcome debug output may contain normalized codes and bounded public categories, never raw source bytes or credential data. + +### Tests + +Add sentinel `geo_country_codes_normalize_and_reject_unsupported`. Cover lowercase normalization, empty/non-ASCII/whitespace/oversize values, reserved and user-assigned country exclusions including `XX`, `ZZ`, `UK`, `XK`, and valid numeric subdivisions. Prove `US/ZZ` is a syntax-valid assertion rather than validated US membership. Validate the country table's ordering, uniqueness and recorded snapshot size. + +Add constructor, equality and debug tests. Compile a portable `GeoProvider` implementation containing `Rc>` to prove the trait itself has no native thread bounds. Verify no public constructor can create a subdivision-only location. + +```sh +scripts/run_test_nonzero.sh geo_country_codes_normalize_and_reject_unsupported cargo test --locked -p edgezero-core --lib geo:: +``` + +### Exit gate + +Portable types and provider behavior compile and pass their focused tests without a native dependency or country/subdivision inference. + +## 7. Stage 2: shared peer matching and protected-ALB policy + +Files: new Axum `peer_networks.rs` and `geo.rs`; reconciled `proxy.rs`; Axum module declarations. + +### Work + +- [x] Extract #409's existing literal-network validation and matching into a crate-private `PeerNetworks` type. Preserve its 256-entry and 16-KiB input budgets, accounting before deduplication, network truncation, mapped-address normalization, and rejection of whole-family unions as well as `/0`. +- [x] Delegate ordinary `TrustedProxyPolicy` to that helper without changing its public API, forwarding-family semantics or empty-policy behavior. Do not move forwarding interpretation into the helper. +- [x] Add opaque `ProtectedCloudFrontAlb` with a nonempty `PeerNetworks` and one credential-header name. It contains no credential value, distribution-authentication claim or AWS handle. +- [x] Use one bounded header-name rule: a valid literal HTTP token, at most 128 ASCII bytes, beginning with `x-` after case normalization. Reject `x-forwarded-*`, `x-real-ip`, `x-amz-*`, `x-amzn-*`, `x-edge-*`, and native request/correlation-ID header names. Standard transport, authorization, cookie and CloudFront Geo names are excluded by the custom-name rule. Do not trim malformed names into validity. +- [x] Keep the configured header name, network contents and parser input out of errors and `Debug`; emit fixed configuration categories and bounded counts only. Guide examples use a dedicated name such as `x-origin-verify-`. +- [x] Add a private request-bound admission helper that compares the actual socket peer with the protected policy. It must not accept `ConnectInfo`, `AxumRequestContext`, public checked metadata, effective reader IP or forwarding values as evidence. + +### Tests + +Preserve all existing proxy-policy assertions and coverage. Mechanically adapt tests that inspect private network fields, or move their helper-level assertions beside `PeerNetworks`; do not weaken them to make the extraction pass. Add sentinel `geo_protected_policy_reuses_network_bounds` covering empty policies, exact/over entry and byte limits, duplicates, IPv4/IPv6, mapped peers, multiple ranges covering an entire family, and missing/nonmatching peers. Add case-insensitive credential-name collision and redaction tests. + +```sh +cargo test --locked -p edgezero-adapter-axum --lib proxy::tests +scripts/run_test_nonzero.sh geo_protected_policy_reuses_network_bounds cargo test --locked -p edgezero-adapter-axum --no-default-features --features axum --lib geo:: +``` + +### Exit gate + +One reused network validator, distinct proxy and protected-source policies, and no credential comparison or forwarding-family dependency in Geo admission. + +## 8. Stage 3: immutable core handoff + +Files: core `app.rs`, `ingress.rs`, `context.rs`; tests in those modules and, if useful, `router.rs`. + +### Work + +- [x] Add private Geo outcome storage to `AdmittedIngress`, defaulting to `NoSelection` in every ordinary/fallback admission constructor. Add only a crate-private consuming installation method for core dispatch code. +- [x] Add `App::dispatch_admitted_with_geo(prepared, request, detail, outcome)`, preserving the pinned `FrameworkErrorDetail` parameter and classified failure behavior. This is the explicit public cross-crate handoff, not a public native trust witness. +- [x] Factor existing admitted dispatch methods through the same implementation with `NoSelection`. Preserve their signatures and existing rendering, response metadata, completion, grant and fallback behavior. +- [x] Install the owned outcome into private admitted state before passing it through the existing router path. The router's public signature need not change. +- [x] Transfer the result into a private `RequestContext` field in `new_routed`; add only `geo(&self) -> &GeoOutcome`. `RequestContext::new` explicitly defaults to `NoSelection` regardless of public extensions. +- [x] Do not preserve Geo by placing it in extensions when converting a context back into an ordinary request. Constructing a later unmanaged context creates a new no-selection dispatch. Keep existing body transfer semantics intact. + +### Tests + +Add sentinel `geo_owned_outcome_survives_public_request_mutation`. Read the same outcome in multiple middleware layers and a handler after changing geographic headers, inserting a contradictory `GeoOutcome` extension, removing it, clearing extensions and injecting same-type app state. Test legacy dispatch and unmanaged construction with forged selected-outcome extensions; both remain no-selection. + +Retain `App: Send + Sync`, token ownership, stale/foreign token rejection, method/path mutation rejection, grant-drop and category-only 404/405 tests. A Geo error must not change HTTP status automatically; the handler must be able to return an unrelated success response. + +```sh +scripts/run_test_nonzero.sh geo_owned_outcome_survives_public_request_mutation cargo test --locked -p edgezero-core --lib geo_ +cargo test --locked -p edgezero-core +``` + +### Exit gate + +There is no extension-based or public context setter path to replace a live result. Existing adapters still compile against unchanged dispatch APIs and obtain `NoSelection`. + +## 9. Stage 4: native selection and early startup validation + +Files: Axum `geo.rs`, `run_options.rs`, `dev_server.rs`, `service.rs`, `lib.rs` and `Cargo.toml`. + +### Work + +- [x] Add `geo-cloudfront = ["axum"]` without adding it to default features, AWS aggregates, CLI features or generated templates. Keep the selection enum and supplied-provider support under native Axum, not the implementation feature. +- [x] Add one private Geo selection field to `AxumRunOptions`, initialized to `Off` by every constructor. Add consuming `with_geo(selection)` and read-only `geo_capability()`; do not read any new environment variables. +- [x] Validate typed policy/identity and implementation availability through one pure validation function shared by capability inspection and runner setup. `CloudFront` without its feature returns a fixed unavailable-implementation category, never `Off`. +- [x] Represent a supplied provider with `Arc`, without requiring provider `Clone`. Keep the ID and policy on the same enum variant. The capability description contains only off/selected identity/source facts, not the provider or credential name. +- [x] Run Geo validation synchronously at the start of `run_production_selected`, before `run_local` creates runtime/signal resources. Keep existing store validation/preparation sequencing and initializer cancellation behavior. +- [x] Construct the stateless built-in provider locally and carry validated composition alongside the existing private prepared resources into `AxumServiceState`. Do not put it in `App`, request extensions or `PreparedStores`. Do not add an async preparation step. +- [x] Preserve every development/unconfigured entrypoint with off composition. The explicit options path is the only new selection path. +- [x] Demonstrate capability inspection by capturing the owned result of `finalized_options.geo_capability()?` in the existing initializer closure, then moving those same finalized options into the runner. The runner validates again. No initializer signature or global capability registry changes. + +### Tests + +Add compile-time assertions for `AxumRunOptions: Clone + Send + Sync` and retain the existing local-`Rc` initializer test. Test off defaults, explicit whole-selection replacement and supplied-provider composition. + +Add sentinel `geo_unavailable_selection_fails_before_runner_resources` under `cfg(not(feature = "geo-cloudfront"))`. Do not gate its module or the public selector on the implementation feature. Prove feature/policy failure precedes the application initializer and deferred store preparation. The test should also work when called from an existing runtime, demonstrating rejection before a nested runner runtime is created. Check no listener is bound and no local store root is created. + +```sh +scripts/run_test_nonzero.sh geo_unavailable_selection_fails_before_runner_resources cargo test --locked -p edgezero-adapter-axum --no-default-features --features axum --lib geo_ +cargo test --locked -p edgezero-adapter-axum --lib run_options::tests +cargo test --locked -p edgezero-adapter-axum --lib dev_server::native_store_tests +``` + +### Exit gate + +Default-off and unavailable-feature behavior are executed, native options retain their compatibility bounds, and Geo adds no dependency on store roots, AWS credentials or provider preparation. + +## 10. Stage 5: CloudFront resolution + +Files: Axum `geo/cloudfront.rs`, with unit tests; shared values remain in core. + +### Work + +- [x] Implement the stateless built-in `GeoProvider` behind `geo-cloudfront`. It accepts only the two bounded assertion slots and returns a `GeoResolution`. +- [x] Map absent country plus absent subdivision to missing metadata. A subdivision without country is invalid. Any invalid slot makes the whole result invalid. +- [x] Delegate country/subdivision normalization to the shared validated constructors. A valid country with absent subdivision is a located country-only result. Never repair an unsupported country or malformed subdivision. +- [x] Keep provider selection, source admission, provider ID and provenance outside the provider implementation. It performs no header lookup, HTTP status choice, logging policy or fallback. + +### Tests + +Add sentinel `geo_cloudfront_parsing_matches_spec`. Cover every trusted parsing row in spec section 6, including identical duplicates represented as invalid slots, joined values, invalid UTF-8/non-ASCII, zero/one/two/three/four-byte boundaries, lowercase, numeric subdivision components and leading zeroes. Cover `US` without a subdivision, `US/ZZ`, unsupported countries and region-only input. + +Use a table-driven test with expected typed outcomes rather than assertions against display strings. Keep provider failure testing in a supplied test implementation; header parsing does not invent internal failure cases. + +```sh +scripts/run_test_nonzero.sh geo_cloudfront_parsing_matches_spec cargo test --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront --lib geo:: +``` + +### Exit gate + +The built-in returns the spec's outcomes with no external operations or silent loss of malformed assertions. + +## 11. Stage 6: managed request integration + +Files: reconciled Axum `service.rs`, `geo.rs` and service tests. Keep the unmanaged request converter unchanged. + +### Work and exact ordering + +- [x] Preserve #409's clock, native request guard, drain gate and actual-peer replacement/removal behavior. These early hooks receive no raw headers; do not change lifecycle timing merely to add Geo. +- [x] Split the unpolled request. Compute the original normalized-head validation/summary result before removing headers, so credential bytes and duplicate fields still count against limits. +- [x] Remove every instance of the configured credential header from live parts. Then handle the saved validation result. No callback that can observe the request or its headers may run before removal, including future callbacks added to this path. Do not retain values or reflect the name in validation diagnostics. +- [x] Run the existing checked forwarding normalization/removal and admission on sanitized parts. Keep #409's original count/byte summary. Refused, aborted, invalid-ingress and draining paths perform no Geo resolution. +- [x] After admission, use only the runner-owned `remote_addr` and selected protected policy to create the private request-bound witness. Off returns `NoSelection`; missing/nonmatching peer returns selected `UntrustedIngress`. Neither path inspects Geo values or calls a provider. +- [x] With a witness, inspect both original Geo header names in the still-owned parts. Admission received a cloned immutable head, so it cannot have changed these values. Detect duplicates and oversize input without collecting values or copying more than two/three bytes. Do not retain another raw HeaderMap. +- [x] Invoke the selected provider exactly once, including both-absent and invalid-slot inputs. Wrap its result with the selection-owned ID and, for located results, source/trust basis. Perform this before `dispatch_prepared` conversion/dispatch; later conversion failures do not retry resolution. +- [x] Pass the owned outcome separately through `dispatch_prepared` to `App::dispatch_admitted_with_geo`, retaining `FrameworkErrorDetail::CategoryOnly` and the existing native egress owner. +- [x] Do not expose the witness, policy, provider handle or a mutable outcome to request extensions, middleware, finalizers or the unmanaged converter. + +### Tests + +Add sentinel `geo_managed_resolution_runs_once_from_owned_ingress`. Use an atomic counting supplied provider to prove one invocation on trusted admitted requests, including absent and invalid input, and zero on off/untrusted/rejected/aborted/draining paths. Finishing, dropping or failing egress must not invoke it again. Test a reported provider failure without fallback or automatic HTTP rejection. + +Forge public `ConnectInfo`, `AxumRequestContext`, checked metadata and Geo outcome extensions while passing a missing/nonmatching actual peer. None may establish trust. An ordinary trusted-proxy configuration with Geo off must return `NoSelection`, not a located result. Active selection cannot omit its protected policy. Separately prove that a matching protected ALB peer admits geography without forwarding assertions or ordinary proxy trust. Do not require `effective_client()` to be absent: #409 initializes that field from the direct peer even when no forwarded viewer identity is resolved. + +Test credential removal in admission and handler observations, repeated credential fields, and validation-error paths. A request whose original head exceeds a limit must still fail even if stripping the credential would make it fit. Summary counters must include removed bytes/fields while logs and responses contain neither the name nor sentinel value. + +No managed-Axum preparer hook exists. Cover mutation at the actual middleware/context boundary and leave that hook absent. + +```sh +scripts/run_test_nonzero.sh geo_managed_resolution_runs_once_from_owned_ingress cargo test --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront --lib geo_ +cargo test --locked -p edgezero-adapter-axum --features geo-cloudfront --lib service::tests +``` + +### Exit gate + +Actual managed dispatch uses owned peer/input facts and a private immutable result, with the specified call counts and unchanged admission/egress ownership. Tower-only calls with no socket peer must remain untrusted; they are not trusted-path proof. + +## 12. Stage 7: hosting and compatibility proof + +Files: existing native production fixture/tests; existing portable adapter contract tests where needed. + +### Work + +- [x] Add programmatic fixture modes for off, compiled-out CloudFront, enabled CloudFront, supplied provider, invalid policy and provider failure. Fixture-only CLI/environment knobs must not become production Geo selectors. +- [x] Use a no-store fixture profile so a CloudFront-only application proves it needs no config/data roots or AWS settings. Reuse existing `env_clear`, child output capture, readiness polling and shutdown helpers. +- [x] Add raw-header request support through the existing socket helper. Use a real loopback listener and a matching `/32` policy for successful source admission. Use a different policy range for real-peer rejection. Missing-peer and forged-extension cases remain private service tests. +- [x] Observe normalized outcomes and credential absence in a test handler. Do not turn test observation into built-in Geo response headers or a public diagnostics endpoint. +- [x] Test the same public `CloudFront` selector in feature-disabled and enabled fixture builds. Disabled selection must exit nonzero before readiness/initializer and never bind the selected port. +- [x] Prove a supplied provider works with the CloudFront implementation feature disabled. This distinguishes pluggable composition from availability of the built-in parser. +- [x] Retain local initializer, cancellation, shutdown, refused/aborted ingress and store-preparation regression checks. Match the existing terminal call counts rather than introducing another completion owner. +- [x] Add a no-selection assertion to existing Fastly, Cloudflare and Spin dispatch contracts. For Fastly's existing preparer, insert a forged Geo outcome into scratch extensions and verify no-selection at the handler. Preserve callback ordering and signatures; do not implement platform Geo. + +### Checks + +Add process sentinels `geo_hosting_proves_real_peer_and_shared_outcome` for the enabled path, `geo_hosting_rejects_unavailable_cloudfront` for the disabled path, and `geo_hosting_supplied_provider_without_builtin` for supplied composition. + +```sh +scripts/run_test_nonzero.sh geo_hosting_rejects_unavailable_cloudfront cargo test --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test production_hosting +scripts/run_test_nonzero.sh geo_hosting_supplied_provider_without_builtin cargo test --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test production_hosting +scripts/run_test_nonzero.sh geo_hosting_proves_real_peer_and_shared_outcome cargo test --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront,test-utils --test production_hosting +``` + +The second disabled-build command may reuse the first suite's execution when its log lists both sentinels and the tested inputs are unchanged. Do not rebuild merely to repeat identical passing evidence. + +### Exit gate + +A real native listener proves the local path, supplied-provider composition and no-store startup. Other adapters retain no-selection behavior. All production-cloud assertions remain unproven until separate deployment checks. + +## 13. Stage 8: deployment guide and CI coverage + +### Documentation + +- [x] Add `docs/guide/native-geo.md`, link it from the Axum guide and navigation. Include programmatic examples for off, CloudFront, supplied provider, startup capability capture and application handling of every outcome. Keep the same snippets in native module rustdoc and execute its doc tests; use `no_run`, not `ignore`, for examples that would start a server. Compare the guide snippets with those compiled examples before acceptance. +- [x] Describe the selected HTTPS CloudFront/ALB path, dedicated origin credential, default-deny listener, CloudFront prefix-list restriction and target security-group isolation. Distinguish declared infrastructure protection from runtime source attestation. +- [x] Show the exact supported credential-header naming rule and a value-only rotation sequence. Renaming requires coordinated stop/reconfigure/restart. Redact both name and value in operational output. +- [x] Require explicit configuration of the two CloudFront-generated headers, not generic viewer-header forwarding or static geographic values. Explain the AWS-network missing-subdivision case and the required spoofing test. +- [x] Require `CachingDisabled`, zero error-caching TTLs for cacheable application-error statuses and no other shared reuse of these responses. No Geo-aware cache-key recipe or automatic cache mutation is added. +- [x] Link the AWS sources already recorded in the spec. If their behavior has changed by implementation time, review the difference before modifying the chosen contract. +- [x] Keep Trusted Server adoption and VPC origins in deferred-work notes, not executable steps in this feature. + +### Feature and dependency checks + +- [x] Add `scripts/check_geo_dependency_contract.sh` using the repository's shell/Cargo-tree conventions. It must fail on missing expected data, not merely print graphs. +- [x] Compare isolated native normal dependency sets with `axum` versus `geo-cloudfront`: the new feature adds implementation code but should add no third-party runtime dependencies. Assert it is absent from defaults and present when explicitly selected. +- [x] Inspect core and all three WASM adapter normal graphs for new Axum/native-runtime/AWS SDK leakage. Preserve the existing native-store graph checks rather than adding Geo to a store registry or changing store feature semantics. +- [x] Add CI steps for the enabled Geo unit suite, disabled-feature sentinel, supplied-provider sentinel, real hosting sentinel and dependency script. Existing default workspace tests do not exercise the opt-in implementation, and all-features tests do not prove disabled behavior. +- [x] Run the new no-selection assertions through the existing WASM contract-test jobs and their runtime runners. Keep them attached to established dispatch sentinels rather than adding a separate deployment harness. + +### Exit gate + +Published examples compile against the implemented API. CI selects nonzero tests in both feature states. No new CLI/environment interface or cloud acceptance claim appears in the guide. + +## 14. Stage 9: final integration checkpoint + +Run these after the coherent implementation is stable. Reuse recorded passing focused results for unchanged inputs. Prepare locked dependencies and installed targets first; an unavailable toolchain or dependency is a coverage gap, not a passing check. + +```sh +cargo fmt --all -- --check +cargo test --locked --workspace --all-targets +cargo test --locked -p edgezero-adapter-axum --all-targets --all-features +cargo test --locked -p edgezero-adapter-axum --features geo-cloudfront --doc +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +cargo check --locked --workspace --all-targets --features "fastly cloudflare spin" +cargo check --locked -p edgezero-adapter-axum --no-default-features +cargo check --locked -p edgezero-adapter-axum --no-default-features --features native-bindings +cargo check --locked -p edgezero-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1 +cargo check --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown +cargo check --locked -p edgezero-adapter-spin --no-default-features --features spin --target wasm32-wasip2 +bash scripts/check_geo_dependency_contract.sh +bash scripts/check_native_store_dependency_contract.sh +RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps +cargo test --locked --manifest-path examples/app-demo/Cargo.toml --workspace --all-targets +cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum +``` + +Also retain the explicit disabled/enabled hosting and unit commands from earlier stages. Run the existing WASM dispatch test jobs with their configured Viceroy, wasm-bindgen and Wasmtime runners. Compile-only WASM results do not satisfy those runtime tests. + +For published documentation changes, install the locked docs dependencies if needed, then run: + +```sh +cd docs +npm run lint +npm run format +npm run build +``` + +The internal `docs/superpowers` documents are excluded from published-site builds and Prettier. Check their relative links, section references, fenced blocks and whitespace separately. A documentation-only plan update does not require executing the implementation commands above. + +### Exit gate + +All applicable local gates pass or have explicit, evidenced baseline exclusions. Report changes, exact tested revisions/features, runtime versus compile-only evidence, and remaining cloud/downstream gaps. Do not call the feature production-accepted solely because local CI is green. + +## 15. Separate deployment acceptance + +This stage requires separate authorization and access to an isolated CloudFront/ALB deployment. It is not part of local CI and does not authorize provisioning from the provider implementation. + +- [ ] Record the application build, enabled features and sanitized CloudFront/ALB/network configuration identifiers. Never store origin credentials in the plan, shell history, screenshots or test logs. +- [ ] Prove an intended request reaches the application, while direct ALB requests and a different distribution without the credential cannot reach it. With prefix-list restrictions, a network denial is an acceptable direct-access result; do not require an HTTP 403 where no connection is admitted. +- [ ] Prove the application target cannot be reached through another listener, proxy, public address or unintended security-group source. +- [ ] Send forged country/subdivision values through the intended distribution. Compare with generated metadata and prove viewer assertions are not accepted. Include an AWS-network request with a forged subdivision while CloudFront omits its own subdivision. +- [ ] Verify country-only and missing-metadata outcomes without guesses or local/remote fallback. +- [ ] Exercise value rotation under the fixed credential-header name, including temporary old/new acceptance at the ALB, CloudFront deployment and old-value removal. Prove the application never sees the credential header and operational outputs do not reveal it. +- [ ] Exercise success and cacheable error responses across requests with different geographic/policy inputs. Verify CloudFront and any other shared cache do not reuse them. Do not infer cache safety from origin-request policy configuration alone. + +Any failure blocks production use of the declared protected policy. Do not work around it by trusting additional headers or adding fallback providers. Trusted Server policy-path tests remain a separate downstream adoption task even after these checks pass. + +## 16. Final evidence and completion + +### Tested source and reconciliation + +The implementation is committed as `d5ec14ed` and `5da2000f` on top of native celld `5426ea65`, with the final test-only split at `56eab85fee2499d46984b077fc9e167693bd4e67`. Remaining publication edits affect only this evidence record and design status. No production source changed after the native all-feature check. The final workspace suite, real-hosting suites and WASM runtime contracts cover the final test state. + +The pinned #409 branch shares merge-base `5527b58e` with the native celld stack but its checked-ingress implementation was absent here. Two read-only investigations compared direct-delta adaptation and selective reimplementation. The selected approach adapted only #409's own commit delta, reused its parser/counters/tests and extracted its bounded peer-network matcher. Newer native stores, local non-Send initialization, `Connections`/`FuturesUnordered`, ingress/config bounds and asynchronous EOF/zero-length validation remain authoritative. No old tree was copied over newer files. + +The separate #407 managed Body carrier, `JsonBodyLimit` setting and 413-cap behavior are intentionally not imported. Native JSON error privacy instead travels through private admitted/context state. Only #407-dependent cap/carrier tests and claims are excluded; checked ingress, original-head limits, routing/JSON privacy, terminal ownership and TLS proxy fixture tests are retained. This is checked-ingress reconciliation, not full #409-plus-#407 parity. + +The required #409 sentinels `owned_peer_checked_metadata_precedes_all_managed_consumers` and `original_forwarding_head_overages_reject_before_stripping_or_body_poll` pass in the native unit suites. A read-only correctness review found no remaining implementation findings. Its credential-error ordering concern was fixed before acceptance: save original validation Result, remove all credential occurrences, then handle the saved Result. Direct rejected-head and managed overage regressions execute that path. + +### Local checks + +All Cargo commands below used Rust/Cargo 1.95.0 and locked dependencies. The workspace test process used stdin from `/dev/null`, matching noninteractive CI consent tests. Logs are local temporary artifacts, not durable CI records. Rows run before `56eab85f` retain their evidence because that commit only split an already-passing core test into two tests; the production implementation is unchanged. The final workspace suite retests both. + +| Check | Tested features/state | Result | Elapsed | Local evidence | +| --- | --- | --- | --- | --- | +| `cargo test --locked --workspace --all-targets` | `56eab85f`, workspace defaults | 2,115 passed, 6 existing ignored, none failed | 55.754 s | `/tmp/geo-workspace-tests-final.log` | +| Native `--all-targets --all-features` tests | `5da2000f`, all native providers + Geo | 422 passed across 8 suites, including 367 units, 6 AWS/mixed startup, 4 celld startup and 32 hosting | 182.120 s | `/tmp/geo-native-all-features-tests.log` | +| Enabled native library | `--no-default-features --features geo-cloudfront` | 322 passed; parser and managed-resolution sentinels included | 13.192 s | `/tmp/geo-final-enabled-units.log` | +| Disabled native library | `--no-default-features --features axum` | 323 passed; resource-precedence, supplied and policy paths included | 19.779 s | `/tmp/geo-final-disabled-units.log` | +| Final core Geo regressions | Final test split, `--lib geo_` | 3 passed, including immutable roundtrip and legacy-forgery cases | 17.64 s compile | `/tmp/geo-core-final-regressions.log` | +| Final enabled real hosting | `56eab85f`, `--no-default-features --features geo-cloudfront,test-utils` | Nonzero sentinel and full suite, 32 passed | 50.890 s | `/tmp/geo-final-enabled-hosting.log` | +| Final disabled/supplied real hosting | `56eab85f`, `--no-default-features --features axum,test-utils` | Full suite, 33 passed; both unavailable and supplied-without-built-in sentinels logged | 16.919 s | `/tmp/geo-final-disabled-hosting.log` | +| Workspace Clippy | `--workspace --all-targets --all-features -- -D warnings` | Pass after splitting the long core regression | 27.72 s | `/tmp/geo-workspace-clippy.log` | +| Native disabled Clippy | `--no-default-features --features axum --lib -- -D warnings` | Pass, including absent-implementation cfg | 2.14 s | `/tmp/geo-final-disabled-clippy.log` | +| Workspace feature compilation | `--all-targets --features "fastly cloudflare spin"` | Pass | 78 s | `/tmp/geo-feature-compilation.log` | +| Minimal native / bindings-only | No defaults; then `native-bindings` only | Both pass | 3 / 4 s | `/tmp/geo-native-{minimal,bindings-only}.log` | +| Three WASM target checks | Fastly wasip1, Cloudflare unknown-unknown, Spin wasip2; adapter feature only | All pass, compile-only | 11 / 32 / 39 s | `/tmp/geo-*-wasm-check.log` | +| Fastly WASM contracts | Viceroy 0.21.0, `fastly,test-utils` on wasip1 | 24 runtime tests passed; preparer/closed-lifecycle sentinel separately passed | 35 / 6 s | `/tmp/geo-fastly-{wasm,preparer}-runtime.log` | +| Cloudflare WASM contracts | wasm-bindgen-test-runner 0.2.128, Node 24.12.0, `cloudflare,test-utils` | 29 runtime tests passed | 59 s | `/tmp/geo-cloudflare-wasm-runtime.log` | +| Spin WASM contracts | Wasmtime 48.0.2, async component/P3/HTTP enabled, `spin,test-utils` on wasip2 | 35 runtime tests passed, including managed Geo-forgery case | 32 s | `/tmp/geo-spin-wasm-runtime.log` | +| Geo / native-store dependency scripts | Isolated normal graphs from final manifests/locks | Both pass; Geo adds no third-party runtime package relative to Axum | 2 / 6 s | `/tmp/geo-{dependency-final,native-store-dependency}.log` | +| Native Geo public doc tests | `--features geo-cloudfront --doc` | 5 passed, including all four guide examples | 4.16 s compile + 2.16 s doc harness | `/tmp/geo-native-doc-tests-final.log` | +| Workspace rustdoc | `RUSTDOCFLAGS='-D warnings'`, all features, no deps | Pass | 64 s | `/tmp/geo-workspace-rustdoc.log` | +| App-demo workspace | Final consumer lock, all targets | 54 passed, none failed | 170.338 s | `/tmp/geo-demo-tests.log` | +| Reusable native consumer | Final consumer lock, `-p fixture-axum` | Pass | 109.243 s | `/tmp/geo-reusable-check.log` | +| Published docs | Locked npm dependencies; lint, format, site build | All pass, exact guide/rustdoc snippets matched | 3.272 / 2.912 / 8.401 s | `/tmp/geo-docs-{lint,format,build}.log` | +| Dependency-script failure cases | 16 mocked success/failure graph scenarios | All pass; real graphs also checked above | 3.206 s | `/tmp/geo-docs-dependency-mocks.log` | +| Formatting, whitespace and internal links | `cargo fmt --all -- --check`, `git diff --check`, local Markdown checks | Pass | Not separately timed | Final coordinator checks | + +The runtime contract commands use the existing `scripts/run_test_nonzero.sh` harness with their established dispatch sentinels. Fastly used the official Viceroy 0.21.0 binary, archive SHA-256 `52ed3c3ed59d926c81015d637509942c3195443c02c2eebbded9125484336a9e`; the installed 0.17.0 binary was not used. Cloudflare used the absolute installed 0.2.128 runner rather than an unconfigured mise shim. Spin used `wasmtime run -W component-model-async=y -S p3=y -S http=y`. + +### Dependencies and proof limits + +The root lockfile adds only four dependency edges for reconciled #409 runtime/test support. Each excluded consumer lockfile adds only two native dependency edges; no package versions were changed. Minimum compatible `getrandom = "0.4.2"` and `ipnet = "2.12.0"` constraints preserve independently locked consumer patch versions instead of forcing unrelated downgrades and WASI dependency churn. Root tests use the pinned lock's 0.4.2/2.12.0; app-demo tests also prove its existing 0.4.3/2.12.2 lock. These are ingress prerequisites, not new Geo feature dependencies. The portable country allowlist is checked in with source revision, attribution and checksums, with no runtime dataset download. + +The Geo graph script explicitly permits the existing Cloudflare `worker 0.8.5 -> tokio 1.52.3` normal edge only with empty Tokio features. Native runtime/network companions or changed versions/features still fail. This baseline is not newly introduced native-runtime leakage. + +The six normally ignored workspace tests are existing generated-consumer or real-service acceptance harnesses. They were not silently selected or treated as passes: generated celld/default-off/restart, real celld TLS with explicit modified binary, generated DynamoDB lifecycle, generated-workspace compilation, native AWS emulator services, and generated native AWS build/startup qualification. The final demo/reusable builds and native mixed-provider startup tests pass, but do not replace those external harnesses. No manual `edgezero serve` run or actual cloud deployment was performed. + +Published docs dependencies report seven existing audit vulnerabilities, two moderate and five high, in their unchanged lockfile. No unrelated dependency upgrades were attempted. ShellCheck was unavailable; Bash syntax, 16 failure-case regressions and actual graph execution passed. Existing ignored core rustdoc examples remain ignored; the new Geo examples execute. + +### Safety evidence and separate acceptance + +1. **Protected source ownership.** Runtime evidence now proves actual-peer policy matching, missing/nonmatching peer rejection despite forged public metadata, distinct ordinary proxy trust, stripping before callbacks/error handling, original-head accounting, and exactly-once resolution only for admitted trusted requests. Evidence is the managed-resolution sentinel and both final real-hosting suites. This proves the local declared-policy boundary, not an ALB listener rule. +2. **Immutable and partial outcomes.** Executed core and real-hosting tests prove consumers share one outcome across header/extension mutation, same-type app-state injection and cleared extensions. Managed context roundtrip and legacy dispatch reset to NoSelection. Country-only, syntax-valid unknown subdivisions, missing/error distinctions, source failures, cancellation/drop/failing egress and no fallback retain their defined behavior. Portable runtime tests prove raw or forged Geo metadata cannot select a source. + +Actual ALB credential enforcement, network isolation, alternate-path denial, CloudFront header replacement including missing subdivision, value rotation and shared-cache isolation remain unproven until separately authorized deployment checks in section 15. Trusted Server policy translation and interpretation are outside this change. Deferred local databases and reader-IP input extension are tracked in [#438](https://github.com/IABTechLab/edgezero/issues/438), with the initial split recorded on [#432](https://github.com/IABTechLab/edgezero/issues/432#issuecomment-6349594498). + +Completion: + +- [x] Full local implementation has its focused and integration proof; the #409/#407 prerequisite distinction is explicit. +- [x] Default-off, no-fallback and unknown/error distinctions survive real native dispatch. +- [x] Provider objects, source admission and outcomes each have one owner; public request mutation cannot rewrite a live managed outcome. +- [x] Existing store preparation, native lifecycle, framework error privacy and portable adapter behavior are preserved. +- [x] Deployment, database and downstream acceptance remain separate from local implementation. +- [x] User-authorized commits and draft publication target `feat/native-celld-stores`, on top of #422; no unrelated edits or automatic deployment are included. diff --git a/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md b/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md new file mode 100644 index 000000000..e373f0600 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md @@ -0,0 +1,217 @@ +# Pluggable Geo providers + +- Status: implemented and locally verified. The implementation plan records test evidence. Cloud deployment and downstream acceptance remain separate and unproven. +- Date: 2026-10-22 +- Owning issue: [EdgeZero #432](https://github.com/IABTechLab/edgezero/issues/432), under [#391](https://github.com/IABTechLab/edgezero/issues/391). +- Initial deployment: CloudFront through an HTTPS, origin-secret-gated ALB to network-isolated native Axum. +- Planning baseline: EdgeZero `5426ea65`. Checked native ingress from [PR #409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6) was reconciled by adapting its own commit delta onto the newer native-store stack. Current asynchronous egress and connection ownership remain intact. The separate #407 buffered JSON-cap/body-carrier feature was not imported. No second forwarding parser was added. +- Composition reference: [native startup lifecycle](2026-10-05-axum-production-startup-lifecycle-design.md). Geo remains separate from [native store providers](2026-10-07-native-store-providers-design.md) and [PR #414](https://github.com/IABTechLab/edgezero/pull/414). + +## 1. Outcome and scope + +Applications obtain an estimated country and optional first-level subdivision through one portable Geo contract. EdgeZero supplies geographic assertions and their provenance. Applications decide jurisdiction, consent, identity behavior and disclosure. An estimate is not proof of residence, identity or consent. + +The first implementation includes portable outcomes, a synchronous provider contract, explicit native selection, the CloudFront provider, a request accessor, focused tests and deployment guidance. Application-supplied providers use the same narrow, local-only contract. No local GeoIP database or external lookup service is included. + +This deliberately narrows #432. Its local-database, reader-IP input and database-lifecycle requirements are tracked separately in [#438](https://github.com/IABTechLab/edgezero/issues/438), not satisfied by CloudFront support. The split is recorded on #432. Trusted Server adoption is a separate follow-up, not an EdgeZero delivery step. + +Required invariants: + +- Select zero or one provider per deployment. No selection means unavailable geography. A selected provider never falls back to another source. +- Country-only geography remains partial. A US country without a state does not identify a privacy regime. +- Invalid configuration or an unavailable selected implementation fails native startup before listen/readiness. +- Missing or rejected request metadata produces an outcome, not a provider-selection change or an automatic HTTP error. +- One immutable outcome belongs to the request. Consumers do not resolve geography again or reinterpret forwarding headers. +- Provider calls use bounded, source-admitted assertions, not the request, reader IP or unrestricted services. +- Core acquires no AWS SDK, Axum, Tokio or filesystem dependency from this feature. + +## 2. Ownership and data flow + +| Concern | Owner | +| --- | --- | +| Socket peer, protected-path policy, admission and source provenance | Native ingress and adapter | +| Selection, assertion normalization and bounded failure categories | Geo composition and providers | +| Country/subdivision outcome and read-only accessor | Portable core | +| CloudFront policies, ALB rules, credentials and network isolation | Deployment operator | +| Jurisdiction, consent, identity and data disclosure | Application | + +```mermaid +flowchart TD + Options[Explicit native selection and protected-path policy] --> Startup[Validate before application initialization] + Request[Incoming request] --> Ingress[Checked ingress and admission] + Startup --> Provider[Selected synchronous provider] + Ingress --> Source{Protected source admitted?} + Source -->|Yes: bounded assertions| Provider + Source -->|No| Unavailable[Unavailable: untrusted ingress] + Provider --> Outcome[Immutable request outcome] + Unavailable --> Outcome + Outcome --> Consumers[Application middleware and handlers] + Consumers --> Policy[Application jurisdiction and permission policy] +``` + +An AWS deployment region, browser language, timezone, arbitrary country header or absent consent signal is not visitor geography. This feature introduces no identity lifecycle, regulatory lists, permission language, device assessments or response-header middleware. + +## 3. Provider and request contract + +Core owns the outcome types and a small synchronous provider interface. Resolution is bounded local computation. It performs no network, filesystem or other blocking I/O, launches no background work and returns an owned outcome without a future. These restrictions apply to application-supplied implementations as well as the built-in provider. + +Applications may supply an already-constructed provider through typed native composition. There is no plugin discovery, registry, per-ID Geo store, asynchronous preparation interface or provider-specific shutdown hook. Any application-owned construction precedes handing the provider to the runner; this feature adds no resource-loading lifecycle. The CloudFront implementation needs no AWS client, credentials or local directories. + +The initial input contains only the two geographic assertion slots described in section 6. Each slot is absent, a bounded value or an invalid-assertion marker. Ingress owns source admission, duplicate detection and byte bounds. The provider owns code validation and normalization. Source-admitted does not mean the geographic value is already valid. Preserve invalid markers rather than treating rejected input as absent. + +Do not include reader IP, cookies, authorization, query/path, arbitrary headers, application settings or service handles. Future GeoIP or remote providers require an explicit input-contract extension. Reuse #409 if reader-IP support is added later; this implementation neither requires a resolved reader IP nor parses forwarding headers for Geo. + +For managed Axum dispatch: + +1. Remove the configured origin-credential header before application callbacks can observe the request. Its contents do not establish trust inside EdgeZero. +2. Complete framework ingress validation and admission. Refused or aborted ingress performs no provider resolution. +3. Use the runner-owned socket peer and original ingress snapshot to establish source admission. No selection yields `Unavailable(NoSelection)`; a selected provider without source admission yields `Unavailable(UntrustedIngress)`. Neither invokes the provider. +4. For an admitted request from the configured source, invoke the selected provider exactly once, including when both assertion slots are absent. Store the outcome before request preparers, application middleware and handlers run. + +`RequestContext` exposes a read-only accessor. Header mutation or insertion/removal of public extensions cannot replace the runner-owned outcome. All consumers see the same result. Admission callbacks do not have Geo; no new Geo-dependent admission hook is introduced. + +No lookup occurs during response finalization. Other adapters and the unmanaged request converter expose `Unavailable(NoSelection)` in v1 and never infer trust from raw headers or caller-inserted extensions. Optional application response middleware can read the outcome, but implementing that middleware is outside this change. + +Application-supplied Rust remains trusted code. The input contract prevents accidental coupling; it is not a sandbox or enforcement against malicious provider code. + +## 4. Outcomes and provenance + +Names below describe behavior, not final Rust spelling. + +| Outcome | Meaning | +| --- | --- | +| `Unavailable(NoSelection)` | No provider is attached to this request path | +| `Unavailable(UntrustedIngress)` | A provider is selected, but the request lacks the required protected-source admission | +| `Unavailable(MissingMetadata)` | The source is admitted, but neither geographic assertion is present | +| `Located` | A supported country is present, with an optional syntax-checked subdivision assertion | +| `Error(InvalidAssertion)` | A selected assertion is malformed, duplicated, unsupported or inconsistent | +| `Error(ProviderFailure)` | A supplied provider reports an execution failure, without exposing a raw source error | + +Attach the selected provider's identity to every outcome except `NoSelection`, including source rejection before invocation. A located result also records the source kind and trust basis. For CloudFront, distinguish `CloudFront` as source from the declared protected-ALB path as trust basis. Neither is a cryptographic attestation of distribution identity. Do not retain raw headers, credentials or reader IP in the outcome. + +A located result requires a country. A subdivision cannot exist without its country. Shared validated types enforce the normalization rules for built-in and supplied-provider outputs alike. The subdivision is a provider assertion with ISO 3166-2 component syntax, not independently verified membership in a worldwide subdivision catalogue. + +These outcomes do not set an HTTP status or grant permissions. Applications distinguish missing geography from an error and decide whether unrelated functionality may proceed. Unknown or unrecognized subdivisions must not silently become known non-regulated jurisdictions. + +## 5. Selected CloudFront trust model + +### One supported deployment recipe + +Use AWS's documented [origin-secret ALB pattern](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/restrict-access-to-load-balancer.html), with these controls required together: + +1. CloudFront adds a dedicated, high-entropy origin credential using a custom header. Only the intended distribution and authorized operators possess it. Do not reuse it across unrelated distributions. Keep its name and value out of logs and responses; document value rotation. +2. Viewer access uses HTTPS. CloudFront connects to the ALB using HTTPS-only with a valid origin certificate. +3. The ALB listener forwards to the application target group only when the credential matches a configured non-wildcard value. Its default action denies access. No other listener or rule may forward unverified viewer traffic to that target group. +4. The ALB security group admits origin traffic only from the CloudFront origin-facing managed prefix lists for the enabled IP families. These lists admit CloudFront infrastructure, not a particular distribution, so they do not replace the credential check. +5. The Axum application port is private and accepts traffic only from the intended ALB security group. The ALB-to-Axum hop stays inside the isolated VPC. No public target port, alternate proxy or other route may bypass the ALB gate. Adding a native TLS server is outside this feature. + +The ALB validates the credential. EdgeZero does not receive its configured value, fetch AWS credentials or implement another authentication layer. AWS documents that [custom origin headers replace viewer-supplied values](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html). + +### Private ingress witness + +Add one explicit protected-ALB policy to native Geo composition, separate from ordinary proxy trust. It contains a nonempty, bounded literal CIDR allowlist for the actual ALB socket peers and the dedicated credential-header name to remove. Reuse #409's CIDR validation and address normalization, including rejecting unrestricted ranges. Reject a credential-header name that conflicts with geographic, forwarding or standard transport/authentication headers. + +Selecting this policy declares that every deployment control above is enforced. It cannot be inferred from generic trusted-proxy CIDRs, a forwarding header, a claimed distribution ID or an application-provided extension. + +After admission, the managed runner compares the actual connection peer with this policy. A match creates an adapter-private, request-bound witness; a missing or nonmatching peer does not. Only this witness admits the two Geo assertion slots. The low-level converter and request preparers cannot create or replace it. Ordinary proxy trust without the separate protected-ALB policy never admits geographic assertions. + +Remove every occurrence of the configured credential header before application admission callbacks, request preparers or middleware run. Neither its name nor value belongs in Geo diagnostics. Live rotation changes the value under a fixed header name: the ALB temporarily accepts old and new values while CloudFront deploys the new value, then removes the old one. Renaming the header requires a coordinated stop/reconfigure/restart; v1 does not add dual-name removal state. + +This witness means the request arrived on the operator-declared protected path. EdgeZero cannot inspect security groups or distinguish a correctly gated ALB from the same ALB with a broken listener rule. Local tests prove peer/policy enforcement, not the external credential check. Deployment tests must prove the latter before production use. + +### Why this option + +This recipe adds no AWS SDK integration, application-side credential comparison or new VPC-origin resources. It is the smallest documented option for this native-Axum feature, at the cost of operator-owned secret rotation and an internet-facing ALB protected by network and listener rules. + +[AWS VPC origins](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html) remove public origin access and are a valid future deployment option. They require separate VPC-origin setup and currently exclude gRPC and Lambda@Edge origin triggers. They are not a second supported trust mode in v1. A private subnet or a CloudFront network range alone still must not be described as authentication of one distribution. + +## 6. CloudFront assertions and normalization + +Explicitly configure CloudFront to add `CloudFront-Viewer-Country` and `CloudFront-Viewer-Country-Region` through an origin request policy. Merely forwarding arbitrary viewer headers is not sufficient. Do not install static custom values for these names or edge functions that rewrite the generated geography. + +[AWS documents header replacement](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html#lambda-at-edge-restrictions-cloudfront-headers) when these CloudFront headers are configured: viewer-supplied values are overwritten after the viewer-request event. No Lambda@Edge function is required by this recipe. The [header reference](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html) also says subdivision is omitted for requests originating from the AWS network. Deployment proof must cover spoofed values in that missing-subdivision case; do not assume that absence of generated data permits forwarding a viewer's value. + +Apply the following rules to values exposed by the HTTP parser. Do not promise detection of whitespace already removed by an upstream HTTP parser. + +| Input | Required behavior | +| --- | --- | +| No selection | `NoSelection`, without inspecting geographic values | +| Selected provider, source not admitted | `UntrustedIngress`, regardless of absent, valid or malformed values | +| Admitted source, both headers absent | `MissingMetadata` | +| Duplicate instances, including identical duplicates, or comma-joined values | `InvalidAssertion`; do not concatenate or choose one | +| Country value | Exactly two ASCII letters; normalize to uppercase, then require membership in the compiled-in ISO 3166-1 alpha-2 allowlist | +| Country sentinel, reserved/user-assigned or unsupported code, such as `XX`, `ZZ`, `UK` or `XK` | `InvalidAssertion`; no alias substitution or guessed country | +| Subdivision value | One to three ASCII letters or digits; normalize letters to uppercase; no country prefix or separator | +| Empty value, whitespace, non-ASCII data, invalid encoding or excess length in either slot | `InvalidAssertion`; do not trim, decode or truncate into a valid value | +| Subdivision present without country | `InvalidAssertion` | +| Valid country, subdivision absent | `Located` with unknown subdivision | +| Valid country, invalid subdivision assertion | `InvalidAssertion` for the whole result, not country-only success | +| `us` with `ca` | `Located` with `US` and `CA` | +| `US` with `ZZ` | `Located` with the syntax-checked `ZZ` assertion; no claim that it is an assigned US subdivision or a known jurisdiction | + +Use the officially assigned ISO country codes from a pinned, checked-in dataset or dependency. Record the source revision beside the allowlist and test reserved/user-assigned exclusions. Updates are explicit code/dependency changes, not runtime downloads. V1 has no subdivision-membership database. Consumers own recognition of subdivision assertions and must preserve unknowns. + +Inspect all occurrences of the two names, but copy at most two country bytes and three subdivision bytes. Mark oversize or duplicate input invalid before copying it. Existing ingress header limits remain in force. All malformed trusted assertions yield the same bounded `InvalidAssertion` category; raw values never enter errors or diagnostics. + +The initial deployment recipe disables shared caching for these application routes using CloudFront's [managed `CachingDisabled` policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html), with minimum/default/maximum TTL zero. Also set [Error Caching Minimum TTL](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/custom-error-pages-expiration.html) to zero for cacheable application-error statuses; CloudFront otherwise defaults to caching errors for ten seconds. Other shared caches must not reuse application responses. A custom Geo-aware cache key is outside this recipe. Forwarding Geo headers alone does not separate cached responses by geography or permissions. + +## 7. Selection and startup + +Use one typed selection value: off, built-in CloudFront, or supplied provider. Do not model selection as independent flags/provider slots with precedence. Both active variants require the protected-ALB input policy in v1. Other sources require a later contract extension. + +Selection is programmatic through native run options. New environment/manifest selectors and CLI configuration are outside v1. Keep the built-in implementation default-off and native-scoped. Its selector must remain recognizable when the implementation feature is absent, so selection returns a bounded startup error rather than silently behaving as off. + +Validate selection, implementation availability and policy inputs before opening runner-owned resources or calling the application initializer. Valid CloudFront selection constructs a local provider; no deferred Geo preparation is polled. Existing initialization, readiness, shutdown and response-terminal ownership remain unchanged. Geo is not part of `PreparedStores`. + +The existing application initializer may inspect the selected Geo capability. This confirms configuration, not per-request coverage or infrastructure protection. Off is valid general hosting. Missing, untrusted or malformed request metadata does not change startup readiness or select another provider. + +## 8. Downstream compatibility, separate follow-up + +A later Trusted Server task supplies the translation and tests through its real request/policy path. It must preserve unavailable/error distinctions, country-only and unrecognized-subdivision handling, and independence from Edge Cookies. This spec does not relax the fail-closed behavior in [Trusted Server #1134](https://github.com/IABTechLab/trusted-server/issues/1134). + +Jurisdiction lists, consent interpretation, assumed-jurisdiction settings, identity lifecycle, auction mapping and response disclosure remain application work. No Trusted Server code or policy change is part of EdgeZero acceptance. + +## 9. Acceptance and proof boundaries + +### EdgeZero implementation acceptance + +1. Selection tests cover off, unavailable implementation, invalid policy and typed supplied-provider composition. Validation precedes runner resource opening and application initialization. No Geo dependency on AWS store features, credentials or local roots appears. +2. Ingress tests cover the selected policy plus a matching real socket peer, missing/nonmatching peers, ordinary proxy trust without the protected policy, forged public extensions and credential-header removal before application callbacks. They do not claim to validate an ALB rule. +3. Parsing fixtures cover every row in section 6, both value-length boundaries, identical duplicates and numeric subdivision components. Supplied providers cannot construct an invalid located value through the shared public types. +4. Real managed dispatch proves one resolution for a trusted admitted request, zero for rejected/untrusted/off paths, immutable outcomes across consumers and preparers, and no finalization lookup. Other adapters and the unmanaged converter retain no-selection behavior. +5. Privacy tests prove bounded inputs and diagnostics with no credential, reader IP, arbitrary headers, path/query or raw error chain. A supplied local test provider proves composition without adding remote execution or general preparation machinery. +6. Default builds, existing adapters and native lifecycle tests remain intact. Fastly, Cloudflare and Spin WASM dependency checks prove no native-runtime/SDK leakage. No new portable `Send` future requirement is introduced. + +### Separate deployment acceptance + +Before claiming the CloudFront deployment is safe, exercise the actual distribution, ALB and application network: + +- Intended requests reach the application; direct ALB access and an unrelated CloudFront distribution without the credential cannot reach it. +- Direct target access and alternate listeners/proxies cannot bypass the ALB gate. +- Viewer-supplied country/subdivision values do not survive as accepted geography. Include AWS-network requests with a forged subdivision where CloudFront omits its own value. +- Origin credential replacement, validation, removal and rotation work without exposing the credential in application-visible headers, responses or logs. +- Missing subdivision remains country-only; missing metadata never becomes guessed geography. +- Responses and application errors are not shared across requests through CloudFront or another shared cache. + +An incorrectly configured ALB behind an otherwise matching socket peer is an external trust failure, not something local parser tests can detect. A failed deployment check blocks production use of the declared policy; it does not authorize fallback or broader header trust. + +Downstream policy tests remain a separate adoption gate. Neither local provider tests nor deployed origin protection proves that an application handles unknown geography safely. The implementation plan records local runtime evidence; this design claims no cloud deployment or downstream acceptance. + +## 10. Delivery order and deferred work + +1. Reconcile the pinned #409 ingress revision and implement the private protected-ALB policy/witness without duplicating forwarding interpretation. +2. Add the portable synchronous contract, validated outcomes, typed selection and startup validation. +3. Add CloudFront parsing, immutable request integration and focused local tests. +4. Publish the selected deployment recipe and acceptance checklist. Record local results separately from any deployed evidence. + +Deferred work: + +- Local GeoIP databases, reader-IP inputs and remote/asynchronous providers. +- VPC-origin and other trust modes, plus Fastly/Cloudflare providers. +- Geo preparation, persistence, hot reload and fallback chains. +- Environment/manifest configuration and CLI support. +- Richer geography and subdivision catalogues. +- Custom geographic shared caching and automatic response headers. +- Trusted Server adoption and its policy tests. + +The earlier Trusted Server [pluggable-module](https://github.com/IABTechLab/trusted-server/blob/a854526e8186c20c4f0320761db6c14cd2cb267f/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md), [device-signal](https://github.com/IABTechLab/trusted-server/blob/a854526e8186c20c4f0320761db6c14cd2cb267f/docs/superpowers/specs/2026-10-12-device-signals-design.md) and [request-based Geo](https://github.com/IABTechLab/trusted-server/blob/79a0421fda634ce75ec06bc336af368daaa0a22f/docs/superpowers/specs/2026-10-15-geo-module-request-design.md) proposals are background references only. They do not expand this delivery or supply its API contract. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 4bbb35ea4..efa94a3e4 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -789,10 +789,12 @@ dependencies = [ "edgezero-store-celld", "futures", "futures-util", + "getrandom 0.4.3", "http", "http-body", "hyper", "hyper-util", + "ipnet", "libc", "log", "redb", diff --git a/scripts/check_geo_dependency_contract.sh b/scripts/check_geo_dependency_contract.sh new file mode 100755 index 000000000..2b3f89baa --- /dev/null +++ b/scripts/check_geo_dependency_contract.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Check isolated normal graphs, not workspace-unified or test dependencies. +set -euo pipefail +export LC_ALL=C + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +fail() { + printf 'Geo dependency contract: %s\n' "$*" >&2 + exit 1 +} + +graph() { + local name="$1" root="$2" + shift 2 + cargo tree --offline --locked --edges normal --prefix none --format '{p}|{f}' \ + -p "$root" "$@" > "$scratch/$name" + # Empty output, a wrong package, or a changed output format is not proof. + grep -Eq "^${root} v[^|]+[|]" "$scratch/$name" \ + || fail "missing expected root package in $name" + grep -Eq '^edgezero-core v[^|]+[|]' "$scratch/$name" \ + || fail "missing portable core in $name" +} + +features() { + local name="$1" root="$2" + awk -F '|' -v root="$root" '$1 ~ "^" root " v" { print $2; exit }' "$scratch/$name" +} + +assert_feature() { + local name="$1" feature="$2" expected="$3" selected + selected="$(features "$name" edgezero-adapter-axum)" + if grep -Eq "(^|,)${feature}(,|$)" <<< "$selected"; then + [[ "$expected" == on ]] || fail "$feature unexpectedly selected in $name" + else + [[ "$expected" == off ]] || fail "$feature missing in $name" + fi +} + +# The implementation must be default-off and add no normal runtime dependency. +graph native-default edgezero-adapter-axum +graph native-off edgezero-adapter-axum --no-default-features --features axum +graph native-on edgezero-adapter-axum --no-default-features --features geo-cloudfront +for name in native-default native-off native-on; do + assert_feature "$name" axum on + if grep -Eq '^aws-(config|sdk-[^ ]+) v' "$scratch/$name"; then + fail "AWS SDK leaked into $name" + fi +done +assert_feature native-default geo-cloudfront off +assert_feature native-off geo-cloudfront off +assert_feature native-on geo-cloudfront on +for name in native-off native-on; do + # Ignore feature annotations and duplicate-tree markers, retaining exact versions. + cut -d '|' -f 1 "$scratch/$name" | sort -u > "$scratch/$name.packages" +done +if ! cmp -s "$scratch/native-off.packages" "$scratch/native-on.packages"; then + diff -u "$scratch/native-off.packages" "$scratch/native-on.packages" >&2 || true + fail 'geo-cloudfront changed the isolated normal dependency set' +fi + +# Portable core and real platform graphs must not acquire native hosting/stores. +assert_portable() { + local name="$1" root="$2" + shift 2 + graph "$name" "$root" "$@" + if grep -Eq '^(edgezero-adapter-axum|edgezero-store-(aws|celld)|axum(-[^ ]+)?|tokio-[^ ]+|reqwest|hyper(-[^ ]+)?|mio|socket2|ipnet|aws-config|aws-sdk-[^ ]+) v' "$scratch/$name"; then + fail "native runtime/store/SDK leaked into $name" + fi + # Pinned worker 0.8.5 already uses tokio 1.52.3 with default-features=false + # and no selected features on wasm32-unknown-unknown. That empty-feature + # dependency is not the native executor. Keep the exception exact so a worker + # upgrade or enabling runtime/net features requires a fresh isolation review. + if [[ "$name" == cloudflare ]]; then + grep -Eq '^worker v0[.]8[.]5[|]' "$scratch/$name" \ + || fail 'Cloudflare worker pin changed; review its empty-feature tokio exception' + grep -Eq '^tokio v1[.]52[.]3[|]$' "$scratch/$name" \ + || fail 'missing expected Cloudflare empty-feature tokio dependency' + fi + if ! awk -F '|' -v name="$name" ' + /^tokio v/ { + sub(/ [(][*][)]$/, "", $2) + if (name != "cloudflare" || $1 != "tokio v1.52.3" || $2 != "") exit 1 + } + ' "$scratch/$name"; then + fail "unexpected tokio version or native runtime features in $name" + fi +} +assert_portable core edgezero-core --no-default-features +for entry in 'cloudflare wasm32-unknown-unknown worker' 'fastly wasm32-wasip1 fastly' 'spin wasm32-wasip2 spin-sdk'; do + read -r adapter target sdk <<< "$entry" + assert_portable "$adapter" "edgezero-adapter-$adapter" \ + --no-default-features --features "$adapter" --target "$target" + grep -Eq "^${sdk} v[^|]+[|]" "$scratch/$adapter" \ + || fail "missing selected platform SDK in $adapter" +done +printf 'Geo default-off, native dependency equality, and portable isolation graphs passed.\n' diff --git a/scripts/check_outbound_legacy_api.sh b/scripts/check_outbound_legacy_api.sh index 2fdeada78..75cad9b2e 100755 --- a/scripts/check_outbound_legacy_api.sh +++ b/scripts/check_outbound_legacy_api.sh @@ -1133,10 +1133,14 @@ assert_active_surface_fixture_rejected() { failed=0 scan \ - 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ + 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy' \ crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ .claude/agents/code-architect.md || failed=1 +# Crate-local proxy paths identify the retired outbound module only in core. +# Axum's independent proxy module handles incoming proxy assertions. +scan 'crate::proxy|pub mod proxy' crates/edgezero-core || failed=1 + # Downstream response delivery is now adapter-owned. Keep the outbound-fetch # buffering helpers: they implement ResponseMode::Buffered and are unrelated to # response egress. From 93b41777cda16ce678ebdb3be0f5bb8b92339e84 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 14:05:20 -0600 Subject: [PATCH 2/3] refactor(axum): name incoming trust module trusted_proxy --- crates/edgezero-adapter-axum/src/dev_server.rs | 2 +- crates/edgezero-adapter-axum/src/diagnostics.rs | 4 ++-- crates/edgezero-adapter-axum/src/lib.rs | 4 ++-- crates/edgezero-adapter-axum/src/peer_networks.rs | 2 +- crates/edgezero-adapter-axum/src/run_options.rs | 2 +- crates/edgezero-adapter-axum/src/service.rs | 4 ++-- .../src/{proxy.rs => trusted_proxy.rs} | 0 docs/guide/axum-diagnostics.md | 2 +- scripts/check_outbound_legacy_api.sh | 6 +----- 9 files changed, 11 insertions(+), 15 deletions(-) rename crates/edgezero-adapter-axum/src/{proxy.rs => trusted_proxy.rs} (100%) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 97735f80c..2685f6818 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -25,11 +25,11 @@ use std::sync::Arc; use std::time::Duration; use crate::diagnostics::{NativeDiagnosticsHandle, NativeSession}; -use crate::proxy::TrustedProxyPolicy; pub use crate::run_options::AxumRunOptions; use crate::run_options::{ JsonBodyLimit, config_path, failure, native_limit_setting, native_settings, shutdown_grace, }; +use crate::trusted_proxy::TrustedProxyPolicy; use anyhow::Context as _; use edgezero_core::probe::LifecyclePhase; use futures_util::FutureExt as _; diff --git a/crates/edgezero-adapter-axum/src/diagnostics.rs b/crates/edgezero-adapter-axum/src/diagnostics.rs index 2cd1e1f34..e810f2277 100644 --- a/crates/edgezero-adapter-axum/src/diagnostics.rs +++ b/crates/edgezero-adapter-axum/src/diagnostics.rs @@ -23,7 +23,7 @@ use edgezero_core::response_egress::{ }; use edgezero_core::{MonotonicClock, MonotonicInstant}; -use crate::proxy::{CheckedProxyMetadata, ForwardingResult, Scheme}; +use crate::trusted_proxy::{CheckedProxyMetadata, ForwardingResult, Scheme}; /// Framework-generated correlation value, not a credential or visitor-supplied ID. #[derive(Clone, Copy, Eq, Hash, PartialEq)] @@ -1329,7 +1329,7 @@ mod tests { #[test] fn metadata_preserves_original_summary_and_public_replacement_cannot_change_private_identity() { - use crate::proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; + use crate::trusted_proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; use edgezero_core::http::{HeaderValue, Request}; use edgezero_core::ingress::{ IngressHeadLimits, validate_normalized_ingress_parts_with_summary, diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index c9fb4aba8..3ae6b7303 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -31,8 +31,6 @@ pub mod outbound; #[cfg(feature = "axum")] mod peer_networks; #[cfg(feature = "axum")] -pub mod proxy; -#[cfg(feature = "axum")] pub mod request; #[cfg(feature = "axum")] mod response; @@ -44,6 +42,8 @@ pub mod secret_env; pub mod secret_store; #[cfg(feature = "axum")] mod service; +#[cfg(feature = "axum")] +pub mod trusted_proxy; #[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; diff --git a/crates/edgezero-adapter-axum/src/peer_networks.rs b/crates/edgezero-adapter-axum/src/peer_networks.rs index dd2fec164..e0b6f0bae 100644 --- a/crates/edgezero-adapter-axum/src/peer_networks.rs +++ b/crates/edgezero-adapter-axum/src/peer_networks.rs @@ -1,5 +1,5 @@ //! Shared bounded socket-peer networks for forwarding and protected Geo admission. -use crate::proxy::ProxyTrustConfigError; +use crate::trusted_proxy::ProxyTrustConfigError; use ipnet::{IpNet, Ipv4Net}; use std::net::{IpAddr, Ipv6Addr}; const MAX_BYTES: usize = 0x4000; diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index cde39336c..b821072b2 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -20,7 +20,7 @@ use crate::native_bindings::{ NativeBindingsLimits, NativeConfigBinding, NativeKvBinding, NativePreparationPolicySources, NativeStoreBindings, NativeStoreBindingsInput, }; -use crate::proxy::TrustedProxyPolicy; +use crate::trusted_proxy::TrustedProxyPolicy; /// Validated native JSON buffering ceiling. #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index abe2b66d3..c07b2e21b 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -49,10 +49,10 @@ use crate::geo::NativeGeo; #[cfg(test)] use crate::ingress_config::AxumIngressConfig; use crate::outbound::{AxumOutboundClient, native_time}; -use crate::proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; use crate::request::{HttpTransport, into_core_request_parts}; use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; use crate::run_options::JsonBodyLimit; +use crate::trusted_proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; /// Private Hyper service error used only to close/reset an admission-aborted connection. #[derive(Debug, thiserror::Error)] @@ -3073,7 +3073,7 @@ mod tests { NativeDiagnosticsSnapshot, NativeRequestFailure, NativeRequestObserver, NativeRequestOutcome, NativeRequestRecord, }; - use crate::proxy::{ForwardingHeaderFamily, ForwardingResult, Scheme}; + use crate::trusted_proxy::{ForwardingHeaderFamily, ForwardingResult, Scheme}; use edgezero_core::extractor::ForwardedHost; use edgezero_core::http::{Authority, Uri}; use edgezero_core::ingress::{CheckedEffectiveHost, CheckedHostSource, IngressHead}; diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/trusted_proxy.rs similarity index 100% rename from crates/edgezero-adapter-axum/src/proxy.rs rename to crates/edgezero-adapter-axum/src/trusted_proxy.rs diff --git a/docs/guide/axum-diagnostics.md b/docs/guide/axum-diagnostics.md index b0aa64729..55e914cef 100644 --- a/docs/guide/axum-diagnostics.md +++ b/docs/guide/axum-diagnostics.md @@ -52,7 +52,7 @@ same bounds and never silently truncate. ```rust use edgezero_adapter_axum::dev_server::AxumRunOptions; use edgezero_adapter_axum::diagnostics::NativeDiagnosticsHandle; -use edgezero_adapter_axum::proxy::{ForwardingHeaderFamily, TrustedProxyPolicy}; +use edgezero_adapter_axum::trusted_proxy::{ForwardingHeaderFamily, TrustedProxyPolicy}; let diagnostics = NativeDiagnosticsHandle::new(); let policy = TrustedProxyPolicy::new( diff --git a/scripts/check_outbound_legacy_api.sh b/scripts/check_outbound_legacy_api.sh index 75cad9b2e..2fdeada78 100755 --- a/scripts/check_outbound_legacy_api.sh +++ b/scripts/check_outbound_legacy_api.sh @@ -1133,14 +1133,10 @@ assert_active_surface_fixture_rejected() { failed=0 scan \ - 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy' \ + 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ crates examples/app-demo docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ .claude/agents/code-architect.md || failed=1 -# Crate-local proxy paths identify the retired outbound module only in core. -# Axum's independent proxy module handles incoming proxy assertions. -scan 'crate::proxy|pub mod proxy' crates/edgezero-core || failed=1 - # Downstream response delivery is now adapter-owned. Keep the outbound-fetch # buffering helpers: they implement ResponseMode::Buffered and are unrelated to # response egress. From 5ddc0d30174a22872bde69625811ad322a673409 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 14:39:25 -0600 Subject: [PATCH 3/3] fix(adapters): shrink wasm dispatch futures and repair Spin Geo test --- crates/edgezero-adapter-cloudflare/src/request.rs | 2 +- crates/edgezero-adapter-spin/src/request.rs | 2 +- crates/edgezero-adapter-spin/tests/contract.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index fe32c7b22..58835f92b 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -849,7 +849,7 @@ where if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = app.dispatch_admitted(prepared, core_request).await; + let response = Box::pin(app.dispatch_admitted(prepared, core_request)).await; deliver(response).map_err(|err| edge_error_to_worker(&err)) } diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index 0b5b9e6bc..96a4ca613 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -464,7 +464,7 @@ where if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = app.dispatch_admitted(prepared, core_request).await; + let response = Box::pin(app.dispatch_admitted(prepared, core_request)).await; Ok(deliver(response)?) } diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 2b7e879fa..5b268be4f 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -573,7 +573,7 @@ mod tests { mod ingress_contract { use edgezero_core::response_egress_framing::ResponseEgressFramingMode; use std::collections::VecDeque; - use std::future::{Future, poll_fn as poll_future, ready}; + use std::future::{Future, pending, poll_fn as poll_future, ready}; use std::io; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -610,11 +610,12 @@ mod tests { #[test] fn geo_managed_dispatch_ignores_forged_extensions() { let app = build_test_app(); - let response = block_on(dispatch_ingress_stream_for_test( + let response = block_on(dispatch_ingress_stream_with_timer( &app, Method::GET, "http://example.com/uri".parse().expect("URI"), empty::>(), + |_remaining| pending::<()>(), )) .expect("Spin admitted dispatch"); assert_eq!(captured_body(response).as_ref(), b"http://example.com/uri");