From d5ec14ed6be86d8a20b1842d4bc5693c33800423 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 22 Oct 2026 14:29:42 -0500 Subject: [PATCH 1/5] feat(core): add portable Geo provider contracts --- crates/edgezero-core/src/geo.rs | 581 ++++++++++++++++++ crates/edgezero-core/src/geo/country_codes.rs | 42 ++ crates/edgezero-core/src/lib.rs | 1 + .../2026-10-22-pluggable-geo-providers.md | 492 +++++++++++++++ ...26-10-22-pluggable-geo-providers-design.md | 217 +++++++ 5 files changed, 1333 insertions(+) create mode 100644 crates/edgezero-core/src/geo.rs create mode 100644 crates/edgezero-core/src/geo/country_codes.rs 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 diff --git a/crates/edgezero-core/src/geo.rs b/crates/edgezero-core/src/geo.rs new file mode 100644 index 00000000..ee95c0fe --- /dev/null +++ b/crates/edgezero-core/src/geo.rs @@ -0,0 +1,581 @@ +//! 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 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!( + format!("{:x}", 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 00000000..6fe08e4a --- /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/lib.rs b/crates/edgezero-core/src/lib.rs index 407e9cb2..e9662b48 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod context; pub mod env_config; pub mod error; pub mod extractor; +pub mod geo; pub mod handler; pub mod http; pub mod ingress; 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 00000000..c98117c7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md @@ -0,0 +1,492 @@ +# Pluggable Geo providers implementation plan + +- Status: implementation started. Stage 1 portable values and provider contract pass focused checks. Stage 0 ingress reconciliation remains blocked; native integration and deployment acceptance have not begun. +- Governing spec: [Pluggable Geo providers](../specs/2026-10-22-pluggable-geo-providers-design.md). +- Planning baseline: `5426ea65f642beafc59e6b4a4d198220d3c20e57`, branch `feature/pluggable-geo-providers`. +- Reviewed spec SHA-256: `12eeada53b62663923531fa43e27aac08386df1c97dbc3b66a032b504c7f9f2b`. The spec is untracked at planning time. Recheck its contents and hash before implementation. +- Required ingress reference: [#409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6). Its Git object was inspected locally; its changes are not integrated into this worktree. +- 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. +- [ ] Arrange the #432 scope update or separate GeoIP follow-up before implementation scheduling. Do not mark the local-database requirements satisfied by this feature. +- [ ] Integrate an approved #409 revision as a prerequisite, or stop native implementation until that integration is available. Review its reconciliation separately from Geo. The pinned commit is a behavioral reference, not permission to copy old files over current ones. +- [ ] 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. +- [ ] Confirm original head accounting, missing-peer cleanup, checked proxy normalization, forwarding-header removal, native diagnostics and category-only framework error behavior survive the reconciliation. +- [ ] 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 + +- [ ] 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`. +- [ ] 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. +- [ ] Add opaque `ProtectedCloudFrontAlb` with a nonempty `PeerNetworks` and one credential-header name. It contains no credential value, distribution-authentication claim or AWS handle. +- [ ] 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. +- [ ] 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-`. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] Factor existing admitted dispatch methods through the same implementation with `NoSelection`. Preserve their signatures and existing rendering, response metadata, completion, grant and fallback behavior. +- [ ] Install the owned outcome into private admitted state before passing it through the existing router path. The router's public signature need not change. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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`. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Preserve every development/unconfigured entrypoint with off composition. The explicit options path is the only new selection path. +- [ ] 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 + +- [ ] Implement the stateless built-in `GeoProvider` behind `geo-cloudfront`. It accepts only the two bounded assertion slots and returns a `GeoResolution`. +- [ ] Map absent country plus absent subdivision to missing metadata. A subdivision without country is invalid. Any invalid slot makes the whole result invalid. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Pass the owned outcome separately through `dispatch_prepared` to `App::dispatch_admitted_with_geo`, retaining `FrameworkErrorDetail::CategoryOnly` and the existing native egress owner. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Prove a supplied provider works with the CloudFront implementation feature disabled. This distinguishes pluggable composition from availability of the built-in parser. +- [ ] 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. +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Keep Trusted Server adoption and VPC origins in deferred-work notes, not executable steps in this feature. + +### Feature and dependency checks + +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. Evidence record and completion checklist + +Populate this table during implementation, not with assumptions from planning: + +| Check | Tested revision and feature set | Result | Elapsed | Evidence | +| --- | --- | --- | --- | --- | +| Reconciled #409 baseline | `5426ea65`, reference `66a05e25` | Blocked, not integrated | Not applicable | Branch comparison described below; only existing core ingress baseline ran | +| Portable values, stage 1 | `5426ea65` + fingerprinted working files below; default core features | Pass, 6 Geo tests | 0.630 s | `/tmp/geo-types-tests.log`, nonzero sentinel command below | +| Core regression suite | Same stage 1 state; default core features | Pass, 745 unit + 2 integration + 5 doctests; 13 existing doctests ignored | 3.734 s | `/tmp/geo-core-tests.log` | +| Core Clippy and rustdoc | Same stage 1 state; all core targets for Clippy | Pass, warnings denied | 10.288 s / 2.088 s | `/tmp/geo-core-clippy.log`, `/tmp/geo-core-doc.log` | +| Portable core WASM compilation | Same stage 1 implementation; three WASM targets below | Pass, compile-only | 21.898 / 18.664 / 8.741 s | `/tmp/geo-core-wasm32-*.log`; not adapter runtime proof | +| Immutable request handoff, stage 3 | Not implemented | Not run | Not recorded | Still requires reconciled dispatch APIs | +| Policy/parser and feature-disabled selection | Not recorded | Not run | Not recorded | Not recorded | +| Real native hosting and supplied provider | Not recorded | Not run | Not recorded | Not recorded | +| Workspace, dependencies and WASM | Not recorded | Not run | Not recorded | Not recorded | +| Published guide and examples | Not recorded | Not run | Not recorded | Not recorded | +| Actual CloudFront/ALB deployment | Not recorded | Not run | Not recorded | Separate authorized evidence | +| Trusted Server adoption | Outside this change | Not run | Not recorded | Separate downstream task | + +### Implementation checkpoint, 2026-10-22 + +Only the independent stage 1 core contract was implemented ahead of ingress reconciliation. This is an explicit sequencing exception for inert portable types, not satisfaction of the stage 0 exit gate. No request path uses Geo yet. Native stages, dispatch handoff and feature selection remain blocked on a separately reviewed #409 integration. + +The pinned ingress branch has merge-base `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a` with HEAD. Its core/Axum tree differs across 52 files, including newer native-store code missing from that branch. Whole-file replacement is unsafe. No merge, cherry-pick or replacement was attempted. The #432 scope update or separate GeoIP issue is still pending; no GitHub issue was changed. + +Before Geo edits, `cargo test --locked -p edgezero-core --lib ingress::` passed 7 tests, with a 30.69 s build, recorded in `/tmp/geo-baseline-ingress.log`. This is not the reconciled #409 baseline. The Geo regression suite first failed compilation due to absent contract types, recorded in `/tmp/geo-red.log`, then passed after implementation. + +Implemented files and final SHA-256 values: + +- `crates/edgezero-core/src/geo.rs`: `1b82be241a120d17eb00c9a9cfac554d67f84be938f77d853a1640d52386d0a6`. +- `crates/edgezero-core/src/geo/country_codes.rs`: `de5fd4d147008d47db626e9694e85678410e7009a2becf8e823fd104b64f55ff`. +- `crates/edgezero-core/src/lib.rs`: `1c147a21c2f39c8045eedd5d422b27b24bc831b23d12d5c6e82a523a84adc3e8`. + +Executed commands: + +```sh +scripts/run_test_nonzero.sh geo_country_codes_normalize_and_reject_unsupported cargo test --locked -p edgezero-core --lib geo:: +cargo test --locked -p edgezero-core +cargo clippy --locked -p edgezero-core --all-targets -- -D warnings +RUSTDOCFLAGS='-D warnings' cargo doc --locked -p edgezero-core --no-deps +cargo check --locked -p edgezero-core --target wasm32-wasip1 +cargo check --locked -p edgezero-core --target wasm32-unknown-unknown +cargo check --locked -p edgezero-core --target wasm32-wasip2 +cargo fmt --all -- --check +git diff --check +``` + +All commands passed on Rust/Cargo 1.95.0. WASM compilation preceded a rustdoc-only example addition; the implementation it checked is unchanged. No dependency, manifest or lockfile changes were needed. Formatting and whitespace checks passed; timings were not recorded for those two checks. Logs are local temporary files, not durable CI artifacts. + +Country data is pinned to `datasets/country-list@ca393dfa16db21ce07be77902c1c129d63c86d9f`. The table records the source CSV checksum and attribution. Only the 249 code elements were extracted and sorted; no country names or upstream software were copied. ISO's code-use statement and the upstream README/attribution were inspected. Tests check the extracted snapshot checksum, uniqueness, ordering, count, all 676 two-letter combinations and their lowercase equivalents. + +For the two safety assumptions in section 1, this checkpoint proves only that the portable model preserves partial geography and bounded errors. Executed constructor tests cover country-only and syntax-valid unknown subdivisions, and input tests prove bounded copies and raw-byte debug redaction. Source admission is still unproven; its next check is the protected-policy and real-peer suite after ingress reconciliation. Permission interpretation remains unproven and belongs to downstream policy tests. + +No workspace-wide, adapter, real-hosting, WASM-runtime, cloud or downstream acceptance is claimed. Those checks remain in their later stages. + +Completion requires: + +- [ ] Every implementation stage has its focused proof and no unchecked prerequisite was silently bypassed. +- [ ] Default-off, no-fallback and unknown/error distinctions survive real dispatch. +- [ ] Provider objects, source admission and outcomes each have one owner; public request mutation cannot rewrite a managed outcome. +- [ ] Existing store preparation, native lifecycle, framework error privacy and portable adapter behavior are preserved. +- [ ] Deployment and downstream acceptance are reported separately from local implementation. +- [ ] The final handoff lists files, checks/outcomes and everything unverified. No unrelated changes, automatic deployment or unrequested commit/push 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 00000000..c73082d7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md @@ -0,0 +1,217 @@ +# Pluggable Geo providers + +- Status: Proposed architecture, revised after scope and contract review. No implementation or deployment acceptance is claimed. +- 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`. Integrate checked native ingress from [PR #409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6). It is not present in this worktree. Review any later ingress revision explicitly; do not implement another forwarding-header parser. +- 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. Split or update that issue before implementation scheduling; its local-database requirements remain deferred, not satisfied by CloudFront support. 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. No runtime, deployment or downstream acceptance is claimed by this document. + +## 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. From 5da2000f58d8e17237b7303b01420be39df7e6a7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 22 Oct 2026 14:51:39 -0500 Subject: [PATCH 2/5] feat(axum): resolve pluggable Geo through checked native ingress --- .github/workflows/test.yml | 36 +- Cargo.lock | 4 + Cargo.toml | 3 + crates/edgezero-adapter-axum/Cargo.toml | 7 + .../edgezero-adapter-axum/src/dev_server.rs | 244 +- .../edgezero-adapter-axum/src/diagnostics.rs | 1521 ++++++++++++ crates/edgezero-adapter-axum/src/geo.rs | 619 +++++ .../src/geo/cloudfront.rs | 140 ++ crates/edgezero-adapter-axum/src/lib.rs | 8 + .../src/peer_networks.rs | 143 ++ crates/edgezero-adapter-axum/src/proxy.rs | 1784 ++++++++++++++ crates/edgezero-adapter-axum/src/response.rs | 257 +- .../edgezero-adapter-axum/src/run_options.rs | 170 ++ crates/edgezero-adapter-axum/src/service.rs | 2060 ++++++++++++++++- .../tests/fixtures/native_proxy.rs | 311 +++ .../tests/fixtures/production_host.rs | 783 ++++++- .../tests/fixtures/tls/README.md | 11 + .../tests/fixtures/tls/ca.der | Bin 0 -> 821 bytes .../tests/fixtures/tls/server-key.der | Bin 0 -> 1217 bytes .../tests/fixtures/tls/server.der | Bin 0 -> 869 bytes .../tests/production_hosting.rs | 1646 ++++++++++++- .../tests/contract.rs | 23 +- .../edgezero-adapter-fastly/tests/contract.rs | 25 +- .../edgezero-adapter-spin/tests/contract.rs | 30 +- crates/edgezero-core/src/app.rs | 385 ++- crates/edgezero-core/src/context.rs | 26 +- crates/edgezero-core/src/error.rs | 115 +- crates/edgezero-core/src/extractor.rs | 55 + crates/edgezero-core/src/ingress.rs | 116 +- crates/edgezero-core/src/response_egress.rs | 284 ++- crates/edgezero-core/src/router.rs | 134 +- docs/.vitepress/config.mts | 2 + docs/guide/adapters/axum.md | 23 + docs/guide/axum-diagnostics.md | 444 ++++ docs/guide/native-geo.md | 317 +++ examples/app-demo/Cargo.lock | 2 + scripts/check_geo_dependency_contract.sh | 98 + tests/fixtures/reusable-app/Cargo.lock | 2 + 38 files changed, 11580 insertions(+), 248 deletions(-) create mode 100644 crates/edgezero-adapter-axum/src/diagnostics.rs 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-adapter-axum/src/proxy.rs create mode 100644 crates/edgezero-adapter-axum/tests/fixtures/native_proxy.rs create mode 100644 crates/edgezero-adapter-axum/tests/fixtures/tls/README.md create mode 100644 crates/edgezero-adapter-axum/tests/fixtures/tls/ca.der create mode 100644 crates/edgezero-adapter-axum/tests/fixtures/tls/server-key.der create mode 100644 crates/edgezero-adapter-axum/tests/fixtures/tls/server.der create mode 100644 docs/guide/axum-diagnostics.md create mode 100644 docs/guide/native-geo.md create mode 100755 scripts/check_geo_dependency_contract.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01c5e084..6d137062 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,8 @@ on: push: branches: [main] pull_request: - # Native-store PRs are currently stacked on the outbound HTTP specification. - branches: [main, docs/outbound-http-spec] + # Keep checks active for native-store and Geo stacked PRs. + branches: [main, docs/outbound-http-spec, feat/native-celld-stores] permissions: contents: read @@ -118,6 +118,38 @@ jobs: - name: Native store dependency isolation 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) run: | cargo test --offline --locked -p edgezero-store-aws --no-default-features --features appconfig-agent --lib diff --git a/Cargo.lock b/Cargo.lock index 42422563..544160b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1186,20 +1186,24 @@ dependencies = [ "flate2", "futures", "futures-util", + "getrandom 0.4.2", "http 1.4.2", "http-body", "hyper", "hyper-util", + "ipnet", "libc", "log", "redb", "reqwest", + "rustls", "serde", "serde_json", "simple_logger", "tempfile", "thiserror 2.0.20", "tokio", + "tokio-rustls", "toml", "tower", "tracing", diff --git a/Cargo.toml b/Cargo.toml index f6ae307c..8b65bc7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,12 +70,14 @@ fern = "0.7" flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] } futures = { version = "0.3", features = ["std", "executor"] } futures-util = { version = "0.3", features = ["alloc", "io"] } +getrandom = "0.4.2" handlebars = "6" http = "=1.4.2" http-body = "1" http-body-util = "0.1" hyper = { version = "=1.12.0", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } +ipnet = "2.12.0" log = "0.4" libc = "0.2" miniz_oxide = { version = "=0.8.9", default-features = false, features = ["simd", "with-alloc"] } @@ -91,6 +93,7 @@ simd-adler32 = { version = "=0.3.9", default-features = false } # file (`/.spin/sqlite_key_value.db`) using the schema # vendored from spinframework/spin's `crates/key-value-spin/src/store.rs`. rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } +rustls = { version = "=0.23.40", default-features = false, features = ["std", "tls12", "aws_lc_rs"] } ryu = "1" serde = { version = "1", features = ["derive"] } serde_json = "=1.0.150" diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 38bb1a44..c7b515a9 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", @@ -32,6 +34,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", @@ -54,6 +57,8 @@ bytes = { workspace = true } ctor = { workspace = true, optional = true } futures = { workspace = true } futures-util = { workspace = true, optional = true } +getrandom = { workspace = true, optional = true } +ipnet = { workspace = true, optional = true } http = { workspace = true } http-body = { workspace = true, optional = true } hyper = { workspace = true, optional = true } @@ -115,6 +120,8 @@ flate2 = { workspace = true } http-body = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } +rustls = { workspace = true } serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "rt-multi-thread", "test-util"] } +tokio-rustls = { workspace = true } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 30629abf..6a8c36e4 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -16,7 +16,7 @@ use std::io::ErrorKind; #[cfg(test)] use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; -use std::panic::AssertUnwindSafe; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process; @@ -37,7 +37,7 @@ use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; -use log::LevelFilter; +use log::{Level, LevelFilter}; use simple_logger::SimpleLogger; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; @@ -53,13 +53,16 @@ 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::diagnostics::{NativeDiagnosticsHandle, NativeSession}; +use crate::geo::NativeGeo; use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; use crate::native_stores::{self, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; use crate::outbound::AxumOutboundClient; +use crate::proxy::TrustedProxyPolicy; use crate::response::EgressConnection; pub use crate::run_options::AxumRunOptions; -use crate::run_options::{failure, shutdown_grace, strip_native_config_prefix}; +use crate::run_options::{failure, native_settings, shutdown_grace, strip_native_config_prefix}; use crate::secret_store::EnvSecretStore; use crate::service::{ AxumServiceState, legacy_config_registry, legacy_kv_registry, legacy_secret_registry, @@ -141,6 +144,7 @@ struct Stores { /// Blocking dev server runner used by the `EdgeZero` CLI. pub struct AxumDevServer { config: AxumDevServerConfig, + native: NativeHosting, router: RouterService, stores: Stores, } @@ -151,6 +155,7 @@ impl AxumDevServer { pub fn new(router: RouterService) -> Self { Self { config: AxumDevServerConfig::default(), + native: NativeHosting::default(), router, stores: Stores::default(), } @@ -164,6 +169,7 @@ impl AxumDevServer { router, config, stores, + native, } = self; run_local( ListenerSource::Bind(config.addr), @@ -171,23 +177,49 @@ impl AxumDevServer { Duration::from_secs(10), config.ingress, future::pending::<()>(), - async move { prepare_transport(App::new(router), stores.into()) }, + async move { prepare_transport(App::new(router), stores.into(), native) }, ) } + /// Installs explicit checked forwarding policy without ambient settings. + #[must_use] + #[inline] + pub fn with_trusted_proxy_policy(mut self, policy: TrustedProxyPolicy) -> Self { + self.native.policy = policy; + self + } + + /// Controls default records, without disabling counters or observers. + #[must_use] + #[inline] + pub fn with_request_records(mut self, enabled: bool) -> Self { + self.native.request_records = enabled; + self + } + + /// Shares the managed native observer and counters. + #[must_use] + #[inline] + pub fn with_diagnostics(mut self, diagnostics: NativeDiagnosticsHandle) -> Self { + self.native.diagnostics = diagnostics; + self + } + #[cfg(test)] async fn run_with_listener(self, listener: TokioTcpListener) -> anyhow::Result<()> { let AxumDevServer { router, config, stores, + native, } = self; - serve_with_stores( + serve_with_native_stores( App::new(router), listener, config.enable_ctrl_c, config.ingress, stores, + native, ) .await } @@ -197,6 +229,7 @@ impl AxumDevServer { pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { Self { config, + native: NativeHosting::default(), router, stores: Stores::default(), } @@ -356,6 +389,26 @@ async fn serve_with_stores( enable_ctrl_c: bool, ingress: AxumIngressConfig, stores: Stores, +) -> anyhow::Result<()> { + serve_with_native_stores( + app, + listener, + enable_ctrl_c, + ingress, + stores, + NativeHosting::default(), + ) + .await +} + +#[cfg(test)] +async fn serve_with_native_stores( + app: App, + listener: TokioTcpListener, + enable_ctrl_c: bool, + ingress: AxumIngressConfig, + stores: Stores, + native: NativeHosting, ) -> anyhow::Result<()> { struct ShutdownOnDrop(Option>); @@ -379,6 +432,7 @@ async fn serve_with_stores( enable_ctrl_c, ingress, stores, + native, shutdown_receiver, ) }); @@ -394,6 +448,7 @@ fn serve_local( enable_ctrl_c: bool, ingress: AxumIngressConfig, stores: Stores, + native: NativeHosting, shutdown_receiver: ShutdownReceiver<()>, ) -> anyhow::Result<()> { run_local( @@ -404,11 +459,30 @@ fn serve_local( async move { let _closed = shutdown_receiver.await; }, - async move { prepare_transport(app, stores.into()) }, + async move { prepare_transport(app, stores.into(), native) }, ) } -type PreparedApp = (App, PreparedStores, Arc); +#[derive(Clone)] +struct NativeHosting { + diagnostics: NativeDiagnosticsHandle, + geo: NativeGeo, + policy: TrustedProxyPolicy, + request_records: bool, +} + +impl Default for NativeHosting { + fn default() -> Self { + Self { + diagnostics: NativeDiagnosticsHandle::new(), + policy: TrustedProxyPolicy::no_trust(), + request_records: true, + geo: NativeGeo::default(), + } + } +} + +type PreparedApp = (App, PreparedStores, Arc, NativeHosting); type ConnectionResult = Result, Box>; type Connections = FuturesUnordered>>>; @@ -439,10 +513,14 @@ fn adopt_listener(source: ListenerSource) -> anyhow::Result { .map_err(|_error| failure("bind", "listener", "runtime adoption failed")) } -fn prepare_transport(app: App, stores: PreparedStores) -> anyhow::Result { +fn prepare_transport( + app: App, + stores: PreparedStores, + native: NativeHosting, +) -> anyhow::Result { let transport = AxumOutboundClient::try_transport() .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; - Ok((app, stores, transport)) + Ok((app, stores, transport, native)) } struct Signals { @@ -527,6 +605,7 @@ where runtime.block_on(local.run_until(async move { let mut signals = Signals::register(enable_signals)?; let (phase, reader) = watch::channel(LifecyclePhase::Starting); + log_native_lifecycle(LifecyclePhase::Starting, "starting", grace, false); let result = run_owned( listener, grace, @@ -539,6 +618,16 @@ where ) .await; phase.send_replace(LifecyclePhase::Stopped); + log_native_lifecycle( + LifecyclePhase::Stopped, + if result.is_ok() { + "stopped" + } else { + "runner_failed" + }, + grace, + result.is_err(), + ); result })) } @@ -578,10 +667,13 @@ where phase.send_replace(LifecyclePhase::Draining); return Ok(()); } - prepared = &mut prepare => prepared?, + prepared = &mut prepare => prepared.inspect_err(|_error| log_native_lifecycle(LifecyclePhase::Starting, "startup_failed", grace, true))?, }; - let (app, stores, transport) = prepared; - let mut service = AxumServiceState::with_transport(app, transport).with_phase(reader.clone()); + let (app, stores, transport, native) = prepared; + let session = NativeSession::new(native.diagnostics, native.request_records)?; + let mut service = + AxumServiceState::with_native_transport(app, transport, session, native.policy, native.geo) + .with_phase(reader.clone()); if let Some(registry) = stores.config { service = service.with_config_registry(registry); } @@ -600,6 +692,7 @@ where }; phase.send_replace(LifecyclePhase::Ready); log::info!("[edgezero] native server ready"); + log_native_lifecycle(LifecyclePhase::Ready, "ready", grace, false); let mut tasks = Connections::new(); let mut outcome = Ok(()); loop { @@ -618,9 +711,11 @@ where continue; } let responses = EgressConnection::default(); - let connection_service = service.for_connection(remote_addr, responses.clone()); + let (activity, connection_guard) = service.connection_activity(); + let connection_service = service.for_connection(remote_addr, responses.clone()).with_activity(activity); let connection = serve_http1_with_shutdown(stream, connection_service, responses, Some(reader.clone()), ingress); - tasks.push(Box::pin(AssertUnwindSafe(connection).catch_unwind())); + let owned_connection = async move { let _guard = connection_guard; connection.await }; + tasks.push(Box::pin(AssertUnwindSafe(owned_connection).catch_unwind())); } Err(_error) => { outcome = Err(failure("runner", "listener", "accept failed")); @@ -631,6 +726,16 @@ where } } phase.send_replace(LifecyclePhase::Draining); + log_native_lifecycle( + LifecyclePhase::Draining, + if outcome.is_ok() { + "drain_requested" + } else { + "runner_failed" + }, + grace, + outcome.is_err(), + ); drop(listener); let result = drain_tasks(&mut tasks, grace, signals, outcome).await; // Service/registry clones are dropped only after every owned connection settles. @@ -656,10 +761,12 @@ async fn drain_tasks( tokio::select! { biased; signal = next_signal(signals) => { + log_native_lifecycle(LifecyclePhase::Draining, "forced_stop", grace, true); outcome = Err(signal.err().unwrap_or_else(|| failure("shutdown", "signal", "forced stop"))); break; } () = sleep_until(deadline) => { + log_native_lifecycle(LifecyclePhase::Draining, "grace_exhausted", grace, true); outcome = Err(failure("shutdown", "budget", "grace exhausted")); break; } @@ -672,6 +779,19 @@ async fn drain_tasks( outcome } +fn log_native_lifecycle(phase: LifecyclePhase, reason: &'static str, grace: Duration, fault: bool) { + let phase_name = match phase { + LifecyclePhase::Starting => "starting", + LifecyclePhase::Ready => "ready", + LifecyclePhase::Draining => "draining", + LifecyclePhase::Stopped => "stopped", + }; + let level = if fault { Level::Error } else { Level::Info }; + let _logged = catch_unwind( + || log::log!(target: "edgezero::native", level, "event=lifecycle phase={phase_name} reason={reason} grace_ms={}", grace.as_millis()), + ); +} + fn report_connection_exit(result: &ConnectionResult) { if !matches!(result, Ok(Ok(_))) { log::debug!("axum HTTP/1 connection rejected or disconnected"); @@ -745,7 +865,13 @@ where A: Hooks, F: FnOnce() -> anyhow::Result<()>, { - let env = EnvConfig::from_env(); + let env = capture_dev_environment()?; + let (policy, request_records) = native_settings(&env)?; + let native = NativeHosting { + policy, + request_records, + ..NativeHosting::default() + }; if !A::owns_logging() { app_logger(&env) .init() @@ -775,11 +901,41 @@ where config: build_config_registry(metadata.config, &env, config_limits)?, secrets: build_secret_registry(metadata.secrets, &env), }; - prepare_transport(app, stores) + prepare_transport(app, stores, native) }, ) } +fn capture_dev_environment() -> anyhow::Result { + let mut pairs = Vec::new(); + for (os_key, os_value) in vars_os() { + let Some(key) = os_key.to_str() else { + continue; + }; + let recognized = strip_native_config_prefix(key).is_some_and(|path| { + [ + "ADAPTER__TRUSTED_PROXY_CIDRS", + "ADAPTER__FORWARDING_HEADER_FAMILY", + "LOGGING__REQUEST_RECORDS", + ] + .iter() + .any(|setting| path.eq_ignore_ascii_case(setting)) + }); + match (os_value.to_str(), recognized) { + (Some(value), _) => pairs.push((key.to_owned(), value.to_owned())), + (None, true) => { + return Err(failure( + "settings", + "native proxy/record policy", + "non-Unicode value", + )); + } + (None, false) => {} + } + } + Ok(EnvConfig::from_vars(pairs)) +} + fn init_logging(level: LevelFilter) -> anyhow::Result<()> { if !A::owns_logging() { SimpleLogger::new() @@ -920,6 +1076,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, @@ -941,7 +1104,7 @@ where ); failure("initializer", "application", &category) })?; - Ok((app, stores, transport)) + Ok((app, stores, transport, native)) }, ) } @@ -3271,6 +3434,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/diagnostics.rs b/crates/edgezero-adapter-axum/src/diagnostics.rs new file mode 100644 index 00000000..c07f5a0a --- /dev/null +++ b/crates/edgezero-adapter-axum/src/diagnostics.rs @@ -0,0 +1,1521 @@ +//! Safe native request observations and managed-lifetime accounting. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "Public record fields follow their diagnostic meaning; native identity and lifetime owners remain grouped with their transitions rather than alphabetized" +)] + +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::time::Duration; + +use edgezero_core::http::{Method, StatusCode}; +use edgezero_core::ingress::{ + CheckedEffectiveHost, CheckedHostSource, NormalizedIngressHeadSummary, +}; +use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressFailureClassification, + ResponseEgressFailureKind, ResponseEgressFallbackDisposition, ResponseEgressOutcome, + ResponseEgressReport, +}; +use edgezero_core::{MonotonicClock, MonotonicInstant}; + +use crate::proxy::{CheckedProxyMetadata, ForwardingResult, Scheme}; + +/// Framework-generated correlation value, not a credential or visitor-supplied ID. +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct RequestId { + nonce: [u8; 16], + host: u64, + request: u64, +} + +impl fmt::Display for RequestId { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.nonce { + write!(f, "{byte:02x}")?; + } + write!(f, "{:016x}{:016x}", self.host, self.request) + } +} + +impl fmt::Debug for RequestId { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// Immutable checked facts captured before managed admission. +#[derive(Clone)] +pub struct NativeIngressMetadata { + request_id: RequestId, + proxy: CheckedProxyMetadata, + received: NormalizedIngressHeadSummary, +} + +impl NativeIngressMetadata { + pub(crate) fn new( + request_id: RequestId, + proxy: CheckedProxyMetadata, + received: NormalizedIngressHeadSummary, + ) -> Self { + Self { + request_id, + proxy, + received, + } + } + + #[must_use] + #[inline] + pub fn request_id(&self) -> RequestId { + self.request_id + } + + #[must_use] + #[inline] + pub fn direct_peer(&self) -> Option { + self.proxy.direct_peer() + } + + #[must_use] + #[inline] + pub fn effective_client(&self) -> Option { + self.proxy.effective_client() + } + + #[must_use] + #[inline] + pub fn effective_host(&self) -> &CheckedEffectiveHost { + self.proxy.effective_host() + } + + #[must_use] + #[inline] + pub fn effective_scheme(&self) -> Scheme { + self.proxy.effective_scheme() + } + + #[must_use] + #[inline] + pub fn client_source(&self) -> CheckedHostSource { + self.proxy.client_source() + } + + #[must_use] + #[inline] + pub fn host_source(&self) -> CheckedHostSource { + self.proxy.effective_host().source() + } + + #[must_use] + #[inline] + pub fn scheme_source(&self) -> CheckedHostSource { + self.proxy.scheme_source() + } + + #[must_use] + #[inline] + pub fn forwarding_result(&self) -> ForwardingResult { + self.proxy.forwarding_result() + } + + #[must_use] + #[inline] + pub fn received_normalized_head(&self) -> &NormalizedIngressHeadSummary { + &self.received + } +} + +/// One coherent, momentary observation of managed guards, not kernel or detached work. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct NativeDiagnosticsSnapshot { + pub active_requests: u64, + /// Includes idle keep-alive connection tasks. + pub active_connections: u64, + pub idle_connections: u64, +} + +pub trait NativeRequestObserver: Send + Sync + 'static { + fn observe(&self, record: &NativeRequestRecord); +} + +/// Sharing this handle explicitly aggregates counters across managed runners. +#[derive(Clone, Default)] +pub struct NativeDiagnosticsHandle { + counters: Arc>, + observer: Option>, +} + +impl NativeDiagnosticsHandle { + #[must_use] + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + #[inline] + pub fn with_request_observer(mut self, observer: O) -> Self + where + O: NativeRequestObserver, + { + self.observer = Some(Arc::new(observer)); + self + } + + #[must_use] + #[inline] + pub fn snapshot(&self) -> NativeDiagnosticsSnapshot { + *lock(&self.counters) + } +} + +impl fmt::Debug for NativeDiagnosticsHandle { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NativeDiagnosticsHandle") + .field("snapshot", &self.snapshot()) + .finish_non_exhaustive() + } +} + +/// A no-response disposition, deliberately distinct from response-egress outcomes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IngressDisposition { + Aborted, + Draining, + Abandoned, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeRequestOutcome { + Egress(ResponseEgressOutcome), + Ingress(IngressDisposition), +} + +/// Categories captured by owning framework boundaries, never error messages or sources. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NativeRequestFailure { + FrameworkRouting { kind: &'static str }, + PropagatedError { kind: &'static str }, + FallbackBodyExceeded, + FallbackReadTimedOut, + AdmissionRefused, +} + +/// Allowlisted observation. Bytes and selected status do not establish client receipt. +#[derive(Clone, Debug)] +pub struct NativeRequestRecord { + pub event: &'static str, + pub request_id: RequestId, + pub method: &'static str, + /// Registered template only, or the fixed `unknown` value. + pub route: String, + pub status: Option, + /// Absent when the owning monotonic clock moved backwards. + pub duration: Option, + pub outcome: NativeRequestOutcome, + pub failure: Option, + pub forwarding_result: ForwardingResult, + pub bytes_written: Option, + pub body_kind: Option, + pub fallback_disposition: Option, +} + +impl NativeRequestRecord { + /// Severity follows typed ownership, never status alone. + #[must_use] + #[inline] + pub fn level(&self) -> log::Level { + if self.duration.is_none() { + return log::Level::Error; + } + let ordinary_failure = matches!( + self.failure, + None | Some(NativeRequestFailure::FrameworkRouting { .. }) + ); + let ordinary_outcome = matches!( + self.outcome, + NativeRequestOutcome::Egress( + ResponseEgressOutcome::Completed | ResponseEgressOutcome::HostHandoff + ) | NativeRequestOutcome::Ingress(IngressDisposition::Draining) + ); + if ordinary_failure + && ordinary_outcome + && self.forwarding_result != ForwardingResult::Malformed + { + log::Level::Info + } else { + log::Level::Warn + } + } +} + +#[derive(Clone)] +pub(crate) struct NativeSession { + identity: Arc, + handle: NativeDiagnosticsHandle, + request_records: bool, +} + +struct SessionIdentity { + nonce: [u8; 16], + host: u64, + requests: AtomicU64, +} + +static PROCESS_NONCE: OnceLock> = OnceLock::new(); +static HOST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, thiserror::Error)] +enum IdentityFault { + #[error("native_identity_entropy_failed")] + Entropy, + #[error("native_identity_host_sequence_exhausted")] + HostSequence, + #[error("native_identity_request_sequence_exhausted")] + RequestSequence, + #[error("native_diagnostics_connection_scope_mismatch")] + ConnectionScope, +} + +impl NativeSession { + pub(crate) fn new( + handle: NativeDiagnosticsHandle, + request_records: bool, + ) -> anyhow::Result { + let nonce = *PROCESS_NONCE.get_or_init(|| nonce_from(getrandom::fill)); + Self::from_sources(handle, request_records, nonce, &HOST_SEQUENCE).map_err(Into::into) + } + + fn from_sources( + handle: NativeDiagnosticsHandle, + request_records: bool, + nonce: Result<[u8; 16], IdentityFault>, + hosts: &AtomicU64, + ) -> Result { + Ok(Self { + identity: Arc::new(SessionIdentity { + nonce: nonce?, + host: next_sequence(hosts).ok_or(IdentityFault::HostSequence)?, + requests: AtomicU64::new(0), + }), + handle, + request_records, + }) + } + + /// Construct before submitting the owned task, including tasks never polled. + pub(crate) fn connection(&self) -> (ConnectionActivity, ConnectionGuard) { + let activity = ConnectionActivity { + state: Arc::new(Mutex::new(ConnectionState { + requests: 0, + closed: false, + })), + counters: Arc::clone(&self.handle.counters), + }; + let mut counts = lock(&activity.counters); + // Metrics must not introduce a panic in managed guard ownership. + counts.active_connections = counts.active_connections.saturating_add(1); + counts.idle_connections = counts.idle_connections.saturating_add(1); + drop(counts); + let guard = ConnectionGuard { + activity: activity.clone(), + }; + (activity, guard) + } + + pub(crate) fn request( + &self, + method: &Method, + start: MonotonicInstant, + clock: MonotonicClock, + activity: Option, + ) -> anyhow::Result { + if activity + .as_ref() + .is_some_and(|link| !Arc::ptr_eq(&link.counters, &self.handle.counters)) + { + return Err(IdentityFault::ConnectionScope.into()); + } + let Some(sequence) = next_sequence(&self.identity.requests) else { + isolated( + || log::error!(target: "edgezero::native", "lifecycle reason=native_identity_request_sequence_exhausted"), + ); + return Err(IdentityFault::RequestSequence.into()); + }; + if let Some(link) = &activity { + let mut connection = lock(&link.state); + let mut counts = lock(&link.counters); + if !connection.closed { + if connection.requests == 0 { + counts.idle_connections = counts.idle_connections.saturating_sub(1); + } + connection.requests = connection.requests.saturating_add(1); + } + counts.active_requests = counts.active_requests.saturating_add(1); + } else { + let mut counts = lock(&self.handle.counters); + counts.active_requests = counts.active_requests.saturating_add(1); + } + Ok(RequestGuard { + session: self.clone(), + id: RequestId { + nonce: self.identity.nonce, + host: self.identity.host, + request: sequence, + }, + method: method_category(method), + start, + clock, + activity, + forwarding_result: ForwardingResult::Absent, + failure: None, + released: false, + }) + } +} + +fn nonce_from(fill: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Result<[u8; 16], IdentityFault> { + let mut nonce = [0; 16]; + fill(&mut nonce).map_err(|_entropy_error| IdentityFault::Entropy)?; + Ok(nonce) +} + +fn next_sequence(sequence: &AtomicU64) -> Option { + sequence + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .ok() +} + +struct ConnectionState { + requests: u64, + closed: bool, +} + +#[derive(Clone)] +pub(crate) struct ConnectionActivity { + state: Arc>, + counters: Arc>, +} + +pub(crate) struct ConnectionGuard { + activity: ConnectionActivity, +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { + let mut connection = lock(&self.activity.state); + let mut counts = lock(&self.activity.counters); + connection.closed = true; + counts.active_connections = counts.active_connections.saturating_sub(1); + if connection.requests == 0 { + counts.idle_connections = counts.idle_connections.saturating_sub(1); + } + } +} + +#[derive(Clone, Default)] +pub(crate) struct SelectedStatus(Arc); + +impl SelectedStatus { + pub(crate) fn select(&self, status: StatusCode) { + self.0.store(status.as_u16(), Ordering::Relaxed); + } + + fn get(&self) -> Option { + StatusCode::from_u16(self.0.load(Ordering::Relaxed)).ok() + } +} + +pub(crate) struct RequestGuard { + session: NativeSession, + id: RequestId, + method: &'static str, + start: MonotonicInstant, + clock: MonotonicClock, + activity: Option, + forwarding_result: ForwardingResult, + failure: Option, + released: bool, +} + +impl RequestGuard { + pub(crate) fn request_id(&self) -> RequestId { + self.id + } + + pub(crate) fn set_forwarding_result(&mut self, result: ForwardingResult) { + self.forwarding_result = result; + } + + pub(crate) fn set_failure(&mut self, failure: Option) { + self.failure = failure.map(|classification| match classification.kind() { + ResponseEgressFailureKind::PropagatedError => { + let kind = classification.error_kind().unwrap_or("unknown"); + if classification.is_framework_route_error() { + NativeRequestFailure::FrameworkRouting { kind } + } else { + NativeRequestFailure::PropagatedError { kind } + } + } + ResponseEgressFailureKind::FallbackBodyExceeded => { + NativeRequestFailure::FallbackBodyExceeded + } + ResponseEgressFailureKind::FallbackReadTimedOut => { + NativeRequestFailure::FallbackReadTimedOut + } + _ => NativeRequestFailure::PropagatedError { kind: "unknown" }, + }); + } + + pub(crate) fn set_ingress_error_kind(&mut self, kind: &'static str) { + self.failure = Some(NativeRequestFailure::PropagatedError { kind }); + } + + pub(crate) fn refused(&mut self) { + self.failure = Some(NativeRequestFailure::AdmissionRefused); + } + + pub(crate) fn completion(mut self) -> (ResponseEgressCompletion, SelectedStatus) { + let selected = SelectedStatus::default(); + let captured = selected.clone(); + let completion = + ResponseEgressCompletion::new_with_terminal_time(move |report, terminal| { + self.release(); + let record = self.record( + NativeRequestOutcome::Egress(report.outcome), + terminal, + captured.get(), + Some(report), + ); + self.emit(&record); + }); + (completion, selected) + } + + pub(crate) fn finish_ingress(mut self, disposition: IngressDisposition) { + self.finish_ingress_at(disposition, self.clock.now()); + } + + fn finish_ingress_at(&mut self, disposition: IngressDisposition, terminal: MonotonicInstant) { + self.release(); + let record = self.record( + NativeRequestOutcome::Ingress(disposition), + terminal, + None, + None, + ); + self.emit(&record); + } + + fn record( + &self, + outcome: NativeRequestOutcome, + terminal: MonotonicInstant, + status: Option, + report: Option<&ResponseEgressReport>, + ) -> NativeRequestRecord { + NativeRequestRecord { + event: if report.is_some() { + "request_terminal" + } else { + "request_ingress" + }, + request_id: self.id, + method: self.method, + route: report + .and_then(|terminal_report| terminal_report.route.as_ref()) + .map_or_else(|| "unknown".into(), |route| route.pattern().to_owned()), + status, + duration: terminal.checked_duration_since(self.start), + outcome, + failure: self.failure, + forwarding_result: self.forwarding_result, + bytes_written: report.map(|terminal_report| terminal_report.bytes_written), + body_kind: report.map(|terminal_report| terminal_report.body_kind), + fallback_disposition: report + .and_then(|terminal_report| terminal_report.fallback_disposition), + } + } + + fn emit(&self, record: &NativeRequestRecord) { + emit_with( + record, + self.session.request_records, + |safe_record| log::log!(target: "edgezero::native", safe_record.level(), "{}", SafeRecord(safe_record)), + self.session.handle.observer.as_deref(), + ); + } + + fn release(&mut self) { + if self.released { + return; + } + self.released = true; + if let Some(activity) = &self.activity { + let mut connection = lock(&activity.state); + let mut counts = lock(&activity.counters); + counts.active_requests = counts.active_requests.saturating_sub(1); + if !connection.closed { + connection.requests = connection.requests.saturating_sub(1); + if connection.requests == 0 { + counts.idle_connections = counts.idle_connections.saturating_add(1); + } + } + } else { + let mut counts = lock(&self.session.handle.counters); + counts.active_requests = counts.active_requests.saturating_sub(1); + } + } +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + if !self.released { + // No report/status for cancellation or an unbegun envelope's abandonment. + self.release(); + isolated(|| self.finish_ingress_at(IngressDisposition::Abandoned, self.clock.now())); + } + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn isolated(action: impl FnOnce()) { + drop(catch_unwind(AssertUnwindSafe(action))); +} + +fn emit_with( + record: &NativeRequestRecord, + enabled: bool, + log_sink: impl FnOnce(&NativeRequestRecord), + observer: Option<&dyn NativeRequestObserver>, +) { + if enabled { + isolated(|| log_sink(record)); + } + if let Some(sink) = observer { + isolated(|| sink.observe(record)); + } +} + +fn method_category(method: &Method) -> &'static str { + match *method { + Method::GET => "GET", + Method::HEAD => "HEAD", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::CONNECT => "CONNECT", + Method::OPTIONS => "OPTIONS", + Method::TRACE => "TRACE", + Method::PATCH => "PATCH", + _ => "other", + } +} + +struct SafeRecord<'record>(&'record NativeRequestRecord); + +impl fmt::Display for SafeRecord<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let record = self.0; + write!( + f, + "{} request_id={} method={} route=", + record.event, record.request_id, record.method + )?; + write!(f, "\"{}\"", record.route.escape_debug())?; + match record.status { + Some(status) => write!(f, " status={}", status.as_u16())?, + None => f.write_str(" status=none")?, + } + match record.duration { + Some(duration) => write!(f, " duration_us={}", duration.as_micros())?, + None => f.write_str(" duration_us=none clock_fault=backwards")?, + } + write!( + f, + " outcome={} failure={} forwarding_result={}", + outcome_name(record.outcome), + failure_name(record.failure), + record.forwarding_result.as_str() + )?; + if let Some(bytes) = record.bytes_written { + write!(f, " bytes_written={bytes}")?; + } + if let Some(kind) = record.body_kind { + f.write_str(match kind { + ResponseEgressBodyKind::Application => " body_kind=application", + ResponseEgressBodyKind::Fallback => " body_kind=fallback", + _ => " body_kind=unknown", + })?; + } + if let Some(disposition) = record.fallback_disposition { + f.write_str(match disposition { + ResponseEgressFallbackDisposition::Aborted => " fallback_disposition=aborted", + ResponseEgressFallbackDisposition::Completed => " fallback_disposition=completed", + _ => " fallback_disposition=unknown", + })?; + } + Ok(()) + } +} + +fn outcome_name(outcome: NativeRequestOutcome) -> &'static str { + match outcome { + NativeRequestOutcome::Ingress(IngressDisposition::Aborted) => "ingress_aborted", + NativeRequestOutcome::Ingress(IngressDisposition::Draining) => "ingress_draining", + NativeRequestOutcome::Ingress(IngressDisposition::Abandoned) => "ingress_abandoned", + NativeRequestOutcome::Egress(egress_outcome) => match egress_outcome { + ResponseEgressOutcome::ClientDisconnected => "client_disconnected", + ResponseEgressOutcome::Completed => "completed", + ResponseEgressOutcome::ConversionError => "conversion_error", + ResponseEgressOutcome::DeadlineExceeded => "deadline_exceeded", + ResponseEgressOutcome::HostHandoff => "host_handoff", + ResponseEgressOutcome::RequestCancelled => "request_cancelled", + ResponseEgressOutcome::SourceError => "source_error", + ResponseEgressOutcome::TransportError => "transport_error", + ResponseEgressOutcome::Unspecified => "unspecified", + _ => "unknown", + }, + } +} + +fn failure_name(failure: Option) -> &'static str { + match failure { + None => "none", + Some( + NativeRequestFailure::FrameworkRouting { kind } + | NativeRequestFailure::PropagatedError { kind }, + ) => kind, + Some(NativeRequestFailure::FallbackBodyExceeded) => "fallback_body_exceeded", + Some(NativeRequestFailure::FallbackReadTimedOut) => "fallback_read_timed_out", + Some(NativeRequestFailure::AdmissionRefused) => "admission_refused", + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::iter::repeat_with; + use std::sync::atomic::AtomicUsize; + use std::thread; + + use edgezero_core::http::{HeaderMap, Version}; + use edgezero_core::response_egress::{ + ResponseEgressAttempt, ResponseEgressHead, ResponseEgressObserverHandle, + }; + use edgezero_core::router::RouteMetadata; + + use super::*; + + #[derive(Clone, Default)] + struct RecordingObserver { + records: Arc>>, + snapshots: Arc>>, + handle: NativeDiagnosticsHandle, + panic: bool, + } + + impl NativeRequestObserver for RecordingObserver { + fn observe(&self, record: &NativeRequestRecord) { + lock(&self.records).push(record.clone()); + lock(&self.snapshots).push(self.handle.snapshot()); + assert!(!self.panic, "observer fixture panic"); + } + } + + fn session(handle: NativeDiagnosticsHandle, enabled: bool) -> NativeSession { + NativeSession::from_sources(handle, enabled, Ok([0x19; 16]), &AtomicU64::new(0)) + .expect("test identity") + } + + fn observed_session(enabled: bool) -> (NativeSession, RecordingObserver) { + let observer = RecordingObserver::default(); + let handle = observer + .handle + .clone() + .with_request_observer(observer.clone()); + (session(handle, enabled), observer) + } + + fn request(session: &NativeSession, activity: Option) -> RequestGuard { + session + .request( + &Method::GET, + MonotonicInstant::now(), + MonotonicClock::default(), + activity, + ) + .expect("request") + } + + fn attempt( + completion: ResponseEgressCompletion, + start: MonotonicInstant, + egress_start: MonotonicInstant, + clock: MonotonicClock, + route: Option<&RouteMetadata>, + ) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + start, + route, + ); + ResponseEgressAttempt::new( + &head, + egress_start, + completion, + ResponseEgressObserverHandle::default(), + clock, + ) + } + + #[test] + fn identity_is_fixed_hex_and_sequences_are_shared_only_with_session_clones() { + let hosts = AtomicU64::new(0); + let first = NativeSession::from_sources( + NativeDiagnosticsHandle::new(), + false, + Ok([0xab; 16]), + &hosts, + ) + .unwrap(); + let second = NativeSession::from_sources( + NativeDiagnosticsHandle::new(), + false, + Ok([0xab; 16]), + &hosts, + ) + .unwrap(); + let ids = [ + request(&first, None).request_id(), + request(&first.clone(), None).request_id(), + request(&second, None).request_id(), + ]; + assert_eq!(ids.into_iter().collect::>().len(), 3); + assert_eq!( + ids[0].to_string(), + format!("{}{:016x}{:016x}", "ab".repeat(16), 0_u64, 0_u64) + ); + assert_eq!(ids[1].request, 1); + assert_eq!(ids[2].host, 1); + assert_eq!(ids[2].request, 0); + for id in ids { + let rendered = id.to_string(); + assert_eq!(rendered.len(), 64); + assert!(rendered.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(format!("{id:?}"), rendered); + } + } + + #[test] + fn entropy_and_host_exhaustion_fail_without_fallback_or_wrap() { + let nonce = nonce_from(|bytes| { + bytes.fill(0x12); + Ok::<_, ()>(()) + }) + .unwrap(); + assert_eq!(nonce, [0x12; 16]); + let failure = nonce_from(|_| Err("secret entropy source")); + assert_eq!( + failure.unwrap_err().to_string(), + "native_identity_entropy_failed" + ); + let hosts = AtomicU64::new(u64::MAX - 1); + let first = + NativeSession::from_sources(NativeDiagnosticsHandle::new(), false, Ok(nonce), &hosts) + .unwrap(); + assert_eq!(first.identity.host, u64::MAX - 1); + let error = + NativeSession::from_sources(NativeDiagnosticsHandle::new(), false, Ok(nonce), &hosts) + .err() + .unwrap(); + assert_eq!(error.to_string(), "native_identity_host_sequence_exhausted"); + assert_eq!(hosts.load(Ordering::Relaxed), u64::MAX); + let unused_hosts = AtomicU64::new(0); + assert!( + NativeSession::from_sources( + NativeDiagnosticsHandle::new(), + false, + failure, + &unused_hosts + ) + .is_err() + ); + assert_eq!(unused_hosts.load(Ordering::Relaxed), 0); + let other = NativeSession::from_sources( + NativeDiagnosticsHandle::new(), + false, + Ok([0x13; 16]), + &AtomicU64::new(0), + ) + .unwrap(); + assert_ne!( + request(&first, None).request_id(), + request(&other, None).request_id() + ); + } + + #[test] + fn request_exhaustion_preserves_counters_and_never_reuses_an_id() { + let session = session(NativeDiagnosticsHandle::new(), false); + session + .identity + .requests + .store(u64::MAX - 1, Ordering::Relaxed); + let guard = request(&session, None); + assert_eq!(guard.request_id().request, u64::MAX - 1); + drop(guard); + assert!( + session + .request( + &Method::GET, + MonotonicInstant::now(), + MonotonicClock::default(), + None + ) + .is_err() + ); + assert_eq!(session.identity.requests.load(Ordering::Relaxed), u64::MAX); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn concurrent_requests_share_checked_sequence_without_collisions() { + let session = session(NativeDiagnosticsHandle::new(), false); + let workers: Vec<_> = repeat_with(|| { + let worker_session = session.clone(); + thread::spawn(move || { + repeat_with(|| request(&worker_session, None).request_id()) + .take(64) + .collect::>() + }) + }) + .take(8) + .collect(); + let ids: HashSet<_> = workers + .into_iter() + .flat_map(|worker| worker.join().unwrap()) + .collect(); + assert_eq!(ids.len(), 512); + assert_eq!(session.handle.snapshot().active_requests, 0); + } + + #[test] + fn connection_counts_exist_before_polling_and_track_two_request_lifetimes() { + let session = session(NativeDiagnosticsHandle::new(), false); + let (activity, connection) = session.connection(); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 0, + active_connections: 1, + idle_connections: 1 + } + ); + let first = request(&session, Some(activity.clone())); + let second = request(&session, Some(activity)); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 2, + active_connections: 1, + idle_connections: 0 + } + ); + drop(first); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 1, + active_connections: 1, + idle_connections: 0 + } + ); + drop(second); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 0, + active_connections: 1, + idle_connections: 1 + } + ); + // No future needs to have been polled for task cancellation to release this guard. + drop(connection); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn closed_connection_late_release_cannot_recreate_idle_connections() { + let session = session(NativeDiagnosticsHandle::new(), false); + let (activity, connection) = session.connection(); + let first = request(&session, Some(activity.clone())); + drop(connection); + let late = request(&session, Some(activity)); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 2, + active_connections: 0, + idle_connections: 0 + } + ); + drop(first); + drop(late); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn explicit_handle_sharing_aggregates_connections_and_requests() { + let handle = NativeDiagnosticsHandle::new(); + let first = session(handle.clone(), false); + let second = session(handle.clone(), false); + let (_, connection) = first.connection(); + let guard = request(&second, None); + assert_eq!( + handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 1, + active_connections: 1, + idle_connections: 1 + } + ); + drop((connection, guard)); + assert_eq!(handle.snapshot(), NativeDiagnosticsSnapshot::default()); + } + + #[test] + fn activity_from_a_different_handle_fails_without_incrementing_either_scope() { + let first = session(NativeDiagnosticsHandle::new(), false); + let second = session(NativeDiagnosticsHandle::new(), false); + let (activity, connection) = first.connection(); + assert!( + second + .request( + &Method::GET, + MonotonicInstant::now(), + MonotonicClock::default(), + Some(activity) + ) + .is_err() + ); + assert_eq!(first.handle.snapshot().active_requests, 0); + assert_eq!( + second.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + drop(connection); + } + + #[test] + fn ingress_and_unbegun_completion_abandonment_have_no_status_or_report_fields() { + let (session, observer) = observed_session(false); + request(&session, None).finish_ingress(IngressDisposition::Aborted); + request(&session, None).finish_ingress(IngressDisposition::Draining); + let (completion, status) = request(&session, None).completion(); + status.select(StatusCode::OK); + drop(completion); + let records = lock(&observer.records); + assert_eq!(records.len(), 3); + assert_eq!( + records[0].outcome, + NativeRequestOutcome::Ingress(IngressDisposition::Aborted) + ); + assert_eq!( + records[1].outcome, + NativeRequestOutcome::Ingress(IngressDisposition::Draining) + ); + assert_eq!( + records[2].outcome, + NativeRequestOutcome::Ingress(IngressDisposition::Abandoned) + ); + for record in records.iter() { + assert_eq!(record.event, "request_ingress"); + assert_eq!(record.status, None); + assert_eq!(record.bytes_written, None); + assert_eq!(record.body_kind, None); + assert_eq!(record.fallback_disposition, None); + } + assert!( + lock(&observer.snapshots) + .iter() + .all(|snapshot| snapshot.active_requests == 0) + ); + } + + #[test] + fn authoritative_terminal_time_excludes_prior_app_callback_and_sink_delays() { + let (session, observer) = observed_session(false); + let start = MonotonicInstant::now(); + let egress_start = start + Duration::from_secs(7); + let terminal = start + Duration::from_secs(11); + let now = Arc::new(Mutex::new(terminal)); + let source = Arc::clone(&now); + let clock = MonotonicClock::new(move || *lock(&source)); + let guard = session + .request(&Method::GET, start, clock.clone(), None) + .unwrap(); + let private_id = guard.request_id(); + let (native, status) = guard.completion(); + status.select(StatusCode::OK); + let app_source = Arc::clone(&now); + let app_reports = Arc::new(Mutex::new(Vec::new())); + let captured_reports = Arc::clone(&app_reports); + let app = ResponseEgressCompletion::new(move |report| { + lock(&captured_reports).push(report.clone()); + *lock(&app_source) = terminal + Duration::from_secs(100); + }); + let route = RouteMetadata::new(Method::GET, "/orders/{id}"); + let mut attempt = attempt(app.join(native), start, egress_start, clock, Some(&route)); + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(9, terminal)); + assert!(attempt.complete(terminal)); + assert!(!attempt.complete(terminal)); + drop(attempt); + let records = lock(&observer.records); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record.request_id, private_id); + assert_eq!(record.duration, Some(Duration::from_secs(11))); + assert_eq!(lock(&app_reports)[0].elapsed, Duration::from_secs(4)); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!(record.route, "/orders/{id}"); + assert_eq!(record.bytes_written, Some(9)); + assert_eq!( + record.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::Completed) + ); + assert_eq!(lock(&observer.snapshots)[0].active_requests, 0); + emit_with( + record, + true, + |_| *lock(&now) = terminal + Duration::from_secs(200), + None, + ); + assert_eq!(record.duration, Some(Duration::from_secs(11))); + } + + #[test] + fn failing_attempt_retains_selected_status_and_fallback_updates_use_same_slot() { + for fallback in [false, true] { + let (session, observer) = observed_session(false); + let start = MonotonicInstant::now(); + let (completion, status) = session + .request(&Method::GET, start, MonotonicClock::default(), None) + .unwrap() + .completion(); + status.select(StatusCode::OK); + let mut attempt = attempt(completion, start, start, MonotonicClock::default(), None); + if fallback { + assert!(attempt.begin_fallback(ResponseEgressOutcome::ConversionError)); + status.select(StatusCode::INTERNAL_SERVER_ERROR); + assert!(attempt.begin_writing()); + assert!( + attempt.finish_fallback(ResponseEgressFallbackDisposition::Completed, start) + ); + } else { + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(3, start)); + assert!(attempt.terminate(ResponseEgressOutcome::SourceError, start)); + } + drop(attempt); + let records = lock(&observer.records); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].status, + Some(if fallback { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + }) + ); + assert_eq!( + records[0].outcome, + NativeRequestOutcome::Egress(if fallback { + ResponseEgressOutcome::ConversionError + } else { + ResponseEgressOutcome::SourceError + }) + ); + assert_eq!(records[0].level(), log::Level::Warn); + assert_eq!(session.handle.snapshot().active_requests, 0); + } + } + + #[test] + fn drop_and_completion_release_before_panicking_observer() { + let observer = RecordingObserver { + panic: true, + ..RecordingObserver::default() + }; + let session = session( + observer + .handle + .clone() + .with_request_observer(observer.clone()), + false, + ); + catch_unwind(AssertUnwindSafe(|| drop(request(&session, None)))) + .expect("observer panic must not escape request Drop"); + let start = MonotonicInstant::now(); + let (completion, status) = request(&session, None).completion(); + status.select(StatusCode::OK); + let mut attempt = attempt(completion, start, start, MonotonicClock::default(), None); + attempt.begin_writing(); + assert!(attempt.complete(start + Duration::from_secs(1))); + drop(attempt); + assert_eq!(lock(&observer.records).len(), 2); + assert!( + lock(&observer.snapshots) + .iter() + .all(|snapshot| snapshot.active_requests == 0) + ); + assert_eq!(session.handle.snapshot().active_requests, 0); + } + + #[test] + fn sink_panics_are_independent_and_never_retry_logging() { + let (session, observer) = observed_session(false); + drop(request(&session, None)); + let record = lock(&observer.records)[0].clone(); + let logs = AtomicUsize::new(0); + emit_with( + &record, + true, + |_| { + logs.fetch_add(1, Ordering::Relaxed); + panic!("logger fixture panic"); + }, + Some(&observer), + ); + assert_eq!(logs.load(Ordering::Relaxed), 1); + assert_eq!(lock(&observer.records).len(), 2); + let panic_observer = RecordingObserver { + panic: true, + ..RecordingObserver::default() + }; + catch_unwind(AssertUnwindSafe(|| { + emit_with( + &record, + true, + |_| { + logs.fetch_add(1, Ordering::Relaxed); + panic!("logger fixture panic"); + }, + Some(&panic_observer), + ); + })) + .expect("logger and observer panics must be contained independently"); + assert_eq!(logs.load(Ordering::Relaxed), 2); + assert_eq!(lock(&panic_observer.records).len(), 1); + } + + #[test] + fn opt_out_suppresses_only_log_sink_and_keeps_observers_and_accounting() { + let (session, observer) = observed_session(false); + drop(request(&session, None)); + let record = lock(&observer.records)[0].clone(); + emit_with( + &record, + false, + |_| panic!("disabled log sink must not run"), + Some(&observer), + ); + assert_eq!(lock(&observer.records).len(), 2); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn severity_is_typed_not_status_and_extension_methods_never_leak() { + let (session, observer) = observed_session(false); + let start = MonotonicInstant::now(); + let extension = Method::from_bytes(b"SECRET-METHOD").unwrap(); + let (completion, status) = session + .request(&extension, start, MonotonicClock::default(), None) + .unwrap() + .completion(); + status.select(StatusCode::PAYLOAD_TOO_LARGE); + let mut attempt = attempt(completion, start, start, MonotonicClock::default(), None); + attempt.begin_writing(); + attempt.complete(start); + let ordinary = lock(&observer.records)[0].clone(); + assert_eq!(ordinary.method, "other"); + assert_eq!(ordinary.level(), log::Level::Info); + assert!(!SafeRecord(&ordinary).to_string().contains("SECRET")); + let mut classified = ordinary.clone(); + classified.failure = Some(NativeRequestFailure::AdmissionRefused); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = Some(NativeRequestFailure::FrameworkRouting { kind: "not_found" }); + assert_eq!(classified.level(), log::Level::Info); + classified.failure = Some(NativeRequestFailure::PropagatedError { kind: "not_found" }); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = Some(NativeRequestFailure::FrameworkRouting { + kind: "method_not_allowed", + }); + assert_eq!(classified.level(), log::Level::Info); + classified.failure = Some(NativeRequestFailure::PropagatedError { + kind: "method_not_allowed", + }); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = Some(NativeRequestFailure::PropagatedError { kind: "internal" }); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = Some(NativeRequestFailure::FallbackBodyExceeded); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = Some(NativeRequestFailure::FallbackReadTimedOut); + assert_eq!(classified.level(), log::Level::Warn); + classified.failure = None; + classified.forwarding_result = ForwardingResult::Malformed; + assert_eq!(classified.level(), log::Level::Warn); + classified.duration = None; + assert_eq!(classified.level(), log::Level::Error); + } + + #[test] + fn backwards_clock_is_an_explicit_safe_fault_not_wall_time_or_zero_duration() { + let (session, observer) = observed_session(false); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let guard = session + .request(&Method::GET, start + Duration::from_secs(1), clock, None) + .unwrap(); + guard.finish_ingress(IngressDisposition::Aborted); + let record = lock(&observer.records)[0].clone(); + assert_eq!(record.duration, None); + assert_eq!(record.level(), log::Level::Error); + assert!( + SafeRecord(&record) + .to_string() + .contains("clock_fault=backwards") + ); + assert_eq!(session.handle.snapshot().active_requests, 0); + } + + #[test] + fn metadata_preserves_original_summary_and_public_replacement_cannot_change_private_identity() { + use crate::proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; + use edgezero_core::http::{HeaderValue, Request}; + use edgezero_core::ingress::{ + IngressHeadLimits, validate_normalized_ingress_parts_with_summary, + }; + + let (session, observer) = observed_session(false); + let guard = request(&session, None); + let authoritative = guard.request_id(); + let other_guard = request(&session, None); + let mut received = Request::new(edgezero_core::Body::empty()); + *received.uri_mut() = "/orders/SECRET-PATH?token=SECRET-QUERY".parse().unwrap(); + received + .headers_mut() + .insert("host", HeaderValue::from_static("direct.example:8080")); + received.headers_mut().insert( + "forwarded", + HeaderValue::from_static("for=192.0.2.99;host=spoof.example;proto=https"), + ); + received.headers_mut().insert( + "x-request-id", + HeaderValue::from_static("SECRET-VISITOR-ID"), + ); + let (mut parts, _) = received.into_parts(); + let summary = + validate_normalized_ingress_parts_with_summary(&parts, IngressHeadLimits::default()) + .unwrap(); + let peer: SocketAddr = "192.0.2.1:4567".parse().unwrap(); + let checked = normalize(&TrustedProxyPolicy::no_trust(), Some(peer), &parts); + let metadata = NativeIngressMetadata::new(authoritative, checked.clone(), summary); + assert_eq!(metadata.request_id(), authoritative); + assert_eq!(metadata.direct_peer(), Some(peer)); + assert_eq!(metadata.effective_client(), Some(peer.ip())); + assert_eq!( + metadata.effective_host().authority(), + Some("direct.example:8080") + ); + assert_eq!(metadata.effective_scheme(), Scheme::Http); + assert_eq!(metadata.client_source(), CheckedHostSource::Direct); + assert_eq!(metadata.host_source(), CheckedHostSource::Direct); + assert_eq!(metadata.scheme_source(), CheckedHostSource::Direct); + assert_eq!( + metadata.forwarding_result(), + ForwardingResult::UntrustedPeer + ); + assert_eq!(*metadata.received_normalized_head(), summary); + strip_forwarding_headers(&mut parts.headers); + let stripped = + validate_normalized_ingress_parts_with_summary(&parts, IngressHeadLimits::default()) + .unwrap(); + assert!(summary.header_bytes() > stripped.header_bytes()); + assert_eq!(summary.header_count(), stripped.header_count() + 1); + assert_eq!(*metadata.received_normalized_head(), summary); + parts.extensions.insert(metadata); + // A caller can replace its public extension with another request's copy. + parts.extensions.insert(NativeIngressMetadata::new( + other_guard.request_id(), + checked, + summary, + )); + assert_ne!( + parts + .extensions + .get::() + .unwrap() + .request_id(), + authoritative + ); + guard.finish_ingress(IngressDisposition::Aborted); + let records = lock(&observer.records); + assert_eq!(records.len(), 1); + assert_eq!(records[0].request_id, authoritative); + let safe = SafeRecord(&records[0]).to_string(); + assert!(!safe.contains("SECRET")); + assert!(!safe.contains("example")); + drop(records); + drop(other_guard); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn cancellation_unwind_releases_counts_even_if_observer_also_panics() { + let observer = RecordingObserver { + panic: true, + ..RecordingObserver::default() + }; + let session = session( + observer + .handle + .clone() + .with_request_observer(observer.clone()), + false, + ); + let (activity, connection) = session.connection(); + let unwind = catch_unwind(AssertUnwindSafe(|| { + let _guard = request(&session, Some(activity)); + panic!("cancel managed request fixture"); + })); + assert!(unwind.is_err()); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 0, + active_connections: 1, + idle_connections: 1 + } + ); + assert_eq!(lock(&observer.records).len(), 1); + drop(connection); + assert_eq!( + session.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn snapshots_remain_coherent_during_connection_request_updates() { + let handle = NativeDiagnosticsHandle::new(); + let session = session(handle.clone(), false); + let finished = Arc::new(AtomicUsize::new(0)); + let workers: Vec<_> = repeat_with(|| { + let worker_session = session.clone(); + let worker_finished = Arc::clone(&finished); + thread::spawn(move || { + for _ in 0_usize..128 { + let (activity, connection) = worker_session.connection(); + let first = request(&worker_session, Some(activity.clone())); + let second = request(&worker_session, Some(activity)); + drop(first); + drop(connection); + drop(second); + } + worker_finished.fetch_add(1, Ordering::Release); + }) + }) + .take(4) + .collect(); + while finished.load(Ordering::Acquire) < 4 { + let snapshot = handle.snapshot(); + assert!(snapshot.idle_connections <= snapshot.active_connections); + if snapshot.active_requests == 0 { + assert_eq!(snapshot.idle_connections, snapshot.active_connections); + } + thread::yield_now(); + } + for worker in workers { + worker.join().unwrap(); + } + assert_eq!(handle.snapshot(), NativeDiagnosticsSnapshot::default()); + } + + #[test] + fn metric_saturation_and_underflow_cannot_panic_during_guard_ownership() { + let saturated = session(NativeDiagnosticsHandle::new(), false); + // Inject impossible-scale telemetry to exercise arithmetic boundaries, + // without changing the checked identity sequence or real guard count. + *lock(&saturated.handle.counters) = NativeDiagnosticsSnapshot { + active_requests: u64::MAX, + active_connections: u64::MAX, + idle_connections: u64::MAX, + }; + let (_, saturated_connection) = saturated.connection(); + let saturated_request = request(&saturated, None); + assert_eq!(saturated.handle.snapshot().active_requests, u64::MAX); + assert_eq!(saturated.handle.snapshot().active_connections, u64::MAX); + assert_eq!(saturated.handle.snapshot().idle_connections, u64::MAX); + drop(saturated_request); + drop(saturated_connection); + assert_eq!(saturated.handle.snapshot().active_requests, u64::MAX - 1); + assert_eq!(saturated.handle.snapshot().active_connections, u64::MAX - 1); + assert_eq!(saturated.handle.snapshot().idle_connections, u64::MAX - 1); + + let underflow = session(NativeDiagnosticsHandle::new(), false); + let (activity, connection) = underflow.connection(); + let guard = request(&underflow, Some(activity)); + *lock(&underflow.handle.counters) = NativeDiagnosticsSnapshot::default(); + drop(guard); + drop(connection); + assert_eq!( + underflow.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[test] + fn route_template_output_is_escaped_for_one_line() { + let (session, observer) = observed_session(false); + drop(request(&session, None)); + let mut record = lock(&observer.records)[0].clone(); + record.route = "/orders/{id}\n\r\t\"\\".into(); + let rendered = SafeRecord(&record).to_string(); + assert!(!rendered.contains(['\n', '\r', '\t'])); + assert!(rendered.contains("/orders/{id}\\n\\r\\t\\\"\\\\")); + } +} diff --git a/crates/edgezero-adapter-axum/src/geo.rs b/crates/edgezero-adapter-axum/src/geo.rs new file mode 100644 index 00000000..8ce1e72f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/geo.rs @@ -0,0 +1,619 @@ +//! 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() {} + 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 00000000..ae633ba0 --- /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 e6c5292b..b79afd5b 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -12,6 +12,10 @@ pub mod context; #[cfg(feature = "axum")] 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")] @@ -25,6 +29,10 @@ 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; #[cfg(feature = "axum")] mod response; 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 00000000..dd2fec16 --- /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 new file mode 100644 index 00000000..b5a58d28 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/proxy.rs @@ -0,0 +1,1784 @@ +//! 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}; +use std::str::from_utf8; + +use edgezero_core::http::{Authority, HeaderMap, HeaderName, RequestParts}; +use edgezero_core::ingress::{CheckedEffectiveHost, CheckedHostSource}; + +const MAX_BYTES: usize = 0x4000; +const MAX_CHAIN_SLOTS: usize = 64; +const MAX_PARAMETER_SLOTS: usize = 16; +const X_HEADERS: [&str; 3] = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto"]; + +/// The sole forwarding assertion family selected by an operator. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ForwardingHeaderFamily { + Forwarded, + XForwarded, +} + +/// Invalid trust configuration. Formatting never includes supplied values. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct ProxyTrustConfigError; + +impl fmt::Display for ProxyTrustConfigError { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid_trusted_proxy_configuration") + } +} + +impl fmt::Debug for ProxyTrustConfigError { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "the source-free safe category uses Error defaults; remaining methods are deprecated or unstable" +)] +impl Error for ProxyTrustConfigError {} + +/// Immutable, bounded literal-network policy. Empty policies never enable trust. +#[derive(Clone, Eq, PartialEq)] +pub struct TrustedProxyPolicy { + family: Option, + networks: PeerNetworks, +} + +impl fmt::Debug for TrustedProxyPolicy { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TrustedProxyPolicy") + .field("family", &self.family) + .field("network_count", &self.networks.len()) + .finish() + } +} + +impl Default for TrustedProxyPolicy { + #[inline] + fn default() -> Self { + Self::no_trust() + } +} + +impl TrustedProxyPolicy { + /// Parses captured standalone settings. Unknown supplied families always fail. + /// + /// # Errors + /// Rejects invalid settings and nonempty networks without a selected family. + #[inline] + pub fn from_values( + cidrs: Option<&str>, + family: Option<&str>, + ) -> Result { + let selected_family = family + .map(|value| match value.trim_matches([' ', '\t']) { + "forwarded" => Ok(ForwardingHeaderFamily::Forwarded), + "x-forwarded" => Ok(ForwardingHeaderFamily::XForwarded), + _ => Err(ProxyTrustConfigError), + }) + .transpose()?; + let raw = cidrs.unwrap_or(""); + if raw.len() > MAX_BYTES + || raw + .bytes() + .any(|byte| byte.is_ascii_control() && byte != b'\t') + { + return Err(ProxyTrustConfigError); + } + if raw.trim_matches([' ', '\t']).is_empty() { + return Ok(Self { + family: selected_family, + networks: PeerNetworks::default(), + }); + } + Self::new( + selected_family.ok_or(ProxyTrustConfigError)?, + raw.split(','), + ) + } + + /// Validates literal IPs/CIDRs without exposing the network-library type. + /// + /// # Errors + /// Rejects invalid literals, parser-budget excesses and whole-family trust. + #[inline] + pub fn new( + family: ForwardingHeaderFamily, + networks: I, + ) -> Result + where + I: IntoIterator, + S: AsRef, + { + Ok(Self { + family: Some(family), + networks: PeerNetworks::new(networks)?, + }) + } + + #[must_use] + #[inline] + pub fn no_trust() -> Self { + Self { + family: None, + networks: PeerNetworks::default(), + } + } + + fn trusts(&self, address: IpAddr) -> bool { + self.networks.trusts(address) + } +} + +/// A bounded assertion outcome, never a raw header or source error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ForwardingResult { + Absent, + Accepted, + Malformed, + Partial, + UnresolvedChain, + UntrustedPeer, +} + +impl ForwardingResult { + #[must_use] + #[inline] + pub const fn as_str(self) -> &'static str { + match self { + Self::Absent => "absent", + Self::Accepted => "accepted", + Self::Partial => "partial", + Self::UntrustedPeer => "untrusted_peer", + Self::Malformed => "malformed", + Self::UnresolvedChain => "unresolved_chain", + } + } +} + +/// The checked public scheme, independent of the client-address chain. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Scheme { + Http, + Https, +} + +impl Scheme { + #[must_use] + #[inline] + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } +} + +/// Checked ingress facts, not host authorization or authenticated visitor identity. +#[derive(Clone)] +pub struct CheckedProxyMetadata { + client_source: CheckedHostSource, + direct_peer: Option, + effective_client: Option, + effective_host: CheckedEffectiveHost, + effective_scheme: Scheme, + forwarding_result: ForwardingResult, + scheme_source: CheckedHostSource, +} + +impl fmt::Debug for CheckedProxyMetadata { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CheckedProxyMetadata") + .field("forwarding_result", &self.forwarding_result) + .finish_non_exhaustive() + } +} + +impl CheckedProxyMetadata { + #[must_use] + #[inline] + pub fn client_source(&self) -> CheckedHostSource { + self.client_source + } + + #[must_use] + #[inline] + pub fn direct_peer(&self) -> Option { + self.direct_peer + } + + #[must_use] + #[inline] + pub fn effective_client(&self) -> Option { + self.effective_client + } + + #[must_use] + #[inline] + pub fn effective_host(&self) -> &CheckedEffectiveHost { + &self.effective_host + } + + #[must_use] + #[inline] + pub fn effective_scheme(&self) -> Scheme { + self.effective_scheme + } + + #[must_use] + #[inline] + pub fn forwarding_result(&self) -> ForwardingResult { + self.forwarding_result + } + + #[must_use] + #[inline] + pub fn scheme_source(&self) -> CheckedHostSource { + self.scheme_source + } +} + +#[derive(Default)] +struct ParsedAssertion { + host: Option, + nodes: Vec>, + scheme: Option, +} + +pub(crate) fn normalize( + policy: &TrustedProxyPolicy, + peer: Option, + parts: &RequestParts, +) -> CheckedProxyMetadata { + let direct_authority = match parts.uri.authority() { + Some(authority) => parse_authority(authority.as_str().as_bytes()).ok(), + None => parts + .headers + .get("host") + .and_then(|value| parse_authority(value.as_bytes()).ok()), + }; + let host_source = if direct_authority.is_some() { + CheckedHostSource::Direct + } else { + CheckedHostSource::Unavailable + }; + let mut metadata = CheckedProxyMetadata { + direct_peer: peer, + effective_client: peer.map(|socket| canonical_ip(socket.ip())), + effective_host: CheckedEffectiveHost::from_ingress(direct_authority, host_source), + effective_scheme: Scheme::Http, + client_source: if peer.is_some() { + CheckedHostSource::Direct + } else { + CheckedHostSource::Unavailable + }, + scheme_source: CheckedHostSource::Direct, + forwarding_result: ForwardingResult::Absent, + }; + let present = match policy.family { + Some(ForwardingHeaderFamily::Forwarded) => parts.headers.contains_key("forwarded"), + Some(ForwardingHeaderFamily::XForwarded) => X_HEADERS + .iter() + .any(|name| parts.headers.contains_key(*name)), + None => parts.headers.keys().any(is_forwarding_header), + }; + if !present { + return metadata; + } + if !peer.is_some_and(|socket| policy.trusts(socket.ip())) { + metadata.forwarding_result = ForwardingResult::UntrustedPeer; + return metadata; + } + let (parse_result, source) = match policy.family { + Some(ForwardingHeaderFamily::Forwarded) => ( + parse_forwarded(&parts.headers), + CheckedHostSource::TrustedForwarded, + ), + Some(ForwardingHeaderFamily::XForwarded) => ( + parse_x_forwarded(&parts.headers), + CheckedHostSource::TrustedXForwarded, + ), + None => return metadata, + }; + let Ok(parsed) = parse_result else { + metadata.forwarding_result = ForwardingResult::Malformed; + return metadata; + }; + let mut accepted_client = false; + let mut unresolved = false; + if !parsed.nodes.is_empty() { + unresolved = true; + for predecessor in parsed.nodes.iter().rev() { + let Some(address) = predecessor else { + break; + }; + if !policy.trusts(*address) { + metadata.effective_client = Some(*address); + metadata.client_source = source; + accepted_client = true; + unresolved = false; + break; + } + } + } + let accepted_host = parsed.host.is_some(); + let accepted_scheme = parsed.scheme.is_some(); + if let Some(host) = parsed.host { + metadata.effective_host = CheckedEffectiveHost::from_ingress(Some(host), source); + } + if let Some(scheme) = parsed.scheme { + metadata.effective_scheme = scheme; + metadata.scheme_source = source; + } + metadata.forwarding_result = if unresolved { + ForwardingResult::UnresolvedChain + } else if accepted_client && accepted_host && accepted_scheme { + ForwardingResult::Accepted + } else if accepted_client || accepted_host || accepted_scheme { + ForwardingResult::Partial + } else { + ForwardingResult::Absent + }; + metadata +} + +fn is_forwarding_header(name: &HeaderName) -> bool { + name.as_str() == "forwarded" || name.as_str().starts_with("x-forwarded-") +} + +/// Removes all raw forwarding assertions, including unselected extensions. +pub(crate) fn strip_forwarding_headers(headers: &mut HeaderMap) { + while let Some(name) = headers + .keys() + .find(|name| is_forwarding_header(name)) + .cloned() + { + headers.remove(name); + } +} + +// Input is preflighted before any parsing scratch or retained nodes are allocated. +fn preflight(headers: &HeaderMap, names: &[&str]) -> Result<(), ()> { + let mut bytes = 0_usize; + for name in names { + for (index, value) in headers.get_all(*name).iter().enumerate() { + bytes = bytes + .checked_add(value.as_bytes().len()) + .and_then(|size| size.checked_add(usize::from(index != 0))) + .filter(|size| *size <= MAX_BYTES) + .ok_or(())?; + } + } + Ok(()) +} + +#[expect( + clippy::indexing_slicing, + reason = "both endpoints are positions found in this slice, and first non-OWS cannot follow last non-OWS" +)] +fn trim_ows(value: &[u8]) -> &[u8] { + let Some(start) = value.iter().position(|byte| !matches!(byte, b' ' | b'\t')) else { + return &[]; + }; + let Some(end) = value.iter().rposition(|byte| !matches!(byte, b' ' | b'\t')) else { + return &[]; + }; + &value[start..=end] +} + +// Slots include empty members; repeated-line boundaries join rather than add slots. +#[expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + reason = "private callers cap slots at 64 or 16; delimiter indices come from the same preflighted slice and start advances at most to its length" +)] +fn split_slots<'input>( + value: &'input [u8], + delimiter: u8, + slots: &mut usize, + limit: usize, + quoting: bool, +) -> Result, ()> { + let mut result = Vec::new(); + let mut start = 0; + let mut quoted = false; + let mut escaped = false; + *slots += 1; + if *slots > limit { + return Err(()); + } + for (index, byte) in value.iter().copied().enumerate() { + if quoting && escaped { + escaped = false; + } else if quoting && quoted && byte == b'\\' { + escaped = true; + } else if quoting && byte == b'"' { + quoted = !quoted; + } else if !quoted && byte == delimiter { + *slots += 1; + if *slots > limit { + return Err(()); + } + result.push(&value[start..index]); + start = index + 1; + } else { + // An ordinary byte does not change delimiter/quoting state. + } + } + if quoted || escaped { + return Err(()); + } + result.push(&value[start..]); + Ok(result) +} + +fn token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte) +} + +// Unknown extensions are validated without allocating or retaining their payload. +#[expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + reason = "the quoted branch checks length >= 2 before excluding the two quote bytes" +)] +fn validate_value(value: &[u8]) -> Result { + if value.first() != Some(&b'"') { + return if !value.is_empty() && value.iter().copied().all(token_byte) { + Ok(false) + } else { + Err(()) + }; + } + if value.len() < 2 || value.last() != Some(&b'"') { + return Err(()); + } + let mut escaped = false; + for byte in value[1..value.len() - 1].iter().copied() { + if escaped { + if !(byte == b'\t' || byte == b' ' || (0x21..=0x7e).contains(&byte) || byte >= 0x80) { + return Err(()); + } + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if !(byte == b'\t' + || byte == b' ' + || byte == 0x21 + || (0x23..=0x5b).contains(&byte) + || (0x5d..=0x7e).contains(&byte) + || byte >= 0x80) + { + return Err(()); + } else { + // RFC qdtext, including obs-text, requires no state change. + } + } + if escaped { Err(()) } else { Ok(true) } +} + +#[expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + reason = "only called after validate_value; quoted input has two validated delimiters and fits the assertion byte budget" +)] +fn decode_value(value: &[u8], quoted: bool) -> Vec { + if !quoted { + return value.to_vec(); + } + let mut decoded = Vec::with_capacity(value.len() - 2); + let mut escaped = false; + for byte in value[1..value.len() - 1].iter().copied() { + if !escaped && byte == b'\\' { + escaped = true; + } else { + decoded.push(byte); + escaped = false; + } + } + decoded +} + +#[expect( + clippy::arithmetic_side_effects, + clippy::indexing_slicing, + reason = "equals is a position found within the same nonempty parameter slice, so both name/value ranges and equals + 1 are bounded" +)] +fn parse_forwarded(headers: &HeaderMap) -> Result { + preflight(headers, &["forwarded"])?; + let mut parsed = ParsedAssertion::default(); + let mut slots = 0; + for field in headers.get_all("forwarded") { + for raw_element in split_slots(field.as_bytes(), b',', &mut slots, MAX_CHAIN_SLOTS, true)? { + let element = trim_ows(raw_element); + if element.is_empty() { + continue; + } + let mut parameter_slots = 0; + let mut names: Vec<&[u8]> = Vec::new(); + let mut node = None; + let mut host = None; + let mut scheme = None; + for raw_parameter in split_slots( + element, + b';', + &mut parameter_slots, + MAX_PARAMETER_SLOTS, + true, + )? { + let parameter = trim_ows(raw_parameter); + if parameter.is_empty() { + continue; + } + let equals = parameter.iter().position(|byte| *byte == b'=').ok_or(())?; + let name = ¶meter[..equals]; + if name.is_empty() + || !name.iter().copied().all(token_byte) + || names + .iter() + .any(|previous| previous.eq_ignore_ascii_case(name)) + { + return Err(()); + } + names.push(name); + let value = ¶meter[equals + 1..]; + let quoted = validate_value(value)?; + if name.eq_ignore_ascii_case(b"for") || name.eq_ignore_ascii_case(b"by") { + let decoded = decode_value(value, quoted); + let address = parse_node(&decoded, false)?; + if name.eq_ignore_ascii_case(b"for") { + node = address; + } + } else if name.eq_ignore_ascii_case(b"host") { + host = Some(parse_authority(&decode_value(value, quoted))?); + } else if name.eq_ignore_ascii_case(b"proto") { + scheme = Some(parse_scheme(&decode_value(value, quoted))?); + } else { + // Unknown payloads are syntactically checked, never retained. + } + } + if names.is_empty() { + continue; + } + parsed.nodes.push(node); + // A new substantive element replaces, rather than borrows, final-hop facts. + parsed.host = host; + parsed.scheme = scheme; + } + } + Ok(parsed) +} + +fn singleton<'headers>( + headers: &'headers HeaderMap, + name: &str, +) -> Result, ()> { + let mut values = headers.get_all(name).iter(); + let value = values.next().map(|field| trim_ows(field.as_bytes())); + if values.next().is_some() || value.is_some_and(|bytes| bytes.contains(&b',')) { + return Err(()); + } + Ok(value) +} + +fn parse_x_forwarded(headers: &HeaderMap) -> Result { + preflight(headers, &X_HEADERS)?; + let mut parsed = ParsedAssertion::default(); + let mut slots = 0; + for value in headers.get_all("x-forwarded-for") { + for node in split_slots(value.as_bytes(), b',', &mut slots, MAX_CHAIN_SLOTS, false)? { + // XFF has no RFC empty-list tolerance: never skip a predecessor slot. + parsed.nodes.push(parse_node(trim_ows(node), true)?); + } + } + parsed.host = singleton(headers, "x-forwarded-host")? + .map(parse_authority) + .transpose()?; + parsed.scheme = singleton(headers, "x-forwarded-proto")? + .map(parse_scheme) + .transpose()?; + Ok(parsed) +} + +fn parse_scheme(value: &[u8]) -> Result { + if value.eq_ignore_ascii_case(b"http") { + Ok(Scheme::Http) + } else if value.eq_ignore_ascii_case(b"https") { + Ok(Scheme::Https) + } else { + Err(()) + } +} + +fn valid_obfuscated(value: &str) -> bool { + value.starts_with('_') + && value.len() > 1 + && value + .as_bytes() + .iter() + .skip(1) + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(byte)) +} + +fn validate_node_port(port: &str) -> Result<(), ()> { + if valid_obfuscated(port) + || (!port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok()) + { + Ok(()) + } else { + Err(()) + } +} + +#[expect( + clippy::arithmetic_side_effects, + clippy::string_slice, + reason = "input is checked ASCII before slicing; closing is the bracket position found in that same string and its successor is at most the string length" +)] +fn parse_node(bytes: &[u8], allow_bare_ipv6: bool) -> Result, ()> { + let value = from_utf8(bytes).map_err(|_error| ())?; + if value.is_empty() || !value.is_ascii() { + return Err(()); + } + if let Some(bracketed) = value.strip_prefix('[') { + let closing = bracketed.find(']').ok_or(())?; + let address = bracketed[..closing] + .parse::() + .map_err(|_error| ())?; + let remainder = &bracketed[closing + 1..]; + if !remainder.is_empty() { + validate_node_port(remainder.strip_prefix(':').ok_or(())?)?; + } + return Ok(Some(canonical_ip(IpAddr::V6(address)))); + } + if allow_bare_ipv6 && let Ok(address) = value.parse::() { + return Ok(Some(canonical_ip(IpAddr::V6(address)))); + } + let (node, optional_port) = value + .split_once(':') + .map_or((value, None), |(name, number)| (name, Some(number))); + if let Some(port) = optional_port { + validate_node_port(port)?; + } + if node.eq_ignore_ascii_case("unknown") || valid_obfuscated(node) { + return Ok(None); + } + node.parse::() + .map(|address| Some(IpAddr::V4(address))) + .map_err(|_error| ()) +} + +#[expect( + clippy::arithmetic_side_effects, + clippy::string_slice, + reason = "input is checked ASCII before slicing; closing is found in the same bracketed authority and closing + 1 cannot exceed its length" +)] +fn parse_authority(bytes: &[u8]) -> Result { + let value = from_utf8(bytes).map_err(|_error| ())?; + if value.is_empty() + || !value.is_ascii() + || value.bytes().any(|byte| { + byte.is_ascii_whitespace() || byte.is_ascii_control() || b"@/?#\\,".contains(&byte) + }) + { + return Err(()); + } + let authority = value.parse::().map_err(|_error| ())?; + if authority.host().is_empty() { + return Err(()); + } + let optional_port = if let Some(bracketed) = value.strip_prefix('[') { + let closing = bracketed.find(']').ok_or(())?; + bracketed[..closing] + .parse::() + .map_err(|_error| ())?; + let suffix = &bracketed[closing + 1..]; + if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':').ok_or(())?) + } + } else { + let mut components = value.split(':'); + let host = components.next().ok_or(())?; + if host.is_empty() || host.contains(['[', ']']) { + return Err(()); + } + let port = components.next(); + if components.next().is_some() { + return Err(()); + } + port + }; + if let Some(port) = optional_port + && (port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port.parse::().is_err()) + { + return Err(()); + } + Ok(authority) +} + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + use std::iter::{empty, repeat, repeat_n}; + + use super::*; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderName, HeaderValue, request_builder}; + + const PEER: &str = "10.0.0.2:1234"; + const CLIENT: &str = "198.51.100.7"; + + fn policy(family: ForwardingHeaderFamily) -> TrustedProxyPolicy { + TrustedProxyPolicy::new(family, ["10.0.0.0/24"]).unwrap() + } + + fn parts(headers: &[(&str, &str)]) -> RequestParts { + let mut request = request_builder().uri("/").body(Body::empty()).unwrap(); + request + .headers_mut() + .insert("host", HeaderValue::from_static("direct.example:8080")); + for (name, value) in headers { + request.headers_mut().append( + name.parse::().unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + request.into_parts().0 + } + + fn checked(family: ForwardingHeaderFamily, headers: &[(&str, &str)]) -> CheckedProxyMetadata { + normalize( + &policy(family), + Some(PEER.parse().unwrap()), + &parts(headers), + ) + } + + fn direct_fallback(metadata: &CheckedProxyMetadata, result: ForwardingResult) { + assert_eq!(metadata.forwarding_result(), result); + assert_eq!(metadata.direct_peer(), Some(PEER.parse().unwrap())); + assert_eq!( + metadata.effective_client(), + Some("10.0.0.2".parse().unwrap()) + ); + assert_eq!(metadata.client_source(), CheckedHostSource::Direct); + assert_eq!( + metadata.effective_host().authority(), + Some("direct.example:8080") + ); + assert_eq!( + metadata.effective_host().source(), + CheckedHostSource::Direct + ); + assert_eq!(metadata.effective_scheme(), Scheme::Http); + assert_eq!(metadata.scheme_source(), CheckedHostSource::Direct); + } + + fn assert_client(metadata: &CheckedProxyMetadata, client: &str) { + assert_eq!(metadata.effective_client(), Some(client.parse().unwrap())); + } + + #[test] + fn defaults_and_strict_configuration() { + let default_policy = TrustedProxyPolicy::default(); + assert_eq!(default_policy, TrustedProxyPolicy::no_trust()); + assert!(!default_policy.trusts("127.0.0.1".parse().unwrap())); + assert_eq!( + TrustedProxyPolicy::from_values(None, None).unwrap(), + default_policy + ); + for raw in ["", " ", "\t "] { + assert!( + TrustedProxyPolicy::from_values(Some(raw), None) + .unwrap() + .networks + .is_empty() + ); + } + for family in ["forwarded", "x-forwarded"] { + assert!( + TrustedProxyPolicy::from_values(None, Some(family)) + .unwrap() + .networks + .is_empty() + ); + } + assert!( + TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, empty::<&str>()) + .unwrap() + .networks + .is_empty() + ); + TrustedProxyPolicy::from_values(Some("10.0.0.1"), None).unwrap_err(); + for family in ["", "auto", "Forwarded", "forwarded\n", "sentinel-secret"] { + TrustedProxyPolicy::from_values(None, Some(family)).unwrap_err(); + } + for cidrs in [ + "all", + "private", + "localhost", + "example.com", + "10.0.0.1,", + ",10.0.0.1", + "10.0.0.1,,10.0.0.2", + "10.0.0.1/33", + "\n", + "10.0.0.1\r", + "10.0.0.1\0", + ] { + TrustedProxyPolicy::from_values(Some(cidrs), Some("forwarded")).expect_err(cidrs); + } + TrustedProxyPolicy::from_values(Some(" \t10.0.0.1 , 10.0.0.2/32\t"), Some("forwarded")) + .unwrap(); + TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, [""]).unwrap_err(); + } + + #[test] + fn configuration_errors_are_safe() { + let error = TrustedProxyPolicy::from_values(Some("sentinel-secret"), Some("forwarded")) + .unwrap_err(); + assert_eq!(error.to_string(), "invalid_trusted_proxy_configuration"); + assert_eq!(format!("{error:?}"), error.to_string()); + let policy = policy(ForwardingHeaderFamily::Forwarded); + assert!(!format!("{policy:?}").contains("10.0.0")); + } + + #[test] + fn configuration_entry_and_byte_bounds_precede_deduplication() { + let family = ForwardingHeaderFamily::Forwarded; + assert_eq!( + TrustedProxyPolicy::new(family, repeat_n("10.0.0.1", MAX_NETWORKS)) + .unwrap() + .networks + .len(), + 1 + ); + TrustedProxyPolicy::new(family, repeat("10.0.0.1")).unwrap_err(); + let exact = format!("{}127.0.0.1", " ".repeat(MAX_BYTES - "127.0.0.1".len())); + TrustedProxyPolicy::new(family, [&exact]).unwrap(); + TrustedProxyPolicy::from_values(Some(&exact), Some("forwarded")).unwrap(); + let over = format!(" {exact}"); + TrustedProxyPolicy::new(family, [&over]).unwrap_err(); + TrustedProxyPolicy::from_values(Some(&over), Some("forwarded")).unwrap_err(); + let second = "10.0.0.2"; + let first = format!( + "{}10.0.0.1", + " ".repeat(MAX_BYTES - 1 - second.len() - "10.0.0.1".len()) + ); + TrustedProxyPolicy::new(family, [first.as_str(), second]).unwrap(); + TrustedProxyPolicy::new(family, [format!(" {first}"), second.to_owned()]).unwrap_err(); + } + + #[test] + fn cidrs_truncate_deduplicate_and_project_mapped_ipv6() { + let policy = TrustedProxyPolicy::new( + ForwardingHeaderFamily::Forwarded, + [ + "192.0.2.19/24", + "192.0.2.0/24", + "::ffff:192.0.2.99/120", + "2001:db8::9/32", + ], + ) + .unwrap(); + assert_eq!(policy.networks.len(), 2); + for address in [ + "192.0.2.0", + "192.0.2.255", + "::ffff:192.0.2.7", + "2001:db8::ffff", + ] { + assert!(policy.trusts(address.parse().unwrap()), "{address}"); + } + for address in [ + "192.0.3.0", + "::ffff:192.0.3.1", + "2001:db9::", + "127.0.0.1", + "10.0.0.1", + ] { + assert!(!policy.trusts(address.parse().unwrap()), "{address}"); + } + let mapped = + TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, ["::ffff:192.0.2.7"]) + .unwrap(); + assert!(mapped.trusts("192.0.2.7".parse().unwrap())); + assert!(!mapped.trusts("192.0.2.8".parse().unwrap())); + } + + #[test] + fn universal_and_mapped_crossing_policies_are_rejected() { + for entries in [ + vec!["0.0.0.0/0"], + vec!["::/0"], + vec!["0.0.0.0/1", "128.0.0.0/1"], + vec!["0.0.0.0/2", "64.0.0.0/2", "128.0.0.0/1"], + vec!["::/1", "8000::/1"], + vec!["::ffff:0.0.0.0/96"], + vec!["::fffe:0:0/95"], + vec!["::/80"], + vec!["0.0.0.0/1", "::ffff:128.0.0.0/97"], + ] { + TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, entries).unwrap_err(); + } + TrustedProxyPolicy::new( + ForwardingHeaderFamily::Forwarded, + ["::fffd:0:0/96", "ffff::/16"], + ) + .unwrap(); + } + + #[test] + #[expect( + clippy::shadow_unrelated, + reason = "the same checked variable describes separate missing-peer and nonmatching-peer fixture observations" + )] + fn absent_disabled_untrusted_and_missing_peer_never_parse_assertions() { + let assertion = parts(&[ + ("forwarded", "invalid sentinel-secret"), + ("x-forwarded-host", "evil.example"), + ]); + for policy in [ + TrustedProxyPolicy::no_trust(), + policy(ForwardingHeaderFamily::Forwarded), + ] { + let peer: SocketAddr = "127.0.0.1:10".parse().unwrap(); + let checked = normalize(&policy, Some(peer), &assertion); + assert_eq!(checked.forwarding_result(), ForwardingResult::UntrustedPeer); + assert_eq!(checked.effective_client(), Some(peer.ip())); + assert_eq!( + checked.effective_host().authority(), + Some("direct.example:8080") + ); + let checked = normalize(&policy, None, &assertion); + assert_eq!(checked.effective_client(), None); + assert_eq!(checked.client_source(), CheckedHostSource::Unavailable); + assert_eq!(checked.forwarding_result(), ForwardingResult::UntrustedPeer); + } + direct_fallback( + &checked(ForwardingHeaderFamily::Forwarded, &[]), + ForwardingResult::Absent, + ); + let empty = + TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, empty::<&str>()).unwrap(); + assert_eq!( + normalize(&empty, Some(PEER.parse().unwrap()), &assertion).forwarding_result(), + ForwardingResult::UntrustedPeer + ); + } + + #[test] + #[expect( + clippy::shadow_unrelated, + reason = "paired metadata observations compare Forwarded and X-Forwarded family selection with the same assertions" + )] + fn family_selection_never_borrows_or_validates_other_family() { + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[ + ("forwarded", "for=198.51.100.7"), + ("x-forwarded-host", "evil.example"), + ("x-forwarded-proto", "garbage"), + ], + ); + assert_client(&metadata, CLIENT); + assert_eq!( + metadata.effective_host().authority(), + Some("direct.example:8080") + ); + assert_eq!(metadata.effective_scheme(), Scheme::Http); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Partial); + let metadata = checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("forwarded", "malformed"), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Partial); + assert_client(&metadata, "10.0.0.2"); + assert_eq!( + metadata.effective_host().authority(), + Some("public.example") + ); + assert_eq!(metadata.effective_scheme(), Scheme::Https); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("x-forwarded-for", CLIENT)], + ), + ForwardingResult::Absent, + ); + } + + #[test] + #[expect( + clippy::shadow_unrelated, + reason = "paired chain fixtures compare a final asserted host/scheme against final-hop omission without changing assertion names" + )] + fn repeated_forwarded_order_and_final_proxy_host_scheme() { + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[ + ( + "forwarded", + "for=203.0.113.99;host=attacker.example;proto=http, for=198.51.100.7", + ), + ("forwarded", "for=10.0.0.1;HOST=public.example;PROTO=HTTPS"), + ], + ); + assert_client(&metadata, CLIENT); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Accepted); + assert_eq!( + metadata.effective_host().authority(), + Some("public.example") + ); + assert_eq!( + metadata.effective_host().source(), + CheckedHostSource::TrustedForwarded + ); + assert_eq!(metadata.effective_scheme(), Scheme::Https); + assert_eq!( + metadata.scheme_source(), + CheckedHostSource::TrustedForwarded + ); + assert_eq!( + metadata.client_source(), + CheckedHostSource::TrustedForwarded + ); + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + "for=198.51.100.7;host=earlier.example;proto=https, for=10.0.0.1", + )], + ); + assert_eq!( + metadata.effective_host().authority(), + Some("direct.example:8080") + ); + assert_eq!(metadata.effective_scheme(), Scheme::Http); + } + + #[test] + fn forwarded_rfc_empty_slots_and_unknown_extensions() { + for value in [",,", "", " ; ; ", " , ; ; , "] { + direct_fallback( + &checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", value)]), + ForwardingResult::Absent, + ); + } + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + " , ;for=198.51.100.7;;host=public.example;proto=https;ext=\"a,b;c\\\"d\\\\e\"; , ", + )], + ); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Accepted); + assert_client(&metadata, CLIENT); + let mut parts = parts(&[]); + parts.headers.append( + "forwarded", + HeaderValue::from_bytes( + b"for=198.51.100.7;host=public.example;proto=https;ext=\"\x80\xff\\\xfe\"", + ) + .unwrap(), + ); + assert_eq!( + normalize( + &policy(ForwardingHeaderFamily::Forwarded), + Some(PEER.parse().unwrap()), + &parts + ) + .forwarding_result(), + ForwardingResult::Accepted + ); + } + + #[test] + fn quoted_values_unescape_before_recognized_validation() { + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + "for=\"198.51.100.7\";host=\"public\\.example:443\";proto=\"h\\ttps\"", + )], + ); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Accepted); + assert_eq!( + metadata.effective_host().authority(), + Some("public.example:443") + ); + for value in [ + "for=\"198.51.100.7", + "for=198.51.100.7;ext=\"a\\\"", + "for=198.51.100.7;ext=\"a\"x", + "for=198.51.100.7;ext=a b", + "for =198.51.100.7", + "for= 198.51.100.7", + "for=198.51.100.7;ext=", + ] { + direct_fallback( + &checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", value)]), + ForwardingResult::Malformed, + ); + } + } + + #[test] + #[expect( + clippy::shadow_unrelated, + reason = "each node table row runs identical metadata assertions against both family-specific results" + )] + fn recognized_nodes_and_ports_follow_family_grammar() { + for (value, expected) in [ + ("198.51.100.7", Some(CLIENT)), + ("198.51.100.7:65535", Some(CLIENT)), + ("198.51.100.7:0", Some(CLIENT)), + ("198.51.100.7:_masked", Some(CLIENT)), + ("[2001:db8::7]", Some("2001:db8::7")), + ("[2001:db8::7]:443", Some("2001:db8::7")), + ("[2001:db8::7]:_masked", Some("2001:db8::7")), + ("[::ffff:198.51.100.7]:80", Some(CLIENT)), + ("unknown", None), + ("UNKNOWN:80", None), + ("_hidden:_port", None), + ] { + let metadata = checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + &format!("for=\"{value}\";host=public.example;proto=https"), + )], + ); + let metadata_x = checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", value), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ); + for metadata in [&metadata, &metadata_x] { + if let Some(address) = expected { + assert_client(metadata, address); + assert_eq!( + metadata.forwarding_result(), + ForwardingResult::Accepted, + "{value}" + ); + } else { + assert_client(metadata, "10.0.0.2"); + assert_eq!( + metadata.forwarding_result(), + ForwardingResult::UnresolvedChain, + "{value}" + ); + assert_eq!(metadata.effective_scheme(), Scheme::Https); + } + } + } + let bare_v6 = checked( + ForwardingHeaderFamily::XForwarded, + &[("x-forwarded-for", "2001:db8::7")], + ); + assert_client(&bare_v6, "2001:db8::7"); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", "for=\"2001:db8::7\";proto=https")], + ), + ForwardingResult::Malformed, + ); + for value in [ + "", + "example.com", + "_", + "_bad!", + "unknown:", + "198.51.100.7:65536", + "198.51.100.7:-1", + "198.51.100.7:+1", + "198.51.100.7:_", + "[2001:db8::7]:65536", + "[fe80::1%eth0]", + "[2001:db8::7]extra", + "[2001:db8::7", + "[198.51.100.7]", + "010.0.0.1", + "[::1]::80", + ] { + parse_node(value.as_bytes(), true).expect_err(value); + parse_node(value.as_bytes(), false).expect_err(value); + } + } + + #[test] + fn whole_family_validation_includes_unselected_prefix_and_by_nodes() { + for value in [ + "for=garbage,for=198.51.100.7;host=public.example;proto=https", + "for=198.51.100.7;by=garbage;host=public.example;proto=https", + "for=198.51.100.7;FOR=198.51.100.8;host=public.example;proto=https", + "for=198.51.100.7;ext=a;EXT=b;host=public.example;proto=https", + "for=203.0.113.9;proto=ftp,for=198.51.100.7;host=public.example;proto=https", + "for=203.0.113.9;host=\"bad.example:65536\",for=198.51.100.7;host=public.example;proto=https", + "for=\"198.51.100.7:65536\";host=public.example;proto=https", + ] { + direct_fallback( + &checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", value)]), + ForwardingResult::Malformed, + ); + } + direct_fallback( + &checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", "garbage,198.51.100.7"), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ), + ForwardingResult::Malformed, + ); + let valid = checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", "for=198.51.100.7;by=\"[2001:db8::1]:_port\"")], + ); + assert_client(&valid, CLIENT); + } + + #[test] + fn xff_empty_members_invalidate_even_good_host_and_proto() { + for value in [ + "", + " ", + ",", + ",198.51.100.7", + "198.51.100.7,", + "198.51.100.7,,10.0.0.1", + "198.51.100.7, \t,10.0.0.1", + ] { + direct_fallback( + &checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", value), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ), + ForwardingResult::Malformed, + ); + } + direct_fallback( + &checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", CLIENT), + ("x-forwarded-for", ""), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ), + ForwardingResult::Malformed, + ); + let no_xff = checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ], + ); + assert_eq!(no_xff.forwarding_result(), ForwardingResult::Partial); + assert_eq!(no_xff.effective_host().authority(), Some("public.example")); + assert_eq!(no_xff.effective_scheme(), Scheme::Https); + assert_client(&no_xff, "10.0.0.2"); + } + + #[test] + fn x_forwarded_host_and_proto_are_strict_singletons() { + for headers in [ + vec![ + ("x-forwarded-for", CLIENT), + ("x-forwarded-host", "public.example"), + ("x-forwarded-host", "public.example"), + ], + vec![ + ("x-forwarded-for", CLIENT), + ("x-forwarded-proto", "https"), + ("x-forwarded-proto", "https"), + ], + vec![ + ("x-forwarded-for", CLIENT), + ("x-forwarded-host", "public.example,evil.example"), + ], + vec![ + ("x-forwarded-for", CLIENT), + ("x-forwarded-proto", "https,http"), + ], + vec![("x-forwarded-for", CLIENT), ("x-forwarded-host", "")], + vec![("x-forwarded-for", CLIENT), ("x-forwarded-proto", "")], + ] { + direct_fallback( + &checked(ForwardingHeaderFamily::XForwarded, &headers), + ForwardingResult::Malformed, + ); + } + let accepted = checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", "198.51.100.7, 10.0.0.1"), + ("x-forwarded-host", " \tpublic.example:443 "), + ("x-forwarded-proto", " HTTPS "), + ], + ); + assert_eq!(accepted.forwarding_result(), ForwardingResult::Accepted); + assert_eq!( + accepted.effective_host().source(), + CheckedHostSource::TrustedXForwarded + ); + assert_eq!( + accepted.client_source(), + CheckedHostSource::TrustedXForwarded + ); + assert_eq!( + accepted.scheme_source(), + CheckedHostSource::TrustedXForwarded + ); + } + + #[test] + fn authorities_and_schemes_are_checked_before_acceptance() { + for host in [ + "public.example", + "public.example:0", + "public.example:65535", + "[2001:db8::1]", + "[::1]:443", + "192.0.2.1:80", + ] { + parse_authority(host.as_bytes()).expect(host); + } + for host in [ + "", + "user@public.example", + "public.example/path", + "public.example?q=secret", + "public.example#secret", + "public.example\\path", + " public.example", + "public.example ", + "public.example:", + "public.example:65536", + "public.example:-1", + "public.example:_port", + "public.example:443:80", + "[fe80::1%eth0]", + "[::1]:", + "[::1]extra", + ":443", + "a,b", + ] { + parse_authority(host.as_bytes()).expect_err(host); + let encoded_host = host.replace('\\', "\\\\").replace('"', "\\\""); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + &format!("for=198.51.100.7;host=\"{encoded_host}\";proto=https"), + )], + ), + ForwardingResult::Malformed, + ); + } + for scheme in ["ftp", "https:", "wss", "", "https http"] { + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + &format!("for=198.51.100.7;host=public.example;proto=\"{scheme}\""), + )], + ), + ForwardingResult::Malformed, + ); + } + } + + #[test] + fn walk_stops_at_first_untrusted_boundary_and_never_skips_gaps() { + for (chain, expected, result) in [ + ( + "203.0.113.99,198.51.100.7,10.0.0.1", + CLIENT, + ForwardingResult::Partial, + ), + ( + "203.0.113.99,198.51.100.7", + CLIENT, + ForwardingResult::Partial, + ), + ( + "203.0.113.99,10.1.0.1", + "10.1.0.1", + ForwardingResult::Partial, + ), + ( + "198.51.100.7,unknown,10.0.0.1", + "10.0.0.2", + ForwardingResult::UnresolvedChain, + ), + ( + "198.51.100.7,_gap,10.0.0.1", + "10.0.0.2", + ForwardingResult::UnresolvedChain, + ), + ("10.0.0.1", "10.0.0.2", ForwardingResult::UnresolvedChain), + ( + "10.0.0.1,10.0.0.3", + "10.0.0.2", + ForwardingResult::UnresolvedChain, + ), + ("unknown,198.51.100.7", CLIENT, ForwardingResult::Partial), + ] { + let metadata = checked( + ForwardingHeaderFamily::XForwarded, + &[("x-forwarded-for", chain)], + ); + assert_client(&metadata, expected); + assert_eq!(metadata.forwarding_result(), result, "{chain}"); + } + let gap = checked( + ForwardingHeaderFamily::Forwarded, + &[( + "forwarded", + "for=198.51.100.7,host=public.example;proto=https", + )], + ); + assert_eq!(gap.forwarding_result(), ForwardingResult::UnresolvedChain); + assert_client(&gap, "10.0.0.2"); + assert_eq!(gap.effective_host().authority(), Some("public.example")); + assert_eq!(gap.effective_scheme(), Scheme::Https); + } + + #[test] + fn mapped_peer_and_asserted_client_match_equivalent_ipv4_trust() { + let peer: SocketAddr = "[::ffff:10.0.0.2]:1234".parse().unwrap(); + let parts = parts(&[( + "forwarded", + "for=\"[::ffff:198.51.100.7]:_p\",for=\"[::ffff:10.0.0.1]\";host=public.example;proto=https", + )]); + let metadata = normalize( + &policy(ForwardingHeaderFamily::Forwarded), + Some(peer), + &parts, + ); + assert_eq!(metadata.direct_peer(), Some(peer)); + assert_client(&metadata, CLIENT); + assert_eq!(metadata.forwarding_result(), ForwardingResult::Accepted); + } + + #[test] + fn forwarded_list_slot_bounds_are_exact_across_repeated_fields_and_empty_members() { + let for_node = "for=198.51.100.7"; + let exact = repeat_n(for_node, MAX_CHAIN_SLOTS) + .collect::>() + .join(","); + assert_eq!( + checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", &exact)]) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &format!("{exact},{for_node}"))], + ), + ForwardingResult::Malformed, + ); + let two_nodes = format!("{for_node},{for_node}"); + let mut repeated = parts(&[]); + for _ in 0_usize..32 { + repeated + .headers + .append("forwarded", HeaderValue::from_str(&two_nodes).unwrap()); + } + assert_eq!( + normalize( + &policy(ForwardingHeaderFamily::Forwarded), + Some(PEER.parse().unwrap()), + &repeated + ) + .forwarding_result(), + ForwardingResult::Partial + ); + repeated + .headers + .append("forwarded", HeaderValue::from_static("")); + direct_fallback( + &normalize( + &policy(ForwardingHeaderFamily::Forwarded), + Some(PEER.parse().unwrap()), + &repeated, + ), + ForwardingResult::Malformed, + ); + let exact_empty_slots = format!("{}{for_node}", ",".repeat(63)); + assert_eq!( + checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &exact_empty_slots)] + ) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &format!(",{exact_empty_slots}"))], + ), + ForwardingResult::Malformed, + ); + } + + #[test] + fn xff_list_slot_bounds_are_exact_across_repeated_fields() { + let exact = repeat_n(CLIENT, MAX_CHAIN_SLOTS) + .collect::>() + .join(","); + assert_eq!( + checked( + ForwardingHeaderFamily::XForwarded, + &[("x-forwarded-for", &exact)] + ) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::XForwarded, + &[("x-forwarded-for", &format!("{exact},{CLIENT}"))], + ), + ForwardingResult::Malformed, + ); + let mut repeated = parts(&[]); + for _ in 0_usize..32 { + repeated.headers.append( + "x-forwarded-for", + HeaderValue::from_str(&format!("{CLIENT},{CLIENT}")).unwrap(), + ); + } + assert_eq!( + normalize( + &policy(ForwardingHeaderFamily::XForwarded), + Some(PEER.parse().unwrap()), + &repeated + ) + .forwarding_result(), + ForwardingResult::Partial + ); + repeated + .headers + .append("x-forwarded-for", HeaderValue::from_str(CLIENT).unwrap()); + direct_fallback( + &normalize( + &policy(ForwardingHeaderFamily::XForwarded), + Some(PEER.parse().unwrap()), + &repeated, + ), + ForwardingResult::Malformed, + ); + } + + #[test] + fn parameter_slot_bounds_include_empty_parameters() { + let exact = format!("for=198.51.100.7{}", ";".repeat(15)); + assert_eq!( + checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", &exact)]) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &format!("{exact};"))], + ), + ForwardingResult::Malformed, + ); + let mut named = String::from("for=198.51.100.7"); + for index in 0_usize..15 { + write!(named, ";e{index}=value").unwrap(); + } + assert_eq!( + checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", &named)]) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &format!("{named};e15=value"))], + ), + ForwardingResult::Malformed, + ); + } + + #[test] + fn assertion_byte_budget_is_exact_and_counts_repeated_delimiters() { + let prefix = "for=198.51.100.7;host=public.example;proto=https;ext=\""; + let exact = format!("{prefix}{}\"", "a".repeat(MAX_BYTES - prefix.len() - 1)); + assert_eq!( + checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", &exact)]) + .forwarding_result(), + ForwardingResult::Accepted + ); + let over = exact.replacen("ext=\"", "ext=\"a", 1); + direct_fallback( + &checked(ForwardingHeaderFamily::Forwarded, &[("forwarded", &over)]), + ForwardingResult::Malformed, + ); + let second = "for=198.51.100.7;host=public.example;proto=https"; + let first = format!("ext=\"{}\"", "a".repeat(MAX_BYTES - second.len() - 1 - 6)); + assert_eq!(first.len() + second.len() + 1, MAX_BYTES); + assert_eq!( + checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &first), ("forwarded", second)] + ) + .forwarding_result(), + ForwardingResult::Accepted + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::Forwarded, + &[("forwarded", &format!(" {first}")), ("forwarded", second)], + ), + ForwardingResult::Malformed, + ); + let padding = " ".repeat(MAX_BYTES - CLIENT.len()); + assert_eq!( + checked( + ForwardingHeaderFamily::XForwarded, + &[("x-forwarded-for", &format!("{padding}{CLIENT}"))] + ) + .forwarding_result(), + ForwardingResult::Partial + ); + direct_fallback( + &checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", &format!(" {padding}{CLIENT}")), + ("x-forwarded-host", "public.example"), + ], + ), + ForwardingResult::Malformed, + ); + } + + #[test] + fn stripping_removes_every_forwarding_extension_without_changing_direct_head() { + let mut parts = parts(&[ + ("Forwarded", "for=198.51.100.7"), + ("X-Forwarded-For", CLIENT), + ("x-forwarded-host", "public.example"), + ("x-forwarded-proto", "https"), + ("X-Forwarded-Secret", "sentinel-secret"), + ("x-request-id", "visitor-id"), + ]); + let original_uri = parts.uri.clone(); + strip_forwarding_headers(&mut parts.headers); + assert!(!parts.headers.keys().any(is_forwarding_header)); + assert_eq!(parts.headers["host"], "direct.example:8080"); + assert_eq!(parts.headers["x-request-id"], "visitor-id"); + assert_eq!(parts.uri, original_uri); + } + + #[test] + #[expect( + clippy::shadow_unrelated, + reason = "sequential direct-head fixtures assert metadata with authority present and then unavailable" + )] + fn direct_authority_uses_http_authority_without_inferring_transport_scheme() { + let mut parts = parts(&[]); + parts.uri = "https://uri.example:443/path?secret=value".parse().unwrap(); + let metadata = normalize(&TrustedProxyPolicy::no_trust(), None, &parts); + assert_eq!( + metadata.effective_host().authority(), + Some("uri.example:443") + ); + assert_eq!(metadata.effective_scheme(), Scheme::Http); + assert_eq!(metadata.scheme_source(), CheckedHostSource::Direct); + parts.uri = "/".parse().unwrap(); + parts.headers.remove("host"); + let metadata = normalize(&TrustedProxyPolicy::no_trust(), None, &parts); + assert_eq!(metadata.effective_host().authority(), None); + assert_eq!( + metadata.effective_host().source(), + CheckedHostSource::Unavailable + ); + parts + .headers + .insert("host", HeaderValue::from_static("bad.example:65536")); + assert_eq!( + normalize(&TrustedProxyPolicy::no_trust(), None, &parts) + .effective_host() + .authority(), + None + ); + } + + #[test] + fn debug_metadata_does_not_dump_private_ingress_values() { + let metadata = checked( + ForwardingHeaderFamily::XForwarded, + &[ + ("x-forwarded-for", CLIENT), + ("x-forwarded-host", "sentinel-secret.example"), + ("x-forwarded-proto", "https"), + ], + ); + let debug = format!("{metadata:?}"); + for private in [CLIENT, "sentinel-secret", "10.0.0.2"] { + assert!(!debug.contains(private)); + } + for result in [ + ForwardingResult::Absent, + ForwardingResult::Accepted, + ForwardingResult::Partial, + ForwardingResult::UntrustedPeer, + ForwardingResult::Malformed, + ForwardingResult::UnresolvedChain, + ] { + assert!( + result + .as_str() + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') + ); + } + assert_eq!(Scheme::Http.as_str(), "http"); + assert_eq!(Scheme::Https.as_str(), "https"); + } +} diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index 8ba11595..44fe9f32 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -29,6 +29,8 @@ use tokio::task::yield_now; use tokio::time::Instant as TokioInstant; use tokio::time::timeout; +use crate::diagnostics::SelectedStatus; + const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; @@ -499,11 +501,8 @@ impl HttpBody for AxumEgressBody { } Poll::Ready(Some(Ok(Frame::data(bytes)))) } - Poll::Ready(Some(Err(error))) => { - log::warn!( - "response-egress Axum body source failed after commit: {}", - response_egress_source_error_category(&error) - ); + Poll::Ready(Some(Err(_error))) => { + // The native terminal owner records this once, including request-log opt-out. this.source = ResponseSource::Done; this.fail(ResponseEgressOutcome::SourceError); Poll::Ready(Some(Err(AxumBodyError(ResponseEgressOutcome::SourceError)))) @@ -527,9 +526,13 @@ impl HttpBody for AxumEgressBody { /// Converts one owned core envelope into the private body used by `EdgeZero`'s Hyper server. pub(crate) async fn prepare_egress_response( - egress: ResponseEgressEnvelope, + mut egress: ResponseEgressEnvelope, connection: &EgressConnection, + selected: Option<&SelectedStatus>, ) -> Response { + if let Some(slot) = selected { + slot.select(egress.response_mut().status()); + } let request_method = egress.request_method().clone(); match egress.begin() { Ok((prepared, policy, attempt, clock)) => { @@ -540,6 +543,7 @@ pub(crate) async fn prepare_egress_response( attempt, &clock, connection, + selected, ); } let validated = match validate_empty_stream( @@ -552,7 +556,14 @@ pub(crate) async fn prepare_egress_response( { Ok(validated) => validated, Err(cause) => { - return prepare_fallback(&request_method, cause, attempt, &clock, connection); + return prepare_fallback( + &request_method, + cause, + attempt, + &clock, + connection, + selected, + ); } }; match start_prepared( @@ -562,6 +573,7 @@ pub(crate) async fn prepare_egress_response( clock.clone(), EgressKind::Application, connection, + selected, ) { Ok(response) => response, Err(returned_attempt) => { @@ -576,13 +588,21 @@ pub(crate) async fn prepare_egress_response( *returned_attempt, &clock, connection, + selected, ) } } } Err(failure) => { let (attempt, clock, cause) = failure.into_parts(); - prepare_fallback(&request_method, cause, attempt, &clock, connection) + prepare_fallback( + &request_method, + cause, + attempt, + &clock, + connection, + selected, + ) } } } @@ -639,10 +659,14 @@ fn start_prepared( clock: MonotonicClock, kind: EgressKind, connection: &EgressConnection, + selected: Option<&SelectedStatus>, ) -> Result, Box> { let declared_length = prepared.declared_length(); let transmits_body = prepared.transmits_body(); let (parts, core_body) = prepared.into_response().into_parts(); + if let Some(slot) = selected { + slot.select(parts.status); + } match AxumEgressBody::owned( core_body, declared_length, @@ -664,11 +688,18 @@ fn prepare_fallback( mut attempt: ResponseEgressAttempt, clock: &MonotonicClock, connection: &EgressConnection, + selected: Option<&SelectedStatus>, ) -> Response { if !attempt.begin_fallback(cause) { + if let Some(slot) = selected { + slot.select(StatusCode::INTERNAL_SERVER_ERROR); + } attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); return detached_fallback(request_method, ResponseEgressOutcome::ConversionError); } + if let Some(slot) = selected { + slot.select(fallback_parts(cause).0); + } let started_at = clock.now(); let Some(fallback_deadline) = started_at.checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) else { @@ -687,6 +718,7 @@ fn prepare_fallback( clock.clone(), EgressKind::Fallback, connection, + selected, ) { Ok(response) => response, Err(mut returned_attempt) => { @@ -739,7 +771,8 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::http::{HeaderMap, Method, StatusCode, Version, response_builder}; use edgezero_core::response_egress::{ - ResponseEgressAttempt, ResponseEgressCompletion, ResponseEgressHead, + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressBodyKind, + ResponseEgressCompletion, ResponseEgressFallbackDisposition, ResponseEgressHead, ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, ResponseEgressReport, }; @@ -752,10 +785,88 @@ mod tests { use http_body::Body as _; use super::{ - AxumEgressBody, EgressConnection, EgressKind, INTERNAL_FALLBACK_BODY, + AxumEgressBody, EgressConnection, EgressKind, INTERNAL_FALLBACK_BODY, SelectedStatus, TIMEOUT_FALLBACK_BODY, prepare_fallback, start_prepared, }; + use crate::diagnostics::{ + NativeDiagnosticsHandle, NativeDiagnosticsSnapshot, NativeRequestObserver, + NativeRequestOutcome, NativeRequestRecord, NativeSession, + }; + + #[derive(Default)] + struct NativeFallbackProbe { + completion_calls: Arc, + diagnostics: NativeDiagnosticsHandle, + native: RecordingNativeObserver, + observer: RecordingObserver, + } + + #[derive(Clone, Default)] + struct RecordingNativeObserver(Arc>>); + + impl NativeFallbackProbe { + fn attempt( + &self, + now: MonotonicInstant, + clock: MonotonicClock, + ) -> (ResponseEgressAttempt, SelectedStatus) { + let session = NativeSession::new( + self.diagnostics + .clone() + .with_request_observer(self.native.clone()), + false, + ) + .expect("native session"); + let guard = session + .request(&Method::GET, now, clock.clone(), None) + .expect("native request"); + let (native_completion, selected) = guard.completion(); + selected.select(StatusCode::OK); + let calls = Arc::clone(&self.completion_calls); + let app_completion = ResponseEgressCompletion::new(move |_report| { + calls.fetch_add(1, Ordering::SeqCst); + }); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + now, + None, + ); + ( + ResponseEgressAttempt::new( + &head, + now, + app_completion.join(native_completion), + ResponseEgressObserverHandle::new(self.observer.clone()), + clock, + ), + selected, + ) + } + + fn record(&self) -> NativeRequestRecord { + assert_eq!(self.completion_calls.load(Ordering::SeqCst), 1); + assert_eq!(self.observer.0.lock().expect("app reports").len(), 1); + assert_eq!( + self.diagnostics.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + let records = self.native.0.lock().expect("native records"); + assert_eq!(records.len(), 1); + records.first().expect("one native record").clone() + } + } + + impl NativeRequestObserver for RecordingNativeObserver { + fn observe(&self, record: &NativeRequestRecord) { + self.0.lock().expect("native records").push(record.clone()); + } + } + #[derive(Clone, Default)] struct RecordingObserver(Arc>>); @@ -923,6 +1034,130 @@ mod tests { assert!(connection.is_empty()); } + #[test] + fn native_duplicate_live_fallback_selects_detached_500_before_terminal_record() { + let probe = NativeFallbackProbe::default(); + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let (mut attempt, selected) = probe.attempt(now, clock.clone()); + assert!(attempt.begin_fallback(ResponseEgressOutcome::DeadlineExceeded)); + selected.select(StatusCode::GATEWAY_TIMEOUT); + assert_eq!(probe.diagnostics.snapshot().active_requests, 1); + let connection = EgressConnection::default(); + let response = prepare_fallback( + &Method::GET, + ResponseEgressOutcome::ConversionError, + attempt, + &clock, + &connection, + Some(&selected), + ); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let record = probe.record(); + assert_eq!(record.status, Some(StatusCode::INTERNAL_SERVER_ERROR)); + assert_eq!( + record.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::DeadlineExceeded) + ); + assert_eq!(record.body_kind, Some(ResponseEgressBodyKind::Fallback)); + assert_eq!( + record.fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + assert_eq!(record.bytes_written, Some(0)); + drop(response); + assert_eq!(probe.record().request_id, record.request_id); + assert!(connection.is_empty()); + } + + #[test] + fn native_already_terminal_fallback_cannot_rewrite_the_prior_200_record() { + let probe = NativeFallbackProbe::default(); + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let (mut attempt, selected) = probe.attempt(now, clock.clone()); + assert!(attempt.begin_writing()); + assert!(attempt.complete(now)); + let original = probe.record(); + assert_eq!(original.status, Some(StatusCode::OK)); + let connection = EgressConnection::default(); + let response = prepare_fallback( + &Method::GET, + ResponseEgressOutcome::DeadlineExceeded, + attempt, + &clock, + &connection, + Some(&selected), + ); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + drop(response); + let retained = probe.record(); + assert_eq!(retained.request_id, original.request_id); + assert_eq!(retained.status, original.status); + assert_eq!(retained.duration, original.duration); + assert_eq!( + retained.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::Completed) + ); + assert_eq!( + retained.body_kind, + Some(ResponseEgressBodyKind::Application) + ); + assert_eq!(retained.fallback_disposition, None); + assert_eq!(retained.bytes_written, Some(0)); + assert!(connection.is_empty()); + } + + #[test] + fn native_failed_fallback_registration_retains_500_or_504_before_aborted_record() { + for (cause, status) in [ + ( + ResponseEgressOutcome::ConversionError, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + ResponseEgressOutcome::DeadlineExceeded, + StatusCode::GATEWAY_TIMEOUT, + ), + ] { + let probe = NativeFallbackProbe::default(); + let now = MonotonicInstant::now(); + let expired = now + .checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + .expect("fallback deadline"); + // Preparation samples now, registration sees expiry, then settlement keeps that instant. + let clock = scripted_clock(vec![now, expired, expired]); + let (attempt, selected) = probe.attempt(now, clock.clone()); + assert_eq!(probe.diagnostics.snapshot().active_requests, 1); + let connection = EgressConnection::default(); + let response = prepare_fallback( + &Method::GET, + cause, + attempt, + &clock, + &connection, + Some(&selected), + ); + assert_eq!(response.status(), status); + let record = probe.record(); + assert_eq!(record.status, Some(status)); + assert_eq!(record.outcome, NativeRequestOutcome::Egress(cause)); + assert_eq!( + record.duration, + Some(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + ); + assert_eq!(record.body_kind, Some(ResponseEgressBodyKind::Fallback)); + assert_eq!( + record.fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + assert_eq!(record.bytes_written, Some(0)); + drop(response); + assert_eq!(probe.record().request_id, record.request_id); + assert!(connection.is_empty()); + } + } + #[tokio::test(flavor = "current_thread")] async fn response_egress_post_ready_deadline_precedence() { let observer = RecordingObserver::default(); @@ -998,6 +1233,7 @@ mod tests { attempt(&observer, started_at), &clock, &connection, + None, ); let status = if cause == ResponseEgressOutcome::DeadlineExceeded { StatusCode::GATEWAY_TIMEOUT @@ -1073,6 +1309,7 @@ mod tests { clock, EgressKind::Application, &connection, + None, ) else { panic!("registered response"); }; diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index 1b6c4a48..5958d56c 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -6,6 +6,9 @@ use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::time::{Duration, Instant}; +use crate::diagnostics::NativeDiagnosticsHandle; +use crate::geo::{NativeGeo, NativeGeoCapability, NativeGeoConfigError, NativeGeoSelection}; +use crate::proxy::TrustedProxyPolicy; use edgezero_core::addr; use edgezero_core::app::StoresMetadata; use edgezero_core::env_config::EnvConfig; @@ -31,10 +34,14 @@ pub struct AxumRunOptions { celld_limits: Option, config_dir: Option, data_dir: Option, + diagnostics: NativeDiagnosticsHandle, env: EnvConfig, + geo: NativeGeoSelection, grace: Duration, level: LevelFilter, non_unicode_overrides: Vec>, + request_records: bool, + trusted_proxy_policy: TrustedProxyPolicy, } #[derive(Clone)] @@ -106,6 +113,10 @@ impl AxumRunOptions { self.data_dir.as_deref() } + pub(crate) fn diagnostics(&self) -> NativeDiagnosticsHandle { + self.diagnostics.clone() + } + pub(crate) fn env_config(&self) -> &EnvConfig { &self.env } @@ -256,6 +267,7 @@ impl AxumRunOptions { .map_err(|_error| failure("settings", "LOGGING__LEVEL", "invalid level"))?; } options.grace = shutdown_grace(&env)?; + (options.trusted_proxy_policy, options.request_records) = native_settings(&env)?; if let Some(root) = env.get(&["adapter", "config_dir"]) { options = options.with_config_dir(root)?; } @@ -293,6 +305,10 @@ impl AxumRunOptions { return Err(failure("settings", "PORT", "invalid nonzero port")); } Ok(Self { + diagnostics: NativeDiagnosticsHandle::new(), + trusted_proxy_policy: TrustedProxyPolicy::no_trust(), + request_records: true, + geo: NativeGeoSelection::Off, bindings: NativeStoreBindingsInput::LocalDefaults, bindings_limits: NativeBindingsLimits::default(), aws_limits: None, @@ -308,6 +324,42 @@ 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) + } + + /// Shares native counters and the optional request observer. + #[must_use] + #[inline] + pub fn with_diagnostics(mut self, diagnostics: NativeDiagnosticsHandle) -> Self { + self.diagnostics = diagnostics; + self + } + + pub(crate) fn request_records(&self) -> bool { + self.request_records + } + pub(crate) fn trusted_proxy_policy(&self) -> TrustedProxyPolicy { + self.trusted_proxy_policy.clone() + } + pub(crate) fn shutdown_grace(&self) -> Duration { self.grace } @@ -546,6 +598,14 @@ impl AxumRunOptions { self } + /// Enables only default native request logs; observers and accounting remain active. + #[inline] + #[must_use] + pub fn with_request_records(mut self, enabled: bool) -> Self { + self.request_records = enabled; + self + } + /// Selects a positive, representable integer-second drain budget. /// /// # Errors @@ -556,6 +616,40 @@ impl AxumRunOptions { self.grace = grace; Ok(self) } + + /// Installs an explicit immutable incoming-proxy policy without reading the environment. + #[inline] + #[must_use] + pub fn with_trusted_proxy_policy(mut self, policy: TrustedProxyPolicy) -> Self { + self.trusted_proxy_policy = policy; + self + } +} + +pub(crate) fn native_settings(env: &EnvConfig) -> anyhow::Result<(TrustedProxyPolicy, bool)> { + let policy = TrustedProxyPolicy::from_values( + env.get(&["adapter", "trusted_proxy_cidrs"]), + env.get(&["adapter", "forwarding_header_family"]), + ) + .map_err(|_error| { + failure( + "settings", + "TRUSTED_PROXY_POLICY", + "invalid literal networks or forwarding family", + ) + })?; + let records = match env.get(&["logging", "request_records"]) { + None | Some("true") => true, + Some("false") => false, + Some(_) => { + return Err(failure( + "settings", + "LOGGING__REQUEST_RECORDS", + "invalid boolean", + )); + } + }; + Ok((policy, records)) } pub(crate) fn failure(stage: &str, subject: &str, category: &str) -> anyhow::Error { @@ -572,6 +666,9 @@ fn fixed_setting(path: &[String]) -> Option<&'static str> { ["adapter", "config_dir"] => Some("CONFIG_DIR"), ["adapter", "data_dir"] => Some("DATA_DIR"), ["adapter", "shutdown_grace_seconds"] => Some("SHUTDOWN_GRACE_SECONDS"), + ["adapter", "trusted_proxy_cidrs"] => Some("TRUSTED_PROXY_CIDRS"), + ["adapter", "forwarding_header_family"] => Some("FORWARDING_HEADER_FAMILY"), + ["logging", "request_records"] => Some("LOGGING__REQUEST_RECORDS"), ["logging", "level"] => Some("LOGGING__LEVEL"), ["store_bindings_file"] => Some("STORE_BINDINGS_FILE"), _ => None, @@ -646,6 +743,79 @@ mod tests { const CELLD: &str = "version=1\n[config.remote]\nprovider='celld-config'\nendpoint='https://unreachable.invalid'\ndefault_key='settings'\n[config.remote.access]\nmode='authenticated'\ntoken_env='CELL_TOKEN'\n[config.remote.documents.settings]\nstore='config-fixture'\nkey='settings'\n[kv.state]\nprovider='celld-kv'\nendpoint='https://unreachable.invalid'\nstore='state-fixture'\n[kv.state.access]\nmode='authenticated'\ntoken_env='CELL_TOKEN'\n"; const CELL_TOKEN: &str = "celld-bootstrap-value-01234567890123456789"; + #[test] + fn geo_selection_defaults_and_whole_replacement_preserve_native_option_bounds() { + use crate::geo::ProtectedCloudFrontAlb; + let options = AxumRunOptions::new("127.0.0.1:8787".parse().unwrap()).unwrap(); + 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") + .unwrap(), + }); + let replaced = selected.with_geo(NativeGeoSelection::Off); + assert!(matches!( + replaced.geo_capability(), + Ok(NativeGeoCapability::Off) + )); + } + + #[test] + fn native_settings_defaults_explicit_builders_and_strict_values() { + let defaults = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); + assert!(defaults.request_records()); + assert_eq!( + defaults.trusted_proxy_policy(), + TrustedProxyPolicy::no_trust() + ); + let configured = AxumRunOptions::from_vars([ + ("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "127.0.0.2/32"), + ("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "forwarded"), + ("EDGEZERO__LOGGING__REQUEST_RECORDS", "false"), + ]) + .expect("configured"); + assert!(!configured.request_records()); + assert_ne!( + configured.trusted_proxy_policy(), + TrustedProxyPolicy::no_trust() + ); + let explicit = AxumRunOptions::new(defaults.addr()) + .expect("explicit") + .with_request_records(false) + .with_trusted_proxy_policy(configured.trusted_proxy_policy()); + assert!(!explicit.request_records()); + assert_eq!( + explicit.trusted_proxy_policy(), + configured.trusted_proxy_policy() + ); + assert!( + explicit + .env_config() + .get(&["adapter", "trusted_proxy_cidrs"]) + .is_none() + ); + for (key, value) in [ + ("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "127.0.0.2"), + ("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "sentinel-policy"), + ( + "EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", + "sentinel-family", + ), + ("EDGEZERO__LOGGING__REQUEST_RECORDS", "TRUE"), + ("EDGEZERO__LOGGING__REQUEST_RECORDS", "false "), + ("EDGEZERO__LOGGING__REQUEST_RECORDS", ""), + ] { + let error = AxumRunOptions::from_vars([(key, value)]) + .err() + .expect("invalid supplied setting"); + assert_eq!(error.chain().count(), 1); + assert!(!error.to_string().contains("sentinel")); + } + } + fn explicit() -> AxumRunOptions { AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit options") } diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 45848c52..8861f8c6 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -1,5 +1,6 @@ use std::future::Future; use std::net::SocketAddr; +use std::panic::catch_unwind; use std::pin::Pin; use std::sync::Arc; #[cfg(test)] @@ -8,13 +9,15 @@ use std::task::{Context, Poll}; use axum::body::Body as AxumBody; use axum::extract::connect_info::ConnectInfo; use axum::http::{Request, Response}; -use edgezero_core::app::App; +use edgezero_core::app::{App, FrameworkErrorDetail}; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::Method; +use edgezero_core::geo::GeoOutcome; +use edgezero_core::http::{Method, RequestParts}; use edgezero_core::ingress::{ IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, - IngressHeadParts, validate_normalized_ingress_parts, + IngressHeadParts, NormalizedIngressHeadSummary, PreparedIngress, + validate_normalized_ingress_parts_with_summary, }; use edgezero_core::key_value_store::KvHandle; use edgezero_core::probe::{LifecyclePhase, LifecycleReader}; @@ -33,10 +36,19 @@ use tower::Service as TowerService; #[cfg(test)] use crate::connection::{ConnectionExit, serve_http1}; +use crate::context::AxumRequestContext; +#[cfg(test)] +use crate::diagnostics::NativeDiagnosticsHandle; +use crate::diagnostics::{ + ConnectionActivity, ConnectionGuard, IngressDisposition, NativeIngressMetadata, NativeSession, + RequestGuard, +}; +use crate::geo::NativeGeo; #[cfg(test)] use crate::ingress_config::AxumIngressConfig; #[cfg(test)] use crate::outbound::AxumOutboundClient; +use crate::proxy::{TrustedProxyPolicy, normalize, strip_forwarding_headers}; use crate::request::into_core_request_parts; use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; @@ -48,15 +60,19 @@ pub(crate) struct AxumIngressAbort; /// Shared application state used to construct one private Hyper service per HTTP/1 connection. #[derive(Clone)] pub(crate) struct AxumServiceState { + activity: Option, app: Arc, config_registry: Option, config_store_handle: Option, + geo: NativeGeo, kv_handle: Option, kv_registry: Option, + native: NativeSession, outbound_transport: Option>, phase: Option>, secret_handle: Option, secret_registry: Option, + trusted_proxy_policy: TrustedProxyPolicy, } #[expect( @@ -75,8 +91,29 @@ impl AxumServiceState { ) } + #[cfg(test)] pub(crate) fn with_transport(app: App, transport: Arc) -> Self { + Self::with_native_transport( + app, + transport, + NativeSession::new(NativeDiagnosticsHandle::new(), true).expect("test native identity"), + TrustedProxyPolicy::no_trust(), + NativeGeo::default(), + ) + } + + pub(crate) fn with_native_transport( + app: App, + transport: Arc, + native: NativeSession, + policy: TrustedProxyPolicy, + geo: NativeGeo, + ) -> Self { Self { + activity: None, + geo, + native, + trusted_proxy_policy: policy, app: Arc::new(app), config_registry: None, config_store_handle: None, @@ -89,6 +126,19 @@ impl AxumServiceState { } } + #[cfg(test)] + pub(crate) fn with_native( + mut self, + native: NativeSession, + policy: TrustedProxyPolicy, + geo: NativeGeo, + ) -> Self { + self.native = native; + self.trusted_proxy_policy = policy; + self.geo = geo; + self + } + #[must_use] #[inline] #[cfg(test)] @@ -180,6 +230,10 @@ impl AxumServiceState { self } + pub(crate) fn connection_activity(&self) -> (ConnectionActivity, ConnectionGuard) { + self.native.connection() + } + pub(crate) fn for_connection( &self, remote_addr: SocketAddr, @@ -222,36 +276,133 @@ impl AxumServiceState { .is_none_or(|phase| *phase.borrow() == LifecyclePhase::Ready) } + fn sanitize_received_head( + &self, + parts: &mut RequestParts, + ) -> Result { + // Original accounting precedes credential removal, including rejected ingress. + 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, + parts: RequestParts, + native_body: AxumBody, + connection: &EgressConnection, + guard: RequestGuard, + geo: GeoOutcome, + ) -> Response { + let app = &self.app; + let read_deadline = prepared.read_deadline(); + let monotonic_clock = prepared.monotonic_clock(); + let Some(outbound_transport) = self.outbound_transport.clone() else { + let egress = app.admitted_error_egress( + prepared, + EdgeError::internal(anyhow::anyhow!( + "failed to initialize outbound HTTP transport" + )), + ); + return prepare_native_egress(egress, connection, guard).await; + }; + let mut core_request = match into_core_request_parts( + parts, + native_body, + Some((read_deadline, monotonic_clock)), + Some(outbound_transport), + ) { + Ok(converted) => converted, + Err(error) => { + let egress = app.admitted_error_egress( + prepared, + EdgeError::internal(anyhow::anyhow!( + "failed to convert inbound request: {error}" + )), + ); + return prepare_native_egress(egress, connection, guard).await; + } + }; + if let Some(phase) = self.phase.clone() { + core_request + .extensions_mut() + .insert(LifecycleReader::new(move || Some(*phase.borrow()))); + } + let (config_registry, kv_registry, secret_registry) = self.resolved_store_registries(); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); + } + let egress = app + .dispatch_admitted_with_geo( + prepared, + core_request, + FrameworkErrorDetail::CategoryOnly, + geo, + ) + .await; + prepare_native_egress(egress, connection, guard).await + } + async fn dispatch_request( &self, mut request: Request, connection: &EgressConnection, remote_addr: Option, ) -> Result, AxumIngressAbort> { - // This private gate defines dispatched work. Public extensions and app - // admission policy cannot admit new work after the owner starts draining. + let request_start = self.app.monotonic_now(); + let mut guard = self.native.request(request.method(), request_start, self.app.monotonic_clock(), self.activity.clone()) + .map_err(|_error| { + let _logged = catch_unwind(|| log::error!(target: "edgezero::native", "event=lifecycle reason=request_identity_exhausted")); + AxumIngressAbort + })?; + // Identity and lifetime start before the gate, without admitting/body polling. if !self.admission_open() { + guard.finish_ingress(IngressDisposition::Draining); return Err(AxumIngressAbort); } + // Only the owning socket supplies native peer facts, including an absent peer. + request.extensions_mut().remove::(); if let Some(peer_addr) = remote_addr { request.extensions_mut().insert(ConnectInfo(peer_addr)); + } else { + request.extensions_mut().remove::>(); } - let request_start = self.app.monotonic_now(); let app = Arc::clone(&self.app); - let outbound_transport_handle = self.outbound_transport.clone(); - let (config_registry, kv_registry, secret_registry) = self.resolved_store_registries(); - let (parts, native_body) = request.into_parts(); + let (mut parts, native_body) = request.into_parts(); let request_method = parts.method.clone(); - if let Err(error) = validate_normalized_ingress_parts(&parts, app.ingress_head_limits()) { - return prepare_detached_ingress_error( - &app, - error, - request_method.clone(), - request_start, - connection, - ) - .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); + parts.extensions.insert(checked.effective_host().clone()); + parts.extensions.insert(NativeIngressMetadata::new( + guard.request_id(), + checked, + received, + )); let head_parts = IngressHeadParts::from_parts( &parts, IngressHeadAccounting::HostManaged, @@ -260,9 +411,13 @@ impl AxumServiceState { let prepared = match app.begin_ingress(head_parts, request_start) { Ok(IngressBeginOutcome::Admitted(prepared)) => prepared, Ok(IngressBeginOutcome::Refused(response)) => { - return Ok(prepare_egress_response(response, connection).await); + guard.refused(); + return Ok(prepare_native_egress(response, connection, guard).await); + } + Ok(IngressBeginOutcome::Aborted) => { + guard.finish_ingress(IngressDisposition::Aborted); + return Err(AxumIngressAbort); } - Ok(IngressBeginOutcome::Aborted) => return Err(AxumIngressAbort), Ok(_) => { return prepare_detached_ingress_error( &app, @@ -270,6 +425,7 @@ impl AxumServiceState { request_method, request_start, connection, + guard, ) .await; } @@ -280,56 +436,15 @@ impl AxumServiceState { request_method.clone(), request_start, connection, + guard, ) .await; } }; - let read_deadline = prepared.read_deadline(); - let monotonic_clock = prepared.monotonic_clock(); - let Some(outbound_transport) = outbound_transport_handle else { - let egress = app.admitted_error_egress( - prepared, - EdgeError::internal(anyhow::anyhow!( - "failed to initialize outbound HTTP transport" - )), - ); - return Ok(prepare_egress_response(egress, connection).await); - }; - let mut core_request = match into_core_request_parts( - parts, - native_body, - Some((read_deadline, monotonic_clock)), - Some(outbound_transport), - ) { - Ok(converted) => converted, - Err(error) => { - let egress = app.admitted_error_egress( - prepared, - EdgeError::internal(anyhow::anyhow!( - "failed to convert inbound request: {error}" - )), - ); - return Ok(prepare_egress_response(egress, connection).await); - } - }; - - if let Some(phase) = self.phase.clone() { - core_request - .extensions_mut() - .insert(LifecycleReader::new(move || Some(*phase.borrow()))); - } - if let Some(registry) = config_registry { - core_request.extensions_mut().insert(registry); - } - if let Some(registry) = kv_registry { - core_request.extensions_mut().insert(registry); - } - if let Some(registry) = secret_registry { - core_request.extensions_mut().insert(registry); - } - - let egress = app.dispatch_admitted(prepared, core_request).await; - Ok(prepare_egress_response(egress, connection).await) + let geo = self.geo.resolve(remote_addr, &parts.headers); + Ok(self + .dispatch_prepared(prepared, parts, native_body, connection, guard, geo) + .await) } } @@ -340,6 +455,13 @@ pub(crate) struct AxumConnectionService { state: AxumServiceState, } +impl AxumConnectionService { + pub(crate) fn with_activity(mut self, activity: ConnectionActivity) -> Self { + self.state.activity = Some(activity); + self + } +} + impl HyperService> for AxumConnectionService { type Error = AxumIngressAbort; type Future = Pin>>>; @@ -406,15 +528,36 @@ async fn prepare_detached_ingress_error( request_method: Method, request_start: MonotonicInstant, connection: &EgressConnection, + mut guard: RequestGuard, ) -> Result, AxumIngressAbort> { - match app.detached_ingress_error_egress(error, request_method, request_start) { - IngressDispatchOutcome::Response(envelope) => { - Ok(prepare_egress_response(*envelope, connection).await) - } - IngressDispatchOutcome::Aborted | _ => Err(AxumIngressAbort), + guard.set_ingress_error_kind(error.kind()); + if let IngressDispatchOutcome::Response(envelope) = + app.detached_ingress_error_egress(error, request_method, request_start) + { + Ok(prepare_native_egress(*envelope, connection, guard).await) + } else { + guard.finish_ingress(IngressDisposition::Aborted); + Err(AxumIngressAbort) } } +async fn prepare_native_egress( + envelope: edgezero_core::ResponseEgressEnvelope, + connection: &EgressConnection, + mut guard: RequestGuard, +) -> Response { + if let Some(failure) = envelope.failure_classification() { + guard.set_failure(Some(failure)); + } + let (completion, selected) = guard.completion(); + prepare_egress_response( + envelope.with_completion(completion), + connection, + Some(&selected), + ) + .await +} + #[cfg(test)] mod tests { #![expect( @@ -423,7 +566,11 @@ mod tests { )] use super::*; + use crate::diagnostics::{ + NativeRequestFailure, NativeRequestObserver, NativeRequestOutcome, NativeRequestRecord, + }; use bytes::{Bytes, BytesMut}; + use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, @@ -460,6 +607,287 @@ mod tests { use tokio::time::{sleep, timeout}; use tower::ServiceExt as _; + #[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 { @@ -811,6 +1239,17 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn normalized_ingress_error_decision_abort_closes_without_response() { + #[derive(Clone)] + struct NativeRecords(Arc>>); + impl NativeRequestObserver for NativeRecords { + #[inline] + fn observe(&self, record: &NativeRequestRecord) { + self.0.lock().expect("native records").push(record.clone()); + } + } + let records = Arc::new(Mutex::new(Vec::new())); + let diagnostics = NativeDiagnosticsHandle::new() + .with_request_observer(NativeRecords(Arc::clone(&records))); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind listener"); @@ -827,7 +1266,8 @@ mod tests { observed_factory_calls.fetch_add(1, Ordering::SeqCst); DetachedResponseEgressDecision::Abort }); - let state = AxumServiceState::from_app(app); + let mut state = AxumServiceState::from_app(app); + state.native = NativeSession::new(diagnostics.clone(), false).expect("test native session"); let server = async move { let (stream, remote_addr) = listener.accept().await.expect("accept client"); @@ -862,6 +1302,23 @@ mod tests { } assert!(response.is_empty()); assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + let captured = records.lock().expect("native records"); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].event, "request_ingress"); + assert_eq!( + captured[0].outcome, + NativeRequestOutcome::Ingress(IngressDisposition::Aborted) + ); + assert_eq!( + captured[0].failure, + Some(NativeRequestFailure::PropagatedError { + kind: "uri_too_long" + }) + ); + assert!(captured[0].status.is_none()); + assert!(captured[0].bytes_written.is_none()); + assert!(captured[0].body_kind.is_none()); + assert_eq!(diagnostics.snapshot().active_requests, 0); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1996,6 +2453,1463 @@ mod tests { assert_eq!(body_at(&service, "/lookup/missing").await, "present=false"); } + mod managed_native { + use std::future::pending; + use std::net::{IpAddr, SocketAddr}; + + use http_body::Body as _; + use serde_json::{Value, json}; + + use crate::context::AxumRequestContext; + use crate::diagnostics::{ + NativeDiagnosticsSnapshot, NativeRequestFailure, NativeRequestObserver, + NativeRequestOutcome, NativeRequestRecord, + }; + use crate::proxy::{ForwardingHeaderFamily, ForwardingResult, Scheme}; + use edgezero_core::extractor::ForwardedHost; + use edgezero_core::http::{Authority, Uri}; + use edgezero_core::ingress::{CheckedEffectiveHost, CheckedHostSource, IngressHead}; + + use super::*; + + #[derive(Clone, Default)] + struct NativeProbe { + handle: NativeDiagnosticsHandle, + records: Arc>>, + snapshots: Arc>>, + events: Arc>>, + panics: bool, + } + + impl NativeRequestObserver for NativeProbe { + fn observe(&self, record: &NativeRequestRecord) { + self.records + .lock() + .expect("native records") + .push(record.clone()); + self.snapshots + .lock() + .expect("native snapshots") + .push(self.handle.snapshot()); + self.events.lock().expect("hook events").push("native"); + assert!(!self.panics, "native observer fixture panic"); + } + } + + struct OrderedAppObserver { + events: Arc>>, + reports: Arc>>, + } + + impl ResponseEgressObserver for OrderedAppObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.events.lock().expect("hook events").push("observer"); + self.reports + .lock() + .expect("app reports") + .push(report.clone()); + } + } + + #[derive(Clone)] + struct PendingCalls(Arc); + + struct MetadataMiddleware(Arc>>); + + #[async_trait::async_trait(?Send)] + impl Middleware for MetadataMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().expect("middleware facts") = Some(context_facts(&ctx)); + next.run(ctx).await + } + } + + fn observed_service( + app: App, + policy: TrustedProxyPolicy, + probe: &NativeProbe, + ) -> AxumServiceState { + let handle = probe.handle.clone().with_request_observer(probe.clone()); + AxumServiceState::with_transport( + app, + AxumOutboundClient::try_transport().expect("fixture transport"), + ) + .with_native( + NativeSession::new(handle, false).expect("fixture identity"), + policy, + NativeGeo::default(), + ) + } + + fn tracked_connection(service: &mut AxumServiceState) -> ConnectionGuard { + let (activity, guard) = service.connection_activity(); + service.activity = Some(activity); + guard + } + + fn source_name(source: CheckedHostSource) -> &'static str { + match source { + CheckedHostSource::Direct => "direct", + CheckedHostSource::TrustedForwarded => "trusted_forwarded", + CheckedHostSource::TrustedXForwarded => "trusted_x_forwarded", + CheckedHostSource::Unavailable => "unavailable", + _ => "unknown", + } + } + + fn metadata_facts( + metadata: &NativeIngressMetadata, + headers: &HeaderMap, + uri: &Uri, + ) -> Value { + let summary = metadata.received_normalized_head(); + json!({ + "request_id": metadata.request_id().to_string(), + "peer": metadata.direct_peer().map(|peer| peer.to_string()), + "client": metadata.effective_client().map(|client| client.to_string()), + "host": metadata.effective_host().authority(), + "scheme": metadata.effective_scheme().as_str(), + "client_source": source_name(metadata.client_source()), + "host_source": source_name(metadata.host_source()), + "scheme_source": source_name(metadata.scheme_source()), + "forwarding_result": metadata.forwarding_result().as_str(), + "host_header": headers.get("host").and_then(|host| host.to_str().ok()), + "uri": uri.to_string(), + "raw_forwarding_absent": headers.keys().all(|name| name.as_str() != "forwarded" && !name.as_str().starts_with("x-forwarded-")), + "normalized": {"target_bytes": summary.target_bytes(), "header_bytes": summary.header_bytes(), "header_count": summary.header_count()}, + }) + } + + fn context_facts(ctx: &RequestContext) -> Value { + metadata_facts( + ctx.extensions() + .get::() + .expect("managed metadata"), + ctx.headers(), + ctx.uri(), + ) + } + + #[action] + async fn metadata_handler( + ctx: RequestContext, + ForwardedHost(host): ForwardedHost, + ) -> Result { + let peer = ctx + .extensions() + .get::() + .and_then(|native| native.remote_addr); + Ok(CoreResponse::new(Body::from( + json!({ + "facts": context_facts(&ctx), "forwarded_host": host, + "direct_context_peer": peer.map(|address| address.to_string()), + }) + .to_string(), + ))) + } + + #[action] + async fn mutate_metadata_handler(mut ctx: RequestContext) -> Result { + let original = ctx + .extensions_mut() + .remove::() + .expect("original metadata"); + ctx.extensions_mut() + .insert(CheckedEffectiveHost::from_ingress( + Some( + "application-mutated.example" + .parse::() + .expect("authority"), + ), + CheckedHostSource::Direct, + )); + Ok(original.request_id().to_string()) + } + + #[action] + async fn ordinary_status_handler(ctx: RequestContext) -> Result { + let status = if ctx.uri().path().ends_with("/413") { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + Ok(response_builder() + .status(status) + .body(Body::from("application status")) + .expect("status response")) + } + + #[action] + async fn success_handler() -> Result<&'static str, EdgeError> { + Ok("ok") + } + + #[action] + async fn internal_error_handler() -> Result<&'static str, EdgeError> { + Err(EdgeError::internal(anyhow::anyhow!( + "PRIVATE_SERVICE_ERROR_SENTINEL" + ))) + } + + #[action] + async fn upstream_error_handler() -> Result<&'static str, EdgeError> { + Err(EdgeError::bad_gateway("PRIVATE_UPSTREAM_SENTINEL")) + } + + #[action] + async fn terminal_stream_handler(ctx: RequestContext) -> Result { + let source_failure = ctx.uri().path().ends_with("/source"); + let mut sent_prefix = false; + let stream = poll_fn(move |_context| { + if !sent_prefix { + sent_prefix = true; + return Poll::Ready(Some(Ok::<_, io::Error>(Bytes::from_static( + b"terminal-prefix", + )))); + } + if source_failure { + Poll::Ready(Some(Err(io::Error::other("PRIVATE_SOURCE_SENTINEL")))) + } else { + Poll::Pending + } + }); + Ok(CoreResponse::new(Body::from_external_stream(stream))) + } + + #[action] + async fn bad_framing_handler() -> Result { + Ok(response_builder() + .header("content-length", "1") + .body(Body::from("mismatched")) + .expect("framing response")) + } + + #[action] + async fn handler_not_found() -> Result<&'static str, EdgeError> { + Err(EdgeError::not_found("APPLICATION_CHOSEN_SENTINEL")) + } + + #[action] + async fn read_body_handler(ctx: RequestContext) -> Result { + ctx.extensions() + .get::() + .expect("handler calls") + .0 + .fetch_add(1, Ordering::SeqCst); + Ok(ctx.body_bytes(1024).await?.len().to_string()) + } + + #[action] + async fn pending_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { + ctx.extensions() + .get::() + .expect("handler calls") + .0 + .fetch_add(1, Ordering::SeqCst); + pending().await + } + + fn assert_one_released(probe: &NativeProbe) -> NativeRequestRecord { + let records = probe.records.lock().expect("native records"); + assert_eq!(records.len(), 1); + assert_eq!( + probe.snapshots.lock().expect("native snapshots")[0].active_requests, + 0 + ); + assert_eq!(probe.handle.snapshot().active_requests, 0); + records[0].clone() + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "The seven-case trust matrix keeps forged inputs, one managed dispatch, and all consumer-witness assertions together so each boundary proof is visible" + )] + async fn owned_peer_checked_metadata_precedes_all_managed_consumers() { + let trusted: SocketAddr = "192.0.2.2:4000".parse().expect("trusted peer"); + let untrusted: SocketAddr = "192.0.2.250:5000".parse().expect("untrusted peer"); + for ( + family, + peer, + enabled, + forwarded, + expected_host, + expected_scheme, + expected_client, + expected_result, + expected_source, + ) in [ + ( + ForwardingHeaderFamily::Forwarded, + Some(trusted), + false, + "for=203.0.113.7;host=public.example;proto=https", + "direct.example", + Scheme::Http, + Some(trusted.ip()), + ForwardingResult::UntrustedPeer, + CheckedHostSource::Direct, + ), + ( + ForwardingHeaderFamily::Forwarded, + Some(trusted), + true, + "for=203.0.113.7;host=public.example;proto=https", + "public.example", + Scheme::Https, + Some("203.0.113.7".parse::().expect("client")), + ForwardingResult::Accepted, + CheckedHostSource::TrustedForwarded, + ), + ( + ForwardingHeaderFamily::Forwarded, + None, + true, + "for=203.0.113.7;host=public.example;proto=https", + "direct.example", + Scheme::Http, + None, + ForwardingResult::UntrustedPeer, + CheckedHostSource::Direct, + ), + ( + ForwardingHeaderFamily::Forwarded, + Some(untrusted), + true, + "for=203.0.113.7;host=public.example;proto=https", + "direct.example", + Scheme::Http, + Some(untrusted.ip()), + ForwardingResult::UntrustedPeer, + CheckedHostSource::Direct, + ), + ( + ForwardingHeaderFamily::XForwarded, + Some(trusted), + true, + "for=203.0.113.7;host=public.example;proto=https", + "x-public.example", + Scheme::Https, + Some("203.0.113.9".parse::().expect("client")), + ForwardingResult::Accepted, + CheckedHostSource::TrustedXForwarded, + ), + ( + ForwardingHeaderFamily::Forwarded, + Some(trusted), + true, + "for=bad-address;host=public.example;proto=https", + "direct.example", + Scheme::Http, + Some(trusted.ip()), + ForwardingResult::Malformed, + CheckedHostSource::Direct, + ), + ( + ForwardingHeaderFamily::Forwarded, + Some(trusted), + true, + "for=unknown;host=public.example;proto=https", + "public.example", + Scheme::Https, + Some(trusted.ip()), + ForwardingResult::UnresolvedChain, + CheckedHostSource::TrustedForwarded, + ), + ] { + let probe = NativeProbe::default(); + let admission = Arc::new(Mutex::new(None)); + let middleware = Arc::new(Mutex::new(None)); + let captured_admission = Arc::clone(&admission); + let admission_handle = probe.handle.clone(); + let router = RouterService::builder() + .get("/native/{id}", metadata_handler) + .middleware(MetadataMiddleware(Arc::clone(&middleware))) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.head_accounting(), + IngressHeadAccounting::HostManaged + )); + assert!(matches!(head.framing(), IngressFraming::HostManaged)); + assert_eq!(admission_handle.snapshot().active_requests, 1); + let metadata = head + .extension::() + .expect("admission metadata"); + assert_eq!( + head.extension::() + .expect("checked host"), + metadata.effective_host() + ); + *captured_admission.lock().expect("admission facts") = + Some(metadata_facts(metadata, head.headers(), head.target())); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + let policy = if enabled { + TrustedProxyPolicy::new(family, ["192.0.2.2"]).expect("policy") + } else { + TrustedProxyPolicy::no_trust() + }; + let mut service = observed_service(app, policy, &probe); + let owned_connection = tracked_connection(&mut service); + let incoming = Request::builder() + .uri("https://direct.example/native/SECRET-PATH?token=SECRET-QUERY") + .header("host", "direct.example") + .header("forwarded", forwarded) + .header("x-forwarded-for", "203.0.113.9") + .header("x-forwarded-host", "x-public.example") + .header("x-forwarded-proto", "https") + .header("X-FoRwArDeD-Private-Secret", "SECRET-HEADER") + .header("x-request-id", "SECRET-VISITOR-ID") + .body(AxumBody::empty()) + .expect("request"); + let (mut parts, body) = incoming.into_parts(); + let received = validate_normalized_ingress_parts_with_summary( + &parts, + IngressHeadLimits::default(), + ) + .expect("original summary"); + let foreign = NativeSession::new(NativeDiagnosticsHandle::new(), false) + .expect("foreign session"); + let foreign_guard = foreign + .request( + &Method::GET, + MonotonicInstant::now(), + MonotonicClock::default(), + None, + ) + .expect("foreign request"); + let foreign_id = foreign_guard.request_id(); + let claimed = normalize( + &TrustedProxyPolicy::new(ForwardingHeaderFamily::Forwarded, ["192.0.2.2"]) + .expect("forged policy"), + Some(trusted), + &parts, + ); + parts + .extensions + .insert(NativeIngressMetadata::new(foreign_id, claimed, received)); + parts.extensions.insert(CheckedEffectiveHost::from_ingress( + Some("FORGED-HOST.example".parse().expect("forged authority")), + CheckedHostSource::TrustedForwarded, + )); + parts + .extensions + .insert(ConnectInfo(if peer == Some(trusted) { + untrusted + } else { + trusted + })); + parts.extensions.insert(AxumRequestContext { + remote_addr: Some(if peer == Some(trusted) { + untrusted + } else { + trusted + }), + }); + drop(foreign_guard); + let response = service + .dispatch_request( + Request::from_parts(parts, body), + &EgressConnection::default(), + peer, + ) + .await + .expect("managed response"); + assert!(!response.headers().contains_key("x-request-id")); + assert!(!response.headers().contains_key("request-id")); + assert_eq!(probe.handle.snapshot().active_requests, 1); + let result: Value = serde_json::from_slice( + &to_bytes(response.into_body(), 8192) + .await + .expect("metadata body"), + ) + .expect("metadata JSON"); + let facts = &result["facts"]; + assert_eq!( + facts, + admission + .lock() + .expect("admission facts") + .as_ref() + .expect("observed admission") + ); + assert_eq!( + facts, + middleware + .lock() + .expect("middleware facts") + .as_ref() + .expect("observed middleware") + ); + assert_eq!( + facts["peer"], + json!(peer.map(|address| address.to_string())) + ); + assert_eq!( + facts["client"], + json!(expected_client.map(|address| address.to_string())) + ); + assert_eq!(facts["host"], expected_host); + assert_eq!(facts["scheme"], expected_scheme.as_str()); + assert_eq!(facts["host_source"], source_name(expected_source)); + assert_eq!(facts["scheme_source"], source_name(expected_source)); + let client_source = if peer.is_none() { + CheckedHostSource::Unavailable + } else if expected_result == ForwardingResult::Accepted { + expected_source + } else { + CheckedHostSource::Direct + }; + assert_eq!(facts["client_source"], source_name(client_source)); + assert_eq!(facts["forwarding_result"], expected_result.as_str()); + assert_eq!(facts["host_header"], "direct.example"); + assert_eq!( + facts["uri"], + "https://direct.example/native/SECRET-PATH?token=SECRET-QUERY" + ); + assert_eq!(facts["raw_forwarding_absent"], true); + assert_eq!( + facts["normalized"], + json!({"target_bytes":received.target_bytes(), "header_bytes":received.header_bytes(), "header_count":received.header_count()}) + ); + assert_eq!(result["forwarded_host"], expected_host); + assert_eq!( + result["direct_context_peer"], + json!(peer.map(|address| address.to_string())) + ); + assert_ne!(facts["request_id"], foreign_id.to_string()); + assert_ne!(facts["request_id"], "SECRET-VISITOR-ID"); + let record = assert_one_released(&probe); + assert_eq!(facts["request_id"], record.request_id.to_string()); + assert_eq!(record.route, "/native/{id}"); + assert_eq!(record.forwarding_result, expected_result); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!( + record.level(), + if expected_result == ForwardingResult::Malformed { + log::Level::Warn + } else { + log::Level::Info + } + ); + assert_eq!(probe.handle.snapshot().idle_connections, 1); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + } + + #[tokio::test] + async fn original_forwarding_head_overages_reject_before_stripping_or_body_poll() { + for limits in [ + IngressHeadLimits::default() + .with_max_request_header_count(1) + .expect("field limit"), + IngressHeadLimits::default() + .with_max_request_header_bytes(32) + .expect("byte limit"), + ] { + let probe = NativeProbe::default(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_guard = DropSignal(Arc::clone(&source_drops)); + let counted_polls = Arc::clone(&body_polls); + let body = AxumBody::from_stream(poll_fn(move |_context| { + let _retained = &source_guard; + counted_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let counted_admission = Arc::clone(&admission_calls); + let app_reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new( + RouterService::builder() + .post("/head", success_handler) + .build(), + ); + app.set_ingress_head_limits(limits); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + app.set_ingress_admission_policy(move |_head| { + counted_admission.fetch_add(1, Ordering::SeqCst); + AdmissionDecision::Abort + }); + let service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let incoming = Request::builder() + .method("POST") + .uri("/head") + .header("host", "d.test") + .header( + "forwarded", + "for=203.0.113.8;host=PUBLIC-SECRET.example;proto=https", + ) + .header("x-forwarded-private", "SECRET-HEADER") + .body(body) + .expect("head request"); + let (mut parts, native_body) = incoming.into_parts(); + let saved = parts.headers.clone(); + strip_forwarding_headers(&mut parts.headers); + validate_normalized_ingress_parts_with_summary(&parts, limits) + .expect("stripped head alone would fit"); + parts.headers = saved; + let response = service + .dispatch_request( + Request::from_parts(parts, native_body), + &EgressConnection::default(), + None, + ) + .await + .expect("detached head response"); + assert_eq!( + response.status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(admission_calls.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + let rendered = to_bytes(response.into_body(), 1024) + .await + .expect("head error body"); + assert!(!String::from_utf8_lossy(&rendered).contains("SECRET")); + let record = assert_one_released(&probe); + assert_eq!( + record.failure, + Some(NativeRequestFailure::PropagatedError { + kind: "request_header_fields_too_large" + }) + ); + assert_eq!( + record.status, + Some(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE) + ); + assert_eq!(record.forwarding_result, ForwardingResult::Absent); + assert!(app_reports.lock().expect("detached app reports").is_empty()); + } + } + + #[tokio::test] + async fn public_metadata_mutation_cannot_change_private_record_identity_or_add_response_id() + { + let probe = NativeProbe::default(); + let router = RouterService::builder() + .get("/mutate", mutate_metadata_handler) + .build(); + let service = + observed_service(App::new(router), TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .uri("/mutate") + .header("x-request-id", "VISITOR-SENTINEL") + .body(AxumBody::empty()) + .expect("request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("response"); + assert!(!response.headers().contains_key("x-request-id")); + assert!(!response.headers().contains_key("request-id")); + let identity = to_bytes(response.into_body(), 1024) + .await + .expect("identity body"); + let record = assert_one_released(&probe); + assert_eq!(identity.as_ref(), record.request_id.to_string().as_bytes()); + assert_eq!(identity.len(), 64); + assert_ne!(identity, "VISITOR-SENTINEL"); + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "The abort, drain and refusal matrix shares lifetime fixtures while checking their distinct no-response versus detached-completion contracts" + )] + async fn native_abort_drain_and_refusal_release_once_without_inventing_egress() { + for mode in ["abort", "drain", "refuse"] { + let probe = NativeProbe::default(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_guard = DropSignal(Arc::clone(&source_drops)); + let body = AxumBody::from_stream(poll_fn(move |_context| { + let _retained = &source_guard; + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let app_reports = Arc::new(Mutex::new(Vec::new())); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let counted_completion = Arc::clone(&completion_calls); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let counted_admission = Arc::clone(&admission_calls); + let admission_handle = probe.handle.clone(); + let mut app = App::new( + RouterService::builder() + .post("/gate", success_handler) + .build(), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + app.set_ingress_admission_policy(move |_head| { + counted_admission.fetch_add(1, Ordering::SeqCst); + assert_eq!(admission_handle.snapshot().active_requests, 1); + if mode == "refuse" { + let completed = Arc::clone(&counted_completion); + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::new(move |_report| { + completed.fetch_add(1, Ordering::SeqCst); + }), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from("admission refusal")) + .expect("refusal"), + } + } else { + AdmissionDecision::Abort + } + }); + let mut service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let (_phase_sender, phase) = watch::channel(if mode == "drain" { + LifecyclePhase::Draining + } else { + LifecyclePhase::Ready + }); + service.phase = Some(phase); + let owned_connection = tracked_connection(&mut service); + let request = Request::builder() + .method("POST") + .uri("/gate") + .body(body) + .expect("request"); + let outcome = service + .dispatch_request(request, &EgressConnection::default(), None) + .await; + if mode == "refuse" { + let response = outcome.expect("refusal response"); + assert_eq!(probe.handle.snapshot().active_requests, 1); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + to_bytes(response.into_body(), 1024) + .await + .expect("refusal body"), + "admission refusal" + ); + let record = assert_one_released(&probe); + assert_eq!(record.status, Some(StatusCode::SERVICE_UNAVAILABLE)); + assert_eq!(record.failure, Some(NativeRequestFailure::AdmissionRefused)); + assert_eq!( + record.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::HostHandoff) + ); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + // Refusal retains its completion, not the admitted-request observer. + assert!(app_reports.lock().expect("app reports").is_empty()); + } else { + let Err(_aborted) = outcome else { + panic!("no response expected on abort or drain"); + }; + let record = assert_one_released(&probe); + assert_eq!(record.event, "request_ingress"); + assert_eq!(record.status, None); + assert_eq!(record.bytes_written, None); + assert_eq!(record.body_kind, None); + assert_eq!( + record.outcome, + NativeRequestOutcome::Ingress(if mode == "drain" { + IngressDisposition::Draining + } else { + IngressDisposition::Aborted + }) + ); + assert_eq!(completion_calls.load(Ordering::SeqCst), 0); + assert!(app_reports.lock().expect("app reports").is_empty()); + } + assert_eq!( + admission_calls.load(Ordering::SeqCst), + usize::from(mode != "drain") + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(probe.handle.snapshot().idle_connections, 1); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "Three cancellation stages retain one fixture and its before-drop and after-drop assertions for native, body, grant and completion ownership" + )] + async fn cancelled_body_handler_and_fallback_futures_release_native_and_app_resources() { + for stage in ["body", "handler", "fallback"] { + let probe = NativeProbe::default(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_guard = DropSignal(Arc::clone(&source_drops)); + let body_handle = probe.handle.clone(); + let body = AxumBody::from_stream(poll_fn(move |_context| { + let _retained = &source_guard; + assert_eq!(body_handle.snapshot().active_requests, 1); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let handler_calls = Arc::new(AtomicUsize::new(0)); + let router = match stage { + "body" => RouterService::builder() + .post("/pending", read_body_handler) + .build(), + "handler" => RouterService::builder() + .post("/pending", pending_handler) + .build(), + _ => RouterService::builder().build(), + }; + let app_reports = Arc::new(Mutex::new(Vec::new())); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let retained_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let captured_completions = Arc::clone(&completion_calls); + let captured_retained = Arc::clone(&retained_drops); + let captured_grants = Arc::clone(&grant_drops); + let mut app = App::new(router); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + app.set_ingress_admission_policy(move |head| { + let calls = Arc::clone(&captured_completions); + let retained = DropSignal(Arc::clone(&captured_retained)); + let completion = ResponseEgressCompletion::new(move |_report| { + let _resource = retained; + calls.fetch_add(1, Ordering::SeqCst); + }); + let grant = IngressGrant::new(DropSignal(Arc::clone(&captured_grants))); + if stage == "fallback" { + AdmissionDecision::ReadBodyBeforeFallback { + completion, + grant, + max_body_bytes: 1024, + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + on_exceeded: BufferedIngressResponse::text(StatusCode::OK, "overflow"), + on_timeout: BufferedIngressResponse::text(StatusCode::OK, "timeout"), + } + } else { + AdmissionDecision::Admit { + completion, + grant, + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + } + }); + let mut service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let owned_connection = tracked_connection(&mut service); + let mut request = Request::builder() + .method("POST") + .uri("/pending") + .body(body) + .expect("request"); + request + .extensions_mut() + .insert(PendingCalls(Arc::clone(&handler_calls))); + let connection = EgressConnection::default(); + let mut response = Box::pin(service.dispatch_request(request, &connection, None)); + assert!(matches!( + futures_util::poll!(response.as_mut()), + Poll::Pending + )); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot { + active_requests: 1, + active_connections: 1, + idle_connections: 0 + } + ); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + assert_eq!(retained_drops.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!( + handler_calls.load(Ordering::SeqCst), + usize::from(stage != "fallback") + ); + assert_eq!(body_polls.load(Ordering::SeqCst) > 0, stage != "handler"); + drop(response); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(retained_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 0); + assert!(app_reports.lock().expect("app reports").is_empty()); + let record = assert_one_released(&probe); + assert_eq!( + record.outcome, + NativeRequestOutcome::Ingress(IngressDisposition::Abandoned) + ); + assert_eq!(record.status, None); + assert_eq!(record.bytes_written, None); + assert_eq!(probe.handle.snapshot().idle_connections, 1); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + } + + #[tokio::test] + async fn prepared_response_drop_is_one_failing_egress_record_with_original_status() { + let probe = NativeProbe::default(); + let app_reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new( + RouterService::builder() + .get("/drop", success_handler) + .build(), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + let mut service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let owned_connection = tracked_connection(&mut service); + let response = service + .dispatch_request( + Request::builder() + .uri("/drop") + .body(AxumBody::empty()) + .expect("request"), + &EgressConnection::default(), + None, + ) + .await + .expect("response"); + assert_eq!(probe.handle.snapshot().active_requests, 1); + drop(response); + let record = assert_one_released(&probe); + assert_eq!(record.event, "request_terminal"); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!( + record.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::TransportError) + ); + assert_eq!(record.level(), log::Level::Warn); + assert_eq!(app_reports.lock().expect("app reports").len(), 1); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[tokio::test] + async fn managed_native_observer_panic_preserves_release_and_application_observer() { + for abort in [false, true] { + let probe = NativeProbe { + panics: true, + ..NativeProbe::default() + }; + let app_reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new( + RouterService::builder() + .get("/observer", success_handler) + .build(), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + if abort { + app.set_ingress_admission_policy(|_head| AdmissionDecision::Abort); + } + let service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .uri("/observer") + .body(AxumBody::empty()) + .expect("request"); + let outcome = service + .dispatch_request(request, &EgressConnection::default(), None) + .await; + if abort { + let Err(_aborted) = outcome else { + panic!("abort produced a response"); + }; + assert_eq!(assert_one_released(&probe).status, None); + assert!(app_reports.lock().expect("app reports").is_empty()); + } else { + let response = outcome.expect("response despite observer panic"); + assert_eq!( + to_bytes(response.into_body(), 1024) + .await + .expect("body despite observer panic"), + "ok" + ); + assert_eq!(assert_one_released(&probe).status, Some(StatusCode::OK)); + assert_eq!(app_reports.lock().expect("app reports").len(), 1); + } + } + } + + #[tokio::test] + async fn configured_fallback_200_has_owner_failure_but_deliberate_413_and_503_do_not() { + for timed_out in [false, true] { + let probe = NativeProbe::default(); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(move |head| { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 1, + read_deadline: if timed_out { + Deadline::at_instant(head.request_start()) + } else { + head.read_deadline_after(Duration::from_secs(30)) + }, + on_exceeded: BufferedIngressResponse::text( + StatusCode::OK, + "configured overflow", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::OK, + "configured timeout", + ), + } + }); + let service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .method("POST") + .uri("/missing") + .body(AxumBody::from("ab")) + .expect("fallback request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("configured response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + to_bytes(response.into_body(), 1024) + .await + .expect("configured body"), + if timed_out { + "configured timeout" + } else { + "configured overflow" + } + ); + let record = assert_one_released(&probe); + assert_eq!( + record.failure, + Some(if timed_out { + NativeRequestFailure::FallbackReadTimedOut + } else { + NativeRequestFailure::FallbackBodyExceeded + }) + ); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!(record.level(), log::Level::Warn); + } + for (path, status) in [ + ("/ordinary/413", StatusCode::PAYLOAD_TOO_LARGE), + ("/ordinary/503", StatusCode::SERVICE_UNAVAILABLE), + ] { + let probe = NativeProbe::default(); + let service = observed_service( + App::new( + RouterService::builder() + .get("/ordinary/{status}", ordinary_status_handler) + .build(), + ), + TrustedProxyPolicy::no_trust(), + &probe, + ); + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .expect("ordinary request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("ordinary response"); + assert_eq!(response.status(), status); + to_bytes(response.into_body(), 1024) + .await + .expect("ordinary body"); + let record = assert_one_released(&probe); + assert_eq!(record.failure, None); + assert_eq!(record.status, Some(status)); + assert_eq!(record.level(), log::Level::Info); + } + } + + // #407 JSON-ceiling coverage is excluded from this narrower ingress reconciliation. + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "Framework routing, handler errors and custom rendering are paired negative controls that keep privacy and provenance assertions in one matrix" + )] + async fn native_routing_privacy_preserves_router_provenance_allow_and_application_control() + { + for (path, method, status, framework, kind, public_sentinel) in [ + ( + "/missing/FRAMEWORK_PATH_SENTINEL", + "GET", + StatusCode::NOT_FOUND, + true, + "not_found", + false, + ), + ( + "/known", + "PRIVATE-METHOD-SENTINEL", + StatusCode::METHOD_NOT_ALLOWED, + true, + "method_not_allowed", + false, + ), + ( + "/handler404", + "GET", + StatusCode::NOT_FOUND, + false, + "not_found", + true, + ), + ( + "/internal", + "GET", + StatusCode::INTERNAL_SERVER_ERROR, + false, + "internal", + false, + ), + ( + "/upstream", + "GET", + StatusCode::BAD_GATEWAY, + false, + "bad_gateway", + false, + ), + ] { + let probe = NativeProbe::default(); + let router = RouterService::builder() + .get("/known", success_handler) + .get("/handler404", handler_not_found) + .get("/internal", internal_error_handler) + .get("/upstream", upstream_error_handler) + .build(); + let service = + observed_service(App::new(router), TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .method(method) + .uri(path) + .body(AxumBody::empty()) + .expect("error request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("error response"); + assert_eq!(response.status(), status); + if status == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!(response.headers().get("allow").expect("Allow"), "GET"); + } + let body = to_bytes(response.into_body(), 2048) + .await + .expect("error body"); + assert_eq!( + String::from_utf8_lossy(&body).contains("SENTINEL"), + public_sentinel + ); + let record = assert_one_released(&probe); + assert_eq!( + record.failure, + Some(if framework { + NativeRequestFailure::FrameworkRouting { kind } + } else { + NativeRequestFailure::PropagatedError { kind } + }) + ); + assert_eq!( + record.level(), + if framework { + log::Level::Info + } else { + log::Level::Warn + } + ); + assert_eq!(record.method, if method == "GET" { "GET" } else { "other" }); + } + let probe = NativeProbe::default(); + let mut app = App::new(RouterService::builder().build()); + app.set_error_response_renderer(|error| { + let chosen_body = error.to_string(); + assert!(chosen_body.contains("CUSTOM_ROUTE_SENTINEL")); + CoreResponse::new(Body::from(chosen_body)) + }); + let service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .uri("/CUSTOM_ROUTE_SENTINEL") + .body(AxumBody::empty()) + .expect("custom request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("custom response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!( + String::from_utf8_lossy( + &to_bytes(response.into_body(), 1024) + .await + .expect("custom body") + ) + .contains("CUSTOM_ROUTE_SENTINEL") + ); + assert_eq!( + assert_one_released(&probe).failure, + Some(NativeRequestFailure::FrameworkRouting { kind: "not_found" }) + ); + } + + #[tokio::test] + async fn managed_source_and_deadline_failures_keep_selected_200_prefix_accounting_and_one_record() + { + for failure in [ + ResponseEgressOutcome::SourceError, + ResponseEgressOutcome::DeadlineExceeded, + ] { + let probe = NativeProbe::default(); + let app_reports = Arc::new(Mutex::new(Vec::new())); + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let captured_clock = Arc::clone(&now); + let mut app = App::new( + RouterService::builder() + .get("/stream/{failure}", terminal_stream_handler) + .build(), + ); + app.set_monotonic_clock(MonotonicClock::new(move || { + *captured_clock.lock().expect("clock") + })); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&app_reports))); + app.set_response_egress_policy(|_head, egress_started_at| ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + egress_started_at + .checked_add(Duration::from_secs(1)) + .expect("write deadline"), + ), + }); + let mut service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let owned_connection = tracked_connection(&mut service); + let path = if failure == ResponseEgressOutcome::SourceError { + "/stream/source" + } else { + "/stream/deadline" + }; + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .expect("stream request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("stream response"); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body(); + let prefix = poll_future(|context| Pin::new(&mut body).poll_frame(context)) + .await + .expect("prefix frame") + .expect("prefix data") + .into_data() + .expect("data frame"); + assert_eq!(prefix, "terminal-prefix"); + assert_eq!(probe.handle.snapshot().active_requests, 1); + assert!(probe.records.lock().expect("native records").is_empty()); + if failure == ResponseEgressOutcome::DeadlineExceeded { + *now.lock().expect("clock") = start + .checked_add(Duration::from_secs(1)) + .expect("expired instant"); + } + let terminal_error = poll_future(|context| Pin::new(&mut body).poll_frame(context)) + .await + .expect("terminal frame") + .expect_err("terminal failure"); + assert!(!terminal_error.to_string().contains("SENTINEL")); + // Hyper stops after a body error; an ended body need not be repolled. + assert!(body.is_end_stream()); + drop(body); + let record = assert_one_released(&probe); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!(record.outcome, NativeRequestOutcome::Egress(failure)); + assert_eq!(record.bytes_written, Some(15)); + assert_eq!(record.fallback_disposition, None); + assert_eq!(record.route, "/stream/{failure}"); + assert_eq!(record.level(), log::Level::Warn); + assert_eq!(app_reports.lock().expect("app reports").len(), 1); + assert_eq!(app_reports.lock().expect("app reports")[0].outcome, failure); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + } + + #[tokio::test] + async fn managed_precommit_framing_fallback_records_selected_500_and_original_cause() { + let probe = NativeProbe::default(); + let service = observed_service( + App::new( + RouterService::builder() + .get("/framing", bad_framing_handler) + .build(), + ), + TrustedProxyPolicy::no_trust(), + &probe, + ); + let request = Request::builder() + .uri("/framing") + .body(AxumBody::empty()) + .expect("request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("fallback response"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = to_bytes(response.into_body(), 1024) + .await + .expect("fallback body"); + assert!(!String::from_utf8_lossy(&body).contains("mismatched")); + let record = assert_one_released(&probe); + assert_eq!(record.status, Some(StatusCode::INTERNAL_SERVER_ERROR)); + assert_eq!( + record.outcome, + NativeRequestOutcome::Egress(ResponseEgressOutcome::ConversionError) + ); + assert_eq!( + record.body_kind, + Some(edgezero_core::ResponseEgressBodyKind::Fallback) + ); + assert_eq!( + record.fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[tokio::test] + async fn managed_hooks_keep_app_native_observer_order_and_authoritative_total_duration() { + let probe = NativeProbe::default(); + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let source = Arc::clone(&now); + let admission_now = Arc::clone(&now); + let completion_now = Arc::clone(&now); + let completion_events = Arc::clone(&probe.events); + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new( + RouterService::builder() + .get("/timed", success_handler) + .build(), + ); + app.set_monotonic_clock(MonotonicClock::new(move || *source.lock().expect("clock"))); + app.set_ingress_admission_policy(move |head: &IngressHead| { + assert_eq!(head.request_start(), start); + *admission_now.lock().expect("clock") = start + .checked_add(Duration::from_secs(3)) + .expect("pre-egress instant"); + let events = Arc::clone(&completion_events); + let observed_clock = Arc::clone(&completion_now); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new(move |_report| { + events.lock().expect("hook events").push("app"); + *observed_clock.lock().expect("clock") = start + .checked_add(Duration::from_secs(80)) + .expect("callback instant"); + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + app.set_response_egress_observer(OrderedAppObserver { + events: Arc::clone(&probe.events), + reports: Arc::clone(&reports), + }); + let mut service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let owned_connection = tracked_connection(&mut service); + let response = service + .dispatch_request( + Request::builder() + .uri("/timed") + .body(AxumBody::empty()) + .expect("request"), + &EgressConnection::default(), + None, + ) + .await + .expect("response"); + assert_eq!(probe.handle.snapshot().active_requests, 1); + *now.lock().expect("clock") = start + .checked_add(Duration::from_secs(8)) + .expect("terminal instant"); + to_bytes(response.into_body(), 1024) + .await + .expect("response body"); + let record = assert_one_released(&probe); + assert_eq!(record.duration, Some(Duration::from_secs(8))); + assert_eq!( + reports.lock().expect("app reports")[0].elapsed, + Duration::from_secs(5) + ); + assert_eq!( + probe.events.lock().expect("hook events").as_slice(), + ["app", "native", "observer"] + ); + assert_eq!(probe.handle.snapshot().idle_connections, 1); + drop(owned_connection); + assert_eq!( + probe.handle.snapshot(), + NativeDiagnosticsSnapshot::default() + ); + } + + #[tokio::test] + async fn managed_backwards_request_clock_is_safe_native_fault_without_fake_duration() { + let probe = NativeProbe::default(); + let now = MonotonicInstant::now(); + let request_start = now + .checked_add(Duration::from_secs(1)) + .expect("request start"); + let samples = Arc::new(AtomicUsize::new(0)); + let captured_samples = Arc::clone(&samples); + let mut app = App::new( + RouterService::builder() + .get("/backwards", success_handler) + .build(), + ); + app.set_monotonic_clock(MonotonicClock::new(move || { + if captured_samples.fetch_add(1, Ordering::SeqCst) == 0 { + request_start + } else { + now + } + })); + let service = observed_service(app, TrustedProxyPolicy::no_trust(), &probe); + let request = Request::builder() + .uri("/backwards") + .body(AxumBody::empty()) + .expect("request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("response"); + to_bytes(response.into_body(), 1024) + .await + .expect("response body"); + let record = assert_one_released(&probe); + assert_eq!(record.status, Some(StatusCode::OK)); + assert_eq!(record.duration, None); + assert_eq!(record.level(), log::Level::Error); + } + } + /// Send a GET request through `service` and return the response body as a UTF-8 string. /// Lifted out of the registry-aware tests so each can stay flat (clippy /// `items_after_statements` rejects nested `async fn` definitions). diff --git a/crates/edgezero-adapter-axum/tests/fixtures/native_proxy.rs b/crates/edgezero-adapter-axum/tests/fixtures/native_proxy.rs new file mode 100644 index 00000000..8d042cac --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/native_proxy.rs @@ -0,0 +1,311 @@ +//! Test-only source-bound proxy and verified TLS ingress. No response is collected here. +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "fixture ownership precedes bounded relay helpers" +)] + +use std::io::{self, ErrorKind}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener as StdTcpListener}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use rustls::{ClientConfig, RootCertStore, ServerConfig}; +use tokio::io::{ + AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _, copy_bidirectional_with_sizes, +}; +use tokio::net::{TcpListener, TcpSocket, TcpStream}; +use tokio::runtime::Builder; +use tokio::sync::oneshot; +use tokio::task::JoinSet; +use tokio::time::timeout; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +const MAX_HEAD: usize = 0x8000; +const RELAY_BUFFER: usize = 0x4000; + +#[derive(Clone, Copy)] +pub(crate) enum Assertions { + /// Keep deliberate malformed/gap inputs unchanged while preserving a real owned peer. + Preserve, + AppendForwarded { + host: &'static str, + scheme: &'static str, + }, + AppendXForwarded { + host: &'static str, + scheme: &'static str, + }, + ReplaceForwarded { + client: IpAddr, + host: &'static str, + scheme: &'static str, + }, +} + +pub(crate) struct LocalProxy { + addr: SocketAddr, + stop: Option>, + worker: Option>, +} + +impl LocalProxy { + #[must_use] + #[inline] + pub(crate) fn addr(&self) -> SocketAddr { + self.addr + } + + #[must_use] + #[inline] + pub(crate) fn start( + upstream: SocketAddr, + source: Ipv4Addr, + assertions: Assertions, + tls: bool, + ) -> Self { + let std_listener = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener"); + let addr = std_listener.local_addr().expect("proxy address"); + std_listener + .set_nonblocking(true) + .expect("proxy nonblocking"); + let (stop, mut stopped) = oneshot::channel(); + let worker = thread::spawn(move || { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("proxy runtime"); + runtime.block_on(async move { + let listener = TcpListener::from_std(std_listener).expect("proxy adoption"); + let tls_acceptor = tls.then(|| TlsAcceptor::from(Arc::new(server_config()))); + let mut tasks = JoinSet::new(); + #[expect(clippy::integer_division_remainder_used, reason = "Tokio select macro branch arithmetic")] + loop { + tokio::select! { + () = async { let _closed = (&mut stopped).await; } => break, + _finished = tasks.join_next(), if !tasks.is_empty() => {}, + accepted = listener.accept(), if tasks.len() < 64 => { + let (socket, peer) = accepted.expect("proxy accept"); + let task_acceptor = tls_acceptor.clone(); + tasks.spawn(async move { + let operation = async { + if let Some(acceptor) = task_acceptor { + if let Ok(mut client) = acceptor.accept(socket).await { + let _relayed = relay(&mut client, peer, upstream, source, assertions).await; + } + } else { + let mut client = socket; + let _relayed = relay(&mut client, peer, upstream, source, assertions).await; + } + }; + let _bounded = timeout(Duration::from_secs(5), operation).await; + }); + } + } + } + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + }); + }); + Self { + addr, + stop: Some(stop), + worker: Some(worker), + } + } +} + +impl Drop for LocalProxy { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _sent = stop.send(()); + } + if let Some(worker) = self.worker.take() { + worker.join().expect("proxy worker"); + } + } +} + +fn invalid() -> io::Error { + io::Error::new(ErrorKind::InvalidData, "invalid bounded fixture head") +} + +async fn read_head( + client: &mut Client, +) -> io::Result<(String, Vec)> { + let mut received = Vec::with_capacity(1024); + let mut chunk = [0_u8; 512]; + loop { + if let Some(end) = received.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + let body_at = end.checked_add(4).ok_or_else(invalid)?; + let head = String::from_utf8(received.get(..end).ok_or_else(invalid)?.to_vec()) + .map_err(|_error| invalid())?; + let body = received.get(body_at..).ok_or_else(invalid)?.to_vec(); + return Ok((head, body)); + } + let count = client.read(&mut chunk).await?; + if count == 0 { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "fixture head incomplete", + )); + } + received + .len() + .checked_add(count) + .filter(|bytes| *bytes <= MAX_HEAD) + .ok_or_else(invalid)?; + received.extend_from_slice(chunk.get(..count).ok_or_else(invalid)?); + } +} + +fn rewrite(head: &str, peer: SocketAddr, assertions: Assertions) -> io::Result { + let mut lines = head.split("\r\n"); + let request = lines + .next() + .filter(|line| !line.is_empty()) + .ok_or_else(invalid)?; + let mut headers = Vec::new(); + for line in lines { + let (name, value) = line.split_once(':').ok_or_else(invalid)?; + headers.push((name.to_owned(), value.trim().to_owned())); + } + let predecessor = match peer.ip() { + IpAddr::V4(ip) => ip.to_string(), + IpAddr::V6(ip) => format!("\"[{ip}]\""), + }; + match assertions { + Assertions::Preserve => {} + Assertions::AppendForwarded { host, scheme } => { + let previous = headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("forwarded")) + .map(|(_, value)| value.as_str()) + .collect::>() + .join(","); + headers.retain(|(name, _)| !name.eq_ignore_ascii_case("forwarded")); + let assertion = format!("for={predecessor};host=\"{host}\";proto={scheme}"); + headers.push(( + "Forwarded".into(), + if previous.is_empty() { + assertion + } else { + format!("{previous},{assertion}") + }, + )); + } + Assertions::AppendXForwarded { host, scheme } => { + let previous = headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("x-forwarded-for")) + .map(|(_, value)| value.as_str()) + .collect::>() + .join(","); + headers.retain(|(name, _)| !name.to_ascii_lowercase().starts_with("x-forwarded-")); + let node = peer.ip().to_string(); + headers.push(( + "X-Forwarded-For".into(), + if previous.is_empty() { + node + } else { + format!("{previous},{node}") + }, + )); + headers.push(("X-Forwarded-Host".into(), host.into())); + headers.push(("X-Forwarded-Proto".into(), scheme.into())); + } + Assertions::ReplaceForwarded { + client, + host, + scheme, + } => { + headers.retain(|(name, _)| { + !name.eq_ignore_ascii_case("forwarded") + && !name.to_ascii_lowercase().starts_with("x-forwarded-") + }); + // The test gateway's client assertion is explicitly supplied by its trusted producer, + // never taken from the visitor's fields. This is not a provider-discovery mechanism. + let node = match client { + IpAddr::V4(ip) => ip.to_string(), + IpAddr::V6(ip) => format!("\"[{ip}]\""), + }; + headers.push(( + "Forwarded".into(), + format!("for={node};host=\"{host}\";proto={scheme}"), + )); + } + } + let mut rewritten = format!("{request}\r\n"); + for (name, value) in headers { + use std::fmt::Write as _; + write!(rewritten, "{name}: {value}\r\n").map_err(|_error| invalid())?; + } + rewritten.push_str("\r\n"); + if rewritten.len() > MAX_HEAD { + return Err(invalid()); + } + Ok(rewritten) +} + +async fn relay( + client: &mut Client, + peer: SocketAddr, + upstream: SocketAddr, + source: Ipv4Addr, + assertions: Assertions, +) -> io::Result<()> { + let (head, body_prefix) = read_head(client).await?; + let rewritten = rewrite(&head, peer, assertions)?; + let socket = TcpSocket::new_v4()?; + socket.bind(SocketAddr::new(IpAddr::V4(source), 0))?; + let mut origin = socket.connect(upstream).await?; + origin.write_all(rewritten.as_bytes()).await?; + origin.write_all(&body_prefix).await?; + // Finite, per-direction relay buffers. Never collect a response or body for proxying. + let _copied = + copy_bidirectional_with_sizes(client, &mut origin, RELAY_BUFFER, RELAY_BUFFER).await?; + Ok(()) +} + +fn server_config() -> ServerConfig { + let certificate = CertificateDer::from(include_bytes!("tls/server.der").to_vec()); + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + include_bytes!("tls/server-key.der").to_vec(), + )); + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("test certificate/key") +} + +#[inline] +pub(crate) fn verified_tls_request( + addr: SocketAddr, + request: &[u8], + server_name: &'static str, +) -> io::Result { + let runtime = Builder::new_current_thread().enable_all().build()?; + runtime.block_on(async move { + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(include_bytes!("tls/ca.der").to_vec())) + .map_err(|_error| invalid())?; + let config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let connector = TlsConnector::from(Arc::new(config)); + let operation = async { + let socket = TcpStream::connect(addr).await?; + let name = ServerName::try_from(server_name).map_err(|_error| invalid())?; + let mut client = connector.connect(name, socket).await?; + client.write_all(request).await?; + let mut response = String::new(); + client.read_to_string(&mut response).await?; + Ok(response) + }; + timeout(Duration::from_secs(5), operation) + .await + .map_err(|_error| io::Error::new(ErrorKind::TimedOut, "TLS fixture timeout"))? + }) +} diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs index 059cf88b..c4eb77ee 100644 --- a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -8,26 +8,171 @@ use std::cell::Cell; use std::env; use std::future; use std::net::SocketAddr; +use std::panic::set_hook; use std::rc::Rc; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use async_trait::async_trait; use bytes::Bytes; +use edgezero_adapter_axum::context::AxumRequestContext; use edgezero_adapter_axum::dev_server::{ AxumRunOptions, run_app, run_app_with_options_and_initializer, run_production_app, run_production_app_with_initializer, }; +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, State}; +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, +}; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::probe::{self, LifecyclePhase, LifecycleReader}; +use edgezero_core::response::response_with_body; +use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressDeadline, + ResponseEgressFallbackDisposition, ResponseEgressObserver, ResponseEgressOutcome, + ResponseEgressReport, +}; use edgezero_core::router::RouterService; -use edgezero_core::{Body, EdgeError, MonotonicClock, MonotonicInstant, action}; +use edgezero_core::{BadGatewayReason, Body, EdgeError, MonotonicClock, MonotonicInstant, action}; use futures::stream; +use log::{LevelFilter, Log, Metadata, Record}; +use serde_json::{Value, json}; +use tokio::signal; +#[cfg(unix)] +use tokio::signal::unix::{SignalKind, signal as unix_signal}; use tokio::sync::oneshot; use tokio::time::sleep; +static ADMISSION_FACTS: Mutex> = Mutex::new(None); +static MIDDLEWARE_FACTS: Mutex> = Mutex::new(None); +static PANIC_COMPLETION: AtomicBool = AtomicBool::new(false); +static LOGGER_FAULT_REPORTED: AtomicBool = AtomicBool::new(false); +static NATIVE_LOGGER: NativeFixtureLogger = NativeFixtureLogger; +static GEO_RESOLUTIONS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Clone, Default)] +struct NativeFailureInputs { + configuration: String, + secret: String, +} + +struct NativeFixtureLogger; + +impl Log for NativeFixtureLogger { + #[inline] + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.target().starts_with("edgezero") + } + + #[inline] + fn flush(&self) {} + + #[inline] + #[expect( + clippy::panic, + reason = "regression intentionally panics during the core completion-fault log attempt to prove the joined native completion and app observer still run" + )] + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let message = record.args().to_string(); + println!( + "FRAMEWORK_LOG {}", + json!({"level": record.level().as_str(), "target": record.target(), "message": message}) + ); + if PANIC_COMPLETION.load(Ordering::Relaxed) + && message == "response-egress completion panicked after terminal transition" + && !LOGGER_FAULT_REPORTED.swap(true, Ordering::Relaxed) + { + println!("LOGGER_FAULT"); + panic!("fixture completion logger fault"); + } + } +} + +struct NativeFixtureObserver(NativeDiagnosticsHandle); + +impl NativeRequestObserver for NativeFixtureObserver { + #[inline] + fn observe(&self, record: &NativeRequestRecord) { + println!( + "NATIVE_OBSERVATION {}", + json!({ + "id": record.request_id.to_string(), + "event": record.event, + "method": record.method, + "route": record.route, + "status": record.status.map(|status| status.as_u16()), + "duration_us": record.duration.map(|duration| duration.as_micros()), + "outcome": native_outcome(record.outcome), + "failure": native_failure(record.failure), + "forwarding_result": record.forwarding_result.as_str(), + "bytes_written": record.bytes_written, + "body_kind": record.body_kind.map(|kind| match kind { + ResponseEgressBodyKind::Application => "application", + ResponseEgressBodyKind::Fallback => "fallback", + _ => "unknown", + }), + "fallback": record.fallback_disposition.map(|disposition| match disposition { + ResponseEgressFallbackDisposition::Aborted => "aborted", + ResponseEgressFallbackDisposition::Completed => "completed", + _ => "unknown", + }), + "level": record.level().as_str(), + "counts": snapshot_json(self.0.snapshot()), + }) + ); + } +} + +struct FixtureEgressObserver; + +impl ResponseEgressObserver for FixtureEgressObserver { + #[inline] + fn complete(&self, report: &ResponseEgressReport) { + println!("APP_OBSERVER {}", egress_outcome(report.outcome)); + } +} + +struct NativeFactsMiddleware; + +#[derive(Clone)] +struct GeoObserved(GeoOutcome); + +struct GeoMutationMiddleware(bool); + +struct FixtureGeoProvider(bool); + +#[async_trait(?Send)] +impl Middleware for NativeFactsMiddleware { + #[inline] + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + if ctx.uri().path() == "/native-metadata" { + let metadata = ingress_metadata(&ctx)?; + *MIDDLEWARE_FACTS + .lock() + .unwrap_or_else(PoisonError::into_inner) = + Some(metadata_facts(metadata, ctx.headers(), ctx.uri())); + } + next.run(ctx).await + } +} + struct FixtureApp; #[expect( @@ -35,7 +180,29 @@ struct FixtureApp; reason = "fixture retains unused Hooks defaults" )] impl Hooks for FixtureApp { + #[expect( + clippy::panic_in_result_fn, + reason = "test-only admission assertion proves credential removal before application callbacks" + )] fn configure(app: &mut App) -> Result<(), EdgeError> { + println!("HOOK_ENTERED"); + let selected = mode(); + if selected.starts_with("diagnostics") { + configure_native_fixture(app, &selected); + } + if selected.starts_with("geo-") { + app.set_ingress_admission_policy(|head| { + assert!( + !head.headers().contains_key("x-origin-verify-fixture"), + "origin credential must be 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"))); } @@ -52,6 +219,7 @@ impl Hooks for FixtureApp { fn routes() -> RouterService { RouterService::builder() + .post("/json", buffered_json) .get("/ready", probe::readiness) .get("/live", probe::liveness) .get("/state", fixture::state) @@ -67,19 +235,43 @@ 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) + .get("/native-status503", native_status503) + .get("/native-handler404", native_handler404) + .get("/native-mutated", native_mutated) + .get("/native-source", native_source) + .get("/native-deadline", native_deadline) + .get("/native-expired", native_expired) + .get("/native-conversion", native_conversion) + .post("/native-json-number", native_json_number) + .get("/native-counts", native_counts) + .get("/native-refuse", native_status503) + .get("/native-abort", native_status503) + .middleware(NativeFactsMiddleware) + .middleware(GeoMutationMiddleware(false)) + .middleware(GeoMutationMiddleware(true)) .build() } fn stores() -> StoresMetadata { - if matches!( - mode().as_str(), - "bare" - | "bare-dev" - | "hook-error" - | "initializer-error" - | "starting" - | "embedding-bare" - ) { + if mode().starts_with("geo-") + || matches!( + mode().as_str(), + "bare" + | "bare-dev" + | "hook-error" + | "initializer-error" + | "starting" + | "embedding-bare" + | "diagnostics" + | "diagnostics-panic" + | "diagnostics-optout" + | "diagnostics-custom" + ) + { return StoresMetadata::default(); } StoresMetadata { @@ -109,6 +301,567 @@ impl Drop for WorkGuard { struct StopState(Mutex>>); +#[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")), + )) + } + } +} + +fn configure_native_fixture(app: &mut App, selected: &str) { + let panic_completion = selected == "diagnostics-panic"; + app.set_response_egress_observer(FixtureEgressObserver); + app.set_ingress_admission_policy(move |head| { + let metadata = head.extension::(); + if head.target().path() == "/native-metadata" + && let Some(checked) = metadata + { + *ADMISSION_FACTS + .lock() + .unwrap_or_else(PoisonError::into_inner) = + Some(metadata_facts(checked, head.headers(), head.target())); + } + if head.target().path() == "/native-abort" { + return AdmissionDecision::Abort; + } + let id = metadata.map_or_else( + || "unavailable".to_owned(), + |checked| checked.request_id().to_string(), + ); + let completion = ResponseEgressCompletion::new(move |_report| { + println!("APP_COMPLETION {id}"); + assert!(!panic_completion, "fixture application completion fault"); + }); + if head.target().path() == "/native-refuse" { + let mut response = Response::new(Body::text("fixture admission refused")); + *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + return AdmissionDecision::Refuse { + completion, + response, + }; + } + AdmissionDecision::Admit { + completion, + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + if selected == "diagnostics-custom" { + app.set_error_response_renderer(|error| { + let mut response = Response::new(Body::text(error.message())); + *response.status_mut() = error.status(); + response + }); + } +} + +fn metadata_facts(metadata: &NativeIngressMetadata, headers: &HeaderMap, uri: &Uri) -> Value { + json!({ + "request_id": metadata.request_id().to_string(), + "peer": metadata.direct_peer().map(|peer| peer.to_string()), + "client": metadata.effective_client().map(|client| client.to_string()), + "host": metadata.effective_host().authority(), + "scheme": metadata.effective_scheme().as_str(), + "client_source": host_source(metadata.client_source()), + "host_source": host_source(metadata.host_source()), + "scheme_source": host_source(metadata.scheme_source()), + "forwarding_result": metadata.forwarding_result().as_str(), + "host_header": headers.get("host").and_then(|value| value.to_str().ok()), + "uri": uri.to_string(), + "raw_forwarding_absent": !headers.keys().any(|name| name.as_str() == "forwarded" || name.as_str().starts_with("x-forwarded-")), + }) +} + +fn ingress_metadata(ctx: &RequestContext) -> Result<&NativeIngressMetadata, EdgeError> { + ctx.extensions() + .get::() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("fixture ingress metadata missing"))) +} + +fn host_source(source: CheckedHostSource) -> &'static str { + match source { + CheckedHostSource::Direct => "direct", + CheckedHostSource::TrustedForwarded => "trusted_forwarded", + CheckedHostSource::TrustedXForwarded => "trusted_x_forwarded", + CheckedHostSource::Unavailable => "unavailable", + _ => "unknown", + } +} + +fn egress_outcome(outcome: ResponseEgressOutcome) -> &'static str { + match outcome { + ResponseEgressOutcome::ClientDisconnected => "client_disconnected", + ResponseEgressOutcome::Completed => "completed", + ResponseEgressOutcome::ConversionError => "conversion_error", + ResponseEgressOutcome::DeadlineExceeded => "deadline_exceeded", + ResponseEgressOutcome::HostHandoff => "host_handoff", + ResponseEgressOutcome::RequestCancelled => "request_cancelled", + ResponseEgressOutcome::SourceError => "source_error", + ResponseEgressOutcome::TransportError => "transport_error", + ResponseEgressOutcome::Unspecified => "unspecified", + _ => "unknown", + } +} + +fn native_outcome(outcome: NativeRequestOutcome) -> &'static str { + match outcome { + NativeRequestOutcome::Egress(egress) => egress_outcome(egress), + NativeRequestOutcome::Ingress(IngressDisposition::Aborted) => "aborted", + NativeRequestOutcome::Ingress(IngressDisposition::Draining) => "draining", + NativeRequestOutcome::Ingress(IngressDisposition::Abandoned) => "abandoned", + } +} + +fn native_failure(failure: Option) -> Option<&'static str> { + failure.map(|category| match category { + NativeRequestFailure::FrameworkRouting { kind } + | NativeRequestFailure::PropagatedError { kind } => kind, + NativeRequestFailure::FallbackBodyExceeded => "fallback_body_exceeded", + NativeRequestFailure::FallbackReadTimedOut => "fallback_read_timed_out", + NativeRequestFailure::AdmissionRefused => "admission_refused", + }) +} + +fn json_response(value: &Value) -> Result { + let mut response = response_with_body( + StatusCode::OK, + Body::json(value).map_err(EdgeError::internal)?, + )?; + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + Ok(response) +} + +fn snapshot_json(snapshot: NativeDiagnosticsSnapshot) -> Value { + json!({ + "active_requests": snapshot.active_requests, + "active_connections": snapshot.active_connections, + "idle_connections": snapshot.idle_connections, + }) +} + +#[expect( + clippy::expect_used, + reason = "fixture-owned SIGTERM registration is fatal setup: without that owner this child cannot exercise graceful stop" +)] +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio::select uses internal bounded remainder to randomize branch polling; the fixture itself introduces no integer division" +)] +async fn native_stop() { + #[cfg(unix)] + { + let mut terminate = unix_signal(SignalKind::terminate()).expect("fixture SIGTERM owner"); + tokio::select! { + _interrupted = signal::ctrl_c() => {}, + _terminated = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _interrupted = signal::ctrl_c().await; + } +} + +fn run_native_fixture(selected: &str) -> anyhow::Result<()> { + PANIC_COMPLETION.store(selected == "diagnostics-panic", Ordering::Relaxed); + set_hook(Box::new(|_info| println!("FIXTURE_PANIC"))); + log::set_logger(&NATIVE_LOGGER)?; + log::set_max_level(LevelFilter::Trace); + println!("APP_LOGGER_INSTALLED"); + let base = NativeDiagnosticsHandle::new(); + // Capture a counter-only clone, not the observer-bearing handle, to avoid a cycle. + let observed = base + .clone() + .with_request_observer(NativeFixtureObserver(base)); + let options = AxumRunOptions::from_env()?.with_diagnostics(observed.clone()); + let state = observed.clone(); + let private_inputs = NativeFailureInputs { + configuration: env::var("PRIVATE_CONFIG_SENTINEL").unwrap_or_default(), + secret: env::var("fixture_token").unwrap_or_default(), + }; + let result = run_app_with_options_and_initializer::( + options, + native_stop(), + move |app, _stores| { + Box::pin(async move { + app.insert_state(state); + app.insert_state(private_inputs); + println!("INITIALIZED"); + Ok(()) + }) + }, + ); + println!("FINAL_SNAPSHOT {}", snapshot_json(observed.snapshot())); + result +} + +#[action] +async fn buffered_json(Json(value): Json) -> Result { + println!("JSON_HANDLER_ENTERED"); + Ok(value.len().to_string()) +} + +#[action] +async fn native_metadata( + ctx: RequestContext, + ForwardedHost(forwarded_host): ForwardedHost, +) -> Result { + let metadata = ingress_metadata(&ctx)?; + let summary = metadata.received_normalized_head(); + let admission = ADMISSION_FACTS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("fixture admission facts missing")))?; + let middleware = MIDDLEWARE_FACTS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("fixture middleware facts missing")))?; + json_response(&json!({ + "admission": admission, + "middleware": middleware, + "handler": metadata_facts(metadata, ctx.headers(), ctx.uri()), + "forwarded_host": forwarded_host, + "direct_context_peer": ctx.extensions().get::().and_then(|context| context.remote_addr).map(|peer| peer.to_string()), + "normalized": { + "target_bytes": summary.target_bytes(), + "header_bytes": summary.header_bytes(), + "header_count": summary.header_count(), + }, + })) +} + +#[action] +async fn native_error( + State(inputs): State, +) -> Result<&'static str, EdgeError> { + println!("PRIVATE_INPUTS_USED"); + Err(EdgeError::internal(anyhow::anyhow!( + "PRIVATE_ERROR_SENTINEL {} {}", + inputs.configuration, + inputs.secret, + ))) +} + +#[action] +async fn native_upstream( + State(inputs): State, +) -> Result<&'static str, EdgeError> { + println!("PRIVATE_INPUTS_USED"); + Err(EdgeError::bad_gateway_with_reason( + format!( + "PRIVATE_ORIGIN_SENTINEL {} {}", + inputs.configuration, inputs.secret + ), + BadGatewayReason::Transport, + )) +} + +#[action] +async fn native_status503() -> Result { + response_with_body( + StatusCode::SERVICE_UNAVAILABLE, + Body::text("application-selected 503"), + ) +} + +#[action] +async fn native_handler404() -> Result<&'static str, EdgeError> { + Err(EdgeError::not_found("HANDLER_404_SENTINEL")) +} + +#[action] +async fn native_mutated(mut ctx: RequestContext) -> Result { + let removed = ctx + .extensions_mut() + .remove::() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("fixture ingress metadata missing")))?; + let removed_id = removed.request_id().to_string(); + ctx.extensions_mut() + .insert(CheckedEffectiveHost::from_ingress( + Some("replacement.example".parse().map_err(EdgeError::internal)?), + CheckedHostSource::Direct, + )); + let ForwardedHost(after_host) = ForwardedHost::from_request(&ctx).await?; + json_response(&json!({ + "removed_id": removed_id, + "metadata_absent": ctx.extensions().get::().is_none(), + "forwarded_host_after": after_host, + })) +} + +#[action] +async fn native_source() -> Result { + let guard = Rc::new(WorkGuard("SOURCE_DROPPED")); + Ok(Body::from_stream(async_stream::stream! { + let _retained = guard; + yield Ok(Bytes::from_static(b"source-prefix")); + sleep(Duration::from_millis(100)).await; + yield Err(EdgeError::internal(anyhow::anyhow!("SOURCE_SENTINEL"))); + })) +} + +#[action] +async fn native_deadline() -> Result { + let guard = Rc::new(WorkGuard("DEADLINE_DROPPED")); + let body = Body::from_stream(async_stream::stream! { + let _retained = guard; + yield Ok(Bytes::from_static(b"deadline-prefix")); + future::pending::<()>().await; + }); + let mut response = Response::new(body); + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(Duration::from_millis(150))); + Ok(response) +} + +#[action] +async fn native_expired() -> Result { + let mut response = Response::new(Body::text("expired-private-body")); + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(Duration::ZERO)); + Ok(response) +} + +#[action] +async fn native_conversion() -> Result { + let mut response = Response::new(Body::text("CONVERSION_PAYLOAD_SENTINEL")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("1")); + Ok(response) +} + +#[action] +async fn native_json_number(Json(value): Json) -> Result { + Ok(value.to_string()) +} + +#[action] +async fn native_counts( + ctx: RequestContext, + State(handle): State, +) -> Result { + let snapshot = handle.snapshot(); + let phase = match reader(&ctx)?.phase() { + Some(LifecyclePhase::Starting) => "starting", + Some(LifecyclePhase::Ready) => "ready", + Some(LifecyclePhase::Draining) => "draining", + Some(LifecyclePhase::Stopped) => "stopped", + None => "unavailable", + }; + json_response(&json!({ + "active_requests": snapshot.active_requests, + "active_connections": snapshot.active_connections, + "idle_connections": snapshot.idle_connections, + "phase": phase, + })) +} + +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 mode() -> String { env::var("FIXTURE_MODE").unwrap_or_else(|_| "initialized".to_owned()) } @@ -258,6 +1011,12 @@ async fn stop(State(state): State>) -> Result<&'static str, EdgeE )] fn main() -> anyhow::Result<()> { let selected = mode(); + if selected.starts_with("diagnostics") { + return run_native_fixture(&selected); + } + if selected.starts_with("geo-") { + return run_geo_fixture(&selected); + } if matches!(selected.as_str(), "development" | "bare-dev") { return run_app::(); } diff --git a/crates/edgezero-adapter-axum/tests/fixtures/tls/README.md b/crates/edgezero-adapter-axum/tests/fixtures/tls/README.md new file mode 100644 index 00000000..0851892e --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/tls/README.md @@ -0,0 +1,11 @@ +# Local proxy TLS fixtures + +These public test assets are only for the local native-host proxy fixture. `server-key.der` is an intentionally published test key, not a credential. Never use it outside tests. + +- `ca.der` is the fixture CA certificate. +- `server.der` is the server certificate for `native-proxy.test`. +- `server-key.der` is its PKCS#8 private key. + +The server certificate is valid from October 6, 2026 to October 3, 2036. The test client trusts only this fixture CA and verifies the certificate and DNS name. A request for a different server name must fail verification. Renew the fixture assets before expiry; do not disable certificate or name checks to extend their lifetime. + +The fixture terminates TLS and relays HTTP to the managed native host. It does not add incoming TLS support to the Axum adapter. diff --git a/crates/edgezero-adapter-axum/tests/fixtures/tls/ca.der b/crates/edgezero-adapter-axum/tests/fixtures/tls/ca.der new file mode 100644 index 0000000000000000000000000000000000000000..025acfc6c8969bc6b1c965b9ca1c028642d9b100 GIT binary patch literal 821 zcmXqLVm35rVv=0I%*4pVB%+v^_2#&jlE}q>H#fhY#2Yndv)BOxUN%mxHjlRNyo`+8 ztPBPkhH3^XY|No7%sjHLDe0+EsYUq;d5I;NWvL1UMfnw#3MHwy(!02eEm|TY2 z&5|?ES4vIvUH0<$9i>wN4?d|_t}TDcyr|->{(Y5+qElDC+{%@gyj0g0hM?{F^K(61K5+-gzFcE#*t(d_rD?zwrs(>HeQ3pfg7z$}vm1 zbfZN2lN;U@If=hQd6iduH?P;Y)fUlWXi`Yny}&qIDqUnumz?sdz| zllz3Ld}jaE3ze~5)Kq_D@4;!u>>TgjH~Co3vdd)KEP=(sH$Ike#bw$Z)tXTBB7C1g zU6E7Ar=>i;Gb-1~TRqrQyJJ(2`EK$~fd;a`n33gU5n~a_bQEfu;I$y-bC8_0vCm02VV#2TzIh82i9rXJpuSk3Eau*kX0G`=-;{=UrYXe_CFcx61y<p>$cFs5{L;i{vnTz_I&$#V zx0qlh0pTzDg_Gx1pFTS4x8s*ohbU9Yx9^^BT7ljvZ`<0Y^=0{if zNi&tl71f`2){A9K2)n3cOH@klunv@>QyC1xtMX&M; mk2d>*dn=_h*Q@P4amQ#^C!ZNd&LNQUrr!ay9qXGc{0)hbn0HNC1fwjAn zca@=(-s`LWm^?st`wY3mT@@y?{`npPrBu=eCCAEEKh zdIH1|qa;6wcUH_An%DZ%uOA}O>r;8KY!0(YXQhB+`4Z5P#yHx7azql|CF|7!U_3Xd zUrv==tIe(L?$lv|$@m4=K?jLCYp|V4M~ugsPA&-FCg{j9zaNAeBiNq&+X$mXB8~BK zB(IwT{!Xv%xS$eCtxo5QP{+szM^#-1fU^aVoc%M73=`M@ze{dJBEkF%`33}EX|@^f zK!YdNJ+Ei)lyg%#e2HU{A=ETr%gwcwJ=-GiopR+I zxYoR5s})I_axGPFXAb8+4Y}d}+6@;UO+pkfHEXk)?bg=!UOcj(ZX;xYS8agg(MoT= zc4w-AIW2f4jw-%n^aseQp(wxv94-pP0S`Gw?wyY^tEEXa@jXKv4OqIaiNOMafdJ1L zu9fY0!fcH6$KMdzSb}itcj*5NA-XSOcRQj4IX`VMVuLnMiyMX1G3TmYu^ov!&a&Z? zRJ3LE`O}0)Rl1EoRt7=#Qmw+>z9)_L1~83Oyk2^Kgk@VeOmD?$rgF^XP#T1+x>S^@ z0W2+VAuPD5FRvf%Z;%;{k@VP3E_nihfdI%7M%FR|FuC*XmL_!%<|u^j$QsKmCc4(_ z?7>Y4WRnKe4g^oXIn$4v3}`EMzE0kIPcbpUH%H?}M8mGsD^RheTw9-jtI88hq8FDL z$9CRAJ=y%h6go+|Xvv#}tn{_znRDFTP)9Kg%6rUVVxg+Y2Z>wu!?t_9fvL}5OmzZ* zfdI2aKFo&CrZhrZOnbRu*M+I;8B?_yGDEQHq1_v#UQLQe;b&Yw+h%iP#Kne}I!w)e<;un8Fj*m?gB8Zd8PZ_Jm`{z%? z?TJVoA>r=3Pdr{>#G0G%_eGfmSw2+v!KkhOi5b(1I%%3xXu9@|O$R{{=X$&lUok2m1T#KA>X literal 0 HcmV?d00001 diff --git a/crates/edgezero-adapter-axum/tests/fixtures/tls/server.der b/crates/edgezero-adapter-axum/tests/fixtures/tls/server.der new file mode 100644 index 0000000000000000000000000000000000000000..3a7ef3ae8557c0e56c6b4439eaa84846b367b0ab GIT binary patch literal 869 zcmXqLVoo$@V)9(T%*4pVBqAtrIyh$Kx7+*HD@jFn3C^?Z%?%37k)fj#dJh)v5I|1dDt1LS=WDEUN5h7@pW+Jh8(`lp6N>)l79+a zn0U)FpW7UZU6gu17OAYC&HT@A{kt6tguK`KJ?{=UeuCXSG?u+#GwXyo ze~tTigl{nH_s(-sI{1hCCmTzA<~GUq4$bP_n z~7;wWG`a7*>{v&t6<%Oi>E?zfwPLG}R^b5u>Y96CG}zasSwAo IX9`k30jD%z@c;k- literal 0 HcmV?d00001 diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs index 97e8db9a..16f99735 100644 --- a/crates/edgezero-adapter-axum/tests/production_hosting.rs +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -1,15 +1,22 @@ //! Real local processes, public entrypoints and ordinary main termination output. #![cfg(all(feature = "axum", feature = "test-utils", unix))] +#[cfg(test)] +#[path = "fixtures/native_proxy.rs"] +mod native_proxy; + #[cfg(test)] #[expect( clippy::arbitrary_source_item_ordering, reason = "subprocess fixtures group setup, process ownership and acceptance scenarios" )] mod tests { + use super::native_proxy::{Assertions, LocalProxy, verified_tls_request}; + use std::collections::HashSet; + use std::ffi::OsString; use std::fs; use std::io::{self, BufRead as _, BufReader, Read, Write as _}; - use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; use std::os::unix::process::ExitStatusExt as _; use std::process::{Child, Command, ExitStatus, Stdio}; @@ -240,6 +247,1643 @@ mod tests { log } + fn upload( + server: &Server, + path: &str, + media: Option<&str>, + body: Vec, + chunked: bool, + ) -> String { + let mut socket = server.socket().expect("upload connection"); + let mut writer = socket.try_clone().expect("upload writer"); + let content_type = + media.map_or_else(String::new, |value| format!("Content-Type: {value}\r\n")); + let framing = if chunked { + "Transfer-Encoding: chunked\r\n".to_owned() + } else { + format!("Content-Length: {}\r\n", body.len()) + }; + let request_head = format!( + "POST {path} HTTP/1.1\r\nHost: fixture\r\n{content_type}{framing}Connection: close\r\n\r\n" + ); + let sending = thread::spawn(move || -> io::Result<()> { + writer.write_all(request_head.as_bytes())?; + for chunk in body.chunks(0x4000) { + if chunked { + write!(writer, "{:x}\r\n", chunk.len())?; + } + writer.write_all(chunk)?; + if chunked { + writer.write_all(b"\r\n")?; + } + } + if chunked { + writer.write_all(b"0\r\n\r\n")?; + } + Ok(()) + }); + let mut response = String::new(); + let reading = socket.read_to_string(&mut response); + let sent = sending.join().expect("bounded upload writer"); + let (head, payload) = response + .split_once("\r\n\r\n") + .expect("usable response head"); + let is_413 = head.starts_with("HTTP/1.1 413"); + let length = head + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("response length")) + }) + .expect("finite response framing"); + assert_eq!(payload.len(), length, "complete response: {response}"); + if let Err(error) = reading { + assert!( + is_413 && error.kind() == io::ErrorKind::ConnectionReset, + "response read: {error}" + ); + } + if let Err(error) = sent { + assert!(is_413, "upload failure without 413: {error}"); + } + response + } + + fn assert_upload_status(response: &str, status: u16) { + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + if status == 413 { + let (_, body) = response.split_once("\r\n\r\n").expect("response"); + let value: Value = serde_json::from_str(body).expect("usable error body"); + assert_eq!(value["error"]["kind"], "payload_too_large"); + assert_eq!(value["error"]["message"], "request body too large"); + } + } + + fn captured_json(log: &str, marker: &str) -> Vec { + log.lines() + .filter_map(|line| line.strip_prefix(marker)) + .map(|value| serde_json::from_str(value).expect("fixture JSON marker")) + .collect() + } + + fn observations(server: &Server) -> Vec { + captured_json(&server.log(), "NATIVE_OBSERVATION ") + } + + fn framework_logs(server: &Server) -> Vec { + captured_json(&server.log(), "FRAMEWORK_LOG ") + } + + fn wait_for(server: &Server, predicate: impl Fn(&str) -> bool) { + let deadline = Instant::now().checked_add(WAIT).expect("observation timer"); + loop { + let log = server.log(); + if predicate(&log) { + return; + } + assert!(Instant::now() < deadline, "observation deadline: {log}"); + thread::sleep(Duration::from_millis(5)); + } + } + + fn route_record(server: &Server, route: &str) -> Value { + wait_for(server, |log| { + captured_json(log, "NATIVE_OBSERVATION ") + .iter() + .any(|record| record["route"] == route) + }); + let records = observations(server) + .into_iter() + .filter(|record| record["route"] == route) + .collect::>(); + assert_eq!(records.len(), 1, "one terminal owner: {}", server.log()); + records.into_iter().next().expect("route record") + } + + fn raw_request(addr: SocketAddr, request: &str) -> String { + let mut socket = TcpStream::connect_timeout(&addr, WAIT).expect("wire connection"); + socket.set_read_timeout(Some(WAIT)).expect("read timeout"); + socket.set_write_timeout(Some(WAIT)).expect("write timeout"); + socket.write_all(request.as_bytes()).expect("wire request"); + let mut response = String::new(); + socket.read_to_string(&mut response).expect("wire response"); + response + } + + fn response_json(response: &str) -> Value { + let (_, body) = response.split_once("\r\n\r\n").expect("response head"); + serde_json::from_str(body).expect("finite fixture JSON response") + } + + fn metadata_request(headers: &str) -> String { + format!( + "GET /native-metadata HTTP/1.1\r\nHost: direct.example:8080\r\n{headers}Connection: close\r\n\r\n" + ) + } + + fn assert_metadata(value: &Value, client: &str, host: &str, scheme: &str, result: &str) { + let facts = &value["handler"]; + assert_eq!(value["admission"], *facts, "admission view"); + assert_eq!(value["middleware"], *facts, "middleware view"); + assert_eq!(facts["client"], client); + assert_eq!(facts["host"], host); + assert_eq!(facts["scheme"], scheme); + assert_eq!(facts["forwarding_result"], result); + assert_eq!(facts["host_header"], "direct.example:8080"); + assert_eq!(facts["uri"], "/native-metadata"); + assert_eq!(facts["raw_forwarding_absent"], true); + assert_eq!(value["forwarded_host"], host); + assert_eq!(value["direct_context_peer"], facts["peer"]); + let id = facts["request_id"].as_str().expect("application ID"); + assert_eq!(id.len(), 64); + assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + + fn assert_no_diagnostic_headers(response: &str) { + let (head, _) = response.split_once("\r\n\r\n").expect("response head"); + for line in head.lines().skip(1) { + let (name, _) = line.split_once(':').expect("response header"); + let normalized_name = name.to_ascii_lowercase(); + assert!( + !normalized_name.contains("request-id") + && !normalized_name.starts_with("x-edgezero") + ); + assert!(!matches!( + normalized_name.as_str(), + "traceparent" | "tracestate" | "server-timing" + )); + } + } + + fn native_command( + mode: &str, + roots: &Roots, + addr: SocketAddr, + networks: &str, + family: &str, + ) -> Command { + let mut launch = command(mode, roots, addr); + launch + .env("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", networks) + .env("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", family); + launch + } + + fn assert_final_zero(server: &Server) { + let snapshots = captured_json(&server.log(), "FINAL_SNAPSHOT "); + assert_eq!( + snapshots, + vec![ + json!({"active_requests": 0_u64, "active_connections": 0_u64, "idle_connections": 0_u64}) + ] + ); + } + + fn assert_hook_order(server: &Server, id: &str) { + let log = server.log(); + let completion = log + .find(&format!("APP_COMPLETION {id}\n")) + .expect("application completion"); + let native_line = log + .lines() + .find(|line| line.starts_with("NATIVE_OBSERVATION ") && line.contains(id)) + .expect("native observation"); + let native = log.find(native_line).expect("native offset"); + let observer = log + .get(native..) + .expect("native suffix") + .find("APP_OBSERVER ") + .expect("application observer"); + assert!(completion < native); + assert!(observer > 0); + assert_eq!(log.matches(&format!("APP_COMPLETION {id}\n")).count(), 1); + assert_eq!( + observations(server) + .iter() + .filter(|record| record["id"] == id) + .count(), + 1 + ); + } + + fn assert_record_log(server: &Server, record: &Value) { + let id = record["id"].as_str().expect("record ID"); + let request_logs = framework_logs(server) + .into_iter() + .filter(|entry| { + let message = entry["message"].as_str().expect("framework message"); + (message.starts_with("request_terminal ") + || message.starts_with("request_ingress ")) + && message.contains(id) + }) + .collect::>(); + assert_eq!( + request_logs.len(), + 1, + "facade cardinality: {}", + server.log() + ); + let entry = request_logs + .into_iter() + .next() + .expect("request facade entry"); + assert_eq!(entry["level"], record["level"]); + let message = entry["message"].as_str().expect("safe request text"); + assert!(message.contains(&format!( + "method={}", + record["method"].as_str().expect("method") + ))); + assert!(message.contains("duration_us=")); + assert!(message.contains("bytes_written=") || record["bytes_written"].is_null()); + } + + #[test] + fn native_direct_boundary_strips_spoofs_and_accounts_the_original_head() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics", &roots, addr), addr); + server.ready(); + let spoof = "Forwarded: for=198.51.100.9;host=FORWARDED_SENTINEL;proto=https\r\nX-Forwarded-For: 203.0.113.9\r\nX-Forwarded-Host: XFH_SENTINEL\r\nX-Forwarded-Proto: https\r\nX-Forwarded-Secret: HEADER_SENTINEL\r\nX-Request-ID: VISITOR_ID_SENTINEL\r\n"; + let request = metadata_request(spoof); + let response = raw_request(addr, &request); + assert_no_diagnostic_headers(&response); + let value = response_json(&response); + assert_metadata( + &value, + "127.0.0.1", + "direct.example:8080", + "http", + "untrusted_peer", + ); + for field in ["client_source", "host_source", "scheme_source"] { + assert_eq!(value["handler"][field], "direct"); + } + let peer: SocketAddr = value["handler"]["peer"] + .as_str() + .expect("owned peer") + .parse() + .expect("socket peer"); + assert_eq!(peer.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); + let expected_bytes = request + .split("\r\n") + .skip(1) + .filter(|line| !line.is_empty()) + .map(|line| line.len().checked_add(2).expect("normalized line bytes")) + .try_fold(2_usize, usize::checked_add) + .expect("normalized head bytes"); + assert_eq!(value["normalized"]["header_bytes"], expected_bytes); + assert_eq!(value["normalized"]["header_count"], 8_u64); + assert_eq!( + value["normalized"]["target_bytes"], + "/native-metadata".len() + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + let record = route_record(&server, "/native-metadata"); + assert_eq!(record["id"], value["handler"]["request_id"]); + assert_record_log(&server, &record); + for entry in framework_logs(&server) { + let message = entry["message"].as_str().expect("framework message"); + assert!(!message.contains("SENTINEL")); + assert!(!message.contains("127.0.0.1") && !message.contains("direct.example")); + } + } + + #[test] + fn native_source_bound_single_proxy_and_mapped_cidr_match_only_owned_peers() { + for networks in [ + "127.0.0.2/32", + "::ffff:127.0.0.2/128", + "::ffff:127.0.0.2/127", + "127.0.0.3/32", + ] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn( + native_command("diagnostics", &roots, addr, networks, "forwarded"), + addr, + ); + server.ready(); + let proxy = LocalProxy::start( + addr, + Ipv4Addr::new(127, 0, 0, 2), + Assertions::AppendForwarded { + host: "public.example", + scheme: "https", + }, + false, + ); + let response = raw_request( + proxy.addr(), + &metadata_request( + "Forwarded: for=198.51.100.99;host=attacker.example;proto=http\r\nX-Forwarded-Host: conflicting.example\r\n", + ), + ); + let value = response_json(&response); + let trusted = networks != "127.0.0.3/32"; + assert_metadata( + &value, + if trusted { "127.0.0.1" } else { "127.0.0.2" }, + if trusted { + "public.example" + } else { + "direct.example:8080" + }, + if trusted { "https" } else { "http" }, + if trusted { + "accepted" + } else { + "untrusted_peer" + }, + ); + let peer: SocketAddr = value["handler"]["peer"] + .as_str() + .expect("proxy peer") + .parse() + .expect("peer address"); + assert_eq!(peer.ip(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))); + assert_no_diagnostic_headers(&response); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + } + } + + #[test] + fn native_two_proxy_chain_stops_at_untrusted_hop_and_gateway_replaces_prefix() { + for (networks, boundary) in [ + ("127.0.0.2/32,127.0.0.3/32", "127.0.0.1"), + ("127.0.0.3/32", "127.0.0.2"), + ] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn( + native_command("diagnostics", &roots, addr, networks, "forwarded"), + addr, + ); + server.ready(); + let ingress = LocalProxy::start( + addr, + Ipv4Addr::new(127, 0, 0, 3), + Assertions::AppendForwarded { + host: "final.example", + scheme: "https", + }, + false, + ); + let first = LocalProxy::start( + ingress.addr(), + Ipv4Addr::new(127, 0, 0, 2), + Assertions::AppendForwarded { + host: "earlier.example", + scheme: "http", + }, + false, + ); + let value = response_json(&raw_request( + first.addr(), + &metadata_request( + "Forwarded: for=198.51.100.99;host=attacker.example;proto=http\r\n", + ), + )); + assert_metadata(&value, boundary, "final.example", "https", "accepted"); + assert_eq!(value["handler"]["host_source"], "trusted_forwarded"); + assert!( + value["handler"]["peer"] + .as_str() + .expect("last proxy peer") + .starts_with("127.0.0.3:") + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + } + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn( + native_command("diagnostics", &roots, addr, "127.0.0.3/32", "forwarded"), + addr, + ); + server.ready(); + let gateway = LocalProxy::start( + addr, + Ipv4Addr::new(127, 0, 0, 3), + Assertions::ReplaceForwarded { + client: "203.0.113.7".parse().expect("verified gateway client"), + host: "gateway.example", + scheme: "https", + }, + false, + ); + let value = response_json(&raw_request( + gateway.addr(), + &metadata_request( + "Forwarded: for=198.51.100.99;host=attacker.example;proto=http\r\nX-Forwarded-Host: attacker.example\r\n", + ), + )); + assert_metadata( + &value, + "203.0.113.7", + "gateway.example", + "https", + "accepted", + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + } + + fn assert_missing_unresolved_and_duplicate_family(addr: SocketAddr, family: &str) { + // This source-bound relay preserves deliberate bad inputs rather than sanitizing them. + let gateway = LocalProxy::start( + addr, + Ipv4Addr::new(127, 0, 0, 2), + Assertions::Preserve, + false, + ); + let absent = if family == "forwarded" { + "Forwarded: host=partial.example;proto=https\r\nX-Forwarded-For: 198.51.100.9\r\n" + } else { + "X-Forwarded-Host: partial.example\r\nX-Forwarded-Proto: https\r\nForwarded: for=198.51.100.9\r\n" + }; + let missing_client = response_json(&raw_request(gateway.addr(), &metadata_request(absent))); + assert_metadata( + &missing_client, + "127.0.0.2", + "partial.example", + "https", + if family == "forwarded" { + "unresolved_chain" + } else { + "partial" + }, + ); + for headers in [ + if family == "forwarded" { + "Forwarded: for=unknown;host=gap.example;proto=https\r\n" + } else { + "X-Forwarded-For: unknown\r\nX-Forwarded-Host: gap.example\r\nX-Forwarded-Proto: https\r\n" + }, + if family == "forwarded" { + "Forwarded: for=127.0.0.2;host=gap.example;proto=https\r\n" + } else { + "X-Forwarded-For: 127.0.0.2\r\nX-Forwarded-Host: gap.example\r\nX-Forwarded-Proto: https\r\n" + }, + ] { + let value = response_json(&raw_request(gateway.addr(), &metadata_request(headers))); + assert_metadata( + &value, + "127.0.0.2", + "gap.example", + "https", + "unresolved_chain", + ); + } + let duplicate = if family == "forwarded" { + "Forwarded: for=198.51.100.8;for=203.0.113.9;host=good.example;proto=https\r\n" + } else { + "X-Forwarded-For: 198.51.100.8\r\nX-Forwarded-Host: good.example\r\nX-Forwarded-Host: duplicate.example\r\nX-Forwarded-Proto: https\r\n" + }; + let malformed_duplicate = + response_json(&raw_request(gateway.addr(), &metadata_request(duplicate))); + assert_metadata( + &malformed_duplicate, + "127.0.0.2", + "direct.example:8080", + "http", + "malformed", + ); + } + + #[test] + fn native_selected_families_conflicts_malformed_sets_gaps_and_exhaustion() { + for family in ["forwarded", "x-forwarded"] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn( + native_command("diagnostics", &roots, addr, "127.0.0.2/32", family), + addr, + ); + server.ready(); + let assertions = if family == "forwarded" { + Assertions::AppendForwarded { + host: "selected.example", + scheme: "https", + } + } else { + Assertions::AppendXForwarded { + host: "selected.example", + scheme: "https", + } + }; + let proxy = LocalProxy::start(addr, Ipv4Addr::new(127, 0, 0, 2), assertions, false); + for (prefix, expected_result, client, host, scheme) in [ + ( + "Forwarded: for=198.51.100.1;host=wrong.example;proto=http\r\nX-Forwarded-For: 203.0.113.1\r\nX-Forwarded-Host: wrong.example\r\nX-Forwarded-Proto: http\r\n", + "accepted", + "127.0.0.1", + "selected.example", + "https", + ), + ( + if family == "forwarded" { + "Forwarded: for=bad-address;host=good.example;proto=https\r\n" + } else { + "X-Forwarded-For: bad-address\r\n" + }, + "malformed", + "127.0.0.2", + "direct.example:8080", + "http", + ), + ( + if family == "forwarded" { + "Forwarded: for=unknown\r\n" + } else { + "X-Forwarded-For: unknown\r\n" + }, + "accepted", + "127.0.0.1", + "selected.example", + "https", + ), + ] { + let value = response_json(&raw_request(proxy.addr(), &metadata_request(prefix))); + assert_metadata(&value, client, host, scheme, expected_result); + } + assert_missing_unresolved_and_duplicate_family(addr, family); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + let records = observations(&server) + .into_iter() + .filter(|record| record["route"] == "/native-metadata") + .collect::>(); + assert_eq!(records.len(), 7); + for record in records { + assert_record_log(&server, &record); + if record["forwarding_result"] == "malformed" { + assert_eq!(record["level"], "WARN"); + } + } + } + } + + #[test] + fn native_verified_tls_ingress_overwrites_public_assertions_and_rejects_wrong_name() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn( + native_command("diagnostics", &roots, addr, "127.0.0.2/32", "forwarded"), + addr, + ); + server.ready(); + let proxy = LocalProxy::start( + addr, + Ipv4Addr::new(127, 0, 0, 2), + Assertions::AppendForwarded { + host: "native-proxy.test", + scheme: "https", + }, + true, + ); + let request = metadata_request( + "Forwarded: for=198.51.100.99;host=attacker.example;proto=http\r\nX-Forwarded-Host: attacker.example\r\nX-Forwarded-Proto: http\r\n", + ); + let response = verified_tls_request(proxy.addr(), request.as_bytes(), "native-proxy.test") + .expect("CA and hostname verified TLS"); + let value = response_json(&response); + assert_metadata( + &value, + "127.0.0.1", + "native-proxy.test", + "https", + "accepted", + ); + assert_eq!(value["handler"]["scheme_source"], "trusted_forwarded"); + assert_no_diagnostic_headers(&response); + let wrong_name = verified_tls_request(proxy.addr(), request.as_bytes(), "wrong-name.test") + .expect_err("wrong server name must fail verification"); + assert_eq!( + wrong_name.kind(), + io::ErrorKind::InvalidData, + "certificate verification failure, not a connection timeout" + ); + assert!( + format!("{wrong_name:?}").contains("NotValidForName"), + "hostname verification must be the rejecting boundary" + ); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + assert_eq!( + observations(&server) + .iter() + .filter(|record| record["route"] == "/native-metadata") + .count(), + 1 + ); + } + + fn assert_native_error_record( + server: &Server, + path: &str, + status: u16, + failure: &str, + level: &str, + ) { + if path.starts_with("/PRIVATE_PATH") { + return; + } + let route = path.split('?').next().expect("registered route"); + let record = route_record(server, route); + assert_eq!(record["status"], status); + assert_eq!(record["level"], level); + if !failure.is_empty() { + assert_eq!(record["failure"], failure); + } + if path == "/native-status503" { + assert!(record["failure"].is_null(), "status alone is not rejection"); + } + if matches!(path, "/native-conversion" | "/native-expired") { + assert_eq!(record["body_kind"], "fallback"); + assert_eq!(record["fallback"], "completed"); + assert_ne!(record["outcome"], "completed"); + } + } + + fn assert_default_error_observations(server: &Server) { + let records = observations(server); + let extension = records + .iter() + .find(|record| record["method"] == "other") + .expect("extension method bounded category"); + assert_eq!(extension["status"], 405_u16); + assert_eq!(extension["level"], "INFO"); + for entry in framework_logs(server) { + assert!( + !entry["message"] + .as_str() + .expect("framework message") + .contains("SENTINEL"), + "framework log leak: {entry}" + ); + let message = entry["message"].as_str().expect("framework message"); + assert!( + !message.contains("CONFIG_VALUE_SENTINEL") + && !message.contains("SECRET_VALUE_SENTINEL"), + "environment-derived private inputs leaked through the facade: {entry}" + ); + } + for record in records { + assert_record_log(server, &record); + } + } + + #[test] + fn native_default_errors_and_logs_keep_sentinels_private() { + let roots = Roots::new(); + let addr = address(); + let mut launch = command("diagnostics", &roots, addr); + launch + .env("PRIVATE_CONFIG_SENTINEL", "CONFIG_VALUE_SENTINEL") + .env("fixture_token", "SECRET_VALUE_SENTINEL"); + let mut server = Server::spawn(launch, addr); + server.ready(); + for (path, status, failure, level) in [ + ( + "/PRIVATE_PATH_SENTINEL?token=QUERY_SENTINEL", + 404, + "not_found", + "INFO", + ), + ( + "/native-error?token=QUERY_SENTINEL", + 500, + "internal", + "WARN", + ), + ("/native-upstream", 502, "bad_gateway", "WARN"), + ("/native-conversion", 500, "", "WARN"), + ("/native-expired", 504, "", "WARN"), + ("/native-status503", 503, "", "INFO"), + ] { + let response = raw_request( + addr, + &format!( + "GET {path} HTTP/1.1\r\nHost: HOST_SENTINEL\r\nAuthorization: Bearer AUTH_SENTINEL\r\nCookie: COOKIE_SENTINEL\r\nX-Request-ID: ID_SENTINEL\r\nConnection: close\r\n\r\n" + ), + ); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + assert!( + !response.contains("SENTINEL"), + "default wire leak: {response}" + ); + assert!( + !response.contains("CONFIG_VALUE_SENTINEL") + && !response.contains("SECRET_VALUE_SENTINEL"), + "environment-derived private inputs leaked through the wire: {response}" + ); + assert_no_diagnostic_headers(&response); + assert_native_error_record(&server, path, status, failure, level); + } + let extension_response = raw_request( + addr, + "METHOD_SENTINEL /native-status503?token=QUERY_SENTINEL HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n", + ); + assert!(extension_response.starts_with("HTTP/1.1 405")); + assert!(!extension_response.contains("SENTINEL")); + assert!(extension_response.to_ascii_lowercase().contains("allow:")); + let json_response = upload( + &server, + "/native-json-number", + Some("application/json"), + b"\"PAYLOAD_SENTINEL\"".to_vec(), + false, + ); + assert_upload_status(&json_response, 400); + assert!(!json_response.contains("PAYLOAD_SENTINEL")); + assert_eq!( + response_json(&json_response)["error"]["message"], + "invalid JSON payload" + ); + let handler_error = server + .request("/native-handler404") + .expect("application error"); + assert!( + handler_error.contains("HANDLER_404_SENTINEL"), + "application error messages remain application policy" + ); + let handler_record = route_record(&server, "/native-handler404"); + assert_eq!(handler_record["level"], "WARN"); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + assert_eq!( + server + .log() + .lines() + .filter(|line| *line == "PRIVATE_INPUTS_USED") + .count(), + 2, + "both error paths consumed the fixture's captured environment-derived configuration and secret inputs" + ); + assert_default_error_observations(&server); + } + + #[test] + fn native_custom_renderer_keeps_ownership_but_framework_json_parse_text_stays_fixed() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics-custom", &roots, addr), addr); + server.ready(); + let custom = server + .request("/CUSTOM_ROUTE_SENTINEL") + .expect("custom route error"); + assert!(custom.starts_with("HTTP/1.1 404")); + assert!( + custom.contains("CUSTOM_ROUTE_SENTINEL"), + "custom renderer retains original routing detail" + ); + let parsed = upload( + &server, + "/native-json-number", + Some("application/json"), + b"\"PAYLOAD_SENTINEL\"".to_vec(), + false, + ); + assert!(parsed.starts_with("HTTP/1.1 400")); + assert!(parsed.ends_with("invalid JSON payload")); + assert!(!parsed.contains("PAYLOAD_SENTINEL")); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + for entry in framework_logs(&server) { + assert!( + !entry["message"] + .as_str() + .expect("framework message") + .contains("SENTINEL") + ); + } + } + + #[test] + fn native_original_head_overage_cannot_be_hidden_by_raw_header_stripping() { + let roots = Roots::new(); + let addr = address(); + let mut launch = command("diagnostics", &roots, addr); + // Let the received head reach managed normalized validation. The newer + // raw-wire parser otherwise rejects this request before service dispatch. + launch.env("EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES", "131072"); + let mut server = Server::spawn(launch, addr); + server.ready(); + let request = metadata_request(&format!( + "X-Forwarded-Secret: {}\r\n", + "HEAD_SENTINEL".repeat(6000) + )); + let response = raw_request(addr, &request); + assert!( + response.starts_with("HTTP/1.1 431"), + "original normalized head limit: {response}" + ); + assert!(!response.contains("SENTINEL")); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + let rejections = observations(&server) + .into_iter() + .filter(|record| record["status"] == 431_u16) + .collect::>(); + assert_eq!(rejections.len(), 1); + let record = rejections + .into_iter() + .next() + .expect("head rejection record"); + assert_eq!(record["level"], "WARN"); + assert_eq!( + server.log().matches("APP_COMPLETION ").count(), + 1, + "head failure precedes app admission; only readiness was admitted" + ); + assert_record_log(&server, &record); + } + + #[test] + fn native_ids_remain_private_under_mutation_and_concurrent_hosting_requests() { + let roots = Roots::new(); + let addr = address(); + let other_addr = address(); + let mut server = Server::spawn(command("diagnostics", &roots, addr), addr); + let mut other = Server::spawn(command("diagnostics", &roots, other_addr), other_addr); + server.ready(); + other.ready(); + let response = raw_request( + addr, + "GET /native-mutated HTTP/1.1\r\nHost: fixture\r\nX-Request-ID: VISITOR_ID_SENTINEL\r\nConnection: close\r\n\r\n", + ); + assert_no_diagnostic_headers(&response); + let mutated = response_json(&response); + assert_eq!(mutated["metadata_absent"], true); + assert_eq!(mutated["forwarded_host_after"], "replacement.example"); + let authoritative = route_record(&server, "/native-mutated"); + assert_eq!(authoritative["id"], mutated["removed_id"]); + let mut clients = Vec::new(); + for target in [addr, other_addr] { + for _request in 0..4_u8 { + clients.push(thread::spawn(move || { + let concurrent_response = raw_request(target, "GET /native-status503 HTTP/1.1\r\nHost: fixture\r\nX-Request-ID: VISITOR_ID_SENTINEL\r\nConnection: close\r\n\r\n"); + assert!(concurrent_response.starts_with("HTTP/1.1 503")); + assert_no_diagnostic_headers(&concurrent_response); + })); + } + } + for client in clients { + client.join().expect("bounded concurrent wire client"); + } + for running in [&mut server, &mut other] { + running.signal("TERM"); + assert!(running.wait().success()); + assert_final_zero(running); + } + let mut ids = HashSet::new(); + for running in [&server, &other] { + let records = observations(running); + assert_eq!( + records + .iter() + .filter(|record| record["route"] == "/native-status503") + .count(), + 4 + ); + assert_eq!( + running.log().matches("APP_COMPLETION ").count(), + records.len() + ); + assert_eq!( + running.log().matches("APP_OBSERVER ").count(), + records.len() + ); + for record in records { + let id = record["id"].as_str().expect("generated ID"); + assert_eq!(id.len(), 64); + assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(ids.insert(id.to_owned()), "no process/request ID collision"); + assert!( + record["counts"]["active_requests"] + .as_u64() + .expect("request count") + <= 3, + "the observed request releases before its sink; only other concurrent requests remain" + ); + assert_record_log(running, &record); + } + assert!(!running.log().contains("VISITOR_ID_SENTINEL")); + } + } + + #[test] + fn native_logger_panic_preserves_order_cardinality_and_resource_release() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics-panic", &roots, addr), addr); + server.ready(); + assert!( + server + .request("/native-status503") + .expect("surviving response") + .starts_with("HTTP/1.1 503") + ); + let selected_record = route_record(&server, "/native-status503"); + wait_for(&server, |log| log.matches("APP_OBSERVER ").count() >= 2); + assert_hook_order(&server, selected_record["id"].as_str().expect("record ID")); + assert_eq!(selected_record["counts"]["active_requests"], 0_u64); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + let log = server.log(); + assert_eq!(log.matches("APP_LOGGER_INSTALLED").count(), 1); + assert_eq!( + log.matches("LOGGER_FAULT").count(), + 1, + "no recursive logger fault reporting" + ); + let records = observations(&server); + assert_eq!(log.matches("APP_COMPLETION ").count(), records.len()); + assert_eq!(log.matches("APP_OBSERVER ").count(), records.len()); + for record in records { + assert_record_log(&server, &record); + } + } + + fn wire_prefix(socket: &mut TcpStream, prefix: &[u8]) -> Vec { + let mut received = Vec::new(); + let deadline = Instant::now().checked_add(WAIT).expect("prefix timer"); + while !received.windows(prefix.len()).any(|bytes| bytes == prefix) { + assert!(Instant::now() < deadline, "wire prefix deadline"); + assert!(received.len() < 0x4000, "finite response head and prefix"); + let mut chunk = [0_u8; 512]; + let count = socket + .read(&mut chunk) + .expect("wire prefix before terminal failure"); + assert_ne!(count, 0, "connection closed before prefix: {received:?}"); + received.extend_from_slice(chunk.get(..count).expect("wire read length")); + } + assert!(received.starts_with(b"HTTP/1.1 200")); + assert!(received.windows(4).any(|bytes| bytes == b"\r\n\r\n")); + received + } + + #[test] + fn native_actual_wire_prefix_precedes_source_and_deadline_failure_without_second_response() { + for (path, prefix, outcome, dropped) in [ + ( + "/native-source", + b"source-prefix".as_slice(), + "source_error", + "SOURCE_DROPPED", + ), + ( + "/native-deadline", + b"deadline-prefix".as_slice(), + "deadline_exceeded", + "DEADLINE_DROPPED", + ), + ] { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics", &roots, addr), addr); + server.ready(); + let mut client = server.socket().expect("streaming client"); + write!( + client, + "GET {path} HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n" + ) + .expect("stream request"); + let mut wire = wire_prefix(&mut client, prefix); + let mut tail = Vec::new(); + let terminal = client.read_to_end(&mut tail); + if let Err(error) = terminal { + assert!( + matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::UnexpectedEof + ), + "failure did not settle before timeout: {error}" + ); + } + wire.extend_from_slice(&tail); + let wire_text = String::from_utf8(wire).expect("ASCII fixture response"); + assert_eq!( + wire_text.matches("HTTP/1.1 ").count(), + 1, + "no replacement response after commitment" + ); + assert!(!wire_text.contains("SENTINEL")); + assert!( + !wire_text.ends_with("0\r\n\r\n"), + "stream failure must not look like completed chunked framing" + ); + let record = route_record(&server, path); + assert_eq!( + record["status"], 200_u16, + "selected status is not successful delivery" + ); + assert_eq!(record["outcome"], outcome); + assert_eq!(record["level"], "WARN"); + assert_eq!(record["bytes_written"], prefix.len()); + assert_eq!(record["body_kind"], "application"); + assert!(record["fallback"].is_null()); + assert_eq!(record["counts"]["active_requests"], 0_u64); + wait_for(&server, |log| { + log.contains(dropped) && log.matches("APP_OBSERVER ").count() >= 2 + }); + assert_hook_order(&server, record["id"].as_str().expect("record ID")); + assert_record_log(&server, &record); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + assert_eq!(server.log().matches(dropped).count(), 1); + assert_eq!( + observations(&server) + .iter() + .filter(|observed| observed["route"] == path) + .count(), + 1 + ); + for entry in framework_logs(&server) { + assert!( + !entry["message"] + .as_str() + .expect("framework message") + .contains("SENTINEL") + ); + } + } + } + + #[test] + fn native_actual_wire_disconnect_keeps_selected_status_and_one_terminal_owner() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics", &roots, addr), addr); + server.ready(); + let mut client = server.socket().expect("stream client"); + client + .write_all(b"GET /stream HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("stream request"); + let wire = wire_prefix(&mut client, b"stream-prefix"); + assert_eq!( + String::from_utf8(wire) + .expect("wire head") + .matches("HTTP/1.1 ") + .count(), + 1 + ); + drop(client); + let record = route_record(&server, "/stream"); + assert_eq!(record["status"], 200_u16); + // The existing HTTP/1 connection owner reports this peer loss as a transport failure. + assert_eq!(record["outcome"], "transport_error"); + assert_eq!(record["level"], "WARN"); + assert_eq!(record["counts"]["active_requests"], 0_u64); + wait_for(&server, |log| { + log.contains("STREAM_DROPPED") && log.matches("APP_OBSERVER ").count() >= 2 + }); + assert_hook_order(&server, record["id"].as_str().expect("record ID")); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + assert_eq!(server.log().matches("STREAM_DROPPED").count(), 1); + assert_record_log(&server, &record); + } + + #[test] + fn native_refusal_and_no_response_abort_keep_separate_observer_contracts() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("diagnostics", &roots, addr), addr); + server.ready(); + let response = server.request("/native-refuse").expect("refusal response"); + assert!(response.starts_with("HTTP/1.1 503")); + let mut client = server.socket().expect("abort connection"); + client + .write_all(b"GET /native-abort HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\n\r\n") + .expect("abort request"); + let mut aborted_wire = Vec::new(); + let abort_read = client.read_to_end(&mut aborted_wire); + if let Err(error) = abort_read { + assert_eq!(error.kind(), io::ErrorKind::ConnectionReset); + } + assert!(aborted_wire.is_empty(), "no fabricated abort response"); + server.signal("TERM"); + assert!(server.wait().success()); + assert_final_zero(&server); + let records = observations(&server); + let refusal = records + .iter() + .filter(|record| record["failure"] == "admission_refused") + .collect::>(); + assert_eq!(refusal.len(), 1); + assert_eq!( + refusal.into_iter().next().expect("refusal record")["status"], + 503_u16 + ); + let abort_records = records + .iter() + .filter(|record| record["outcome"] == "aborted") + .collect::>(); + assert_eq!(abort_records.len(), 1); + let abort_record = abort_records + .into_iter() + .next() + .expect("no response ingress record"); + assert_eq!(abort_record["event"], "request_ingress"); + assert!(abort_record["status"].is_null() && abort_record["bytes_written"].is_null()); + assert_eq!( + server.log().matches("APP_COMPLETION ").count(), + 2, + "readiness plus supplied refusal completion only" + ); + assert_eq!( + server.log().matches("APP_OBSERVER ").count(), + 1, + "refusal is detached; abort has no egress observer" + ); + for record in records { + assert_record_log(&server, &record); + } + } + + fn native_counts(server: &Server, expected_requests: u64, expected_idle: u64) -> Value { + let deadline = Instant::now().checked_add(WAIT).expect("snapshot timer"); + loop { + let counts = response_json(&server.request("/native-counts").expect("counter query")); + if counts["active_requests"] == expected_requests + && counts["idle_connections"] == expected_idle + { + return counts; + } + assert!( + Instant::now() < deadline, + "snapshot did not converge: {counts}; {}", + server.log() + ); + thread::sleep(Duration::from_millis(5)); + } + } + + fn assert_shutdown_observations(server: &Server, path: &str, graceful: bool, optout: bool) { + let logs = framework_logs(server); + for phase in ["starting", "ready", "draining", "stopped"] { + assert!( + logs.iter().any(|entry| entry["message"] + .as_str() + .expect("lifecycle message") + .contains(&format!("event=lifecycle phase={phase}"))), + "missing lifecycle {phase}" + ); + } + if !graceful { + assert!(logs.iter().any(|entry| { + entry["message"] + .as_str() + .expect("fault category") + .contains("reason=grace_exhausted") + })); + } + let records = observations(server); + assert!(!records.is_empty(), "native observer retained"); + assert!(server.log().contains("APP_OBSERVER ") && server.log().contains("APP_COMPLETION ")); + if optout { + assert!( + !logs.iter().any(|entry| { + let message = entry["message"].as_str().expect("framework message"); + message.starts_with("request_terminal ") + || message.starts_with("request_ingress ") + }), + "request-record opt-out" + ); + } + let abandoned = records + .iter() + .filter(|record| record["outcome"] == "abandoned") + .collect::>(); + if matches!(path, "/pending" | "/json") { + assert_eq!(abandoned.len(), 1); + let record = abandoned + .into_iter() + .next() + .expect("pre-attempt abandonment"); + assert!(record["status"].is_null()); + assert_eq!(record["event"], "request_ingress"); + } + } + + #[test] + fn native_idle_and_held_work_snapshots_drain_to_zero_with_request_records_opted_out() { + for optout in [false, true] { + for (path, prefix, marker, graceful) in [ + ("/finite", None, "DISPATCH", true), + ("/pending", None, "DISPATCH", false), + ( + "/stream", + Some(b"stream-prefix".as_slice()), + "STREAM_READY", + false, + ), + ("/json", None, "", false), + ] { + let roots = Roots::new(); + let addr = address(); + let mut launch = command( + if optout { + "diagnostics-optout" + } else { + "diagnostics" + }, + &roots, + addr, + ); + if optout { + launch.env("EDGEZERO__LOGGING__REQUEST_RECORDS", "false"); + } + let mut server = Server::spawn(launch, addr); + server.ready(); + let mut idle = server.socket().expect("owned keepalive connection"); + idle.write_all(b"GET /ready HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("keepalive request"); + let mut ready = Vec::new(); + while !ready.ends_with(b"ready") { + let mut buffer = [0_u8; 512]; + let count = idle.read(&mut buffer).expect("keepalive ready response"); + assert_ne!(count, 0); + ready.extend_from_slice(buffer.get(..count).expect("read length")); + } + let idle_counts = native_counts(&server, 1, 1); + assert_eq!( + idle_counts["active_connections"], 2_u64, + "idle connection plus count query" + ); + let mut held = server.socket().expect("held request"); + if path == "/json" { + held.write_all(b"POST /json HTTP/1.1\r\nHost: fixture\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n\"x").expect("partial body held open"); + } else { + write!(held, "GET {path} HTTP/1.1\r\nHost: fixture\r\n\r\n") + .expect("held request"); + server.wait_line(marker); + } + if let Some(expected_prefix) = prefix { + let _wire = wire_prefix(&mut held, expected_prefix); + } + let held_counts = native_counts(&server, 2, 1); + assert_eq!( + held_counts["active_connections"], 3_u64, + "held, idle and query connections" + ); + assert_eq!(held_counts["phase"], "ready"); + let started = Instant::now(); + server.signal("TERM"); + if graceful { + let mut response = String::new(); + held.read_to_string(&mut response) + .expect("graceful finite response"); + assert!( + response.starts_with("HTTP/1.1 200") + && response.contains("finite-complete") + ); + } + let status = server.wait(); + assert_eq!(status.success(), graceful, "{}", server.log()); + assert!(started.elapsed() < Duration::from_secs(4)); + if !graceful { + assert!(started.elapsed() >= Duration::from_millis(700)); + } + assert_final_zero(&server); + assert_eq!(idle.read(&mut [0_u8; 1]).expect("idle close"), 0); + assert_shutdown_observations(&server, path, graceful, optout); + } + } + } + + #[test] + fn native_invalid_proxy_and_record_settings_fail_before_ready_without_input_disclosure() { + let roots = Roots::new(); + for (networks, family, records, category) in [ + ( + Some("NETWORK_SENTINEL.invalid"), + Some("forwarded"), + None, + "TRUSTED_PROXY_POLICY", + ), + ( + Some("0.0.0.0/0"), + Some("forwarded"), + None, + "TRUSTED_PROXY_POLICY", + ), + ( + Some("::/0"), + Some("forwarded"), + None, + "TRUSTED_PROXY_POLICY", + ), + ( + Some("0.0.0.0/1,128.0.0.0/1"), + Some("forwarded"), + None, + "TRUSTED_PROXY_POLICY", + ), + (Some("127.0.0.2/32"), None, None, "TRUSTED_PROXY_POLICY"), + ( + Some("127.0.0.2/32"), + Some("FAMILY_SENTINEL"), + None, + "TRUSTED_PROXY_POLICY", + ), + ( + Some(""), + Some("FAMILY_SENTINEL"), + None, + "TRUSTED_PROXY_POLICY", + ), + (None, None, Some("RECORDS_SENTINEL"), "REQUEST_RECORDS"), + (None, None, Some("FALSE"), "REQUEST_RECORDS"), + ] { + let addr = address(); + let mut launch = command("diagnostics", &roots, addr); + if let Some(value) = networks { + launch.env("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", value); + } + if let Some(value) = family { + launch.env("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", value); + } + if let Some(value) = records { + launch.env("EDGEZERO__LOGGING__REQUEST_RECORDS", value); + } + let log = failed(launch, addr, category); + assert!( + !log.contains("HOOK_ENTERED"), + "invalid settings precede app construction" + ); + assert!(!log.contains("phase=ready")); + if let Some(value) = networks.filter(|text| !text.is_empty()) { + assert!(!log.contains(value), "network input leaked: {log}"); + } + if let Some(value) = records { + assert!(!log.contains(value), "record-setting input leaked: {log}"); + } + } + } + + #[test] + fn native_non_unicode_proxy_and_record_settings_fail_instead_of_defaulting() { + use std::os::unix::ffi::OsStringExt as _; + let roots = Roots::new(); + for (key, category) in [ + ( + "EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", + "TRUSTED_PROXY_CIDRS", + ), + ( + "EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", + "FORWARDING_HEADER_FAMILY", + ), + ("EDGEZERO__LOGGING__REQUEST_RECORDS", "REQUEST_RECORDS"), + ] { + let addr = address(); + let mut launch = command("diagnostics", &roots, addr); + let mut raw = b"NON_UNICODE_SENTINEL".to_vec(); + raw.push(0xff); + launch.env(key, OsString::from_vec(raw)); + let log = failed(launch, addr, category); + assert!( + log.contains("non-Unicode"), + "captured native setting was rejected: {log}" + ); + assert!(!log.contains("HOOK_ENTERED") && !log.contains("phase=ready")); + assert!( + !log.contains('\u{fffd}'), + "no lossy rendering of non-Unicode input" + ); + } + } + + 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 both_signals_stop_idle_processes() { for signal in ["INT", "TERM"] { diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index e957e0cb..3d4b42d6 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -128,8 +128,21 @@ mod tests { } } + fn forged_geo() -> edgezero_core::geo::GeoOutcome { + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + 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(), &edgezero_core::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) @@ -204,7 +217,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 { @@ -215,6 +230,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 842aeee0..dc10b1f8 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -108,8 +108,21 @@ mod tests { } } + fn forged_geo() -> edgezero_core::geo::GeoOutcome { + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + 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(), &edgezero_core::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) @@ -167,7 +180,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 { @@ -176,6 +191,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()); } @@ -260,6 +277,11 @@ mod tests { let request_events = Arc::clone(&handler_events); async move { request_events.lock().expect("events").push("handler"); + assert_eq!(ctx.geo(), &edgezero_core::geo::GeoOutcome::NoSelection); + assert_eq!( + ctx.extensions().get::(), + Some(&forged_geo()) + ); let value = ctx .extensions() .get::() @@ -281,6 +303,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 067622ee..60c2492d 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -213,8 +213,21 @@ mod tests { } } + fn forged_geo() -> edgezero_core::geo::GeoOutcome { + use edgezero_core::geo::{GeoError, GeoOutcome, GeoProviderId, GeoStatus}; + 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(), &edgezero_core::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) @@ -316,7 +329,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] @@ -583,6 +598,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 530e990c..61769462 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -1,8 +1,10 @@ +use std::panic::catch_unwind; use std::sync::Arc; use crate::body::Body; use crate::config_store::ConfigExtractionLimits; use crate::error::EdgeError; +use crate::geo::GeoOutcome; use crate::http::header::CONTENT_TYPE; use crate::http::{HeaderValue, Method, Request, Response, StatusCode}; use crate::ingress::{ @@ -12,15 +14,14 @@ use crate::ingress::{ }; use crate::manifest::BakedManifest; use crate::platform::PlatformMetadata; -use crate::response::IntoResponse as _; use crate::response_egress::{ DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, DetachedResponseEgressDecisionFactory, DetachedResponseEgressHead, ResponseEgressCompletion, - ResponseEgressDeadline, ResponseEgressEnvelope, ResponseEgressHead, ResponseEgressObserver, - ResponseEgressObserverHandle, ResponseEgressPolicy, ResponseEgressPolicyCallback, - ResponseEgressRequestMetadata, default_response_egress_policy, + ResponseEgressDeadline, ResponseEgressEnvelope, ResponseEgressFailureClassification, + ResponseEgressHead, ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressPolicy, + ResponseEgressPolicyCallback, ResponseEgressRequestMetadata, default_response_egress_policy, }; -use crate::router::{RouteMetadata, RouterService}; +use crate::router::{RouteErrorKind, RouteMetadata, RouterService}; use crate::time::{MonotonicClock, MonotonicInstant}; /// Canonical adapter name for the Axum adapter. @@ -32,7 +33,18 @@ const DEFAULT_APP_NAME: &str = "EdgeZero App"; pub const FASTLY_ADAPTER: &str = "fastly"; /// Canonical adapter name for the Spin adapter. pub const SPIN_ADAPTER: &str = "spin"; -type ErrorResponseRenderer = Arc Response + Send + Sync + 'static>; +enum ErrorResponseRenderer { + Custom(Arc Response + Send + Sync + 'static>), + Default, +} + +/// Presentation of framework-generated routing errors on a managed adapter path. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FrameworkErrorDetail { + CategoryOnly, + Default, +} /// Lightweight container around a `RouterService` that can be extended via hook implementations. pub struct App { @@ -92,6 +104,7 @@ impl App { let request_start = prepared.request_start(); let route = prepared.route_metadata().cloned(); let (_resolved, _admitted, completion) = prepared.into_parts(); + let failure = ResponseEgressFailureClassification::error(error.kind()); self.response_egress_envelope( self.render_error_response(error), request_method, @@ -99,6 +112,7 @@ impl App { route, completion, ) + .with_failure(Some(failure)) } /// Resolves and admits a normalized request head before an adapter transfers native body @@ -188,6 +202,7 @@ impl App { request_method: Method, request_start: MonotonicInstant, ) -> IngressDispatchOutcome { + let failure = ResponseEgressFailureClassification::error(error.kind()); let head = DetachedResponseEgressHead::new( &request_method, request_start, @@ -204,12 +219,15 @@ impl App { response .extensions_mut() .insert(ResponseEgressDeadline::at(deadline)); - IngressDispatchOutcome::Response(Box::new(self.detached_response_egress( - response, - request_method, - request_start, - completion, - ))) + IngressDispatchOutcome::Response(Box::new( + self.detached_response_egress( + response, + request_method, + request_start, + completion, + ) + .with_failure(Some(failure)), + )) } } } @@ -237,20 +255,73 @@ impl App { &self, prepared: PreparedIngress, request: Request, + ) -> ResponseEgressEnvelope { + self.dispatch_admitted_with_framework_error_detail( + prepared, + request, + FrameworkErrorDetail::Default, + ) + .await + } + + /// Dispatches with native default routing-error presentation, preserving custom renderers. + #[doc(hidden)] + #[inline] + pub async fn dispatch_admitted_with_framework_error_detail( + &self, + 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(); let route = prepared.route_metadata().cloned(); let (resolved, admitted, completion) = prepared.into_parts(); - let response = match self + let (result, route_error, mut failure) = self .router - .dispatch_resolved(resolved, request, admitted) + .dispatch_resolved_outcome( + resolved, + request, + admitted.with_request_context(geo, detail == FrameworkErrorDetail::CategoryOnly), + ) .await - { + .into_parts(); + let response = match result { Ok(response) => response, - Err(error) => self.render_error_response(error), + Err(error) => { + failure = Some(if route_error.is_some() { + ResponseEgressFailureClassification::routing(error.kind()) + } else { + ResponseEgressFailureClassification::error(error.kind()) + }); + let fixed = if detail == FrameworkErrorDetail::CategoryOnly { + match route_error { + Some(RouteErrorKind::NotFound) => Some("route not found"), + Some(RouteErrorKind::MethodNotAllowed) => Some("method not allowed"), + None => None, + } + } else { + None + }; + self.render_error_response_with_message(error, fixed) + } }; self.response_egress_envelope(response, request_method, request_start, route, completion) + .with_failure(failure) } /// Resolves, admits, and dispatches one normalized inbound request into owned egress. @@ -345,12 +416,25 @@ impl App { } fn render_error_response(&self, error: EdgeError) -> Response { + self.render_error_response_with_message(error, None) + } + + fn render_error_response_with_message( + &self, + error: EdgeError, + fixed: Option<&'static str>, + ) -> Response { let status = error.status(); let required_headers = match error.required_response_headers() { Ok(headers) => headers, Err(header_error) => return render_default_error_response(header_error), }; - let mut response = (self.error_response_renderer)(error); + let mut response = match &self.error_response_renderer { + ErrorResponseRenderer::Default => { + render_default_error_response_with_message(error, fixed) + } + ErrorResponseRenderer::Custom(renderer) => renderer(error), + }; *response.status_mut() = status; response.headers_mut().extend(required_headers); response @@ -432,7 +516,7 @@ impl App { where Renderer: Fn(EdgeError) -> Response + Send + Sync + 'static, { - self.error_response_renderer = Arc::new(renderer); + self.error_response_renderer = ErrorResponseRenderer::Custom(Arc::new(renderer)); } /// Installs the synchronous body-blind ingress admission callback. @@ -514,7 +598,7 @@ impl App { deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), } }), - error_response_renderer: Arc::new(render_default_error_response), + error_response_renderer: ErrorResponseRenderer::Default, ingress_head_limits: IngressHeadLimits::default(), ingress_policy: default_admission_policy(), monotonic_clock: MonotonicClock::default(), @@ -625,10 +709,18 @@ pub trait Hooks { } fn render_default_error_response(error: EdgeError) -> Response { - match error.into_response() { + render_default_error_response_with_message(error, None) +} + +fn render_default_error_response_with_message( + error: EdgeError, + fixed: Option<&'static str>, +) -> Response { + match error.into_response_with_message(fixed) { Ok(response) => response, - Err(render_error) => { - log::error!("failed to render typed ingress error response: {render_error}"); + Err(_render_error) => { + let _logged = + catch_unwind(|| log::error!("failed to render typed ingress error response")); // Keep the last-resort response independent of JSON serialization. let mut response = Response::new(Body::from( r#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, @@ -1994,6 +2086,255 @@ 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" + ); + + let mut unmanaged = request_builder().body(Body::empty()).unwrap(); + unmanaged.extensions_mut().insert(selected()); + 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(selected()); + 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, + path: &str, + detail: FrameworkErrorDetail, + ) -> ResponseEgressEnvelope { + let request = request_builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request"); + let head = IngressHeadParts::from_request( + &request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, app.monotonic_now()) + .expect("admission") + else { + panic!("admission"); + }; + block_on(app.dispatch_admitted_with_framework_error_detail(prepared, request, detail)) + } + + #[test] + fn native_default_routing_errors_do_not_reflect_path_or_custom_method() { + let app = App::new( + RouterService::builder() + .get("/known", |_ctx: RequestContext| async { + Ok::<_, EdgeError>("ok") + }) + .build(), + ); + for (method, path, status, fixed) in [ + ( + Method::GET, + "/private-sentinel", + StatusCode::NOT_FOUND, + "route not found", + ), + ( + Method::from_bytes(b"PRIVATE-SENTINEL").expect("method"), + "/known", + StatusCode::METHOD_NOT_ALLOWED, + "method not allowed", + ), + ] { + let envelope = managed_dispatch(&app, method, path, FrameworkErrorDetail::CategoryOnly); + let failure = envelope.failure_classification().expect("routing category"); + assert!(failure.is_framework_route_error()); + let response = complete_dispatch(IngressDispatchOutcome::Response(Box::new(envelope))); + assert_eq!(response.status(), status); + if status == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!(response.headers()["allow"], "GET"); + } + let payload: serde_json::Value = + serde_json::from_slice(response.body().as_bytes().expect("body")).expect("json"); + assert_eq!(payload["error"]["message"], fixed); + assert!( + !str::from_utf8(response.body().as_bytes().expect("body")) + .expect("utf8") + .contains("sentinel") + ); + } + let legacy = managed_dispatch( + &app, + Method::GET, + "/private-sentinel", + FrameworkErrorDetail::Default, + ); + let response = complete_dispatch(IngressDispatchOutcome::Response(Box::new(legacy))); + assert!( + str::from_utf8(response.body().as_bytes().expect("body")) + .expect("utf8") + .contains("private-sentinel") + ); + } + + #[test] + fn native_routing_detail_preserves_custom_renderer_input_and_handler_errors() { + let mut app = App::new( + RouterService::builder() + .get("/handler", |_ctx: RequestContext| async { + Err::<&'static str, _>(EdgeError::not_found("handler-sentinel")) + }) + .build(), + ); + let handler = managed_dispatch( + &app, + Method::GET, + "/handler", + FrameworkErrorDetail::CategoryOnly, + ); + assert!( + !handler + .failure_classification() + .expect("handler error") + .is_framework_route_error() + ); + let handler_response = + complete_dispatch(IngressDispatchOutcome::Response(Box::new(handler))); + assert!( + str::from_utf8(handler_response.body().as_bytes().expect("body")) + .expect("utf8") + .contains("handler-sentinel") + ); + let seen = Arc::new(Mutex::new(String::new())); + let renderer_seen = Arc::clone(&seen); + app.set_error_response_renderer(move |error| { + *renderer_seen.lock().expect("seen") = error.to_string(); + Response::new(Body::from("application-owned")) + }); + let envelope = managed_dispatch( + &app, + Method::GET, + "/route-sentinel", + FrameworkErrorDetail::CategoryOnly, + ); + assert!( + envelope + .failure_classification() + .expect("routing error") + .is_framework_route_error() + ); + let response = complete_dispatch(IngressDispatchOutcome::Response(Box::new(envelope))); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.body().as_bytes().expect("body"), + b"application-owned" + ); + assert!(seen.lock().expect("seen").contains("route-sentinel")); + } + #[test] fn custom_error_response_renderer_handles_admitted_dispatch_errors() { let router = RouterService::builder() diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 76f84a54..50cb0d9e 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -5,6 +5,7 @@ use crate::config_store::ConfigExtractionLimits; use crate::error::{ BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, StoreExtractionReason, }; +use crate::geo::GeoOutcome; use crate::http::{Extensions, HeaderMap, Method, Request, RequestParts, Uri, Version}; use crate::ingress::{AdmittedIngress, IngressGrant}; use crate::outbound::HttpClient; @@ -161,7 +162,9 @@ impl StoredError { /// Request context exposed to handlers and middleware. pub struct RequestContext { body: RefCell, + category_only_json_errors: bool, config_extraction_limits: ConfigExtractionLimits, + geo: GeoOutcome, ingress_grant: RefCell>, monotonic_clock: MonotonicClock, parts: RequestParts, @@ -349,6 +352,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 { @@ -396,8 +406,13 @@ impl RequestContext { T: DeserializeOwned, { let bytes = self.body_bytes(max).await?; - serde_json::from_slice(bytes.as_ref()) - .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) + serde_json::from_slice(bytes.as_ref()).map_err(|err| { + if self.category_only_json_errors { + EdgeError::bad_request("invalid JSON payload") + } else { + EdgeError::bad_request(format!("invalid JSON payload: {err}")) + } + }) } /// Resolve the [`BoundKvStore`] for `id`. Strict lookup: when a @@ -437,6 +452,8 @@ impl RequestContext { pub fn new(request: Request, params: PathParams) -> Self { let (parts, body) = request.into_parts(); Self { + geo: GeoOutcome::NoSelection, + category_only_json_errors: false, body: RefCell::new(BodyState::Initial(body)), config_extraction_limits: ConfigExtractionLimits::default(), ingress_grant: RefCell::new(None), @@ -453,12 +470,15 @@ impl RequestContext { request: Request, params: PathParams, route_metadata: RouteMetadata, - ingress: AdmittedIngress, + mut ingress: AdmittedIngress, ) -> Self { + let (geo, category_only_json_errors) = ingress.take_request_context(); let (request_start, read_deadline, grant, config_extraction_limits, monotonic_clock) = ingress.into_parts(); let (parts, body) = request.into_parts(); Self { + geo, + category_only_json_errors, body: RefCell::new(BodyState::Initial(body)), config_extraction_limits, ingress_grant: RefCell::new(Some(grant)), diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 66ead099..9e7d6e1b 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -286,6 +286,66 @@ impl EdgeError { } } + pub(crate) fn into_response_with_message( + self, + fixed_message: Option<&'static str>, + ) -> Result { + let required_headers = self.required_response_headers()?; + let kind = self.kind(); + // `ConfigOutOfDate { field_path: String::new(), .. }` (the missing-blob + // path) must OMIT the `field_path` JSON key entirely, not emit + // `"field_path": ""`. Per spec 6.3.1. + let field_path_opt: Option<&str> = match &self { + EdgeError::ConfigOutOfDate { field_path, .. } if !field_path.is_empty() => { + Some(field_path.as_str()) + } + EdgeError::StoreExtraction { + field_path: Some(field_path), + .. + } => Some(field_path.as_str()), + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } + | EdgeError::ConfigOutOfDate { .. } + | EdgeError::GatewayTimeout { .. } + | EdgeError::Internal { .. } + | EdgeError::MethodNotAllowed { .. } + | EdgeError::NotFound { .. } + | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } + | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } + | EdgeError::Validation { .. } => None, + }; + let status = self.status(); + let message = fixed_message.map_or_else(|| self.wire_message(), str::to_owned); + + let mut payload = json!({ + "error": { + "status": status.as_u16(), + "kind": kind, + "message": message, + }, + }); + if let Some(field_path) = field_path_opt + && let Some(error_obj) = payload + .get_mut("error") + .and_then(serde_json::Value::as_object_mut) + { + error_obj.insert("field_path".into(), json!(field_path)); + } + + let body = json_or_text(&payload); + let mut response = response_with_body(status, body)?; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response.headers_mut().extend(required_headers); + Ok(response) + } + /// Returns the stable public category without provider or application diagnostics. #[must_use] #[inline] @@ -556,60 +616,7 @@ impl From for EdgeError { impl IntoResponse for EdgeError { #[inline] fn into_response(self) -> Result { - let required_headers = self.required_response_headers()?; - let kind = self.kind(); - // `ConfigOutOfDate { field_path: String::new(), .. }` (the missing-blob - // path) must OMIT the `field_path` JSON key entirely, not emit - // `"field_path": ""`. Per spec 6.3.1. - let field_path_opt: Option<&str> = match &self { - EdgeError::ConfigOutOfDate { field_path, .. } if !field_path.is_empty() => { - Some(field_path.as_str()) - } - EdgeError::StoreExtraction { - field_path: Some(field_path), - .. - } => Some(field_path.as_str()), - EdgeError::BadGateway { .. } - | EdgeError::BadRequest { .. } - | EdgeError::ConfigOutOfDate { .. } - | EdgeError::GatewayTimeout { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::RequestHeaderFieldsTooLarge { .. } - | EdgeError::RequestTimeout { .. } - | EdgeError::ResponseTooLarge { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::StoreExtraction { .. } - | EdgeError::UriTooLong { .. } - | EdgeError::Validation { .. } => None, - }; - let status = self.status(); - let message = self.wire_message(); - - let mut payload = json!({ - "error": { - "status": status.as_u16(), - "kind": kind, - "message": message, - }, - }); - if let Some(field_path) = field_path_opt - && let Some(error_obj) = payload - .get_mut("error") - .and_then(serde_json::Value::as_object_mut) - { - error_obj.insert("field_path".into(), json!(field_path)); - } - - let body = json_or_text(&payload); - let mut response = response_with_body(status, body)?; - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - response.headers_mut().extend(required_headers); - Ok(response) + self.into_response_with_message(None) } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index ddf5fb9e..40c5353c 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -17,6 +17,7 @@ use crate::config_store::{ConfigExtractionLimits, ConfigStoreError, ConfigStoreH use crate::context::{DEFAULT_INBOUND_FORM_BYTES, DEFAULT_INBOUND_JSON_BYTES, RequestContext}; use crate::error::{EdgeError, StoreExtractionReason}; use crate::http::HeaderMap; +use crate::ingress::CheckedEffectiveHost; use crate::secret_store::SecretError; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, @@ -247,6 +248,10 @@ pub struct ForwardedHost(pub String); impl FromRequest for ForwardedHost { #[inline] async fn from_request(ctx: &RequestContext) -> Result { + if let Some(checked) = ctx.extensions().get::() { + return Ok(Self(checked.authority().unwrap_or("localhost").to_owned())); + } + // Legacy unmanaged paths retain their existing raw-header behavior. let headers = ctx.headers(); let host = headers .get("x-forwarded-host") @@ -2324,6 +2329,56 @@ mod tests { } // ForwardedHost extractor tests + #[test] + fn forwarded_host_checked_value_precedes_both_raw_headers() { + use crate::ingress::CheckedHostSource; + let mut request = request_builder() + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("direct.example")); + request.headers_mut().insert( + "x-forwarded-host", + HeaderValue::from_static("spoofed.example"), + ); + request + .extensions_mut() + .insert(CheckedEffectiveHost::from_ingress( + Some("verified.example:8443".parse().expect("authority")), + CheckedHostSource::TrustedForwarded, + )); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(ForwardedHost::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "verified.example:8443"); + } + + #[test] + fn forwarded_host_checked_unavailable_never_falls_through_to_raw_headers() { + use crate::ingress::CheckedHostSource; + let mut request = request_builder() + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("direct.example")); + request.headers_mut().insert( + "x-forwarded-host", + HeaderValue::from_static("spoofed.example"), + ); + request + .extensions_mut() + .insert(CheckedEffectiveHost::from_ingress( + None, + CheckedHostSource::Unavailable, + )); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(ForwardedHost::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "localhost"); + } + #[test] fn forwarded_host_extractor_uses_x_forwarded_host_first() { let mut request = request_builder() diff --git a/crates/edgezero-core/src/ingress.rs b/crates/edgezero-core/src/ingress.rs index 85582596..0cc6eee1 100644 --- a/crates/edgezero-core/src/ingress.rs +++ b/crates/edgezero-core/src/ingress.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::fmt; +use std::mem; use std::sync::Arc; use std::time::Duration; @@ -8,9 +9,10 @@ use bytes::Bytes; use crate::body::Body; use crate::config_store::ConfigExtractionLimits; use crate::error::EdgeError; +use crate::geo::GeoOutcome; use crate::http::{ - Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Response, StatusCode, Uri, - Version, + Authority, Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Response, + StatusCode, Uri, Version, header::{CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING}, }; use crate::response_egress::{ResponseEgressCompletion, ResponseEgressEnvelope}; @@ -249,11 +251,13 @@ 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, monotonic_clock: MonotonicClock, read_deadline: Deadline, + request_geo: GeoOutcome, request_start: MonotonicInstant, } @@ -321,10 +325,93 @@ impl AdmittedIngress { 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 } + + 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. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CheckedHostSource { + Direct, + TrustedForwarded, + TrustedXForwarded, + Unavailable, +} + +/// Adapter-checked authority for portable host consumers. +/// This witness does not itself authorize forwarding assertions. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckedEffectiveHost { + authority: Option, + source: CheckedHostSource, +} + +impl CheckedEffectiveHost { + #[must_use] + #[inline] + pub fn authority(&self) -> Option<&str> { + self.authority.as_ref().map(Authority::as_str) + } + + /// Constructs a witness only after adapter-owned trust and syntax checks. + #[doc(hidden)] + #[must_use] + #[inline] + pub const fn from_ingress(authority: Option, source: CheckedHostSource) -> Self { + Self { authority, source } + } + + #[must_use] + #[inline] + pub const fn source(&self) -> CheckedHostSource { + self.source + } +} + +/// Received normalized-head sizes; never proof of raw-wire accounting. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NormalizedIngressHeadSummary { + header_bytes: u64, + header_count: u64, + target_bytes: u64, +} + +impl NormalizedIngressHeadSummary { + #[must_use] + #[inline] + pub const fn header_bytes(self) -> u64 { + self.header_bytes + } + #[must_use] + #[inline] + pub const fn header_count(self) -> u64 { + self.header_count + } + #[must_use] + #[inline] + pub const fn target_bytes(self) -> u64 { + self.target_bytes + } } /// Whether request-head accounting was enforced before normalized request construction. @@ -520,6 +607,7 @@ impl IngressHeadParts { #[inline] pub fn validate_normalized(&self, limits: IngressHeadLimits) -> Result<(), EdgeError> { validate_normalized_head(&self.target, self.version, &self.headers, limits) + .map(|_summary| ()) } #[must_use] @@ -803,6 +891,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, @@ -834,6 +924,19 @@ pub fn validate_normalized_ingress_parts( parts: &RequestParts, limits: IngressHeadLimits, ) -> Result<(), EdgeError> { + validate_normalized_ingress_parts_with_summary(parts, limits).map(|_summary| ()) +} + +/// Validates and accounts for the received head in the same bounded pass. +/// Call before stripping assertions; returned sizes remain normalized, not raw-wire. +/// +/// # Errors +/// Returns the same head/framing errors as [`validate_normalized_ingress_parts`]. +#[inline] +pub fn validate_normalized_ingress_parts_with_summary( + parts: &RequestParts, + limits: IngressHeadLimits, +) -> Result { validate_normalized_head(&parts.uri, parts.version, &parts.headers, limits) } @@ -842,7 +945,7 @@ fn validate_normalized_head( version: Version, headers: &HeaderMap, limits: IngressHeadLimits, -) -> Result<(), EdgeError> { +) -> Result { let target_bytes = u64::try_from(target.to_string().len()).map_err(|_length_error| { EdgeError::uri_too_long("normalized request target is too large") })?; @@ -886,7 +989,12 @@ fn validate_normalized_head( } } - validate_normalized_framing(version, headers) + validate_normalized_framing(version, headers)?; + Ok(NormalizedIngressHeadSummary { + header_bytes, + header_count, + target_bytes, + }) } fn validate_normalized_framing(version: Version, headers: &HeaderMap) -> Result<(), EdgeError> { diff --git a/crates/edgezero-core/src/response_egress.rs b/crates/edgezero-core/src/response_egress.rs index f7857b72..5bbf8e2a 100644 --- a/crates/edgezero-core/src/response_egress.rs +++ b/crates/edgezero-core/src/response_egress.rs @@ -300,7 +300,68 @@ pub struct ResponseEgressReport { pub route: Option, } -type ResponseEgressCompletionCallback = Box; +type ResponseEgressCompletionCallback = + Box; + +/// Bounded reason retained independently of an error renderer's response. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressFailureKind { + FallbackBodyExceeded, + FallbackReadTimedOut, + PropagatedError, +} + +/// Framework-owned failure classification with no diagnostic source or input text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseEgressFailureClassification { + error_kind: Option<&'static str>, + framework_route_error: bool, + kind: ResponseEgressFailureKind, +} + +impl ResponseEgressFailureClassification { + pub(crate) const fn error(kind: &'static str) -> Self { + Self { + kind: ResponseEgressFailureKind::PropagatedError, + error_kind: Some(kind), + framework_route_error: false, + } + } + + #[must_use] + #[inline] + pub const fn error_kind(self) -> Option<&'static str> { + self.error_kind + } + + pub(crate) const fn fallback(kind: ResponseEgressFailureKind) -> Self { + Self { + kind, + error_kind: None, + framework_route_error: false, + } + } + + #[must_use] + #[inline] + pub const fn is_framework_route_error(self) -> bool { + self.framework_route_error + } + + #[must_use] + #[inline] + pub const fn kind(self) -> ResponseEgressFailureKind { + self.kind + } + + pub(crate) const fn routing(kind: &'static str) -> Self { + Self { + framework_route_error: true, + ..Self::error(kind) + } + } +} /// Non-clone owner of one response-scoped terminal callback. /// @@ -425,13 +486,18 @@ struct ResponseEgressResourceState { } impl ResponseEgressCompletion { + #[cfg(test)] fn complete(&mut self, report: &ResponseEgressReport) { + self.complete_at(report, report.request_start); + } + + fn complete_at(&mut self, report: &ResponseEgressReport, observed_at: MonotonicInstant) { let Some(callback) = self.callback.take() else { return; }; - let result = catch_unwind(AssertUnwindSafe(|| callback(report))); + let result = catch_unwind(AssertUnwindSafe(|| callback(report, observed_at))); if result.is_err() { - log::error!("response-egress completion panicked after terminal transition"); + log_callback_fault("response-egress completion panicked after terminal transition"); } } @@ -455,9 +521,9 @@ impl ResponseEgressCompletion { #[must_use] #[inline] pub fn join(mut self, mut other: Self) -> Self { - Self::new(move |report| { - self.complete(report); - other.complete(report); + Self::new_with_terminal_time(move |report, observed_at| { + self.complete_at(report, observed_at); + other.complete_at(report, observed_at); }) } @@ -489,6 +555,18 @@ impl ResponseEgressCompletion { pub fn new(complete: Complete) -> Self where Complete: FnOnce(&ResponseEgressReport) + Send + 'static, + { + Self::new_with_terminal_time(move |report, _observed_at| complete(report)) + } + + /// Retains a callback receiving the existing terminal transition's instant. + /// The instant is captured before any application callback or logger runs. + #[doc(hidden)] + #[must_use] + #[inline] + pub fn new_with_terminal_time(complete: Complete) -> Self + where + Complete: FnOnce(&ResponseEgressReport, MonotonicInstant) + Send + 'static, { Self { callback: Some(Box::new(complete)), @@ -527,6 +605,7 @@ pub struct ResponseEgressObserverHandle { pub struct ResponseEgressEnvelope { clock: MonotonicClock, completion: ResponseEgressCompletion, + failure_classification: Option, observer: ResponseEgressObserverHandle, policy: ResponseEgressPolicyCallback, request_method: Method, @@ -616,7 +695,7 @@ impl ResponseEgressEnvelope { let Ok(selected_policy) = catch_unwind(AssertUnwindSafe(|| (self.policy)(&head, egress_started_at))) else { - log::error!("response-egress policy panicked before conversion"); + log_callback_fault("response-egress policy panicked before conversion"); return Err(Box::new(ResponseEgressBeginFailure { attempt, cause: ResponseEgressOutcome::ConversionError, @@ -644,7 +723,7 @@ impl ResponseEgressEnvelope { )), }; let Ok(prepared) = prepare_response_egress(&self.request_method, self.response) else { - log::error!("response-egress framing validation failed before commit"); + log_callback_fault("response-egress framing validation failed before commit"); return Err(Box::new(ResponseEgressBeginFailure { attempt, cause: ResponseEgressOutcome::ConversionError, @@ -671,6 +750,13 @@ impl ResponseEgressEnvelope { ) } + /// Returns the framework-owned category captured before rendering. + #[must_use] + #[inline] + pub const fn failure_classification(&self) -> Option { + self.failure_classification + } + pub(crate) fn new( response: Response, metadata: ResponseEgressRequestMetadata, @@ -680,6 +766,7 @@ impl ResponseEgressEnvelope { clock: MonotonicClock, ) -> Self { Self { + failure_classification: None, clock, completion, observer, @@ -705,13 +792,30 @@ impl ResponseEgressEnvelope { pub fn response_mut(&mut self) -> &mut Response { &mut self.response } + + /// Composes adapter diagnostics without replacing application ownership. + #[doc(hidden)] + #[must_use] + #[inline] + pub fn with_completion(mut self, completion: ResponseEgressCompletion) -> Self { + self.completion = self.completion.join(completion); + self + } + + pub(crate) fn with_failure( + mut self, + failure: Option, + ) -> Self { + self.failure_classification = failure; + self + } } impl ResponseEgressObserverHandle { fn complete(&self, report: &ResponseEgressReport) { let result = catch_unwind(AssertUnwindSafe(|| self.observer.complete(report))); if result.is_err() { - log::error!("response-egress observer panicked after terminal transition"); + log_callback_fault("response-egress observer panicked after terminal transition"); } } @@ -927,7 +1031,7 @@ impl ResponseEgressAttempt { if let Some(elapsed) = observed_at.checked_duration_since(self.egress_started_at) { elapsed } else { - log::error!("response-egress monotonic clock moved backwards"); + log_callback_fault("response-egress monotonic clock moved backwards"); outcome = ResponseEgressOutcome::Unspecified; Duration::ZERO }; @@ -942,7 +1046,7 @@ impl ResponseEgressAttempt { }; self.state = AttemptState::Terminal(report); if let AttemptState::Terminal(terminal_report) = &self.state { - self.completion.complete(terminal_report); + self.completion.complete_at(terminal_report, observed_at); self.observer.complete(terminal_report); true } else { @@ -987,6 +1091,12 @@ pub fn default_response_egress_policy( } } +fn log_callback_fault(category: &'static str) { + // A logger panic must not escape the owner's independent unwind boundary. + // Never retry through a logger that has itself failed. + let _result = catch_unwind(AssertUnwindSafe(|| log::error!("{category}"))); +} + #[cfg(test)] mod tests { use std::cell::Cell; @@ -1787,6 +1897,158 @@ mod tests { assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); } + #[test] + #[expect( + clippy::too_many_lines, + reason = "one isolated facade fixture exercises all three owner fault boundaries" + )] + fn core_fault_reporting_with_panicking_facade_is_isolated() { + use log::{Level, LevelFilter, Log, Metadata, Record, set_logger, set_max_level}; + use std::env::{current_exe, var_os}; + use std::process::Command; + + struct PanickingLogger; + static LOGGER: PanickingLogger = PanickingLogger; + static CALLS: AtomicUsize = AtomicUsize::new(0); + impl Log for PanickingLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() == Level::Error + } + fn flush(&self) {} + fn log(&self, record: &Record<'_>) { + if self.enabled(record.metadata()) { + CALLS.fetch_add(1, Ordering::SeqCst); + panic!("fixture logger fault"); + } + } + } + const CHILD: &str = "EDGEZERO_TEST_CORE_FAULT_LOGGER"; + if var_os(CHILD).is_none() { + let output = Command::new(current_exe().expect("test executable")) + .args(["--exact", "response_egress::tests::core_fault_reporting_with_panicking_facade_is_isolated", "--nocapture"]) + .env(CHILD, "1").output().expect("isolated child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + set_logger(&LOGGER).expect("single child logger"); + set_max_level(LevelFilter::Error); + let started = MonotonicInstant::now(); + let terminal = started + .checked_add(Duration::from_millis(42)) + .expect("instant"); + let after_callback = terminal + .checked_add(Duration::from_secs(3)) + .expect("instant"); + let current = Arc::new(Mutex::new(terminal)); + let clock_current = Arc::clone(¤t); + let clock = MonotonicClock::new(move || *clock_current.lock().expect("clock")); + let events = Arc::new(Mutex::new(Vec::new())); + let app_events = Arc::clone(&events); + let native_events = Arc::clone(&events); + let observed = Arc::new(Mutex::new(None)); + let native_observed = Arc::clone(&observed); + let callback_current = Arc::clone(¤t); + let completion = ResponseEgressCompletion::new(move |_| { + app_events.lock().expect("events").push("app"); + *callback_current.lock().expect("clock") = after_callback; + panic!("application completion fault"); + }) + .join(ResponseEgressCompletion::new_with_terminal_time( + move |_, at| { + native_events.lock().expect("events").push("native"); + *native_observed.lock().expect("observed") = Some(at); + }, + )); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + started, + None, + ); + let mut attempt = ResponseEgressAttempt::new( + &head, + started, + completion, + ResponseEgressObserverHandle::new(OrderedObserver(Arc::clone(&events))), + clock.clone(), + ); + assert!(attempt.terminate(ResponseEgressOutcome::Completed, terminal)); + assert!(!attempt.terminate(ResponseEgressOutcome::SourceError, after_callback)); + assert_eq!( + *events.lock().expect("events"), + vec!["app", "native", "observer"] + ); + assert_eq!(*observed.lock().expect("observed"), Some(terminal)); + assert_eq!(clock.now(), after_callback); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); + + let mut observer_attempt = ResponseEgressAttempt::new( + &head, + started, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(PanickingObserver), + clock.clone(), + ); + assert!(observer_attempt.terminate(ResponseEgressOutcome::Completed, terminal)); + assert!(!observer_attempt.terminate(ResponseEgressOutcome::TransportError, after_callback)); + assert_eq!(CALLS.load(Ordering::SeqCst), 2); + + let policy_calls = Arc::new(Mutex::new(Vec::new())); + let callback_calls = Arc::clone(&policy_calls); + let envelope = ResponseEgressEnvelope::new( + Response::new(Body::empty()), + ResponseEgressRequestMetadata::new(Method::GET, started, None), + ResponseEgressCompletion::new_with_terminal_time(move |_, at| { + callback_calls.lock().expect("calls").push(at); + }), + Arc::new(|_, _| panic!("application policy fault")), + ResponseEgressObserverHandle::default(), + clock, + ); + let failure = envelope.begin().err().expect("bounded policy failure"); + assert!(policy_calls.lock().expect("calls").is_empty()); + let (mut policy_attempt, _clock, cause) = failure.into_parts(); + assert_eq!(cause, ResponseEgressOutcome::ConversionError); + assert!(policy_attempt.terminate(cause, after_callback)); + assert_eq!(*policy_calls.lock().expect("calls"), vec![after_callback]); + assert_eq!(CALLS.load(Ordering::SeqCst), 3); + } + + #[test] + fn timed_nested_completion_preserves_terminal_instant_and_order() { + let events = Arc::new(Mutex::new(Vec::<(u8, MonotonicInstant)>::new())); + let started = MonotonicInstant::now(); + let terminal = started + .checked_add(Duration::from_millis(42)) + .expect("instant"); + let first = Arc::clone(&events); + let second = Arc::clone(&events); + let third = Arc::clone(&events); + let mut completion = ResponseEgressCompletion::new(move |_| { + first.lock().expect("events").push((1, terminal)); + }) + .join(ResponseEgressCompletion::new_with_terminal_time( + move |_, at| second.lock().expect("events").push((2, at)), + )) + .join(ResponseEgressCompletion::new_with_terminal_time( + move |_, at| third.lock().expect("events").push((3, at)), + )); + let report = completed_report(started); + completion.complete_at(&report, terminal); + completion.complete_at(&report, started); + assert_eq!( + *events.lock().expect("events"), + vec![(1, terminal), (2, terminal), (3, terminal)] + ); + } + #[test] fn zero_byte_completion_has_zero_accounting() { let completed_observer = RecordingObserver::default(); diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index ea1fd52c..748a9edb 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -14,6 +14,30 @@ use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; +use crate::response_egress::{ResponseEgressFailureClassification, ResponseEgressFailureKind}; + +pub(crate) enum RouteErrorKind { + MethodNotAllowed, + NotFound, +} + +pub(crate) struct DispatchOutcome { + failure: Option, + response: Result, + route_error: Option, +} + +impl DispatchOutcome { + pub(crate) fn into_parts( + self, + ) -> ( + Result, + Option, + Option, + ) { + (self.response, self.route_error, self.failure) + } +} struct RouteEntry { handler: BoxHandler, @@ -488,45 +512,73 @@ impl RouterService { request: Request, ingress: AdmittedIngress, ) -> Result { - if !Arc::ptr_eq(&self.inner, &resolved.owner) { - return Err(EdgeError::internal(anyhow::anyhow!( - "resolved dispatch token belongs to another router" - ))); - } - if request.method() != resolved.method || request.uri().path() != resolved.path { - return Err(EdgeError::internal(anyhow::anyhow!( - "request method or path changed after ingress route resolution" - ))); - } + self.dispatch_resolved_outcome(resolved, request, ingress) + .await + .response + } - match resolved.target { - ResolvedTarget::Found(entry, params) => { - if ingress.is_fallback() { - return Err(EdgeError::internal(anyhow::anyhow!( - "fallback body policy cannot dispatch a matched route" - ))); - } - self.inner - .dispatch_found(request, &entry, params, Some(ingress)) - .await + pub(crate) async fn dispatch_resolved_outcome( + &self, + resolved: ResolvedDispatch, + request: Request, + ingress: AdmittedIngress, + ) -> DispatchOutcome { + let mut route_error = None; + let mut failure = None; + let response = async { + if !Arc::ptr_eq(&self.inner, &resolved.owner) { + return Err(EdgeError::internal(anyhow::anyhow!( + "resolved dispatch token belongs to another router" + ))); } - ResolvedTarget::MethodNotAllowed(allowed) => { - if let Some(response) = fallback_terminal_response(request, ingress).await? { - return Ok(response); - } - let methods = allowed - .iter() - .map(|metadata| metadata.method().clone()) - .collect::>(); - Err(EdgeError::method_not_allowed(&resolved.method, &methods)) + if request.method() != resolved.method || request.uri().path() != resolved.path { + return Err(EdgeError::internal(anyhow::anyhow!( + "request method or path changed after ingress route resolution" + ))); } - ResolvedTarget::NotFound => { - if let Some(response) = fallback_terminal_response(request, ingress).await? { - return Ok(response); + match resolved.target { + ResolvedTarget::Found(entry, params) => { + if ingress.is_fallback() { + return Err(EdgeError::internal(anyhow::anyhow!( + "fallback body policy cannot dispatch a matched route" + ))); + } + self.inner + .dispatch_found(request, &entry, params, Some(ingress)) + .await + } + ResolvedTarget::MethodNotAllowed(allowed) => { + if let Some((response, category)) = + fallback_terminal_response(request, ingress).await? + { + failure = Some(category); + return Ok(response); + } + let methods = allowed + .iter() + .map(|metadata| metadata.method().clone()) + .collect::>(); + route_error = Some(RouteErrorKind::MethodNotAllowed); + Err(EdgeError::method_not_allowed(&resolved.method, &methods)) + } + ResolvedTarget::NotFound => { + if let Some((response, category)) = + fallback_terminal_response(request, ingress).await? + { + failure = Some(category); + return Ok(response); + } + route_error = Some(RouteErrorKind::NotFound); + Err(EdgeError::not_found(resolved.path)) } - Err(EdgeError::not_found(resolved.path)) } } + .await; + DispatchOutcome { + failure, + response, + route_error, + } } pub(crate) fn insert_state(&mut self, value: T) @@ -605,7 +657,7 @@ impl RouterService { async fn fallback_terminal_response( request: Request, ingress: AdmittedIngress, -) -> Result, EdgeError> { +) -> Result, EdgeError> { let Some(fallback) = ingress.into_fallback() else { return Ok(None); }; @@ -622,8 +674,18 @@ async fn fallback_terminal_response( match outcome? { FallbackDrainOutcome::Complete => Ok(None), - FallbackDrainOutcome::Exceeded => Ok(Some(on_exceeded.into_response())), - FallbackDrainOutcome::TimedOut => Ok(Some(on_timeout.into_response())), + FallbackDrainOutcome::Exceeded => Ok(Some(( + on_exceeded.into_response(), + ResponseEgressFailureClassification::fallback( + ResponseEgressFailureKind::FallbackBodyExceeded, + ), + ))), + FallbackDrainOutcome::TimedOut => Ok(Some(( + on_timeout.into_response(), + ResponseEgressFailureClassification::fallback( + ResponseEgressFailureKind::FallbackReadTimedOut, + ), + ))), } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4d68d82b..5b1f28d6 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,6 +50,8 @@ export default defineConfig({ { text: 'Cloudflare Workers', link: '/guide/adapters/cloudflare' }, { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, + { text: 'Axum diagnostics', 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 ccd1da40..12b455bd 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -209,6 +209,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 injects `AxumOutboundClient`, backed by `reqwest`. Application handlers use the diff --git a/docs/guide/axum-diagnostics.md b/docs/guide/axum-diagnostics.md new file mode 100644 index 00000000..37595154 --- /dev/null +++ b/docs/guide/axum-diagnostics.md @@ -0,0 +1,444 @@ +# Axum diagnostics and trusted proxies + +The managed native host exposes checked ingress metadata, safe request records, and work +snapshots. Incoming proxy trust is off by default. A forwarding header never establishes +trust by itself. + +These APIs belong to `edgezero_adapter_axum`, with the `axum` feature enabled. They apply to +`run_app`, `run_production_app`, the explicit-options runners, and `AxumDevServer`. Other +adapters retain their existing behavior. Geographic source admission is a separate policy; +ordinary proxy trust never admits CloudFront geography. See [Native Geo providers](./native-geo.md). + +## Startup settings + +| Setting | Default | Accepted values | +| --------------------------------------------- | ------------------- | ---------------------------------------------------------------------- | +| `EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS` | No trusted networks | Comma-separated literal IPv4/IPv6 addresses or CIDRs | +| `EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY` | No selected family | `forwarded` or `x-forwarded`, required when the trust list is nonempty | +| `EDGEZERO__LOGGING__REQUEST_RECORDS` | `true` | Exactly `true` or `false` | + +For example, trust one deployment-owned proxy address and use RFC Forwarded: + +```bash +EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS=10.20.0.8/32 \ +EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY=forwarded \ +EDGEZERO__LOGGING__REQUEST_RECORDS=true \ +./my-app-adapter-axum +``` + +An unset trust list, an empty string, or a string containing only spaces/tabs means no trust. +The network parser trims spaces/tabs around entries. It rejects empty entries inside a list, +other control characters, invalid literals, DNS names, and tokens such as `private`, `all`, or +provider names. Family names are lowercase, with surrounding spaces/tabs allowed. Unknown +supplied families fail even with an empty trust list. A valid family alone enables nothing. +Request-record booleans accept no case changes or whitespace. + +Invalid supplied values, including non-Unicode environment values for these settings, fail +startup before readiness. They do not silently revert to defaults. Both development and +production validate these three settings strictly. Production also validates its other hosting +settings; this does not change unrelated development-setting fallback behavior. + +Network entries are truncated to their prefix and deduplicated. IPv4-mapped IPv6 peers and +assertions match their IPv4 equivalents. Mapped networks within `::ffff:0:0/96` become equivalent +IPv4 networks. `/0`, unions covering an entire address family, and IPv6 prefixes that would +trust the entire mapped IPv4 range are rejected. Private, loopback, and container networks +receive no implicit trust. EdgeZero does not discover provider ranges or refresh them. + +The trust configuration has a 16,384-byte text budget, including separators, and at most 256 +supplied entries. Duplicates count before normalization. Explicit policy iterators have the +same bounds and never silently truncate. + +### Explicit hosting + +```rust +use edgezero_adapter_axum::dev_server::AxumRunOptions; +use edgezero_adapter_axum::diagnostics::NativeDiagnosticsHandle; +use edgezero_adapter_axum::proxy::{ForwardingHeaderFamily, TrustedProxyPolicy}; + +let diagnostics = NativeDiagnosticsHandle::new(); +let policy = TrustedProxyPolicy::new( + ForwardingHeaderFamily::Forwarded, + ["10.20.0.8/32"], +)?; +let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? + .with_trusted_proxy_policy(policy) + .with_diagnostics(diagnostics.clone()) + .with_request_records(false); +``` + +Pass these options and a caller-owned stop future to +`dev_server::run_app_with_options::`. Use +`run_app_with_options_and_initializer` when startup needs application checks. The explicit +runners install no process-wide signal handlers. The corresponding `AxumDevServer` builders +are `with_trusted_proxy_policy`, `with_diagnostics`, and `with_request_records`. + +Explicit options/builders do not merge ambient proxy or request-record settings. Use +`AxumRunOptions::from_env()` to capture environment-driven settings deliberately, or +`from_vars` for supplied string pairs. Each run captures its settings once. +`TrustedProxyPolicy::no_trust()` is the explicit no-trust policy. + +## What makes a forwarding assertion trustworthy + +Protect the proxy-to-application network first. Only deployment-owned proxies should be able +to connect from addresses in the allowlist. A shared private subnet or loopback address is not +proof that every process using it is trustworthy. Trust is neither user authentication nor +permission to use any asserted host as an outbound destination. + +The service matches the socket-owned peer, not `Forwarded by=`, a header, URI, or +caller-supplied `ConnectInfo`. Without a matching peer, it does not parse forwarding assertions. +It uses direct facts instead. + +```mermaid +flowchart TD + A[Owned socket peer and received head] --> B[Validate and account original normalized head] + B --> C[Match explicit proxy policy] + C --> D[Validate selected family within bounds] + D --> E[Compute checked facts and provenance] + E --> F[Remove both raw forwarding families] + F --> G[Admission policy] + G --> H[Middleware and handlers] +``` + +Select exactly one family. `forwarded` reads ordered RFC 7239 elements, including repeated +field lines and quoted IPv6 nodes. `x-forwarded` reads an ordered X-Forwarded-For chain and +singleton X-Forwarded-Host and X-Forwarded-Proto values. Repeated or comma-listed host/proto +values are ambiguous and invalidate that family. The unselected family never fills a gap. + +All recognized assertions are validated before choosing a client boundary, including entries +that the eventual trust walk will ignore. The service walks predecessors right to left while +the current hop is trusted. The first untrusted numeric predecessor is the effective client +boundary. It never selects the leftmost value unconditionally or skips an unknown hop. + +| `ForwardingResult` | Meaning | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `Absent` | No usable selected assertions | +| `UntrustedPeer` | Forwarding fields are present but the direct peer cannot supply trusted assertions | +| `Accepted` | Client, host, and scheme assertions were accepted | +| `Partial` | Some fields were accepted; missing fields use direct facts | +| `UnresolvedChain` | A valid chain has a missing/unknown/obfuscated predecessor or exhausts only trusted addresses; client falls back to the direct peer | +| `Malformed` | Invalid syntax, recognized values, ambiguity, or bounds invalidate the whole selected assertion | + +An unresolved client chain can still supply valid final-proxy host and scheme assertions. +Malformed client text cannot. With malformed optional forwarding metadata, an otherwise valid +request continues using direct facts for every selected field. Existing HTTP head/framing +violations still follow their normal rejection or abort path. + +The selected assertion has a 16,384-byte budget, counting conceptual separators between +repeated field values, and at most 64 chain/list slots. Forwarded allows at most 16 parameter +slots per element. Empty slots count against bounds. RFC Forwarded tolerates empty list +members and empty semicolon slots. X-Forwarded-For does not: a present-empty value or empty +member invalidates the complete selected family, including otherwise-valid host/proto. +Absent X-Forwarded-For still permits individual host/proto acceptance. + +These bounds cover additional forwarding parsing and retained metadata. They do not cap +Hyper's initial receive allocation, total process memory, or concurrent work. The original +normalized head is validated and counted before stripping, so removed headers cannot evade +head limits. That accounting is normalized head size, not raw wire bytes. + +### Host and scheme belong to the final proxy + +For Forwarded, only the final, rightmost element can supply host/proto. For X-Forwarded-*, only +the singleton host/proto fields can supply them. Earlier host/proto values and the client-address +chain cannot establish the public origin. + +The final trusted ingress must overwrite these fields with values it verified. If another proxy +terminated public TLS, verify that upstream assertion or enforce a known HTTPS-only public +route. The final hop's own connection scheme is not necessarily the visitor's scheme. + +Hosts must be valid nonempty authorities with an optional numeric port, without userinfo, +whitespace, path, query, or fragment. Schemes normalize only `http` and `https`. There is no DNS +or IDNA lookup. Missing fields fall back independently. Direct host comes from the +HTTP-authoritative request head; unavailable authority stays unavailable. The native direct +transport scheme is HTTP, even when an absolute request URI says HTTPS. + +## Nginx terminating public TLS + +This example replaces the chain for a single public ingress. Put the `map` in the existing +Nginx `http` block and add the server alongside your TLS certificate configuration. The map +quotes and brackets IPv6 addresses for Forwarded; IPv4 remains unquoted. + +```nginx +map $remote_addr $edgezero_forwarded_for { + default $remote_addr; + ~: '"[$remote_addr]"'; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name app.example.com; + # Configure ssl_certificate and ssl_certificate_key here. + + location / { + proxy_pass http://127.0.0.1:8787; + proxy_set_header Host app.example.com; + proxy_set_header Forwarded "for=$edgezero_forwarded_for;host=app.example.com;proto=https"; + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Forwarded-Host ""; + proxy_set_header X-Forwarded-Proto ""; + } +} +``` + +Configure EdgeZero with `TRUSTED_PROXY_CIDRS=127.0.0.1/32` and +`FORWARDING_HEADER_FAMILY=forwarded`, using the full environment prefixes above. Bind its +listener to loopback and keep untrusted local processes away from this trusted connection. +If Nginx uses IPv6 for the backend hop, configure that actual peer instead. + +The snippet assumes `$remote_addr` is the actual incoming peer, with no header-driven real-IP +rewrite. The HTTPS-only server establishes `proto=https`. It supplies a fixed public host, +not `$http_host`, and replaces visitor-supplied Forwarded values rather than appending them. +Configure your default virtual host to reject unexpected public hosts and handle HTTP +redirects separately. Test both IPv4 and IPv6 visitors in the deployed configuration. +See Nginx's [map](https://nginx.org/en/docs/http/ngx_http_map_module.html) and +[proxy header](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header) +documentation for directive scope and inheritance. + +## Cloudflare plus a load balancer + +For `visitor → Cloudflare → load balancer → EdgeZero`, configure the final load balancer to +append its actual incoming Cloudflare peer to the verified predecessor chain. It must replace +X-Forwarded-Host with the allowed public authority and X-Forwarded-Proto with the verified +visitor scheme. Do not merely pass through a visitor-controlled host/proto field. + +For illustration, suppose the final peer is `10.20.0.8` and the Cloudflare peer is +`198.51.100.10`. These are example addresses, not provider ranges: + +```http +X-Forwarded-For: 203.0.113.7, 198.51.100.10 +X-Forwarded-Host: app.example.com +X-Forwarded-Proto: https +``` + +Select `x-forwarded` and explicitly trust the actual load-balancer addresses and relevant +Cloudflare ranges for your deployment. The walk can then stop at `203.0.113.7`. If only the +load balancer is trusted, it stops at the Cloudflare address. If every represented address is +trusted, it falls back to the direct peer as unresolved. + +Restrict origin access to the intended Cloudflare producers, including verification appropriate +to your zone and route. Restrict EdgeZero access to the final load balancer. Provider defaults, +address membership alone, or an HTTPS load-balancer hop do not prove correct visitor metadata. +Maintain the allowlist and inspect your deployed header mode. EdgeZero has no built-in Cloudflare/AWS forwarding trust list and does not consume +`CF-Connecting-IP` automatically. The separate protected CloudFront/ALB Geo policy does not +select a forwarding family or establish checked reader-IP metadata. +Cloudflare's [header documentation](https://developers.cloudflare.com/fundamentals/reference/http-request-headers/) +explains how Workers, transforms, and Pseudo IPv4 can change assertions. + +### A gateway can replace the chain + +If the load balancer cannot produce verified singleton host/proto fields, place a sanitizing +gateway between it and EdgeZero. The gateway must validate its upstream producer and chain, +reject or discard unverified fields, and replace the selected family entirely. For a verified +visitor address, it can emit: + +```http +Forwarded: for=203.0.113.7;host=app.example.com;proto=https +``` + +Trust only that gateway's actual outgoing peer and select `forwarded`. Emit HTTPS only after +verifying the public TLS assertion or enforcing the HTTPS-only public route. If the gateway +cannot establish the client boundary, `for=unknown` is honest; EdgeZero then uses direct client +fallback while retaining valid host/proto. Renaming untouched client input to a new header +does not verify it. + +## Migrate applications from raw headers + +Before admission, the managed host removes Forwarded and every case-insensitive +X-Forwarded-* header, including accepted fields and extensions. It overwrites incoming checked +metadata extensions with its own results. Host, URI, and +`context::AxumRequestContext::remote_addr` remain unchanged; `remote_addr` is always the direct +peer, not the effective client. + +At admission, read `IngressHead::extension::()`. In middleware or a +handler, read the same facts from the request context: + +```rust +use edgezero_adapter_axum::diagnostics::NativeIngressMetadata; + +if let Some(metadata) = ctx.extensions().get::() { + let authority = metadata.effective_host().authority(); + let scheme = metadata.effective_scheme().as_str(); + let client_boundary = metadata.effective_client(); + let source = metadata.host_source(); + // Apply application host authorization before using the public origin. +} +``` + +`request_id`, `direct_peer`, `client_source`, `scheme_source`, `forwarding_result`, and +`received_normalized_head` are also read-only accessors. Per-field sources use +`edgezero_core::ingress::CheckedHostSource`: `Direct`, `TrustedForwarded`, +`TrustedXForwarded`, or `Unavailable`. Read each source independently from the aggregate +forwarding result. Metadata describes original ingress, not later application mutations. + +`edgezero_core::ForwardedHost` now reads `CheckedEffectiveHost` first on the managed native +path, including accepted RFC Forwarded host values. Checked-unavailable uses its existing +`localhost` fallback without consulting raw forwarding fields. Prefer the metadata accessor +when you must distinguish absence from that convenience fallback. Without checked metadata, +other adapters and legacy conversions retain the extractor's old behavior. + +`request::into_core_request` is a low-level bypass. It does not perform managed admission or +socket-owned proxy checking, and raw `ForwardedHost` behavior can remain there. Supplying +ConnectInfo or setting an environment variable does not turn that converter into a trusted +ingress. Use managed hosting for this policy. The converter does not acquire managed-native +JSON error detail policy or a Geo outcome. Do not recreate removed headers for automatic +outbound propagation; outbound assertions need their own explicit authorization decision. + +## Records and logger ownership + +Default facade records use target `edgezero::native`. `request_terminal` describes begun +egress attempts; `request_ingress` describes no-response abort, draining, or abandonment. +`lifecycle` describes runner transitions and fixed failure categories. + +Request records contain a generated ID, known method or `other`, registered route template or +`unknown`, optional selected status and total duration, typed outcome/failure/forwarding +categories, and optional egress bytes/body/fallback accounting. Default framework request +records do not contain addresses, hosts, raw paths/queries, headers, payloads, config/secret values, +origin URLs, or source error strings. Route templates are application-owned names; do not +put secrets in them. Default output escapes route text to one line. + +Severity follows typed outcomes, not status alone. Ordinary completion, framework route misses, +and expected draining use INFO. Refusal, propagated errors, malformed forwarding, and +failed/abandoned attempts use WARN. Startup/runtime faults and unusable clock/identity state +use ERROR. An application-selected 503 is not automatically a framework failure. + +`Hooks::owns_logging()` controls installation only. Set `owns_logging = true` in the `app!` +declaration and install your logger once before entering the runner. For example, in your +Axum entrypoint with your own logger dependency: + +```rust +use edgezero_adapter_axum::dev_server::run_production_app; +use my_app_core::App; + +fn main() -> anyhow::Result<()> { + // App declares owns_logging = true. + simple_logger::SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init()?; + run_production_app::() +} +``` + +Keep the `edgezero::native` target enabled in your logger's filters when you want these records. +No competing logger/subscriber is installed when the application owns logging. + +`with_request_records(false)`, or `EDGEZERO__LOGGING__REQUEST_RECORDS=false` on an +environment-driven runner, suppresses default request-terminal and request-ingress logs only. +Native and application observers, lifecycle events, counts, and snapshots remain active. + +### Request identity + +`RequestId` displays 64 hexadecimal characters derived from a process nonce, hosting sequence, +and request sequence. IDs do not repeat within the process; separation between processes is +entropy-based, not a mathematical global uniqueness guarantee. Entropy failure prevents +startup; sequence exhaustion never wraps or reuses an ID. + +Incoming IDs are not adopted. There is no automatic response ID header, tracing-header +propagation, or authentication meaning. Applications can expose an ID deliberately. +Changing the public metadata extension cannot change the framework's private correlation ID. +Never use request IDs as metric labels. + +## Snapshots and optional metrics + +Retain a clone of the handle passed to `with_diagnostics` and sample it from application-owned +management code: + +```rust +let snapshot = diagnostics.snapshot(); +let active_requests = snapshot.active_requests; +let active_connections = snapshot.active_connections; +let idle_connections = snapshot.idle_connections; +``` + +Active requests include managed admission, body read, handlers, and egress until completion or +abandonment releases the guard. Active connections count owned connection futures, including +idle keep-alive connections; idle connections are their idle subset. Detached application work, +kernel buffers, and external services are not counted. + +A snapshot coherently reads the three counters at one moment. It is not atomic with lifecycle +phase or subsequent work. A request used to query counts counts itself while active, and its +connection also contributes. Sample externally or account for the query before asserting zero. +Accounting releases before native logger/observer delivery. + +Each runner creates its own handle by default. Explicitly sharing one handle aggregates its +counters across runners; clones of an observer-equipped handle also receive their records. +Each runner still has its own identity namespace and lifecycle. Attach the observer before +cloning the handle for runners: `with_request_observer` configures the returned handle, not +previous clones. + +Implement `NativeRequestObserver` for application metrics. This small example keeps fixed +counters and cumulative duration without per-request labels: + +```rust +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use edgezero_adapter_axum::diagnostics::{ + NativeDiagnosticsHandle, NativeRequestObserver, NativeRequestRecord, +}; + +#[derive(Default)] +struct Totals { + observed: u64, + timed: u64, + duration: Duration, +} + +struct Metrics(Arc>); + +impl NativeRequestObserver for Metrics { + fn observe(&self, record: &NativeRequestRecord) { + if let Ok(mut totals) = self.0.lock() { + totals.observed = totals.observed.saturating_add(1); + if let Some(duration) = record.duration { + totals.timed = totals.timed.saturating_add(1); + totals.duration = totals.duration.saturating_add(duration); + } + } + } +} + +let totals = Arc::new(Mutex::new(Totals::default())); +let diagnostics = NativeDiagnosticsHandle::new() + .with_request_observer(Metrics(Arc::clone(&totals))); +``` + +Use your metrics library for histograms and bounded method, registered-route, status-class, or +outcome labels. Handle missing status/duration and future enum variants. Never label metrics +with IDs, addresses, raw URLs, payloads, or secrets. The native observer adds no exporter, +queue, metrics server, or public diagnostics endpoint and does not replace application egress +observers/completions. Do not capture an observer-bearing handle in its own observer and +create an ownership cycle. + +## Timing, failures, and privacy limits + +Native total duration runs from the parsed service boundary to the existing authoritative +terminal observation on the application monotonic clock. It includes pre-egress work but +excludes subsequent completion-callback and logger delay. `ResponseEgressReport::elapsed` +remains egress-only. A backwards clock produces absent duration and a safe clock-fault +category, not a wall-clock substitute. Optional application timing collectors do not replace +this clock or completion owner. + +Selected status is not proof that headers reached the wire or that the client received them. +A selected 200 may end with `SourceError`, `DeadlineExceeded`, `ClientDisconnected`, or +`TransportError`. A precommit fallback can select 500/504; a post-commit failure does not send +a second error response. `bytes_written` counts egress-accounted bytes, not delivered TCP +bytes. No-response paths have no invented status or egress report. + +On the managed native path, default framework routing 404/405 errors use fixed public text. +Custom renderers retain the original routing errors. Native-managed JSON deserialization +errors return HTTP 400 with the fixed message `invalid JSON payload`, without serde's value, +line, or type detail. Custom renderers also receive this fixed native-generated JSON error; +the serde detail is already discarded. Legacy/unmanaged JSON errors retain their existing +detail. Application-authored `EdgeError` messages and responses are not globally sanitized, +and custom renderers remain responsible for their own output. This integration adds no JSON +size selector or managed body carrier; existing body-read limits remain unchanged. + +Loggers and observers run synchronously. They can block body progress or shutdown, so keep +them short. Unwind-capable targets isolate diagnostic sink panics independently and release +native accounting, but panic-abort, SIGKILL, and process failure have no completion or log-flush +promise. There is no retry, durable delivery guarantee, or added shutdown flush budget. + +Deployment acceptance remains downstream. Parser tests and local socket/TLS fixtures do not +prove final-image behavior, proxy configuration, client receipt, signal delivery, or bounded +sink latency. Recheck the accepted prerequisite stack and test the actual image and ingress +before relying on these deployment claims. Packaging and final-image acceptance are separate +from this guide's API contract. diff --git a/docs/guide/native-geo.md b/docs/guide/native-geo.md new file mode 100644 index 00000000..7a1b1f05 --- /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/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 2f563e14..d25d3cdf 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -784,10 +784,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 00000000..2b3f89ba --- /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/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock index a86cc4f9..aac13f14 100644 --- a/tests/fixtures/reusable-app/Cargo.lock +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -527,10 +527,12 @@ dependencies = [ "edgezero-store-celld", "futures", "futures-util", + "getrandom 0.4.2", "http", "http-body", "hyper", "hyper-util", + "ipnet", "libc", "log", "redb", From 56eab85fee2499d46984b077fc9e167693bd4e67 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 22 Oct 2026 15:14:39 -0500 Subject: [PATCH 3/5] test(core): separate legacy Geo dispatch regression --- crates/edgezero-core/src/app.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 61769462..bb013ac0 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -2164,9 +2164,18 @@ mod tests { 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(selected()); + unmanaged.extensions_mut().insert(forged.clone()); assert_eq!( RequestContext::new(unmanaged, PathParams::default()).geo(), &GeoOutcome::NoSelection @@ -2183,7 +2192,7 @@ mod tests { .uri("/legacy") .body(Body::empty()) .unwrap(); - legacy_request.extensions_mut().insert(selected()); + legacy_request.extensions_mut().insert(forged.clone()); let legacy_head = IngressHeadParts::from_request( &legacy_request, IngressHeadAccounting::HostManaged, From 3c784b776aef2f29a92ec65025f3fbbe77493920 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 22 Oct 2026 15:20:42 -0500 Subject: [PATCH 4/5] docs: record complete Geo implementation and verification --- .../2026-10-22-pluggable-geo-providers.md | 238 +++++++++--------- ...26-10-22-pluggable-geo-providers-design.md | 8 +- 2 files changed, 126 insertions(+), 120 deletions(-) diff --git a/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md b/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md index c98117c7..6ac078a5 100644 --- a/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md +++ b/docs/superpowers/plans/2026-10-22-pluggable-geo-providers.md @@ -1,10 +1,10 @@ # Pluggable Geo providers implementation plan -- Status: implementation started. Stage 1 portable values and provider contract pass focused checks. Stage 0 ingress reconciliation remains blocked; native integration and deployment acceptance have not begun. +- 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 spec SHA-256: `12eeada53b62663923531fa43e27aac08386df1c97dbc3b66a032b504c7f9f2b`. The spec is untracked at planning time. Recheck its contents and hash before implementation. -- Required ingress reference: [#409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6). Its Git object was inspected locally; its changes are not integrated into this worktree. +- 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 @@ -109,11 +109,11 @@ One implementation owner should handle core/native dispatch integration. Indepen ### 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. -- [ ] Arrange the #432 scope update or separate GeoIP follow-up before implementation scheduling. Do not mark the local-database requirements satisfied by this feature. -- [ ] Integrate an approved #409 revision as a prerequisite, or stop native implementation until that integration is available. Review its reconciliation separately from Geo. The pinned commit is a behavioral reference, not permission to copy old files over current ones. -- [ ] 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. -- [ ] Confirm original head accounting, missing-peer cleanup, checked proxy normalization, forwarding-header removal, native diagnostics and category-only framework error behavior survive the reconciliation. -- [ ] 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. +- [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: @@ -179,12 +179,12 @@ Files: new Axum `peer_networks.rs` and `geo.rs`; reconciled `proxy.rs`; Axum mod ### Work -- [ ] 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`. -- [ ] 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. -- [ ] Add opaque `ProtectedCloudFrontAlb` with a nonempty `PeerNetworks` and one credential-header name. It contains no credential value, distribution-authentication claim or AWS handle. -- [ ] 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. -- [ ] 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-`. -- [ ] 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. +- [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 @@ -205,12 +205,12 @@ Files: core `app.rs`, `ingress.rs`, `context.rs`; tests in those modules and, if ### Work -- [ ] 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. -- [ ] 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. -- [ ] Factor existing admitted dispatch methods through the same implementation with `NoSelection`. Preserve their signatures and existing rendering, response metadata, completion, grant and fallback behavior. -- [ ] Install the owned outcome into private admitted state before passing it through the existing router path. The router's public signature need not change. -- [ ] 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. -- [ ] 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. +- [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 @@ -233,14 +233,14 @@ Files: Axum `geo.rs`, `run_options.rs`, `dev_server.rs`, `service.rs`, `lib.rs` ### Work -- [ ] 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. -- [ ] 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. -- [ ] 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`. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] Preserve every development/unconfigured entrypoint with off composition. The explicit options path is the only new selection path. -- [ ] 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. +- [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 @@ -264,10 +264,10 @@ Files: Axum `geo/cloudfront.rs`, with unit tests; shared values remain in core. ### Work -- [ ] Implement the stateless built-in `GeoProvider` behind `geo-cloudfront`. It accepts only the two bounded assertion slots and returns a `GeoResolution`. -- [ ] Map absent country plus absent subdivision to missing metadata. A subdivision without country is invalid. Any invalid slot makes the whole result invalid. -- [ ] 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. -- [ ] 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. +- [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 @@ -289,15 +289,15 @@ Files: reconciled Axum `service.rs`, `geo.rs` and service tests. Keep the unmana ### Work and exact ordering -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] Pass the owned outcome separately through `dispatch_prepared` to `App::dispatch_admitted_with_geo`, retaining `FrameworkErrorDetail::CategoryOnly` and the existing native egress owner. -- [ ] Do not expose the witness, policy, provider handle or a mutable outcome to request extensions, middleware, finalizers or the unmanaged converter. +- [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 @@ -324,14 +324,14 @@ Files: existing native production fixture/tests; existing portable adapter contr ### Work -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] Prove a supplied provider works with the CloudFront implementation feature disabled. This distinguishes pluggable composition from availability of the built-in parser. -- [ ] 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. -- [ ] 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. +- [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 @@ -353,21 +353,21 @@ A real native listener proves the local path, supplied-provider composition and ### Documentation -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] Keep Trusted Server adoption and VPC origins in deferred-work notes, not executable steps in this feature. +- [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 -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] 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. +- [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 @@ -427,66 +427,72 @@ This stage requires separate authorization and access to an isolated CloudFront/ 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. Evidence record and completion checklist +## 16. Final evidence and completion -Populate this table during implementation, not with assumptions from planning: +### Tested source and reconciliation -| Check | Tested revision and feature set | Result | Elapsed | Evidence | -| --- | --- | --- | --- | --- | -| Reconciled #409 baseline | `5426ea65`, reference `66a05e25` | Blocked, not integrated | Not applicable | Branch comparison described below; only existing core ingress baseline ran | -| Portable values, stage 1 | `5426ea65` + fingerprinted working files below; default core features | Pass, 6 Geo tests | 0.630 s | `/tmp/geo-types-tests.log`, nonzero sentinel command below | -| Core regression suite | Same stage 1 state; default core features | Pass, 745 unit + 2 integration + 5 doctests; 13 existing doctests ignored | 3.734 s | `/tmp/geo-core-tests.log` | -| Core Clippy and rustdoc | Same stage 1 state; all core targets for Clippy | Pass, warnings denied | 10.288 s / 2.088 s | `/tmp/geo-core-clippy.log`, `/tmp/geo-core-doc.log` | -| Portable core WASM compilation | Same stage 1 implementation; three WASM targets below | Pass, compile-only | 21.898 / 18.664 / 8.741 s | `/tmp/geo-core-wasm32-*.log`; not adapter runtime proof | -| Immutable request handoff, stage 3 | Not implemented | Not run | Not recorded | Still requires reconciled dispatch APIs | -| Policy/parser and feature-disabled selection | Not recorded | Not run | Not recorded | Not recorded | -| Real native hosting and supplied provider | Not recorded | Not run | Not recorded | Not recorded | -| Workspace, dependencies and WASM | Not recorded | Not run | Not recorded | Not recorded | -| Published guide and examples | Not recorded | Not run | Not recorded | Not recorded | -| Actual CloudFront/ALB deployment | Not recorded | Not run | Not recorded | Separate authorized evidence | -| Trusted Server adoption | Outside this change | Not run | Not recorded | Separate downstream task | - -### Implementation checkpoint, 2026-10-22 +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. -Only the independent stage 1 core contract was implemented ahead of ingress reconciliation. This is an explicit sequencing exception for inert portable types, not satisfaction of the stage 0 exit gate. No request path uses Geo yet. Native stages, dispatch handoff and feature selection remain blocked on a separately reviewed #409 integration. +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 pinned ingress branch has merge-base `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a` with HEAD. Its core/Axum tree differs across 52 files, including newer native-store code missing from that branch. Whole-file replacement is unsafe. No merge, cherry-pick or replacement was attempted. The #432 scope update or separate GeoIP issue is still pending; no GitHub issue was changed. +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. -Before Geo edits, `cargo test --locked -p edgezero-core --lib ingress::` passed 7 tests, with a 30.69 s build, recorded in `/tmp/geo-baseline-ingress.log`. This is not the reconciled #409 baseline. The Geo regression suite first failed compilation due to absent contract types, recorded in `/tmp/geo-red.log`, then passed after implementation. +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. -Implemented files and final SHA-256 values: +### Local checks -- `crates/edgezero-core/src/geo.rs`: `1b82be241a120d17eb00c9a9cfac554d67f84be938f77d853a1640d52386d0a6`. -- `crates/edgezero-core/src/geo/country_codes.rs`: `de5fd4d147008d47db626e9694e85678410e7009a2becf8e823fd104b64f55ff`. -- `crates/edgezero-core/src/lib.rs`: `1c147a21c2f39c8045eedd5d422b27b24bc831b23d12d5c6e82a523a84adc3e8`. +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. -Executed commands: - -```sh -scripts/run_test_nonzero.sh geo_country_codes_normalize_and_reject_unsupported cargo test --locked -p edgezero-core --lib geo:: -cargo test --locked -p edgezero-core -cargo clippy --locked -p edgezero-core --all-targets -- -D warnings -RUSTDOCFLAGS='-D warnings' cargo doc --locked -p edgezero-core --no-deps -cargo check --locked -p edgezero-core --target wasm32-wasip1 -cargo check --locked -p edgezero-core --target wasm32-unknown-unknown -cargo check --locked -p edgezero-core --target wasm32-wasip2 -cargo fmt --all -- --check -git diff --check -``` - -All commands passed on Rust/Cargo 1.95.0. WASM compilation preceded a rustdoc-only example addition; the implementation it checked is unchanged. No dependency, manifest or lockfile changes were needed. Formatting and whitespace checks passed; timings were not recorded for those two checks. Logs are local temporary files, not durable CI artifacts. - -Country data is pinned to `datasets/country-list@ca393dfa16db21ce07be77902c1c129d63c86d9f`. The table records the source CSV checksum and attribution. Only the 249 code elements were extracted and sorted; no country names or upstream software were copied. ISO's code-use statement and the upstream README/attribution were inspected. Tests check the extracted snapshot checksum, uniqueness, ordering, count, all 676 two-letter combinations and their lowercase equivalents. - -For the two safety assumptions in section 1, this checkpoint proves only that the portable model preserves partial geography and bounded errors. Executed constructor tests cover country-only and syntax-valid unknown subdivisions, and input tests prove bounded copies and raw-byte debug redaction. Source admission is still unproven; its next check is the protected-policy and real-peer suite after ingress reconciliation. Permission interpretation remains unproven and belongs to downstream policy tests. - -No workspace-wide, adapter, real-hosting, WASM-runtime, cloud or downstream acceptance is claimed. Those checks remain in their later stages. - -Completion requires: - -- [ ] Every implementation stage has its focused proof and no unchecked prerequisite was silently bypassed. -- [ ] Default-off, no-fallback and unknown/error distinctions survive real dispatch. -- [ ] Provider objects, source admission and outcomes each have one owner; public request mutation cannot rewrite a managed outcome. -- [ ] Existing store preparation, native lifecycle, framework error privacy and portable adapter behavior are preserved. -- [ ] Deployment and downstream acceptance are reported separately from local implementation. -- [ ] The final handoff lists files, checks/outcomes and everything unverified. No unrelated changes, automatic deployment or unrequested commit/push are included. +| 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 index c73082d7..e373f060 100644 --- a/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md +++ b/docs/superpowers/specs/2026-10-22-pluggable-geo-providers-design.md @@ -1,10 +1,10 @@ # Pluggable Geo providers -- Status: Proposed architecture, revised after scope and contract review. No implementation or deployment acceptance is claimed. +- 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`. Integrate checked native ingress from [PR #409 at `66a05e25bee674ced69387467acec3c87b06c8b6`](https://github.com/IABTechLab/edgezero/tree/66a05e25bee674ced69387467acec3c87b06c8b6). It is not present in this worktree. Review any later ingress revision explicitly; do not implement another forwarding-header parser. +- 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 @@ -13,7 +13,7 @@ Applications obtain an estimated country and optional first-level subdivision th 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. Split or update that issue before implementation scheduling; its local-database requirements remain deferred, not satisfied by CloudFront support. Trusted Server adoption is a separate follow-up, not an EdgeZero delivery step. +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: @@ -195,7 +195,7 @@ Before claiming the CloudFront deployment is safe, exercise the actual distribut 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. No runtime, deployment or downstream acceptance is claimed by this document. +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 From e50149c93495e3496be8f8c39a2599c6c23357ba Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 23 Oct 2026 06:46:53 -0500 Subject: [PATCH 5/5] test(axum): simplify JSON privacy upload helper --- .../tests/production_hosting.rs | 85 +++---------------- 1 file changed, 10 insertions(+), 75 deletions(-) diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs index 16f99735..8c4f1296 100644 --- a/crates/edgezero-adapter-axum/tests/production_hosting.rs +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -247,48 +247,17 @@ mod tests { log } - fn upload( - server: &Server, - path: &str, - media: Option<&str>, - body: Vec, - chunked: bool, - ) -> String { - let mut socket = server.socket().expect("upload connection"); - let mut writer = socket.try_clone().expect("upload writer"); - let content_type = - media.map_or_else(String::new, |value| format!("Content-Type: {value}\r\n")); - let framing = if chunked { - "Transfer-Encoding: chunked\r\n".to_owned() - } else { - format!("Content-Length: {}\r\n", body.len()) - }; - let request_head = format!( - "POST {path} HTTP/1.1\r\nHost: fixture\r\n{content_type}{framing}Connection: close\r\n\r\n" + fn post_json(server: &Server, path: &str, body: &str) -> String { + let response = raw_request( + server.addr, + &format!( + "POST {path} HTTP/1.1\r\nHost: fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), ); - let sending = thread::spawn(move || -> io::Result<()> { - writer.write_all(request_head.as_bytes())?; - for chunk in body.chunks(0x4000) { - if chunked { - write!(writer, "{:x}\r\n", chunk.len())?; - } - writer.write_all(chunk)?; - if chunked { - writer.write_all(b"\r\n")?; - } - } - if chunked { - writer.write_all(b"0\r\n\r\n")?; - } - Ok(()) - }); - let mut response = String::new(); - let reading = socket.read_to_string(&mut response); - let sent = sending.join().expect("bounded upload writer"); let (head, payload) = response .split_once("\r\n\r\n") .expect("usable response head"); - let is_413 = head.starts_with("HTTP/1.1 413"); let length = head .lines() .find_map(|line| { @@ -298,31 +267,9 @@ mod tests { }) .expect("finite response framing"); assert_eq!(payload.len(), length, "complete response: {response}"); - if let Err(error) = reading { - assert!( - is_413 && error.kind() == io::ErrorKind::ConnectionReset, - "response read: {error}" - ); - } - if let Err(error) = sent { - assert!(is_413, "upload failure without 413: {error}"); - } response } - fn assert_upload_status(response: &str, status: u16) { - assert!( - response.starts_with(&format!("HTTP/1.1 {status}")), - "{response}" - ); - if status == 413 { - let (_, body) = response.split_once("\r\n\r\n").expect("response"); - let value: Value = serde_json::from_str(body).expect("usable error body"); - assert_eq!(value["error"]["kind"], "payload_too_large"); - assert_eq!(value["error"]["message"], "request body too large"); - } - } - fn captured_json(log: &str, marker: &str) -> Vec { log.lines() .filter_map(|line| line.strip_prefix(marker)) @@ -1002,14 +949,8 @@ mod tests { assert!(extension_response.starts_with("HTTP/1.1 405")); assert!(!extension_response.contains("SENTINEL")); assert!(extension_response.to_ascii_lowercase().contains("allow:")); - let json_response = upload( - &server, - "/native-json-number", - Some("application/json"), - b"\"PAYLOAD_SENTINEL\"".to_vec(), - false, - ); - assert_upload_status(&json_response, 400); + let json_response = post_json(&server, "/native-json-number", "\"PAYLOAD_SENTINEL\""); + assert!(json_response.starts_with("HTTP/1.1 400"), "{json_response}"); assert!(!json_response.contains("PAYLOAD_SENTINEL")); assert_eq!( response_json(&json_response)["error"]["message"], @@ -1053,13 +994,7 @@ mod tests { custom.contains("CUSTOM_ROUTE_SENTINEL"), "custom renderer retains original routing detail" ); - let parsed = upload( - &server, - "/native-json-number", - Some("application/json"), - b"\"PAYLOAD_SENTINEL\"".to_vec(), - false, - ); + let parsed = post_json(&server, "/native-json-number", "\"PAYLOAD_SENTINEL\""); assert!(parsed.starts_with("HTTP/1.1 400")); assert!(parsed.ends_with("invalid JSON payload")); assert!(!parsed.contains("PAYLOAD_SENTINEL"));