diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index 1e6aed662..9a105c9c0 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -4,8 +4,8 @@ on:
push:
branches: [main]
pull_request:
- # Keep the lint gates active for the Geo PR stacked on native stores.
- branches: [main, feat/native-celld-stores]
+ # Keep lint gates active for native-store and Geo stacked PRs.
+ branches: [main, feat/native-celld-stores, feature/pluggable-geo-providers]
permissions:
contents: read
@@ -118,6 +118,12 @@ jobs:
feature_sets: |-
axum
axum,test-utils
+ geo-cloudfront
+ geo-cloudfront,test-utils
+ geo-maxmind
+ geo-maxmind,test-utils
+ geo-cloudfront,geo-maxmind
+ geo-cloudfront,geo-maxmind,test-utils
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6d1370621..1155297e1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5,7 +5,13 @@ on:
branches: [main]
pull_request:
# Keep checks active for native-store and Geo stacked PRs.
- branches: [main, docs/outbound-http-spec, feat/native-celld-stores]
+ branches:
+ [
+ main,
+ docs/outbound-http-spec,
+ feat/native-celld-stores,
+ feature/pluggable-geo-providers,
+ ]
permissions:
contents: read
@@ -121,6 +127,11 @@ jobs:
- name: Geo dependency isolation
run: bash scripts/check_geo_dependency_contract.sh
+ - name: Synthetic GeoIP fixture reproducibility
+ run: |
+ node --check scripts/generate_geoip_fixtures.mjs
+ node scripts/generate_geoip_fixtures.mjs --check
+
- 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_
@@ -147,9 +158,6 @@ jobs:
- 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
@@ -254,6 +262,81 @@ jobs:
cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly
cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin
+ geo-features:
+ name: Geo isolated features [${{ matrix.features }}]
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - features: axum
+ cache_key: axum
+ builtin_sentinel: geo_unavailable_selection_fails_before_runner_resources
+ - features: geo-cloudfront
+ cache_key: cloudfront
+ builtin_sentinel: geo_cloudfront_parsing_matches_spec
+ - features: geo-maxmind
+ cache_key: maxmind
+ builtin_sentinel: geo_maxmind_structural_pass_closes_upstream_data_gaps
+ - features: geo-cloudfront,geo-maxmind
+ cache_key: cloudfront-maxmind
+ builtin_sentinel: geo_maxmind_structural_pass_closes_upstream_data_gaps
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Cache Cargo dependencies
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-cargo-geo-${{ matrix.cache_key }}-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Retrieve Rust version
+ id: rust-version
+ run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT"
+ shell: bash
+
+ - name: Set up Rust tool chain
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: ${{ steps.rust-version.outputs.rust-version }}
+
+ - name: Fetch dependencies (locked)
+ run: cargo fetch --locked
+
+ - name: Isolated Geo unit contracts
+ env:
+ FEATURES: ${{ matrix.features }}
+ SENTINEL: ${{ matrix.builtin_sentinel }}
+ run: |
+ scripts/run_test_nonzero.sh "$SENTINEL" cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES" --lib geo_
+ scripts/run_test_nonzero.sh geo_reader_admission_uses_private_checked_provenance cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES" --lib geo_reader_admission_uses_private_checked_provenance
+
+ - name: Supplied reader hosting without required built-ins
+ env:
+ FEATURES: ${{ matrix.features }}
+ run: scripts/run_test_nonzero.sh geo_hosting_supplied_reader_without_builtin cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES,test-utils" --test production_hosting geo_hosting_supplied_reader_without_builtin
+
+ - name: Disabled MaxMind fails before hosting
+ if: "!contains(matrix.features, 'geo-maxmind')"
+ env:
+ FEATURES: ${{ matrix.features }}
+ run: scripts/run_test_nonzero.sh geo_hosting_rejects_unavailable_maxmind cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES,test-utils" --test production_hosting geo_hosting_rejects_unavailable_maxmind
+
+ - name: MaxMind snapshot and restart hosting
+ if: contains(matrix.features, 'geo-maxmind')
+ env:
+ FEATURES: ${{ matrix.features }}
+ run: scripts/run_test_nonzero.sh geo_hosting_maxmind_snapshot_and_restart cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES,test-utils" --test production_hosting geo_hosting_
+
+ - name: Public examples with isolated features
+ env:
+ FEATURES: ${{ matrix.features }}
+ run: cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features "$FEATURES" --doc
+
adapter-wasm-tests:
name: ${{ matrix.adapter }} wasm tests
runs-on: ubuntu-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0473fa462..42209bc04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,32 @@
## Unreleased
+### Axum local GeoIP and breaking Geo input migration
+
+Draft implementation, not accepted for release. Full metadata-corruption rejection remains blocked
+by the pinned reader API, with an explicit ignored regression. File acquisition is Unix-only;
+non-Unix selection reports implementation unavailability. Production-size measurements remain open.
+
+- Add default-off `geo-maxmind` for an operator-supplied GeoLite2 City or Country MMDB.
+ Prepare and validate a bounded, read-only in-memory snapshot before initialization and binding.
+ Updates require restart; active processes retain their snapshot after source replacement/deletion.
+ Preparation precedes runtime signal/stop polling and has no filesystem I/O deadline.
+- Add explicit `PeerAllowed`/`ForwardedRequired` checked-reader selection and
+ `SuppliedReaderIp` composition, which works with `axum` alone. No geographic-header fallback,
+ downloader, remote lookup or automatic permission decision is added.
+- Breaking pre-release migration: replace `GeoInput { country, subdivision }` with
+ `GeoInput::Assertions { country, subdivision }`, match fields by variant, and explicitly handle
+ or reject `ReaderIp { address, source }`. Update exhaustive matches for resolution `NoMatch`,
+ unavailable `MissingReaderIp`/`NoMatch`, source `LocalDatabase`/`CheckedReaderIp` and trust basis
+ `CheckedReaderIp(GeoIpSource)`. Unsupported inputs return `ProviderFailure`.
+- Replace capability's `Selected { provider, source, trust_basis }` with
+ `Selected { provider, input: NativeGeoInputContract }`. Capability validates configuration
+ without database I/O and does not describe request provenance. Reserve custom provider ID
+ `maxmind` alongside `cloudfront`. No compatibility aliases or unrelated version bumps.
+- Document mounts, proxy trust, redaction, uncertainty, dataset attribution and operator-owned
+ update/deletion duties, including running snapshots and rollback copies, in the
+ [native Geo guide](docs/guide/native-geo.md).
+
### Portable HTTP And Request Lifecycles (PR #275)
The changes below cover [PR #275](https://github.com/stackpop/edgezero/pull/275), including its
diff --git a/Cargo.lock b/Cargo.lock
index 544160b80..8406c0cd4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1194,11 +1194,13 @@ dependencies = [
"ipnet",
"libc",
"log",
+ "maxminddb",
"redb",
"reqwest",
"rustls",
"serde",
"serde_json",
+ "sha2 0.10.9",
"simple_logger",
"tempfile",
"thiserror 2.0.20",
@@ -2121,6 +2123,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+[[package]]
+name = "ipnetwork"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2307,6 +2315,18 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608"
+[[package]]
+name = "maxminddb"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6"
+dependencies = [
+ "ipnetwork",
+ "memchr",
+ "serde",
+ "thiserror 2.0.20",
+]
+
[[package]]
name = "memchr"
version = "2.8.1"
diff --git a/Cargo.toml b/Cargo.toml
index 8b65bc7a5..319a6c6a5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -83,6 +83,7 @@ libc = "0.2"
miniz_oxide = { version = "=0.8.9", default-features = false, features = ["simd", "with-alloc"] }
log-fastly = "=0.13.1"
matchit = "0.9"
+maxminddb = { version = "=0.32.0", default-features = false }
once_cell = "1"
redb = "4.1.0"
reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "blocking", "json"] }
diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml
index c7b515a9d..24bf2ff5d 100644
--- a/crates/edgezero-adapter-axum/Cargo.toml
+++ b/crates/edgezero-adapter-axum/Cargo.toml
@@ -35,6 +35,7 @@ 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"]
+geo-maxmind = ["axum", "dep:maxminddb", "dep:sha2"]
cli = [
"native-bindings",
"dep:edgezero-adapter",
@@ -78,6 +79,8 @@ tracing = { workspace = true }
walkdir = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+maxminddb = { workspace = true, optional = true }
+sha2 = { workspace = true, optional = true }
edgezero-store-aws = { workspace = true, optional = true }
edgezero-store-celld = { workspace = true, optional = true }
diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs
index 6a8c36e47..0c7c5f48a 100644
--- a/crates/edgezero-adapter-axum/src/dev_server.rs
+++ b/crates/edgezero-adapter-axum/src/dev_server.rs
@@ -1093,6 +1093,7 @@ where
let metadata = A::stores();
let sources = NativeSourcePlan::validate(metadata, &options, overrides)?;
init_logging::(options.logging_level())?;
+ native.geo.log_snapshot();
let mut app = build_app_for_dispatch::()?;
let stores = build_production_stores(metadata, &options, sources).await?;
let transport = AxumOutboundClient::try_transport()
@@ -3481,6 +3482,56 @@ mod native_store_tests {
drop(listener);
}
+ #[cfg(feature = "geo-maxmind")]
+ #[test]
+ fn geo_maxmind_prepared_before_ready_stop_skips_initializer_and_bind() {
+ use crate::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+ let options = options().with_geo(NativeGeoSelection::MaxMind {
+ config: MaxMindGeoConfig::new(
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/geoip/city.mmdb"),
+ ReaderIpExpectation::PeerAllowed,
+ ),
+ });
+ let address = options.addr();
+ let initialized = Rc::new(Cell::new(false));
+ let observed = Rc::clone(&initialized);
+ run_app_with_options_and_initializer::(
+ options,
+ future::ready(()),
+ move |_app, _stores| {
+ Box::pin(async move {
+ observed.set(true);
+ Ok(())
+ })
+ },
+ )
+ .unwrap();
+ assert!(!initialized.get());
+ drop(StdTcpListener::bind(address).expect("ready stop never binds"));
+ }
+
+ #[cfg(feature = "geo-maxmind")]
+ #[tokio::test(flavor = "current_thread")]
+ async fn geo_maxmind_invalid_database_fails_before_runner_resources() {
+ use crate::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+ let root = tempfile::tempdir().unwrap();
+ let path = root.path().join("PRIVATE_DATABASE_PATH");
+ fs::write(&path, b"not an MMDB").unwrap();
+ let options = options().with_geo(NativeGeoSelection::MaxMind {
+ config: MaxMindGeoConfig::new(path, ReaderIpExpectation::PeerAllowed),
+ });
+ let address = options.addr();
+ let error = run_app_with_options_and_initializer::(
+ options,
+ future::pending::<()>(),
+ |_app, _stores| Box::pin(async { panic!("initializer must not run") }),
+ )
+ .unwrap_err();
+ assert_eq!(error.to_string(), "native_geo: invalid database");
+ assert!(!format!("{error:?}").contains("PRIVATE_DATABASE_PATH"));
+ drop(StdTcpListener::bind(address).expect("invalid database never binds"));
+ }
+
#[test]
fn ready_and_rc_deferred_inputs_share_completed_registries_with_requests() {
let options = options();
diff --git a/crates/edgezero-adapter-axum/src/geo.rs b/crates/edgezero-adapter-axum/src/geo.rs
index 8ce1e72f6..02aea65b6 100644
--- a/crates/edgezero-adapter-axum/src/geo.rs
+++ b/crates/edgezero-adapter-axum/src/geo.rs
@@ -1,6 +1,47 @@
-//! Explicit native Geo composition for an operator-protected CloudFront/ALB path.
+//! Explicit native Geo composition for local `GeoLite` databases and protected assertions.
//!
-//! This policy declares external infrastructure protection. A matching socket peer
+//! Local database selection works without `CloudFront`, AWS stores or credentials.
+//! Select `geo-maxmind` to load an operator-supplied read-only snapshot before the
+//! runner initializes. Request lookup uses only checked ingress and memory.
+//! Database preparation is synchronous, before signal registration/stop polling.
+//!
+//! Direct-reader configuration, with no file I/O during capability inspection:
+//! ```no_run
+//! use edgezero_adapter_axum::dev_server::AxumRunOptions;
+//! use edgezero_adapter_axum::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+//!
+//! let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)?
+//! .with_geo(NativeGeoSelection::MaxMind {
+//! config: MaxMindGeoConfig::new(
+//! "/var/lib/edgezero/geo/GeoLite2-City.mmdb",
+//! ReaderIpExpectation::PeerAllowed,
+//! ),
+//! });
+//! let _configured = options.geo_capability()?;
+//! # Ok::<(), anyhow::Error>(())
+//! ```
+//!
+//! A reverse proxy must resolve the reader through the existing trusted policy:
+//! ```no_run
+//! use edgezero_adapter_axum::dev_server::AxumRunOptions;
+//! use edgezero_adapter_axum::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+//! use edgezero_adapter_axum::proxy::{ForwardingHeaderFamily, TrustedProxyPolicy};
+//!
+//! let proxy = TrustedProxyPolicy::new(
+//! ForwardingHeaderFamily::Forwarded, ["10.20.0.8/32"],
+//! )?;
+//! let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)?
+//! .with_trusted_proxy_policy(proxy)
+//! .with_geo(NativeGeoSelection::MaxMind {
+//! config: MaxMindGeoConfig::new(
+//! "/var/lib/edgezero/geo/GeoLite2-City.mmdb",
+//! ReaderIpExpectation::ForwardedRequired,
+//! ),
+//! });
+//! # Ok::<(), anyhow::Error>(())
+//! ```
+//!
+//! The `CloudFront` 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.
//!
@@ -47,12 +88,15 @@
//! 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};
+//! use edgezero_core::geo::{GeoError, GeoInput, GeoProvider, GeoProviderId, GeoResolution};
//!
//! struct ExampleProvider;
//! impl GeoProvider for ExampleProvider {
-//! fn resolve(&self, _input: &GeoInput) -> GeoResolution {
-//! GeoResolution::MissingMetadata
+//! fn resolve(&self, input: &GeoInput) -> GeoResolution {
+//! match input {
+//! GeoInput::Assertions { .. } => GeoResolution::MissingMetadata,
+//! GeoInput::ReaderIp { .. } => GeoResolution::Error(GeoError::ProviderFailure),
+//! }
//! }
//! }
//! let policy = ProtectedCloudFrontAlb::new(
@@ -78,6 +122,8 @@
//! GeoOutcome::Selected { status, .. } => match status {
//! GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress) => "source not admitted",
//! GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) => "metadata missing",
+//! GeoStatus::Unavailable(GeoUnavailableReason::MissingReaderIp) => "reader unavailable",
+//! GeoStatus::Unavailable(GeoUnavailableReason::NoMatch) => "no geographic match",
//! GeoStatus::Error(GeoError::InvalidAssertion) => "assertion rejected",
//! GeoStatus::Error(GeoError::ProviderFailure) => "provider failed",
//! GeoStatus::Located { location, .. } if location.subdivision().is_none() => "country only",
@@ -92,7 +138,7 @@
reason = "public composition types precede private request-bound resolution and tests"
)]
#![cfg_attr(
- feature = "geo-cloudfront",
+ any(feature = "geo-cloudfront", feature = "geo-maxmind"),
expect(
clippy::self_named_module_files,
reason = "repo convention uses geo.rs with submodules under geo/, not mod.rs"
@@ -101,18 +147,26 @@
#[cfg(feature = "geo-cloudfront")]
mod cloudfront;
+#[cfg(feature = "geo-maxmind")]
+mod maxmind;
+#[cfg(test)]
+#[path = "geo/reader_tests.rs"]
+mod reader_tests;
use std::fmt;
use std::net::SocketAddr;
+use std::path::PathBuf;
use std::sync::Arc;
use edgezero_core::geo::{
- GeoAssertion, GeoInput, GeoOutcome, GeoProvider, GeoProviderId, GeoResolution, GeoSource,
- GeoStatus, GeoTrustBasis, GeoUnavailableReason,
+ GeoAssertion, GeoInput, GeoIpSource, GeoOutcome, GeoProvider, GeoProviderId, GeoResolution,
+ GeoSource, GeoStatus, GeoTrustBasis, GeoUnavailableReason,
};
use edgezero_core::http::{HeaderMap, HeaderName};
+use edgezero_core::ingress::CheckedHostSource;
use crate::peer_networks::PeerNetworks;
+use crate::proxy::CheckedProxyMetadata;
/// Bounded startup configuration categories. No policy inputs are retained.
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
@@ -125,6 +179,87 @@ pub enum NativeGeoConfigError {
UnavailableCloudFront,
#[error("native_geo: reserved supplied provider identity")]
ReservedProviderId,
+ #[error("native_geo: maxmind implementation unavailable")]
+ UnavailableMaxMind,
+ #[error("native_geo: invalid database preparation limits")]
+ InvalidPreparationLimits,
+ #[error("native_geo: database unavailable")]
+ UnavailableDatabase,
+ #[error("native_geo: invalid database")]
+ InvalidDatabase,
+ #[error("native_geo: database preparation limit exceeded")]
+ DatabaseLimit,
+ #[error("native_geo: unsupported database")]
+ UnsupportedDatabase,
+ #[error("native_geo: incompatible database schema")]
+ InvalidDatabaseSchema,
+}
+
+/// Whether checked ingress must explicitly resolve a reader through a proxy.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReaderIpExpectation {
+ PeerAllowed,
+ ForwardedRequired,
+}
+
+/// Configurable startup bounds. They do not impose an I/O deadline or RSS quota.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MaxMindPreparationLimits {
+ pub max_database_bytes: usize,
+ pub max_validation_networks: usize,
+}
+
+impl Default for MaxMindPreparationLimits {
+ #[inline]
+ fn default() -> Self {
+ Self {
+ max_database_bytes: 128 * 1024 * 1024,
+ max_validation_networks: 32_000_000,
+ }
+ }
+}
+
+impl MaxMindPreparationLimits {
+ fn validate(self) -> Result<(), NativeGeoConfigError> {
+ if self.max_database_bytes == 0
+ || self.max_database_bytes.checked_add(1).is_none()
+ || self.max_validation_networks == 0
+ {
+ return Err(NativeGeoConfigError::InvalidPreparationLimits);
+ }
+ Ok(())
+ }
+}
+
+/// Operator-owned database location and explicit reader expectation. No I/O on construction.
+#[derive(Clone)]
+pub struct MaxMindGeoConfig {
+ pub path: PathBuf,
+ pub limits: MaxMindPreparationLimits,
+ pub expectation: ReaderIpExpectation,
+}
+
+impl MaxMindGeoConfig {
+ #[inline]
+ #[must_use]
+ pub fn new>(path: P, expectation: ReaderIpExpectation) -> Self {
+ Self {
+ path: path.into(),
+ limits: MaxMindPreparationLimits::default(),
+ expectation,
+ }
+ }
+}
+
+impl fmt::Debug for MaxMindGeoConfig {
+ #[inline]
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("MaxMindGeoConfig")
+ .field("path", &"[redacted]")
+ .field("limits", &self.limits)
+ .field("expectation", &self.expectation)
+ .finish()
+ }
}
/// An operator declaration of a credential-gated, isolated CloudFront/ALB path.
@@ -204,6 +339,14 @@ pub enum NativeGeoSelection {
CloudFront {
policy: ProtectedCloudFrontAlb,
},
+ MaxMind {
+ config: MaxMindGeoConfig,
+ },
+ SuppliedReaderIp {
+ expectation: ReaderIpExpectation,
+ id: GeoProviderId,
+ provider: Arc,
+ },
Supplied {
policy: ProtectedCloudFrontAlb,
id: GeoProviderId,
@@ -220,6 +363,14 @@ impl fmt::Debug for NativeGeoSelection {
.debug_struct("CloudFront")
.field("policy", policy)
.finish(),
+ Self::MaxMind { config } => f.debug_struct("MaxMind").field("config", config).finish(),
+ Self::SuppliedReaderIp {
+ expectation, id, ..
+ } => f
+ .debug_struct("SuppliedReaderIp")
+ .field("expectation", expectation)
+ .field("id", id)
+ .finish_non_exhaustive(),
Self::Supplied { policy, id, .. } => f
.debug_struct("Supplied")
.field("policy", policy)
@@ -229,97 +380,258 @@ impl fmt::Debug for NativeGeoSelection {
}
}
-/// Owned startup capability, not proof of per-request coverage or infrastructure.
+/// Configured input contract, without eventual request provenance or database readiness.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum NativeGeoInputContract {
+ ProtectedCloudFrontAlb,
+ ReaderIp { expectation: ReaderIpExpectation },
+}
+
+/// Owned startup configuration capability. Inspection performs no database I/O.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NativeGeoCapability {
Off,
Selected {
provider: GeoProviderId,
+ input: NativeGeoInputContract,
+ },
+}
+
+impl NativeGeoSelection {
+ pub(crate) fn capability(&self) -> Result {
+ let (provider, input) = match self {
+ Self::Off => return Ok(NativeGeoCapability::Off),
+ Self::CloudFront { .. } => {
+ if !cfg!(feature = "geo-cloudfront") {
+ return Err(NativeGeoConfigError::UnavailableCloudFront);
+ }
+ (
+ GeoProviderId::cloudfront(),
+ NativeGeoInputContract::ProtectedCloudFrontAlb,
+ )
+ }
+ Self::MaxMind { config } => {
+ config.limits.validate()?;
+ if !cfg!(all(feature = "geo-maxmind", unix)) {
+ return Err(NativeGeoConfigError::UnavailableMaxMind);
+ }
+ (
+ GeoProviderId::maxmind(),
+ NativeGeoInputContract::ReaderIp {
+ expectation: config.expectation,
+ },
+ )
+ }
+ Self::Supplied { id, .. } | Self::SuppliedReaderIp { id, .. } => {
+ if id == &GeoProviderId::cloudfront() || id == &GeoProviderId::maxmind() {
+ return Err(NativeGeoConfigError::ReservedProviderId);
+ }
+ let input = if let Self::SuppliedReaderIp { expectation, .. } = self {
+ NativeGeoInputContract::ReaderIp {
+ expectation: *expectation,
+ }
+ } else {
+ NativeGeoInputContract::ProtectedCloudFrontAlb
+ };
+ (id.clone(), input)
+ }
+ };
+ Ok(NativeGeoCapability::Selected { provider, input })
+ }
+}
+
+/// Source admission remains framework-owned and independent of provider identity.
+#[derive(Clone)]
+pub(crate) enum NativeGeoAdmission {
+ Assertions(ProtectedCloudFrontAlb),
+ ReaderIp {
+ expectation: ReaderIpExpectation,
source: GeoSource,
- trust_basis: GeoTrustBasis,
},
}
-/// Validated runner-owned composition, never installed in public extensions.
+/// Prepared runner-owned composition, never installed in public extensions.
#[derive(Clone, Default)]
pub(crate) enum NativeGeo {
#[default]
Off,
Selected {
- policy: ProtectedCloudFrontAlb,
+ admission: NativeGeoAdmission,
id: GeoProviderId,
provider: Arc,
+ #[cfg(feature = "geo-maxmind")]
+ snapshot: Option,
},
}
impl NativeGeo {
- pub(crate) fn validate(selection: &NativeGeoSelection) -> Result {
- match selection {
- NativeGeoSelection::Off => Ok(Self::Off),
+ pub(crate) fn prepare(selection: &NativeGeoSelection) -> Result {
+ let _capability = selection.capability()?;
+ let (admission, id, provider): (_, _, Arc) = match selection
+ {
+ NativeGeoSelection::Off => return Ok(Self::Off),
#[cfg(feature = "geo-cloudfront")]
- NativeGeoSelection::CloudFront { policy } => Ok(Self::Selected {
- policy: policy.clone(),
- id: GeoProviderId::cloudfront(),
- provider: Arc::new(cloudfront::CloudFrontProvider),
- }),
+ NativeGeoSelection::CloudFront { policy } => (
+ NativeGeoAdmission::Assertions(policy.clone()),
+ GeoProviderId::cloudfront(),
+ Arc::new(cloudfront::CloudFrontProvider),
+ ),
#[cfg(not(feature = "geo-cloudfront"))]
NativeGeoSelection::CloudFront { .. } => {
- Err(NativeGeoConfigError::UnavailableCloudFront)
+ return Err(NativeGeoConfigError::UnavailableCloudFront);
+ }
+ #[cfg(feature = "geo-maxmind")]
+ NativeGeoSelection::MaxMind { config } => {
+ let provider = maxmind::MaxMindProvider::prepare(config)?;
+ let snapshot = Some(provider.metadata.clone());
+ return Ok(Self::Selected {
+ admission: NativeGeoAdmission::ReaderIp {
+ expectation: config.expectation,
+ source: GeoSource::LocalDatabase,
+ },
+ id: GeoProviderId::maxmind(),
+ provider: Arc::new(provider),
+ snapshot,
+ });
+ }
+ #[cfg(not(feature = "geo-maxmind"))]
+ NativeGeoSelection::MaxMind { .. } => {
+ return Err(NativeGeoConfigError::UnavailableMaxMind);
}
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),
- })
- }
- }
+ } => (
+ NativeGeoAdmission::Assertions(policy.clone()),
+ id.clone(),
+ Arc::clone(provider),
+ ),
+ NativeGeoSelection::SuppliedReaderIp {
+ expectation,
+ id,
+ provider,
+ } => (
+ NativeGeoAdmission::ReaderIp {
+ expectation: *expectation,
+ source: GeoSource::CheckedReaderIp,
+ },
+ id.clone(),
+ Arc::clone(provider),
+ ),
+ };
+ Ok(Self::Selected {
+ admission,
+ id,
+ provider,
+ #[cfg(feature = "geo-maxmind")]
+ snapshot: None,
+ })
}
- 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,
- },
+ #[cfg_attr(
+ not(feature = "geo-maxmind"),
+ expect(
+ clippy::unused_self,
+ reason = "the stable runner hook has no snapshot when the built-in reader is absent"
+ )
+ )]
+ pub(crate) fn log_snapshot(&self) {
+ #[cfg(feature = "geo-maxmind")]
+ if let Self::Selected {
+ snapshot: Some(snapshot),
+ ..
+ } = self
+ {
+ log::info!(target: "edgezero::native", "event=geo_database provider=maxmind database_type={} build_epoch={} sha256={}", snapshot.database_type, snapshot.build_epoch, snapshot.sha256);
}
}
pub(crate) fn strip_credential(&self, headers: &mut HeaderMap) {
- if let Self::Selected { policy, .. } = self {
+ if let Self::Selected {
+ admission: NativeGeoAdmission::Assertions(policy),
+ ..
+ } = self
+ {
let _removed = headers.remove(&policy.credential_header);
}
}
- pub(crate) fn resolve(&self, peer: Option, headers: &HeaderMap) -> GeoOutcome {
+ pub(crate) fn reader_input(&self, checked: &CheckedProxyMetadata) -> Option {
+ let Self::Selected {
+ admission: NativeGeoAdmission::ReaderIp { expectation, .. },
+ ..
+ } = self
+ else {
+ return None;
+ };
+ let _peer = checked.direct_peer()?;
+ let source = match checked.client_source() {
+ CheckedHostSource::Direct if *expectation == ReaderIpExpectation::PeerAllowed => {
+ GeoIpSource::DirectPeer
+ }
+ CheckedHostSource::TrustedForwarded => GeoIpSource::TrustedForwarded,
+ CheckedHostSource::TrustedXForwarded => GeoIpSource::TrustedXForwarded,
+ CheckedHostSource::Direct | CheckedHostSource::Unavailable | _ => return None,
+ };
+ Some(GeoInput::ReaderIp {
+ address: checked.effective_client()?,
+ source,
+ })
+ }
+
+ pub(crate) fn resolve(
+ &self,
+ peer: Option,
+ headers: &HeaderMap,
+ reader: Option<&GeoInput>,
+ ) -> GeoOutcome {
let Self::Selected {
- policy,
+ admission,
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()) {
+ let admitted = match admission {
+ NativeGeoAdmission::Assertions(policy) => policy
+ .admit(peer, headers)
+ .map(|admitted| {
+ (
+ admitted.input(),
+ GeoSource::CloudFront,
+ GeoTrustBasis::DeclaredProtectedAlb,
+ )
+ })
+ .ok_or(GeoUnavailableReason::UntrustedIngress),
+ NativeGeoAdmission::ReaderIp { source, .. } => match reader {
+ Some(
+ input @ GeoInput::ReaderIp {
+ source: reader_source,
+ ..
+ },
+ ) => Ok((
+ input.clone(),
+ *source,
+ GeoTrustBasis::CheckedReaderIp(*reader_source),
+ )),
+ _ => Err(GeoUnavailableReason::MissingReaderIp),
+ },
+ };
+ let status = match admitted {
+ Err(reason) => GeoStatus::Unavailable(reason),
+ Ok((input, source, trust_basis)) => match provider.resolve(&input) {
GeoResolution::MissingMetadata => {
GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata)
}
+ GeoResolution::NoMatch => GeoStatus::Unavailable(GeoUnavailableReason::NoMatch),
GeoResolution::Error(error) => GeoStatus::Error(error),
GeoResolution::Located(location) => GeoStatus::Located {
location,
- source: GeoSource::CloudFront,
- trust_basis: GeoTrustBasis::DeclaredProtectedAlb,
+ source,
+ trust_basis,
},
},
};
@@ -337,7 +649,7 @@ struct ProtectedAssertions<'request> {
impl ProtectedAssertions<'_> {
fn input(&self) -> GeoInput {
- GeoInput {
+ GeoInput::Assertions {
country: assertion(self.headers, "cloudfront-viewer-country"),
subdivision: assertion(self.headers, "cloudfront-viewer-country-region"),
}
@@ -480,7 +792,7 @@ mod tests {
#[test]
fn source_admission_precedes_header_inspection_and_provider_calls() {
let calls = Arc::new(AtomicUsize::new(0));
- let selected = NativeGeo::validate(&NativeGeoSelection::Supplied {
+ let selected = NativeGeo::prepare(&NativeGeoSelection::Supplied {
policy: policy(),
id: GeoProviderId::new("counting").unwrap(),
provider: Arc::new(CountingProvider {
@@ -494,7 +806,7 @@ mod tests {
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),
+ selected.resolve(peer, &headers, None),
GeoOutcome::Selected {
provider: GeoProviderId::new("counting").unwrap(),
status: GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress)
@@ -502,13 +814,13 @@ mod tests {
);
}
assert_eq!(
- NativeGeo::Off.resolve(Some("127.0.0.1:80".parse().unwrap()), &headers),
+ NativeGeo::Off.resolve(Some("127.0.0.1:80".parse().unwrap()), &headers, None),
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),
+ selected.resolve(Some("127.0.0.1:80".parse().unwrap()), values, None),
GeoOutcome::Selected {
provider: GeoProviderId::new("counting").unwrap(),
status: GeoStatus::Error(GeoError::ProviderFailure)
@@ -545,7 +857,7 @@ mod tests {
#[test]
fn credential_removal_strips_all_instances_and_preserves_geography() {
- let selected = NativeGeo::validate(&NativeGeoSelection::Supplied {
+ let selected = NativeGeo::prepare(&NativeGeoSelection::Supplied {
policy: policy(),
id: GeoProviderId::new("custom").unwrap(),
provider: Arc::new(CountingProvider {
@@ -570,7 +882,7 @@ mod tests {
fn supplied_identity_cannot_claim_builtin_and_options_keep_thread_bounds() {
fn assert_bounds() {}
assert_bounds::();
- let error = NativeGeo::validate(&NativeGeoSelection::Supplied {
+ let error = NativeGeo::prepare(&NativeGeoSelection::Supplied {
policy: policy(),
id: GeoProviderId::cloudfront(),
provider: Arc::new(CountingProvider {
@@ -612,7 +924,7 @@ mod tests {
fn compiled_out_builtin_is_an_error_not_off() {
let selected = NativeGeoSelection::CloudFront { policy: policy() };
assert_eq!(
- NativeGeo::validate(&selected).err(),
+ NativeGeo::prepare(&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
index ae633ba0d..67c63a5a7 100644
--- a/crates/edgezero-adapter-axum/src/geo/cloudfront.rs
+++ b/crates/edgezero-adapter-axum/src/geo/cloudfront.rs
@@ -11,8 +11,14 @@ 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())
+ let GeoInput::Assertions {
+ country,
+ subdivision,
+ } = input
+ else {
+ return GeoResolution::Error(GeoError::ProviderFailure);
+ };
+ let (Ok(country_slot), Ok(subdivision_slot)) = (country.as_bytes(), subdivision.as_bytes())
else {
return GeoResolution::Error(GeoError::InvalidAssertion);
};
@@ -48,10 +54,10 @@ impl GeoProvider for CloudFrontProvider {
#[cfg(test)]
mod tests {
use super::*;
- use edgezero_core::geo::GeoAssertion;
+ use edgezero_core::geo::{GeoAssertion, GeoIpSource};
fn input(country: Option<&[u8]>, subdivision: Option<&[u8]>) -> GeoInput {
- GeoInput {
+ GeoInput::Assertions {
country: country.map_or_else(GeoAssertion::absent, GeoAssertion::from_bytes),
subdivision: subdivision.map_or_else(GeoAssertion::absent, GeoAssertion::from_bytes),
}
@@ -68,6 +74,13 @@ mod tests {
fn geo_cloudfront_parsing_matches_spec() {
let invalid = GeoResolution::Error(GeoError::InvalidAssertion);
let cases = [
+ (
+ GeoInput::ReaderIp {
+ address: "192.0.2.1".parse().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ },
+ GeoResolution::Error(GeoError::ProviderFailure),
+ ),
(input(None, None), GeoResolution::MissingMetadata),
(input(Some(b"us"), Some(b"ca")), located("US", Some("CA"))),
(input(Some(b"US"), None), located("US", None)),
@@ -111,21 +124,21 @@ mod tests {
(input(Some(b"US"), Some(b"A_B")), invalid.clone()),
(input(Some(b"US"), Some(b"%41")), invalid.clone()),
(
- GeoInput {
+ GeoInput::Assertions {
country: GeoAssertion::invalid(),
subdivision: GeoAssertion::absent(),
},
invalid.clone(),
),
(
- GeoInput {
+ GeoInput::Assertions {
country: GeoAssertion::from_bytes(b"US"),
subdivision: GeoAssertion::invalid(),
},
invalid.clone(),
),
(
- GeoInput {
+ GeoInput::Assertions {
country: GeoAssertion::absent(),
subdivision: GeoAssertion::invalid(),
},
diff --git a/crates/edgezero-adapter-axum/src/geo/maxmind.rs b/crates/edgezero-adapter-axum/src/geo/maxmind.rs
new file mode 100644
index 000000000..402cc2a02
--- /dev/null
+++ b/crates/edgezero-adapter-axum/src/geo/maxmind.rs
@@ -0,0 +1,255 @@
+//! Immutable local MMDB snapshots. Preparation is synchronous and Unix-only.
+//!
+//! File bytes and network visits are caller-bounded. Before verification the
+//! upstream tree allowance `3 * node_count + 1` must fit that visit budget.
+//! Upstream verification charges at most eight times each section's bytes and
+//! memoizes shared values. Each additional structural decode and borrowed path
+//! decode has the reader's 65,536-value, payload and depth limits. Structural
+//! decoding deliberately expands shared values through the normal decoder.
+//! Thus total decode work is bounded per unique record, not globally by bytes.
+//! The dedup set is bounded by network visits; upstream tables are O(file bytes).
+//! These bounds are not wall-clock or fixed-RSS promises.
+//!
+//! Metadata receives upstream typed decoding and verification. Version 0.32.0
+//! exposes neither a raw metadata decoder nor a metadata `LookupResult`. Therefore
+//! unknown metadata fields retain upstream verifier gaps for non-string map keys
+//! and pointer chains. Reachable data records receive the stricter pass below.
+
+#![expect(
+ clippy::field_scoped_visibility_modifiers,
+ reason = "snapshot metadata is shared only with the native composition parent"
+)]
+#![expect(
+ clippy::map_err_ignore,
+ clippy::wildcard_enum_match_arm,
+ reason = "vendor and I/O details deliberately collapse to source-free error categories"
+)]
+
+use std::collections::HashSet;
+#[cfg(any(unix, test))]
+use std::io::{ErrorKind, Read};
+
+use edgezero_core::geo::{
+ CountryCode, GeoError, GeoInput, GeoLocation, GeoProvider, GeoResolution, SubdivisionCode,
+};
+use maxminddb::{LookupResult, MaxMindDbError, Reader, WithinOptions, path};
+use sha2::{Digest as _, Sha256};
+
+use super::{MaxMindGeoConfig, NativeGeoConfigError};
+
+mod structure;
+#[cfg(test)]
+mod tests;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct MaxMindSnapshot {
+ pub(super) database_type: String,
+ pub(super) build_epoch: u64,
+ pub(super) sha256: String,
+}
+
+#[derive(Clone, Copy)]
+enum Dataset {
+ City,
+ Country,
+}
+
+pub(super) struct MaxMindProvider {
+ reader: Reader>,
+ dataset: Dataset,
+ pub(super) metadata: MaxMindSnapshot,
+}
+
+impl MaxMindProvider {
+ pub(super) fn prepare(config: &MaxMindGeoConfig) -> Result {
+ let byte_cap = config.limits.max_database_bytes;
+ let visit_cap = config.limits.max_validation_networks;
+ if byte_cap == 0 || visit_cap == 0 || byte_cap.checked_add(1).is_none() {
+ return Err(NativeGeoConfigError::DatabaseLimit);
+ }
+ let bytes = acquire(config)?;
+ let checksum = format!("{:x}", Sha256::digest(&bytes));
+ let reader = Reader::from_source(bytes).map_err(database_error)?;
+ let reader_metadata = reader.metadata();
+ let dataset = match reader_metadata.database_type.as_str() {
+ "GeoLite2-City" => Dataset::City,
+ "GeoLite2-Country" => Dataset::Country,
+ _ => return Err(NativeGeoConfigError::UnsupportedDatabase),
+ };
+ if !matches!(reader_metadata.ip_version, 4 | 6) {
+ return Err(NativeGeoConfigError::UnsupportedDatabase);
+ }
+ let node_count = usize::try_from(reader_metadata.node_count)
+ .map_err(|_| NativeGeoConfigError::DatabaseLimit)?;
+ let tree_allowance = node_count
+ .checked_mul(3)
+ .and_then(|n| n.checked_add(1))
+ .ok_or(NativeGeoConfigError::DatabaseLimit)?;
+ if tree_allowance > visit_cap {
+ return Err(NativeGeoConfigError::DatabaseLimit);
+ }
+ reader.verify().map_err(database_error)?;
+ let options = WithinOptions::default()
+ .include_aliased_networks()
+ .include_networks_without_data();
+ let networks = reader.networks(options).map_err(database_error)?;
+ let mut offsets = HashSet::new();
+ for (index, next_network) in networks.enumerate() {
+ // Count no-data leaves and aliases before deduplication.
+ if index >= visit_cap {
+ return Err(NativeGeoConfigError::DatabaseLimit);
+ }
+ let result = next_network.map_err(database_error)?;
+ if let Some(offset) = result.offset()
+ && offsets.insert(offset)
+ {
+ result
+ .decode::()
+ .map_err(database_error)?;
+ projection(&result, dataset).map_err(|error| match error {
+ MaxMindDbError::ResourceLimit { .. } => NativeGeoConfigError::DatabaseLimit,
+ _ => NativeGeoConfigError::InvalidDatabaseSchema,
+ })?;
+ }
+ }
+ let metadata = MaxMindSnapshot {
+ database_type: reader_metadata.database_type.clone(),
+ build_epoch: reader_metadata.build_epoch,
+ sha256: checksum,
+ };
+ Ok(Self {
+ reader,
+ dataset,
+ metadata,
+ })
+ }
+}
+
+#[expect(
+ clippy::needless_pass_by_value,
+ reason = "map_err transfers ownership and intentionally drops vendor diagnostics"
+)]
+fn database_error(error: MaxMindDbError) -> NativeGeoConfigError {
+ match error {
+ MaxMindDbError::ResourceLimit { .. } => NativeGeoConfigError::DatabaseLimit,
+ _ => NativeGeoConfigError::InvalidDatabase,
+ }
+}
+
+// The identical borrowed paths validate consumed types during preparation and
+// resolve them during lookup. Duplicate keys consequently use the first value.
+fn projection<'database>(
+ result: &LookupResult<'database, Vec>,
+ dataset: Dataset,
+) -> Result<(Option<&'database str>, Option<&'database str>), MaxMindDbError> {
+ let country = result.decode_path::<&str>(&path!["country", "iso_code"])?;
+ let subdivision = match dataset {
+ Dataset::City => result.decode_path::<&str>(&path!["subdivisions", 0_usize, "iso_code"])?,
+ Dataset::Country => None,
+ };
+ Ok((country, subdivision))
+}
+
+impl GeoProvider for MaxMindProvider {
+ fn resolve(&self, input: &GeoInput) -> GeoResolution {
+ let GeoInput::ReaderIp { address, .. } = input else {
+ return GeoResolution::Error(GeoError::ProviderFailure);
+ };
+ // Checked ingress already normalizes mapped addresses. Do not invent
+ // source admission or address rewriting inside the database provider.
+ if address.is_ipv6() && self.reader.metadata().ip_version == 4 {
+ return GeoResolution::NoMatch;
+ }
+ let Ok(result) = self.reader.lookup(*address) else {
+ return GeoResolution::Error(GeoError::ProviderFailure);
+ };
+ let Ok((country_slot, subdivision_slot)) = projection(&result, self.dataset) else {
+ return GeoResolution::Error(GeoError::ProviderFailure);
+ };
+ let Some(country_value) = country_slot else {
+ return if subdivision_slot.is_none() {
+ GeoResolution::NoMatch
+ } else {
+ GeoResolution::Error(GeoError::InvalidAssertion)
+ };
+ };
+ let Ok(country) = CountryCode::new(country_value) else {
+ return GeoResolution::Error(GeoError::InvalidAssertion);
+ };
+ let Ok(subdivision) = subdivision_slot.map(SubdivisionCode::new).transpose() else {
+ return GeoResolution::Error(GeoError::InvalidAssertion);
+ };
+ GeoResolution::Located(GeoLocation::new(country, subdivision))
+ }
+}
+
+#[cfg(unix)]
+#[expect(
+ clippy::filetype_is_file,
+ reason = "only regular files are accepted; all special file kinds must fail"
+)]
+fn acquire(config: &MaxMindGeoConfig) -> Result, NativeGeoConfigError> {
+ use std::fs::OpenOptions;
+ use std::os::unix::fs::OpenOptionsExt as _;
+
+ // Opening first avoids pathname races; NONBLOCK prevents waiting for a FIFO
+ // writer. Descriptor metadata rejects pipes/devices before any read.
+ let file = OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_NONBLOCK)
+ .open(&config.path)
+ .map_err(|_| NativeGeoConfigError::UnavailableDatabase)?;
+ let metadata = file
+ .metadata()
+ .map_err(|_| NativeGeoConfigError::UnavailableDatabase)?;
+ if !metadata.file_type().is_file() {
+ return Err(NativeGeoConfigError::UnavailableDatabase);
+ }
+ let cap = config.limits.max_database_bytes;
+ let file_cap = u64::try_from(cap).map_err(|_| NativeGeoConfigError::DatabaseLimit)?;
+ if metadata.len() > file_cap {
+ return Err(NativeGeoConfigError::DatabaseLimit);
+ }
+ read_bounded(file, cap)
+}
+
+// No special-file-safe acquisition has been proven for other platforms. Fail
+// before opening anything rather than potentially blocking on a named pipe.
+#[cfg(not(unix))]
+fn acquire(_config: &MaxMindGeoConfig) -> Result, NativeGeoConfigError> {
+ Err(NativeGeoConfigError::UnavailableDatabase)
+}
+
+#[cfg(any(unix, test))]
+#[expect(
+ clippy::indexing_slicing,
+ reason = "slice ends are bounded by chunk length and Read's returned count"
+)]
+fn read_bounded(mut source: impl Read, cap: usize) -> Result, NativeGeoConfigError> {
+ let limit = cap
+ .checked_add(1)
+ .ok_or(NativeGeoConfigError::DatabaseLimit)?;
+ let mut bytes = Vec::new();
+ // Incremental fixed-size reads avoid reserving the full operator limit for a
+ // tiny database. Vec capacity can exceed length; it is O(cap), not cap exact.
+ let mut chunk = [0; 8192];
+ while bytes.len() < limit {
+ let remaining = chunk.len().min(limit.saturating_sub(bytes.len()));
+ let count = match source.read(&mut chunk[..remaining]) {
+ Ok(count) => count,
+ Err(error) if error.kind() == ErrorKind::Interrupted => continue,
+ Err(_) => return Err(NativeGeoConfigError::UnavailableDatabase),
+ };
+ if count == 0 {
+ break;
+ }
+ bytes
+ .try_reserve(count)
+ .map_err(|_| NativeGeoConfigError::DatabaseLimit)?;
+ bytes.extend_from_slice(&chunk[..count]);
+ }
+ if bytes.len() > cap {
+ return Err(NativeGeoConfigError::DatabaseLimit);
+ }
+ Ok(bytes)
+}
diff --git a/crates/edgezero-adapter-axum/src/geo/maxmind/structure.rs b/crates/edgezero-adapter-axum/src/geo/maxmind/structure.rs
new file mode 100644
index 000000000..53fb77594
--- /dev/null
+++ b/crates/edgezero-adapter-axum/src/geo/maxmind/structure.rs
@@ -0,0 +1,89 @@
+//! Non-materializing traversal through the reader's bounded normal decoder.
+//! Unlike `IgnoredAny`, this follows every pointer. The identifier entry point
+//! rejects pointer chains before dispatching non-string values to `deserialize_any`.
+//! Calling `deserialize_any` directly would retain the upstream pointer-chain gap.
+
+#![expect(
+ clippy::missing_trait_methods,
+ reason = "Serde defaults reject types that are not emitted by the pinned MMDB decoder"
+)]
+
+use std::fmt;
+
+use serde::de::{Error, MapAccess, SeqAccess, Visitor};
+use serde::{Deserialize, Deserializer};
+
+pub(super) struct StructuralValue;
+
+impl<'de> Deserialize<'de> for StructuralValue {
+ fn deserialize>(deserializer: D) -> Result {
+ // Pinned reader's try_read_identifier_bytes checks pointer targets even
+ // for non-strings, then rewinds and uses normal any decoding. Strings
+ // arrive as borrowed bytes; verify() has already checked their UTF-8.
+ deserializer.deserialize_identifier(StructuralVisitor)
+ }
+}
+
+struct StructuralVisitor;
+
+impl<'de> Visitor<'de> for StructuralVisitor {
+ type Value = StructuralValue;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("an MMDB value with string map keys")
+ }
+
+ fn visit_map>(self, mut map: A) -> Result {
+ // &str uses strict string decoding, not deserialize_identifier, which
+ // may accept numeric struct field identifiers.
+ while map.next_key::<&'de str>()?.is_some() {
+ map.next_value::()?;
+ }
+ Ok(StructuralValue)
+ }
+
+ fn visit_seq>(self, mut seq: A) -> Result {
+ while seq.next_element::()?.is_some() {}
+ Ok(StructuralValue)
+ }
+
+ fn visit_borrowed_str(self, _: &'de str) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_borrowed_bytes(self, _: &'de [u8]) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_bool(self, _: bool) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_i32(self, _: i32) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_u16(self, _: u16) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_u32(self, _: u32) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_u64(self, _: u64) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_u128(self, _: u128) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_f32(self, _: f32) -> Result {
+ Ok(StructuralValue)
+ }
+
+ fn visit_f64(self, _: f64) -> Result {
+ Ok(StructuralValue)
+ }
+}
diff --git a/crates/edgezero-adapter-axum/src/geo/maxmind/tests.rs b/crates/edgezero-adapter-axum/src/geo/maxmind/tests.rs
new file mode 100644
index 000000000..cbace7d7a
--- /dev/null
+++ b/crates/edgezero-adapter-axum/src/geo/maxmind/tests.rs
@@ -0,0 +1,375 @@
+#![cfg_attr(
+ unix,
+ expect(
+ clippy::absolute_paths,
+ reason = "filesystem tests spell out standard-library operations"
+ )
+)]
+
+use super::*;
+use crate::geo::{MaxMindPreparationLimits, ReaderIpExpectation};
+#[cfg(unix)]
+use edgezero_core::geo::{GeoAssertion, GeoIpSource};
+use std::path::PathBuf;
+
+fn fixture(name: &str) -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("tests/fixtures/geoip")
+ .join(format!("{name}.mmdb"))
+}
+
+fn config(name: &str) -> MaxMindGeoConfig {
+ MaxMindGeoConfig {
+ path: fixture(name),
+ limits: MaxMindPreparationLimits {
+ max_database_bytes: 128 * 1024 * 1024,
+ max_validation_networks: 32_000_000,
+ },
+ expectation: ReaderIpExpectation::PeerAllowed,
+ }
+}
+
+#[cfg(unix)]
+fn input(address: &str) -> GeoInput {
+ GeoInput::ReaderIp {
+ address: address.parse().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ }
+}
+
+#[cfg(unix)]
+fn located(country: &str, subdivision: Option<&str>) -> GeoResolution {
+ GeoResolution::Located(GeoLocation::new(
+ CountryCode::new(country).unwrap(),
+ subdivision.map(|code| SubdivisionCode::new(code).unwrap()),
+ ))
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_city_projection_and_request_code_validation() {
+ let provider = MaxMindProvider::prepare(&config("city")).unwrap();
+ let invalid = GeoResolution::Error(GeoError::InvalidAssertion);
+ for (address, expected) in [
+ ("127.0.0.1", located("US", Some("CA"))),
+ ("192.0.2.1", located("US", Some("CA"))),
+ ("192.0.2.2", located("GB", None)),
+ ("192.0.2.3", located("JP", Some("01"))),
+ ("192.0.2.4", located("US", Some("ZZ"))),
+ ("192.0.2.5", located("US", None)),
+ ("192.0.2.6", located("US", None)),
+ ("192.0.2.7", GeoResolution::NoMatch),
+ ("192.0.2.8", invalid.clone()),
+ ("192.0.2.9", invalid.clone()),
+ ("192.0.2.10", invalid.clone()),
+ ("192.0.2.11", invalid.clone()),
+ ("192.0.2.12", invalid.clone()),
+ ("192.0.2.13", GeoResolution::NoMatch),
+ ("192.0.2.14", GeoResolution::NoMatch),
+ ("192.0.2.15", located("US", None)),
+ ("192.0.2.16", located("US", Some("CA"))),
+ ("192.0.2.17", located("US", Some("A1B"))),
+ ("192.0.2.18", invalid.clone()),
+ ("192.0.2.19", invalid.clone()),
+ ("192.0.2.20", invalid),
+ ("192.0.2.254", GeoResolution::NoMatch),
+ ("10.0.0.1", GeoResolution::NoMatch),
+ ("2001:db8::1", GeoResolution::NoMatch),
+ ("::ffff:192.0.2.1", GeoResolution::NoMatch),
+ ] {
+ assert_eq!(provider.resolve(&input(address)), expected, "{address}");
+ }
+ for source in [
+ GeoIpSource::DirectPeer,
+ GeoIpSource::TrustedForwarded,
+ GeoIpSource::TrustedXForwarded,
+ ] {
+ assert_eq!(
+ provider.resolve(&GeoInput::ReaderIp {
+ address: "192.0.2.1".parse().unwrap(),
+ source
+ }),
+ located("US", Some("CA"))
+ );
+ }
+ assert_eq!(
+ provider.resolve(&GeoInput::Assertions {
+ country: GeoAssertion::from_bytes(b"US"),
+ subdivision: GeoAssertion::absent()
+ }),
+ GeoResolution::Error(GeoError::ProviderFailure)
+ );
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_country_ignores_subdivisions_and_record_sizes_work() {
+ let provider = MaxMindProvider::prepare(&config("country")).unwrap();
+ assert_eq!(provider.resolve(&input("192.0.2.1")), located("US", None));
+ for name in ["record-28", "record-32"] {
+ let sized_provider = MaxMindProvider::prepare(&config(name)).unwrap();
+ assert_eq!(
+ sized_provider.resolve(&input("192.0.2.1")),
+ located("US", Some("CA"))
+ );
+ assert_eq!(
+ sized_provider.resolve(&input("198.51.100.1")),
+ GeoResolution::NoMatch
+ );
+ }
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_ipv6_and_ipv4_subtree() {
+ let provider = MaxMindProvider::prepare(&config("ipv6")).unwrap();
+ assert_eq!(
+ provider.resolve(&input("2001:db8::7")),
+ located("GB", Some("ENG"))
+ );
+ assert_eq!(
+ provider.resolve(&input("192.0.2.1")),
+ located("US", Some("CA"))
+ );
+ assert_eq!(
+ provider.resolve(&input("2001:db9::7")),
+ GeoResolution::NoMatch
+ );
+ assert_eq!(
+ provider.resolve(&input("192.0.2.2")),
+ GeoResolution::NoMatch
+ );
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_consumed_type_failures_are_startup_errors_even_off_path() {
+ for name in [
+ "bad-country-parent",
+ "bad-country-code",
+ "bad-subdivisions-parent",
+ "bad-first-subdivision",
+ "bad-subdivision-code",
+ ] {
+ assert!(
+ matches!(
+ MaxMindProvider::prepare(&config(name)),
+ Err(NativeGeoConfigError::InvalidDatabaseSchema)
+ ),
+ "{name}"
+ );
+ }
+}
+
+#[cfg(unix)]
+#[test]
+#[ignore = "#438 acceptance blocker: maxminddb 0.32.0 exposes no strict raw metadata decoding"]
+fn geo_maxmind_rejects_unused_metadata_nonstring_keys() {
+ assert!(
+ MaxMindProvider::prepare(&config("bad-metadata-key")).is_err(),
+ "selected malformed metadata must fail startup; upstream verification currently accepts it"
+ );
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_structural_pass_closes_upstream_data_gaps() {
+ for name in ["bad-unrelated-key", "bad-unrelated-chain", "bad-root-chain"] {
+ let reader = Reader::from_source(std::fs::read(fixture(name)).unwrap()).unwrap();
+ reader.verify().unwrap(); // Regression: verification alone is insufficient.
+ assert!(
+ matches!(
+ MaxMindProvider::prepare(&config(name)),
+ Err(NativeGeoConfigError::InvalidDatabase)
+ ),
+ "{name}"
+ );
+ }
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_off_path_corruption_and_metadata_fail_preparation() {
+ for name in [
+ "bad-offpath-pointer",
+ "bad-offpath-utf8",
+ "bad-metadata",
+ "bad-tree-cycle",
+ "bad-separator",
+ "truncated",
+ ] {
+ assert!(
+ matches!(
+ MaxMindProvider::prepare(&config(name)),
+ Err(NativeGeoConfigError::InvalidDatabase)
+ ),
+ "{name}"
+ );
+ }
+ assert!(matches!(
+ MaxMindProvider::prepare(&config("unsupported")),
+ Err(NativeGeoConfigError::UnsupportedDatabase)
+ ));
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_limits_fail_without_partial_publication() {
+ let mut settings = config("city");
+ for bytes in [0, 1, usize::MAX] {
+ settings.limits.max_database_bytes = bytes;
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ }
+ settings = config("city");
+ let bytes = std::fs::read(&settings.path).unwrap();
+ settings.limits.max_database_bytes = bytes.len();
+ MaxMindProvider::prepare(&settings).unwrap();
+ settings.limits.max_database_bytes = bytes.len().saturating_sub(1);
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ settings = config("city");
+ let reader = Reader::from_source(bytes).unwrap();
+ let allowance = usize::try_from(reader.metadata().node_count)
+ .unwrap()
+ .saturating_mul(3)
+ .saturating_add(1);
+ settings.limits.max_validation_networks = allowance.saturating_sub(1);
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ settings.limits.max_validation_networks = allowance;
+ MaxMindProvider::prepare(&settings).unwrap();
+ settings.limits.max_validation_networks = 0;
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ let expansion =
+ Reader::from_source(std::fs::read(fixture("expansion-limit")).unwrap()).unwrap();
+ expansion.verify().unwrap();
+ for name in [
+ "expansion-limit",
+ "verification-limit",
+ "metadata-verification-limit",
+ ] {
+ assert!(
+ matches!(
+ MaxMindProvider::prepare(&config(name)),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ),
+ "{name}"
+ );
+ }
+ let scalar_provider = MaxMindProvider::prepare(&config("all-values")).unwrap();
+ assert_eq!(
+ scalar_provider.resolve(&input("192.0.2.1")),
+ located("US", None)
+ );
+}
+
+#[test]
+fn geo_maxmind_actual_read_limit_checks_the_extra_byte() {
+ use std::io::Cursor;
+ assert_eq!(read_bounded(Cursor::new(b"1234"), 4).unwrap(), b"1234");
+ assert!(matches!(
+ read_bounded(Cursor::new(b"12345"), 4),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ let mut source = Cursor::new(b"123456789");
+ assert!(matches!(
+ read_bounded(&mut source, 4),
+ Err(NativeGeoConfigError::DatabaseLimit)
+ ));
+ assert_eq!(source.position(), 5);
+ assert!(read_bounded(Cursor::new(b""), 4).unwrap().is_empty());
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_rejects_missing_directory_device_and_fifo_without_reading() {
+ let mut settings = config("does-not-exist");
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::UnavailableDatabase)
+ ));
+ settings.path = fixture("city").parent().unwrap().to_owned();
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::UnavailableDatabase)
+ ));
+ settings.path = PathBuf::from("/dev/zero");
+ assert!(matches!(
+ MaxMindProvider::prepare(&settings),
+ Err(NativeGeoConfigError::UnavailableDatabase)
+ ));
+ let path = temporary("fifo");
+ assert!(
+ std::process::Command::new("mkfifo")
+ .arg(&path)
+ .status()
+ .unwrap()
+ .success()
+ );
+ settings.path = path.clone();
+ let result = MaxMindProvider::prepare(&settings);
+ std::fs::remove_file(path).unwrap();
+ assert!(matches!(
+ result,
+ Err(NativeGeoConfigError::UnavailableDatabase)
+ ));
+}
+
+#[cfg(unix)]
+fn temporary(label: &str) -> PathBuf {
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+ std::env::temp_dir().join(format!(
+ "edgezero-geo-{label}-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ))
+}
+
+#[cfg(unix)]
+#[test]
+fn geo_maxmind_owned_snapshot_survives_replacement_and_deletion() {
+ use std::os::unix::fs::PermissionsExt as _;
+ let path = temporary("snapshot.mmdb");
+ std::fs::copy(fixture("city"), &path).unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
+ let mut settings = config("city");
+ settings.path = path.clone();
+ let old = MaxMindProvider::prepare(&settings).unwrap();
+ let original = old.metadata.clone();
+ assert_eq!(original.database_type, "GeoLite2-City");
+ assert_eq!(original.build_epoch, 1_793_750_400);
+ assert_eq!(
+ original.sha256,
+ format!("{:x}", Sha256::digest(std::fs::read(&path).unwrap()))
+ );
+ let replacement = temporary("replacement.mmdb");
+ std::fs::copy(fixture("city-replacement"), &replacement).unwrap();
+ std::fs::rename(replacement, &path).unwrap();
+ let new = MaxMindProvider::prepare(&settings).unwrap();
+ assert_ne!(new.metadata.sha256, original.sha256);
+ assert_ne!(new.metadata.build_epoch, original.build_epoch);
+ std::fs::remove_file(path).unwrap();
+ assert_eq!(old.metadata, original);
+ assert_eq!(old.resolve(&input("127.0.0.1")), located("US", Some("CA")));
+ assert_eq!(new.resolve(&input("127.0.0.1")), located("US", Some("NY")));
+}
+
+#[cfg(not(unix))]
+#[test]
+fn geo_maxmind_unsupported_platform_does_not_open_source() {
+ assert!(matches!(
+ MaxMindProvider::prepare(&config("city")),
+ Err(NativeGeoConfigError::UnavailableDatabase)
+ ));
+}
diff --git a/crates/edgezero-adapter-axum/src/geo/reader_tests.rs b/crates/edgezero-adapter-axum/src/geo/reader_tests.rs
new file mode 100644
index 000000000..ecdb48bb6
--- /dev/null
+++ b/crates/edgezero-adapter-axum/src/geo/reader_tests.rs
@@ -0,0 +1,258 @@
+//! Checked-IP composition tests run without either built-in implementation.
+
+use super::*;
+use crate::proxy::{ForwardingHeaderFamily, TrustedProxyPolicy, normalize};
+use edgezero_core::geo::GeoError;
+use edgezero_core::http::{HeaderValue, request_builder};
+use std::sync::Mutex;
+
+struct Counted(Arc>>);
+
+impl GeoProvider for Counted {
+ fn resolve(&self, input: &GeoInput) -> GeoResolution {
+ self.0.lock().unwrap().push(input.clone());
+ GeoResolution::NoMatch
+ }
+}
+
+fn selected(expectation: ReaderIpExpectation, calls: &Arc>>) -> NativeGeo {
+ NativeGeo::prepare(&NativeGeoSelection::SuppliedReaderIp {
+ expectation,
+ id: GeoProviderId::new("counted-reader").unwrap(),
+ provider: Arc::new(Counted(Arc::clone(calls))),
+ })
+ .unwrap()
+}
+
+#[expect(
+ clippy::too_many_lines,
+ reason = "the table keeps both forwarding families and reader expectations under the same counted admission assertions"
+)]
+#[test]
+fn geo_reader_admission_uses_private_checked_provenance() {
+ for (family, name, source) in [
+ (
+ ForwardingHeaderFamily::Forwarded,
+ "forwarded",
+ GeoIpSource::TrustedForwarded,
+ ),
+ (
+ ForwardingHeaderFamily::XForwarded,
+ "x-forwarded-for",
+ GeoIpSource::TrustedXForwarded,
+ ),
+ ] {
+ let policy = TrustedProxyPolicy::new(family, ["127.0.0.0/8"]).unwrap();
+ for expectation in [
+ ReaderIpExpectation::PeerAllowed,
+ ReaderIpExpectation::ForwardedRequired,
+ ] {
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let geo = selected(expectation, &calls);
+ for (raw_peer, value, address, provenance) in [
+ (
+ Some("127.0.0.1:80"),
+ Some(if name == "forwarded" {
+ "for=203.0.113.9"
+ } else {
+ "203.0.113.9"
+ }),
+ Some("203.0.113.9"),
+ Some(source),
+ ),
+ (
+ Some("127.0.0.1:80"),
+ Some(if name == "forwarded" {
+ "for=\"[2001:db8::9]\""
+ } else {
+ "2001:db8::9"
+ }),
+ Some("2001:db8::9"),
+ Some(source),
+ ),
+ (
+ Some("127.0.0.1:80"),
+ Some(if name == "forwarded" {
+ "for=\"[::ffff:203.0.113.9]\""
+ } else {
+ "::ffff:203.0.113.9"
+ }),
+ Some("203.0.113.9"),
+ Some(source),
+ ),
+ (Some("127.0.0.1:80"), Some("malformed"), None, None),
+ (
+ Some("127.0.0.1:80"),
+ Some(if name == "forwarded" {
+ "for=127.0.0.2"
+ } else {
+ "127.0.0.2"
+ }),
+ None,
+ None,
+ ),
+ (Some("127.0.0.1:80"), None, None, None),
+ (
+ Some("192.0.2.1:80"),
+ Some(if name == "forwarded" {
+ "for=203.0.113.9"
+ } else {
+ "203.0.113.9"
+ }),
+ None,
+ None,
+ ),
+ (Some("[::ffff:192.0.2.1]:80"), None, None, None),
+ (Some("[2001:db8::1]:80"), None, None, None),
+ (
+ None,
+ Some(if name == "forwarded" {
+ "for=203.0.113.9"
+ } else {
+ "203.0.113.9"
+ }),
+ None,
+ None,
+ ),
+ ] {
+ let peer: Option = raw_peer.map(|socket| socket.parse().unwrap());
+ let (mut parts, ()) = request_builder().uri("/").body(()).unwrap().into_parts();
+ if let Some(assertion) = value {
+ parts
+ .headers
+ .insert(name, HeaderValue::from_str(assertion).unwrap());
+ }
+ parts
+ .headers
+ .insert("cloudfront-viewer-country", HeaderValue::from_static("DE"));
+ parts.extensions.insert(GeoInput::ReaderIp {
+ address: "198.51.100.1".parse().unwrap(),
+ source,
+ });
+ let checked = normalize(&policy, peer, &parts);
+ let input = geo.reader_input(&checked);
+ let expected = match (address, provenance) {
+ (Some(expected_address), Some(expected_source)) => Some(GeoInput::ReaderIp {
+ address: expected_address.parse().unwrap(),
+ source: expected_source,
+ }),
+ _ if expectation == ReaderIpExpectation::PeerAllowed && peer.is_some() => {
+ Some(GeoInput::ReaderIp {
+ address: checked.effective_client().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ })
+ }
+ _ => None,
+ };
+ assert_eq!(input, expected, "{family:?} {expectation:?} {value:?}");
+ let before = calls.lock().unwrap().len();
+ let outcome = geo.resolve(peer, &parts.headers, input.as_ref());
+ let reason = if input.is_some() {
+ GeoUnavailableReason::NoMatch
+ } else {
+ GeoUnavailableReason::MissingReaderIp
+ };
+ assert_eq!(
+ outcome,
+ GeoOutcome::Selected {
+ provider: GeoProviderId::new("counted-reader").unwrap(),
+ status: GeoStatus::Unavailable(reason)
+ }
+ );
+ assert_eq!(
+ calls.lock().unwrap().len(),
+ before + usize::from(input.is_some())
+ );
+ if let Some(admitted_input) = input {
+ assert_eq!(calls.lock().unwrap().last(), Some(&admitted_input));
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn geo_reader_capability_is_pure_and_configuration_debug_is_redacted() {
+ let config = MaxMindGeoConfig::new(
+ "MISSING_PATH_SENTINEL/not-present.mmdb",
+ ReaderIpExpectation::ForwardedRequired,
+ );
+ let selection = NativeGeoSelection::MaxMind { config };
+ assert!(!format!("{selection:?}").contains("MISSING_PATH_SENTINEL"));
+ for _ in 0_u8..3 {
+ let actual = selection.capability();
+ if cfg!(all(feature = "geo-maxmind", unix)) {
+ assert_eq!(
+ actual.unwrap(),
+ NativeGeoCapability::Selected {
+ provider: GeoProviderId::maxmind(),
+ input: NativeGeoInputContract::ReaderIp {
+ expectation: ReaderIpExpectation::ForwardedRequired
+ }
+ }
+ );
+ } else {
+ assert_eq!(
+ actual.unwrap_err(),
+ NativeGeoConfigError::UnavailableMaxMind
+ );
+ }
+ }
+ let mut invalid_config =
+ MaxMindGeoConfig::new("PRIVATE_PATH", ReaderIpExpectation::PeerAllowed);
+ invalid_config.limits.max_database_bytes = 0;
+ assert_eq!(
+ NativeGeoSelection::MaxMind {
+ config: invalid_config
+ }
+ .capability()
+ .unwrap_err(),
+ NativeGeoConfigError::InvalidPreparationLimits
+ );
+}
+
+#[test]
+fn geo_reader_supplied_rejects_both_reserved_ids() {
+ for id in [GeoProviderId::cloudfront(), GeoProviderId::maxmind()] {
+ let selection = NativeGeoSelection::SuppliedReaderIp {
+ expectation: ReaderIpExpectation::PeerAllowed,
+ id,
+ provider: Arc::new(Counted(Arc::default())),
+ };
+ assert_eq!(
+ selection.capability().unwrap_err(),
+ NativeGeoConfigError::ReservedProviderId
+ );
+ assert_eq!(
+ NativeGeo::prepare(&selection).err(),
+ Some(NativeGeoConfigError::ReservedProviderId)
+ );
+ }
+}
+
+#[test]
+fn geo_reader_provider_failures_remain_selected_outcomes() {
+ struct Failed;
+ impl GeoProvider for Failed {
+ fn resolve(&self, _input: &GeoInput) -> GeoResolution {
+ GeoResolution::Error(GeoError::ProviderFailure)
+ }
+ }
+ let geo = NativeGeo::prepare(&NativeGeoSelection::SuppliedReaderIp {
+ expectation: ReaderIpExpectation::PeerAllowed,
+ id: GeoProviderId::new("failed-reader").unwrap(),
+ provider: Arc::new(Failed),
+ })
+ .unwrap();
+ let input = GeoInput::ReaderIp {
+ address: "192.0.2.7".parse().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ };
+ assert_eq!(
+ geo.resolve(None, &HeaderMap::new(), Some(&input)),
+ GeoOutcome::Selected {
+ provider: GeoProviderId::new("failed-reader").unwrap(),
+ status: GeoStatus::Error(GeoError::ProviderFailure)
+ }
+ );
+}
diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs
index 5958d56ca..7ea2ce358 100644
--- a/crates/edgezero-adapter-axum/src/run_options.rs
+++ b/crates/edgezero-adapter-axum/src/run_options.rs
@@ -338,11 +338,11 @@ impl AxumRunOptions {
/// Rejects unavailable implementations and invalid protected-source policies.
#[inline]
pub fn geo_capability(&self) -> Result {
- self.native_geo().map(|geo| geo.capability())
+ self.geo.capability()
}
pub(crate) fn native_geo(&self) -> Result {
- NativeGeo::validate(&self.geo)
+ NativeGeo::prepare(&self.geo)
}
/// Shares native counters and the optional request observer.
diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs
index 8861f8c6d..2a170dc47 100644
--- a/crates/edgezero-adapter-axum/src/service.rs
+++ b/crates/edgezero-adapter-axum/src/service.rs
@@ -395,6 +395,7 @@ impl AxumServiceState {
}
};
let checked = normalize(&self.trusted_proxy_policy, remote_addr, &parts);
+ let reader_input = self.geo.reader_input(&checked);
guard.set_forwarding_result(checked.forwarding_result());
strip_forwarding_headers(&mut parts.headers);
parts.extensions.insert(checked.effective_host().clone());
@@ -441,7 +442,9 @@ impl AxumServiceState {
.await;
}
};
- let geo = self.geo.resolve(remote_addr, &parts.headers);
+ let geo = self
+ .geo
+ .resolve(remote_addr, &parts.headers, reader_input.as_ref());
Ok(self
.dispatch_prepared(prepared, parts, native_body, connection, guard, geo)
.await)
@@ -558,6 +561,10 @@ async fn prepare_native_egress(
.await
}
+#[cfg(test)]
+#[path = "service_geo_reader_tests.rs"]
+mod geo_reader_tests;
+
#[cfg(test)]
mod tests {
#![expect(
@@ -625,7 +632,8 @@ mod tests {
impl GeoProvider for Counted {
fn resolve(&self, input: &GeoInput) -> GeoResolution {
self.0.lock().unwrap().push(input.clone());
- if input.country.as_bytes().is_err() {
+ if matches!(input, GeoInput::Assertions { country, .. } if country.as_bytes().is_err())
+ {
GeoResolution::Error(GeoError::InvalidAssertion)
} else {
GeoResolution::MissingMetadata
@@ -656,7 +664,7 @@ mod tests {
id: GeoProviderId::new("counted").unwrap(),
provider: Arc::new(Counted(Arc::clone(&calls))),
};
- let geo = NativeGeo::validate(&selection).unwrap();
+ let geo = NativeGeo::prepare(&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);
@@ -769,11 +777,12 @@ mod tests {
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!(
+ matches!(&calls.lock().unwrap()[0], GeoInput::Assertions { country, .. } if country.as_bytes().unwrap() == Some(b"US".as_slice()))
+ );
+ assert!(
+ matches!(&calls.lock().unwrap()[1], GeoInput::Assertions { country, .. } if country.as_bytes().unwrap().is_none())
);
- 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()
@@ -813,7 +822,9 @@ mod tests {
"terminal work does not resolve Geo again"
);
}
- calls.lock().unwrap()[2].country.as_bytes().unwrap_err();
+ assert!(
+ matches!(&calls.lock().unwrap()[2], GeoInput::Assertions { country, .. } if country.as_bytes().is_err())
+ );
for path in ["/refuse", "/abort"] {
let request = Request::builder()
.uri(path)
@@ -888,7 +899,10 @@ mod tests {
assert_eq!(calls.lock().unwrap().len(), 6);
}
- async fn to_bytes(mut body: AxumEgressBody, limit: usize) -> Result {
+ pub(super) async fn to_bytes(
+ mut body: AxumEgressBody,
+ limit: usize,
+ ) -> Result {
let mut buffered = BytesMut::new();
loop {
let next_frame = poll_future(|context| Pin::new(&mut body).poll_frame(context)).await;
diff --git a/crates/edgezero-adapter-axum/src/service_geo_reader_tests.rs b/crates/edgezero-adapter-axum/src/service_geo_reader_tests.rs
new file mode 100644
index 000000000..262de44ed
--- /dev/null
+++ b/crates/edgezero-adapter-axum/src/service_geo_reader_tests.rs
@@ -0,0 +1,185 @@
+//! Managed checked-IP dispatch retains the existing admission and terminal owner.
+
+use super::tests::to_bytes;
+use super::*;
+use crate::geo::{NativeGeoSelection, ReaderIpExpectation};
+use crate::proxy::ForwardingHeaderFamily;
+use edgezero_core::Body;
+use edgezero_core::context::RequestContext;
+use edgezero_core::geo::{
+ GeoInput, GeoIpSource, GeoProvider, GeoProviderId, GeoResolution, GeoStatus,
+ GeoUnavailableReason,
+};
+use edgezero_core::http::Response as CoreResponse;
+use edgezero_core::http::{HeaderValue, StatusCode, response_builder};
+use edgezero_core::ingress::{AdmissionDecision, IngressGrant};
+use edgezero_core::middleware::{Middleware, Next};
+use edgezero_core::response_egress::ResponseEgressCompletion;
+use std::sync::Mutex;
+use std::time::Duration;
+
+struct Counted(Arc>>);
+impl GeoProvider for Counted {
+ fn resolve(&self, input: &GeoInput) -> GeoResolution {
+ self.0.lock().unwrap().push(input.clone());
+ GeoResolution::NoMatch
+ }
+}
+
+struct Mutate;
+#[async_trait::async_trait(?Send)]
+impl Middleware for Mutate {
+ async fn handle(
+ &self,
+ mut ctx: RequestContext,
+ next: Next<'_>,
+ ) -> Result {
+ let original = ctx.geo().clone();
+ ctx.extensions_mut().insert(GeoOutcome::NoSelection);
+ ctx.extensions_mut().insert(GeoInput::ReaderIp {
+ address: "198.51.100.7".parse().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ });
+ ctx.headers_mut()
+ .insert("x-forwarded-for", HeaderValue::from_static("198.51.100.7"));
+ assert_eq!(ctx.geo(), &original);
+ next.run(ctx).await
+ }
+}
+
+#[edgezero_core::action]
+async fn observe(ctx: RequestContext) -> Result<&'static str, EdgeError> {
+ Ok(match ctx.geo() {
+ GeoOutcome::Selected {
+ status: GeoStatus::Unavailable(GeoUnavailableReason::NoMatch),
+ ..
+ } => "no_match",
+ GeoOutcome::Selected {
+ status: GeoStatus::Unavailable(GeoUnavailableReason::MissingReaderIp),
+ ..
+ } => "missing_reader",
+ GeoOutcome::NoSelection | GeoOutcome::Selected { .. } => "unexpected",
+ })
+}
+
+#[expect(
+ clippy::too_many_lines,
+ reason = "one counted service scenario proves admission and terminal call counts for both reader expectations"
+)]
+#[tokio::test(flavor = "current_thread")]
+async fn geo_reader_managed_dispatch_resolves_once_after_admission() {
+ for expectation in [
+ ReaderIpExpectation::PeerAllowed,
+ ReaderIpExpectation::ForwardedRequired,
+ ] {
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let geo = NativeGeo::prepare(&NativeGeoSelection::SuppliedReaderIp {
+ expectation,
+ id: GeoProviderId::new("counted-reader").unwrap(),
+ provider: Arc::new(Counted(Arc::clone(&calls))),
+ })
+ .unwrap();
+ let router = RouterService::builder()
+ .middleware(Mutate)
+ .get("/{mode}", observe)
+ .build();
+ let mut app = App::new(router);
+ app.set_ingress_admission_policy(|head| 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 service = AxumServiceState::with_native_transport(
+ app,
+ AxumOutboundClient::try_transport().unwrap(),
+ NativeSession::new(NativeDiagnosticsHandle::new(), true).unwrap(),
+ TrustedProxyPolicy::new(ForwardingHeaderFamily::XForwarded, ["127.0.0.1/32"]).unwrap(),
+ geo,
+ );
+ let peer: SocketAddr = "127.0.0.1:1234".parse().unwrap();
+ for (actual_peer, forwarded, expected, invoked) in [
+ (Some(peer), Some("203.0.113.9"), "no_match", true),
+ (
+ Some(peer),
+ None,
+ if expectation == ReaderIpExpectation::PeerAllowed {
+ "no_match"
+ } else {
+ "missing_reader"
+ },
+ expectation == ReaderIpExpectation::PeerAllowed,
+ ),
+ (None, Some("203.0.113.9"), "missing_reader", false),
+ ] {
+ let before = calls.lock().unwrap().len();
+ let mut request = Request::builder()
+ .uri("/observe")
+ .body(AxumBody::empty())
+ .unwrap();
+ request.extensions_mut().insert(ConnectInfo(peer));
+ request.extensions_mut().insert(GeoInput::ReaderIp {
+ address: "198.51.100.7".parse().unwrap(),
+ source: GeoIpSource::DirectPeer,
+ });
+ if let Some(assertion) = forwarded {
+ request
+ .headers_mut()
+ .insert("x-forwarded-for", HeaderValue::from_static(assertion));
+ }
+ let response = service
+ .dispatch_request(request, &EgressConnection::default(), actual_peer)
+ .await
+ .unwrap();
+ assert_eq!(to_bytes(response.into_body(), 128).await.unwrap(), expected);
+ assert_eq!(calls.lock().unwrap().len(), before + usize::from(invoked));
+ }
+ assert_eq!(
+ calls.lock().unwrap().first(),
+ Some(&GeoInput::ReaderIp {
+ address: "203.0.113.9".parse().unwrap(),
+ source: GeoIpSource::TrustedXForwarded
+ })
+ );
+ let before = calls.lock().unwrap().len();
+ for path in ["/refuse", "/abort"] {
+ let request = Request::builder()
+ .uri(path)
+ .header("x-forwarded-for", "203.0.113.9")
+ .body(AxumBody::empty())
+ .unwrap();
+ let response = service
+ .dispatch_request(request, &EgressConnection::default(), Some(peer))
+ .await;
+ if path == "/refuse" {
+ assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
+ } else {
+ assert!(response.is_err());
+ }
+ assert_eq!(calls.lock().unwrap().len(), before);
+ }
+ let (_phase, reader) = watch::channel(LifecyclePhase::Draining);
+ let draining = service.with_phase(reader);
+ let request = Request::builder()
+ .uri("/observe")
+ .header("x-forwarded-for", "203.0.113.9")
+ .body(AxumBody::empty())
+ .unwrap();
+ assert!(
+ draining
+ .dispatch_request(request, &EgressConnection::default(), Some(peer))
+ .await
+ .is_err()
+ );
+ assert_eq!(calls.lock().unwrap().len(), before);
+ }
+}
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geo_hosting.rs b/crates/edgezero-adapter-axum/tests/fixtures/geo_hosting.rs
new file mode 100644
index 000000000..4877c44c6
--- /dev/null
+++ b/crates/edgezero-adapter-axum/tests/fixtures/geo_hosting.rs
@@ -0,0 +1,663 @@
+// Real-listener reader-IP scenarios. Included by production_hosting's tests so
+// process ownership and root-free shutdown retain the existing fixture helpers.
+
+#[cfg(feature = "geo-maxmind")]
+use std::path::{Path, PathBuf};
+
+fn geo_reader_address(ipv6: bool) -> SocketAddr {
+ TcpListener::bind(if ipv6 { "[::1]:0" } else { "127.0.0.1:0" })
+ .expect("reader listener reservation")
+ .local_addr()
+ .expect("reader listener address")
+}
+
+fn geo_forwarding(family: &str, reader: &str) -> String {
+ match family {
+ "forwarded" if reader.contains(':') => format!("Forwarded: for=\"[{reader}]\"\r\n"),
+ "forwarded" => format!("Forwarded: for={reader}\r\n"),
+ "x-forwarded" => format!("X-Forwarded-For: {reader}\r\n"),
+ _ => panic!("unknown fixture forwarding family"),
+ }
+}
+
+fn assert_reader_location(value: &Value, provider: &str, source: &str, reader_source: &str) {
+ assert_eq!(value["geo"]["status"], "located");
+ assert_eq!(value["geo"]["provider"], provider);
+ assert_eq!(value["geo"]["country"], "US");
+ assert_eq!(value["geo"]["subdivision"], "CA");
+ assert_eq!(value["geo"]["source"], source);
+ assert_eq!(
+ value["geo"]["trust_basis"],
+ format!("CheckedReaderIp({reader_source})")
+ );
+}
+
+fn assert_geo_startup_rejected(server: &mut Server, cwd: &TempDir, category: &str) {
+ assert!(!server.wait().success(), "{}", server.log());
+ let log = server.log();
+ assert!(log.contains(category), "{log}");
+ for forbidden in [
+ "INITIALIZED",
+ "HOOK_ENTERED",
+ "ready",
+ "event=geo_database",
+ "PRIVATE_RECORD_SENTINEL",
+ ] {
+ assert!(!log.contains(forbidden), "{forbidden}: {log}");
+ }
+ TcpStream::connect_timeout(&server.addr, Duration::from_millis(50))
+ .expect_err("rejected preparation must not bind");
+ assert_eq!(fs::read_dir(cwd.path()).expect("unrelated cwd").count(), 0);
+}
+
+#[test]
+#[cfg(not(feature = "geo-maxmind"))]
+fn geo_hosting_rejects_unavailable_maxmind() {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let database = tempfile::tempdir().expect("separate database root");
+ let path = database.path().join("PRIVATE_DATABASE_PATH.mmdb");
+ let addr = address();
+ let mut command = geo_command("geo-maxmind", &cwd, addr);
+ command.env("FIXTURE_GEO_DB", &path);
+ let mut server = Server::spawn(command, addr);
+ assert_geo_startup_rejected(&mut server, &cwd, "maxmind implementation unavailable");
+ assert!(!server.log().contains(&path.display().to_string()));
+ assert!(!server.log().contains("PRIVATE_DATABASE_PATH"));
+ assert_eq!(
+ fs::read_dir(database.path())
+ .expect("database root")
+ .count(),
+ 0
+ );
+}
+
+#[test]
+fn geo_hosting_supplied_reader_without_builtin() {
+ // This test deliberately has no built-in feature gate. The axum-only lane
+ // exercises the same programmatic composition as combined-feature builds.
+ for ipv6 in [false, true] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let addr = geo_reader_address(ipv6);
+ let mut server = Server::spawn(geo_command("geo-supplied-reader", &cwd, addr), addr);
+ server.ready();
+ let first = geo_response(&server, "CloudFront-Viewer-Country: GB\r\n");
+ assert_reader_location(&first, "fixture-reader", "CheckedReaderIp", "DirectPeer");
+ let second = geo_response(&server, "");
+ assert_reader_location(&second, "fixture-reader", "CheckedReaderIp", "DirectPeer");
+ assert_eq!(
+ second["resolutions"].as_u64(),
+ first["resolutions"].as_u64().map(|n| n + 1)
+ );
+ finish_geo(&mut server, &cwd);
+ let log = server.log();
+ let calls = log
+ .lines()
+ .filter(|line| line.starts_with("GEO_CALL "))
+ .collect::>();
+ let last = calls
+ .last()
+ .expect("admitted requests invoke supplied provider");
+ let total: usize = last
+ .split_whitespace()
+ .nth(1)
+ .expect("count")
+ .parse()
+ .expect("numeric count");
+ assert_eq!(calls.len(), total, "each lookup increments exactly once");
+ for call in calls {
+ assert!(call.contains("[redacted]"), "{call}");
+ assert!(!call.contains("127.0.0.1"), "{call}");
+ assert!(!call.contains("::1"), "{call}");
+ }
+ }
+}
+
+#[expect(clippy::cognitive_complexity, reason = "one process-owned matrix checks both forwarding families, both socket families and terminal call counts")]
+#[test]
+fn geo_hosting_supplied_reader_checked_forwarding_and_call_counts() {
+ for family in ["forwarded", "x-forwarded"] {
+ for ipv6 in [false, true] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let addr = geo_reader_address(ipv6);
+ let mut command = geo_command("geo-supplied-reader", &cwd, addr);
+ command
+ .env("FIXTURE_GEO_EXPECTATION", "forwarded-required")
+ .env("FIXTURE_GEO_PROXY_FAMILY", family);
+ let mut server = Server::spawn(command, addr);
+ server.ready();
+ let absent = geo_response(&server, "");
+ assert_eq!(absent["geo"]["status"], "missing_reader_ip");
+ assert_eq!(absent["geo"]["provider"], "fixture-reader");
+ assert_eq!(absent["resolutions"], 0_u64);
+ let source = if family == "forwarded" {
+ "TrustedForwarded"
+ } else {
+ "TrustedXForwarded"
+ };
+ for (index, reader) in ["192.0.2.1", "2001:db8::1", "::ffff:192.0.2.1"]
+ .into_iter()
+ .enumerate()
+ {
+ // Missing forwarded host/scheme is intentionally a partial
+ // result. A checked client is sufficient for reader admission.
+ let value = geo_response(&server, &geo_forwarding(family, reader));
+ assert_reader_location(&value, "fixture-reader", "CheckedReaderIp", source);
+ assert_eq!(value["resolutions"], index + 1);
+ }
+ let no_match = geo_response(&server, &geo_forwarding(family, "198.51.100.9"));
+ assert_eq!(no_match["geo"]["status"], "no_match");
+ assert_eq!(no_match["geo"]["provider"], "fixture-reader");
+ assert_eq!(no_match["resolutions"], 4_u64);
+ let malformed = if family == "forwarded" {
+ "Forwarded: for=invalid\r\n"
+ } else {
+ "X-Forwarded-For: invalid\r\n"
+ };
+ let wrong_family = if family == "forwarded" {
+ "x-forwarded"
+ } else {
+ "forwarded"
+ };
+ for headers in [
+ malformed.to_owned(),
+ geo_forwarding(family, "127.0.0.1"),
+ geo_forwarding(wrong_family, "192.0.2.1"),
+ "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country-Region: CA\r\n"
+ .to_owned(),
+ ] {
+ let value = geo_response(&server, &headers);
+ assert_eq!(value["geo"]["status"], "missing_reader_ip");
+ assert_eq!(
+ value["resolutions"], 4_u64,
+ "inadmissible readers never call provider"
+ );
+ }
+ let rejected = raw_request(
+ addr,
+ &format!(
+ "GET /native-refuse HTTP/1.1\r\nHost: fixture\r\n{}Connection: close\r\n\r\n",
+ geo_forwarding(family, "192.0.2.1")
+ ),
+ );
+ assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
+ let mut aborted_socket = server.socket().expect("abort socket");
+ write!(
+ aborted_socket,
+ "GET /native-abort HTTP/1.1\r\nHost: fixture\r\n{}Connection: close\r\n\r\n",
+ geo_forwarding(family, "192.0.2.1")
+ )
+ .expect("abort request");
+ let mut aborted = Vec::new();
+ if let Err(error) = aborted_socket.read_to_end(&mut aborted) {
+ assert_eq!(error.kind(), io::ErrorKind::ConnectionReset);
+ }
+ assert!(aborted.is_empty(), "admission abort must not dispatch");
+ let after = geo_response(&server, &geo_forwarding(family, "192.0.2.1"));
+ assert_eq!(
+ after["resolutions"], 5_u64,
+ "refused and aborted requests invoke no provider"
+ );
+ server.wait_line("GEO_COMPLETED 5");
+ finish_geo(&mut server, &cwd);
+ let log = server.log();
+ let calls = log
+ .lines()
+ .filter(|line| line.starts_with("GEO_CALL "))
+ .collect::>();
+ assert_eq!(calls.len(), 5, "no finalization lookup");
+ for call in calls {
+ assert!(call.contains("[redacted]"), "{call}");
+ for reader in ["192.0.2.1", "2001:db8::1", "198.51.100.9"] {
+ assert!(
+ !call.contains(reader),
+ "reader input Debug must redact addresses: {call}"
+ );
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn geo_hosting_reader_expectations_and_untrusted_forwarding() {
+ for family in ["forwarded", "x-forwarded"] {
+ for expectation in ["peer-allowed", "forwarded-required"] {
+ for untrusted in [false, true] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let addr = address();
+ let mut command = geo_command("geo-supplied-reader", &cwd, addr);
+ command
+ .env("FIXTURE_GEO_EXPECTATION", expectation)
+ .env("FIXTURE_GEO_PROXY_FAMILY", family);
+ if untrusted {
+ command.env("FIXTURE_GEO_PROXY_NETWORKS", "203.0.113.0/24");
+ }
+ let mut server = Server::spawn(command, addr);
+ server.ready();
+ let direct = geo_response(&server, "");
+ if expectation == "peer-allowed" {
+ assert_reader_location(
+ &direct,
+ "fixture-reader",
+ "CheckedReaderIp",
+ "DirectPeer",
+ );
+ } else {
+ assert_eq!(direct["geo"]["status"], "missing_reader_ip");
+ assert_eq!(direct["resolutions"], 0_u64);
+ }
+ let forwarded = geo_response(&server, &geo_forwarding(family, "192.0.2.1"));
+ if untrusted {
+ if expectation == "peer-allowed" {
+ assert_reader_location(
+ &forwarded,
+ "fixture-reader",
+ "CheckedReaderIp",
+ "DirectPeer",
+ );
+ } else {
+ assert_eq!(forwarded["geo"]["status"], "missing_reader_ip");
+ assert_eq!(forwarded["resolutions"], 0_u64);
+ }
+ } else {
+ let source = if family == "forwarded" {
+ "TrustedForwarded"
+ } else {
+ "TrustedXForwarded"
+ };
+ assert_reader_location(&forwarded, "fixture-reader", "CheckedReaderIp", source);
+ }
+ finish_geo(&mut server, &cwd);
+ }
+ }
+ }
+}
+
+#[test]
+fn geo_hosting_supplied_reader_drain_does_not_resolve_pipeline() {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let addr = address();
+ let mut command = geo_command("geo-supplied-reader", &cwd, addr);
+ command
+ .env("FIXTURE_GEO_EXPECTATION", "forwarded-required")
+ .env("FIXTURE_GEO_PROXY_FAMILY", "forwarded");
+ let mut server = Server::spawn(command, addr);
+ server.ready();
+ let mut client = server.socket().expect("finite pipeline socket");
+ client.write_all(b"GET /finite HTTP/1.1\r\nHost: fixture\r\nForwarded: for=192.0.2.1\r\n\r\nGET /geo HTTP/1.1\r\nHost: fixture\r\nForwarded: for=192.0.2.1\r\nConnection: close\r\n\r\n").expect("pipeline");
+ server.wait_line("DISPATCH");
+ assert!(
+ server
+ .request("/stop")
+ .expect("caller stop")
+ .ends_with("stopping")
+ );
+ server.wait_line("DRAINING");
+ let mut response = String::new();
+ client
+ .read_to_string(&mut response)
+ .expect("drained finite response");
+ assert!(response.contains("finite-complete"), "{response}");
+ assert!(
+ !response.contains("immutable"),
+ "pipelined Geo must not dispatch"
+ );
+ assert!(server.wait().success(), "{}", server.log());
+ assert_eq!(
+ server
+ .log()
+ .lines()
+ .filter(|line| line.starts_with("GEO_CALL "))
+ .count(),
+ 1
+ );
+ assert_eq!(fs::read_dir(cwd.path()).expect("unrelated cwd").count(), 0);
+}
+
+#[cfg(feature = "geo-maxmind")]
+fn geo_database_fixture(name: &str) -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("tests/fixtures/geoip")
+ .join(name)
+}
+
+#[cfg(feature = "geo-maxmind")]
+fn geo_maxmind_command(cwd: &TempDir, addr: SocketAddr, path: &Path) -> Command {
+ let mut command = geo_command("geo-maxmind", cwd, addr);
+ command.env("FIXTURE_GEO_DB", path);
+ command
+}
+
+#[cfg(feature = "geo-maxmind")]
+fn assert_geo_snapshot(
+ server: &Server,
+ fixture: &str,
+ database_type: &str,
+ epoch: u64,
+ private_path: &Path,
+) {
+ let sums = fs::read_to_string(geo_database_fixture("SHA256SUMS")).expect("fixture checksums");
+ let hash = sums
+ .lines()
+ .find_map(|line| {
+ let (hash, name) = line.split_once(" ")?;
+ (name == fixture).then_some(hash)
+ })
+ .expect("snapshot checksum");
+ let log = server.log();
+ let snapshots = captured_json(&log, "FRAMEWORK_LOG ")
+ .into_iter()
+ .filter(|record| {
+ record["message"]
+ .as_str()
+ .is_some_and(|message| message.contains("event=geo_database"))
+ })
+ .collect::>();
+ assert_eq!(snapshots.len(), 1, "one loaded snapshot diagnostic: {log}");
+ let message = snapshots[0]["message"].as_str().expect("snapshot message");
+ assert!(
+ message.contains(&format!("database_type={database_type}")),
+ "{message}"
+ );
+ assert!(
+ message.contains(&format!("build_epoch={epoch}")),
+ "{message}"
+ );
+ assert!(message.contains(&format!("sha256={hash}")), "{message}");
+ assert!(!message.contains("127.0.0.1"));
+ assert!(!message.contains("192.0.2"));
+ assert!(
+ !log.contains(&private_path.display().to_string()),
+ "database paths must stay redacted: {log}"
+ );
+ assert!(!log.contains("PRIVATE_DATABASE_PATH"), "{log}");
+}
+
+#[test]
+#[cfg(feature = "geo-maxmind")]
+fn geo_hosting_maxmind_snapshot_and_restart() {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let database = tempfile::tempdir().expect("separate database root");
+ let path = database.path().join("PRIVATE_DATABASE_PATH.mmdb");
+ fs::copy(geo_database_fixture("city.mmdb"), &path).expect("initial City snapshot");
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).expect("read-only database");
+ fs::set_permissions(database.path(), fs::Permissions::from_mode(0o555))
+ .expect("read-only database directory");
+ let addr = address();
+ let mut server = Server::spawn(geo_maxmind_command(&cwd, addr, &path), addr);
+ server.ready();
+ let first = geo_response(&server, "CloudFront-Viewer-Country: GB\r\n");
+ assert_reader_location(&first, "maxmind", "LocalDatabase", "DirectPeer");
+ assert_geo_snapshot(&server, "city.mmdb", "GeoLite2-City", 1_793_750_400, &path);
+ assert_eq!(
+ fs::read_dir(database.path()).expect("no sidecars").count(),
+ 1
+ );
+ assert_eq!(
+ fs::metadata(&path)
+ .expect("source permissions")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o444
+ );
+ assert_eq!(
+ fs::read(&path).expect("unchanged source"),
+ fs::read(geo_database_fixture("city.mmdb")).expect("City bytes")
+ );
+ fs::set_permissions(database.path(), fs::Permissions::from_mode(0o755))
+ .expect("publish replacement");
+ let staged = database.path().join("next.mmdb");
+ fs::copy(geo_database_fixture("city-replacement.mmdb"), &staged)
+ .expect("stage complete replacement");
+ fs::rename(&staged, &path).expect("publish replacement atomically");
+ let changed = geo_response(&server, "");
+ assert_eq!(
+ changed["geo"], first["geo"],
+ "active reader retains original bytes"
+ );
+ fs::remove_file(&path).expect("delete loaded source");
+ let deleted = geo_response(&server, "");
+ assert_eq!(
+ deleted["geo"], first["geo"],
+ "active reader needs no filesystem access"
+ );
+ assert_eq!(
+ fs::read_dir(database.path())
+ .expect("no sidecars after deletion")
+ .count(),
+ 0
+ );
+ finish_geo(&mut server, &cwd);
+ assert_geo_snapshot(&server, "city.mmdb", "GeoLite2-City", 1_793_750_400, &path);
+ fs::copy(geo_database_fixture("city-replacement.mmdb"), &path)
+ .expect("publish restart snapshot");
+ let restart_addr = address();
+ let mut restarted = Server::spawn(geo_maxmind_command(&cwd, restart_addr, &path), restart_addr);
+ restarted.ready();
+ let replacement = geo_response(&restarted, "");
+ assert_eq!(replacement["geo"]["country"], "US");
+ assert_eq!(replacement["geo"]["subdivision"], "NY");
+ assert_eq!(replacement["geo"]["provider"], "maxmind");
+ assert_geo_snapshot(
+ &restarted,
+ "city-replacement.mmdb",
+ "GeoLite2-City",
+ 1_793_836_800,
+ &path,
+ );
+ finish_geo(&mut restarted, &cwd);
+ fs::write(&path, b"PRIVATE_RECORD_SENTINEL invalid replacement").expect("invalid replacement");
+ let invalid_addr = address();
+ let mut invalid = Server::spawn(geo_maxmind_command(&cwd, invalid_addr, &path), invalid_addr);
+ assert_geo_startup_rejected(&mut invalid, &cwd, "invalid database");
+ assert!(!invalid.log().contains("PRIVATE_DATABASE_PATH"));
+ assert_eq!(
+ fs::read_dir(database.path())
+ .expect("no replacement sidecars")
+ .count(),
+ 1
+ );
+}
+
+#[test]
+#[cfg(feature = "geo-maxmind")]
+fn geo_hosting_maxmind_checked_inputs_and_record_outcomes() {
+ for family in ["forwarded", "x-forwarded"] {
+ for (fixture, reader, country, subdivision) in [
+ ("city.mmdb", "192.0.2.1", "US", Some("CA")),
+ ("country.mmdb", "192.0.2.1", "US", None),
+ ("ipv6.mmdb", "2001:db8::1", "GB", Some("ENG")),
+ ] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let database = tempfile::tempdir().expect("separate database root");
+ let path = database.path().join("PRIVATE_DATABASE_PATH.mmdb");
+ fs::copy(geo_database_fixture(fixture), &path).expect("reader database");
+ let addr = address();
+ let mut command = geo_maxmind_command(&cwd, addr, &path);
+ command
+ .env("FIXTURE_GEO_EXPECTATION", "forwarded-required")
+ .env("FIXTURE_GEO_PROXY_FAMILY", family);
+ let mut server = Server::spawn(command, addr);
+ server.ready();
+ let absent = geo_response(&server, "");
+ assert_eq!(absent["geo"]["status"], "missing_reader_ip");
+ assert_eq!(absent["geo"]["provider"], "maxmind");
+ let located = geo_response(&server, &geo_forwarding(family, reader));
+ assert_eq!(located["geo"]["status"], "located");
+ assert_eq!(located["geo"]["country"], country);
+ assert_eq!(located["geo"]["subdivision"].as_str(), subdivision);
+ assert_eq!(located["geo"]["source"], "LocalDatabase");
+ let source = if family == "forwarded" {
+ "TrustedForwarded"
+ } else {
+ "TrustedXForwarded"
+ };
+ assert_eq!(
+ located["geo"]["trust_basis"],
+ format!("CheckedReaderIp({source})")
+ );
+ let no_match = geo_response(&server, &geo_forwarding(family, "198.51.100.9"));
+ assert_eq!(no_match["geo"]["status"], "no_match");
+ assert_eq!(no_match["geo"]["provider"], "maxmind");
+ if fixture == "city.mmdb" {
+ for (case_reader, expected, case_subdivision) in [
+ ("192.0.2.2", "located", None),
+ ("192.0.2.3", "located", Some("01")),
+ ("192.0.2.4", "located", Some("ZZ")),
+ ("192.0.2.5", "located", None),
+ ("192.0.2.7", "no_match", None),
+ ("192.0.2.8", "invalid_assertion", None),
+ ("192.0.2.10", "invalid_assertion", None),
+ ("2001:db8::1", "no_match", None),
+ ("::ffff:192.0.2.1", "located", Some("CA")),
+ ] {
+ let value = geo_response(&server, &geo_forwarding(family, case_reader));
+ assert_eq!(value["geo"]["status"], expected, "reader={case_reader}");
+ assert_eq!(value["geo"]["subdivision"].as_str(), case_subdivision);
+ }
+ }
+ finish_geo(&mut server, &cwd);
+ }
+ }
+}
+
+#[test]
+#[cfg(feature = "geo-maxmind")]
+fn geo_hosting_maxmind_reader_expectations_on_real_peers() {
+ for ipv6 in [false, true] {
+ for family in ["forwarded", "x-forwarded"] {
+ for expectation in ["peer-allowed", "forwarded-required"] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let database = tempfile::tempdir().expect("separate database root");
+ let path = database.path().join("PRIVATE_DATABASE_PATH.mmdb");
+ fs::copy(geo_database_fixture("city.mmdb"), &path).expect("City database");
+ let addr = geo_reader_address(ipv6);
+ let mut command = geo_maxmind_command(&cwd, addr, &path);
+ command
+ .env("FIXTURE_GEO_EXPECTATION", expectation)
+ .env("FIXTURE_GEO_PROXY_FAMILY", family);
+ let mut server = Server::spawn(command, addr);
+ server.ready();
+ let direct = geo_response(&server, "");
+ if expectation == "forwarded-required" {
+ assert_eq!(direct["geo"]["status"], "missing_reader_ip");
+ } else if ipv6 {
+ assert_eq!(
+ direct["geo"]["status"], "no_match",
+ "IPv6 peer against IPv4-only database"
+ );
+ } else {
+ assert_reader_location(&direct, "maxmind", "LocalDatabase", "DirectPeer");
+ }
+ let forwarded = geo_response(&server, &geo_forwarding(family, "192.0.2.1"));
+ let source = if family == "forwarded" {
+ "TrustedForwarded"
+ } else {
+ "TrustedXForwarded"
+ };
+ assert_reader_location(&forwarded, "maxmind", "LocalDatabase", source);
+ let malformed = geo_response(
+ &server,
+ if family == "forwarded" {
+ "Forwarded: for=invalid\r\n"
+ } else {
+ "X-Forwarded-For: invalid\r\n"
+ },
+ );
+ assert_eq!(
+ malformed["geo"], direct["geo"],
+ "malformed forwarding cannot invent a checked client"
+ );
+ finish_geo(&mut server, &cwd);
+ }
+ }
+ }
+}
+
+#[test]
+#[cfg(feature = "geo-maxmind")]
+fn geo_hosting_maxmind_preparation_failures_never_initialize_or_bind() {
+ for (fixture, category, byte_limit, visit_limit) in [
+ ("missing", "database unavailable", None, None),
+ ("directory", "database unavailable", None, None),
+ ("empty", "invalid database", None, None),
+ ("invalid", "invalid database", None, None),
+ ("truncated", "invalid database", None, None),
+ ("unsupported.mmdb", "unsupported database", None, None),
+ (
+ "bad-country-code.mmdb",
+ "incompatible database schema",
+ None,
+ None,
+ ),
+ (
+ "bad-subdivision-code.mmdb",
+ "incompatible database schema",
+ None,
+ None,
+ ),
+ ("bad-offpath-pointer.mmdb", "invalid database", None, None),
+ ("bad-offpath-utf8.mmdb", "invalid database", None, None),
+ ("bad-metadata.mmdb", "invalid database", None, None),
+ (
+ "expansion-limit.mmdb",
+ "preparation limit exceeded",
+ None,
+ None,
+ ),
+ ("city.mmdb", "preparation limit exceeded", Some(1_usize), None),
+ ("city.mmdb", "preparation limit exceeded", None, Some(1_usize)),
+ (
+ "city.mmdb",
+ "invalid database preparation limits",
+ Some(0_usize),
+ None,
+ ),
+ (
+ "city.mmdb",
+ "invalid database preparation limits",
+ None,
+ Some(0_usize),
+ ),
+ ] {
+ let cwd = tempfile::tempdir().expect("unrelated cwd");
+ let database = tempfile::tempdir().expect("separate database root");
+ let path = database.path().join("PRIVATE_DATABASE_PATH.mmdb");
+ match fixture {
+ "missing" => {}
+ "directory" => fs::create_dir_all(&path).expect("nonregular database"),
+ "empty" => fs::write(&path, []).expect("empty database"),
+ "invalid" => fs::write(&path, b"PRIVATE_RECORD_SENTINEL").expect("invalid database"),
+ "truncated" => {
+ let mut bytes = fs::read(geo_database_fixture("city.mmdb")).expect("City bytes");
+ bytes.truncate(bytes.len().div_ceil(2));
+ fs::write(&path, bytes).expect("truncated database");
+ }
+ name => {
+ fs::copy(geo_database_fixture(name), &path).expect("malformed or limited database");
+ }
+ }
+ let addr = address();
+ let mut command = geo_maxmind_command(&cwd, addr, &path);
+ if let Some(limit) = byte_limit {
+ command.env("FIXTURE_GEO_MAX_BYTES", limit.to_string());
+ }
+ if let Some(limit) = visit_limit {
+ command.env("FIXTURE_GEO_MAX_NETWORKS", limit.to_string());
+ }
+ let mut server = Server::spawn(command, addr);
+ assert_geo_startup_rejected(&mut server, &cwd, category);
+ let log = server.log();
+ assert!(!log.contains(&path.display().to_string()), "{log}");
+ assert!(!log.contains("PRIVATE_DATABASE_PATH"), "{log}");
+ assert!(!log.contains("192.0.2.1"), "{log}");
+ assert!(!log.contains("203.0.113.1"), "{log}");
+ assert_eq!(
+ fs::read_dir(database.path())
+ .expect("no preparation sidecars")
+ .count(),
+ usize::from(fixture != "missing")
+ );
+ }
+}
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/README.md b/crates/edgezero-adapter-axum/tests/fixtures/geoip/README.md
new file mode 100644
index 000000000..fa4eaa5bd
--- /dev/null
+++ b/crates/edgezero-adapter-axum/tests/fixtures/geoip/README.md
@@ -0,0 +1,26 @@
+# Synthetic GeoIP fixtures
+
+These files are original test data for EdgeZero, licensed under this repository's Apache-2.0 license. They contain no MaxMind, GeoLite, DB-IP or other production dataset content. Metadata names describe the schema under test and do not claim publisher authenticity. Addresses and locations are fabricated. Loopback US/CA and US/NY records exist solely for local hosting tests.
+
+Rebuild from the repository root with Node.js:
+
+```sh
+node scripts/generate_geoip_fixtures.mjs
+node scripts/generate_geoip_fixtures.mjs --check
+```
+
+The dependency-free generator writes deterministic MMDB version 2 files and `SHA256SUMS`. The fixed build epochs are synthetic. The default 24-bit tree, 28/32-bit variants, IPv4 and IPv6 records exercise reader format support, not data accuracy. No production download, account, credentials or network access is required.
+
+`city.mmdb` includes 127/8 as US/CA and a matrix at 192.0.2.1 through 192.0.2.20 covering case normalization, country-only records, numeric subdivision `01`, unrecognized `ZZ`, missing first code with a later code, empty arrays, empty records, unsupported/empty/malformed codes, subdivision without country, registered/represented-country non-substitution, duplicate-key first-value behavior and unrelated later-entry types. Other addresses have no record. `city-replacement.mmdb` changes loopback and 192.0.2.1 to US/NY and changes the epoch. `country.mmdb` deliberately gives subdivisions an incompatible geographic shape to prove Country ignores them. `ipv6.mmdb` contains 2001:db8::/32 as GB/ENG and ::c000:201/128 for IPv4 lookup 192.0.2.1 as US/CA.
+
+The `bad-*` files deliberately contain consumed type errors, off-path invalid pointers/UTF-8, nested non-string map keys, prohibited pointer chains, a tree cycle, a corrupt separator, or an unknown metadata pointer outside its section. `truncated.mmdb` is incomplete. `unsupported.mmdb` has an unsupported dataset type. `expansion-limit.mmdb` uses repeated shared arrays that pass memoized verification but exceed normal decoder expansion limits. `verification-limit.mmdb` and `metadata-verification-limit.mmdb` use overlapping valid string targets to exhaust the respective verifier work budgets. They are intentionally not usable databases. `all-values.mmdb` covers all scalar types and valid single pointers without imposing unrelated geographic schemas.
+
+## Validation boundary
+
+Preparation uses maxminddb 0.32.0 without mmap or unsafe string decoding. Its verifier checks metadata and reachable tree/data structure. An additional non-materializing Serde traversal follows all reachable data values through the normal decoder, requires string map keys and catches prohibited pointer chains even in unrelated fields. It uses the pinned reader's `deserialize_identifier` entry point recursively because that entry point checks pointer targets before falling back to normal value decoding. Direct `deserialize_any` would still accept pointer chains. This behavior is covered by regressions that first prove upstream verification accepts each malformed data fixture. Geographic paths are borrowed strings and are identical at startup and lookup. Geographic code semantics are intentionally checked only on lookup.
+
+Residual metadata limitation: `bad-metadata-key.mmdb` reproduces a known startup-acceptance failure, retained in the explicitly ignored `geo_maxmind_rejects_unused_metadata_nonstring_keys` regression. Running that test with `--ignored` currently fails. This blocks #438 acceptance. The pinned public API exposes typed metadata and `verify()`, but no raw metadata decode operation. Consequently unknown metadata fields retain the upstream verifier's acceptance of non-string nested map keys and pointer-to-pointer chains. Known metadata fields receive upstream typed decoding; malformed unknown metadata pointers and UTF-8 still fail verification. No claim of stricter full metadata conformance is made. Unreachable data bytes are outside the reachable-record contract.
+
+Normal decoding uses upstream finite value, payload and depth budgets per unique root. Shared graphs are expanded, not silently skipped. Network visits include no-data leaves and aliases before record-offset deduplication. A precheck reserves upstream's `3 * node_count + 1` tree allowance before verification. Bounds do not guarantee startup duration or a fixed RSS. No production-size GeoLite performance or memory measurement is established by these tiny fixtures.
+
+File acquisition is supported only on Unix, with read-only `O_NONBLOCK` open, descriptor regular-file validation and bounded actual bytes read. Other platforms reject selected MaxMind as an unavailable implementation before opening a source because equivalent special-file safety has not been proven. Nonblocking open does not impose a deadline on regular-file filesystem I/O. Snapshots retain owned bytes, not a source handle or mmap, and remain valid after source replacement/deletion.
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/SHA256SUMS b/crates/edgezero-adapter-axum/tests/fixtures/geoip/SHA256SUMS
new file mode 100644
index 000000000..c6d8613a5
--- /dev/null
+++ b/crates/edgezero-adapter-axum/tests/fixtures/geoip/SHA256SUMS
@@ -0,0 +1,26 @@
+0b362c5d6db97a61b2509796331c5214238bc252fb676ec6a000f93a83d55684 city.mmdb
+a9573925a4368b737a4f2d775234f8e5cd577af47de7803c0a1962dd2c3c2ae5 city-replacement.mmdb
+89d9b81bbef523ca1a0f4c16659067264da6394d6bd292a9bd6ca409f90ae17f country.mmdb
+7ada6d5baaf8a293981620732b9b6d15a889976f4fb45b9e13119574e2c48f49 ipv6.mmdb
+bedb529c817853078dfeb76281974f0a7e40fb5bb2a2f9490da28005f11919b2 record-28.mmdb
+8eadd10f167996b65ab582b7326ac6361a54c302351cab325a9f475e0dcd719e record-32.mmdb
+53b973ef30fe31bb3c50bc8684f31d532197c3f333cfd774b31d63bd16486099 bad-country-parent.mmdb
+e2d9f6a7774deafc59ff90fc951be37584bc9bfacbc23417fa56454f3b04dbe8 bad-country-code.mmdb
+e08d35ffd9ee0f8a6a671f4306e6aaf2b64ca50a4e2d7d911c0a3be3da3a80ce bad-subdivisions-parent.mmdb
+329ca8a0081bab2c813c29f8a22c5a2f00ec5d7ecf43b4eae83ed49a174e8ebc bad-first-subdivision.mmdb
+fae49fa6bd8049e7403a978aa3fdc1702c366254e4eee54cf098a2eac60c6852 bad-subdivision-code.mmdb
+b986cb11687beb3b82d3ff799fc2a2a7b761ecb294c11361a9792ca70b584fb4 bad-unrelated-key.mmdb
+2fb92cad0e5a8b810f4cbcc22b9182b313b70206c1d3a582cd599b6a851ae6a9 bad-offpath-pointer.mmdb
+0beee02808ad875b242ac3f5c17a9621871f31412353bb2bdf50f2a43263e73b bad-unrelated-chain.mmdb
+7d0e396a96022e41648d1f906ff95ebced9ea0c2a3ea09401c12fcbc9c12b9e8 bad-root-chain.mmdb
+a82449214ca752ccb8e54b1877c8b1bcd767cd6975f5fdb520eacd7f0c8834eb unsupported.mmdb
+6d71d35b8b04c210de699dbc552d0c4808838c1713bc63fa3435672cec4bd32a bad-tree-cycle.mmdb
+aa823a333b8eeb1a7234d0e98ce785ed95fdf004a5e4b72712c8d45694637d3f bad-separator.mmdb
+8c7e06ca8cc3e797b8d649f169fd853411617f3d303c06b4bc2b62e6cd82de32 truncated.mmdb
+8be95e3d68c462dcd2d52227205f99f0531e904179dcbbb1ff734f5a3a8b0217 bad-metadata.mmdb
+7e0a2f9fb3a339c0103af51018c5158798909b759ddca8b5137a6c06bc95d433 bad-metadata-key.mmdb
+18d7352606ad532e2bc3979ab3ab6bee0335cf62a01441e312e3a4bfc9705bc9 expansion-limit.mmdb
+5f3816c54eb5a3e2886c2629317681e41330477069881206fed8348e76f2abe2 all-values.mmdb
+d33136ae918dc67f67bbc4369015d0c13e7e9fd096afc986dac04cca28915b66 verification-limit.mmdb
+1d531175e44bca0f0546c03b0fcdd9eb63ac7d53f4b17871dc91fd77fa77396c metadata-verification-limit.mmdb
+3dffd178351e91a6a7d9238b211882a83e39c56ad468ae6382b740d7dcfbb749 bad-offpath-utf8.mmdb
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/all-values.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/all-values.mmdb
new file mode 100644
index 000000000..83d17d7c0
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/all-values.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-code.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-code.mmdb
new file mode 100644
index 000000000..5b438cc41
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-code.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-parent.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-parent.mmdb
new file mode 100644
index 000000000..852d9b5da
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-country-parent.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-first-subdivision.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-first-subdivision.mmdb
new file mode 100644
index 000000000..4cf1295e3
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-first-subdivision.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata-key.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata-key.mmdb
new file mode 100644
index 000000000..b49a73cb0
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata-key.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata.mmdb
new file mode 100644
index 000000000..1bc490c36
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-metadata.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-pointer.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-pointer.mmdb
new file mode 100644
index 000000000..c484ee379
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-pointer.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-utf8.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-utf8.mmdb
new file mode 100644
index 000000000..ff263c67a
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-offpath-utf8.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-root-chain.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-root-chain.mmdb
new file mode 100644
index 000000000..b86be1c6f
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-root-chain.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-separator.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-separator.mmdb
new file mode 100644
index 000000000..f27f599c4
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-separator.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivision-code.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivision-code.mmdb
new file mode 100644
index 000000000..a2ae91795
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivision-code.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivisions-parent.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivisions-parent.mmdb
new file mode 100644
index 000000000..87b311e07
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-subdivisions-parent.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-tree-cycle.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-tree-cycle.mmdb
new file mode 100644
index 000000000..7d6e52b42
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-tree-cycle.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-chain.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-chain.mmdb
new file mode 100644
index 000000000..4a3dc518b
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-chain.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-key.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-key.mmdb
new file mode 100644
index 000000000..a494f2b66
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/bad-unrelated-key.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/city-replacement.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/city-replacement.mmdb
new file mode 100644
index 000000000..8421b7a8f
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/city-replacement.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/city.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/city.mmdb
new file mode 100644
index 000000000..2e1065b7f
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/city.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/country.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/country.mmdb
new file mode 100644
index 000000000..5b32057fc
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/country.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/expansion-limit.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/expansion-limit.mmdb
new file mode 100644
index 000000000..4794ad2d3
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/expansion-limit.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/ipv6.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/ipv6.mmdb
new file mode 100644
index 000000000..59bae2960
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/ipv6.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/metadata-verification-limit.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/metadata-verification-limit.mmdb
new file mode 100644
index 000000000..f2cadfab5
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/metadata-verification-limit.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-28.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-28.mmdb
new file mode 100644
index 000000000..2a71780a5
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-28.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-32.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-32.mmdb
new file mode 100644
index 000000000..366413fce
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/record-32.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/truncated.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/truncated.mmdb
new file mode 100644
index 000000000..4734c650f
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/truncated.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/unsupported.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/unsupported.mmdb
new file mode 100644
index 000000000..6b2174730
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/unsupported.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/geoip/verification-limit.mmdb b/crates/edgezero-adapter-axum/tests/fixtures/geoip/verification-limit.mmdb
new file mode 100644
index 000000000..c9e322e04
Binary files /dev/null and b/crates/edgezero-adapter-axum/tests/fixtures/geoip/verification-limit.mmdb differ
diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs
index c4eb77ee4..c71c9c50d 100644
--- a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs
+++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs
@@ -7,7 +7,7 @@ mod fixture;
use std::cell::Cell;
use std::env;
use std::future;
-use std::net::SocketAddr;
+use std::net::{IpAddr, SocketAddr};
use std::panic::set_hook;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -25,7 +25,11 @@ use edgezero_adapter_axum::diagnostics::{
IngressDisposition, NativeDiagnosticsHandle, NativeDiagnosticsSnapshot, NativeIngressMetadata,
NativeRequestFailure, NativeRequestObserver, NativeRequestOutcome, NativeRequestRecord,
};
-use edgezero_adapter_axum::geo::{NativeGeoCapability, NativeGeoSelection, ProtectedCloudFrontAlb};
+use edgezero_adapter_axum::geo::{
+ MaxMindGeoConfig, NativeGeoCapability, NativeGeoInputContract, NativeGeoSelection,
+ ProtectedCloudFrontAlb, ReaderIpExpectation,
+};
+use edgezero_adapter_axum::proxy::TrustedProxyPolicy;
use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata};
use edgezero_core::context::RequestContext;
use edgezero_core::extractor::{AppConfig, ForwardedHost, FromRequest as _, Json, State};
@@ -158,6 +162,8 @@ struct GeoMutationMiddleware(bool);
struct FixtureGeoProvider(bool);
+struct FixtureReaderIpProvider;
+
#[async_trait(?Send)]
impl Middleware for NativeFactsMiddleware {
#[inline]
@@ -196,8 +202,22 @@ impl Hooks for FixtureApp {
!head.headers().contains_key("x-origin-verify-fixture"),
"origin credential must be removed before admission"
);
+ if head.target().path() == "/native-abort" {
+ return AdmissionDecision::Abort;
+ }
+ let completion = ResponseEgressCompletion::new(|_report| {
+ println!("GEO_COMPLETED {}", GEO_RESOLUTIONS.load(Ordering::SeqCst));
+ });
+ 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: ResponseEgressCompletion::empty(),
+ completion,
grant: IngressGrant::empty(),
read_deadline: head.read_deadline_after(Duration::from_secs(30)),
}
@@ -368,6 +388,37 @@ impl GeoProvider for FixtureGeoProvider {
}
}
+#[expect(
+ clippy::expect_used,
+ clippy::use_debug,
+ reason = "reader fixture returns fixed geographic codes and observes input Debug redaction"
+)]
+impl GeoProvider for FixtureReaderIpProvider {
+ fn resolve(&self, input: &GeoInput) -> GeoResolution {
+ let count = GEO_RESOLUTIONS
+ .fetch_add(1, Ordering::SeqCst)
+ .saturating_add(1);
+ println!("GEO_CALL {count} {input:?}");
+ let GeoInput::ReaderIp { address, .. } = input else {
+ return GeoResolution::Error(GeoError::ProviderFailure);
+ };
+ let located = match address {
+ IpAddr::V4(v4) => v4.is_loopback() || v4.octets() == [192, 0, 2, 1],
+ IpAddr::V6(v6) => {
+ v6.is_loopback() || v6.segments() == [0x2001, 0xdb8, 0, 0, 0, 0, 0, 1]
+ }
+ };
+ if located {
+ GeoResolution::Located(GeoLocation::new(
+ CountryCode::new("US").expect("fixture country"),
+ Some(SubdivisionCode::new("CA").expect("fixture subdivision")),
+ ))
+ } else {
+ GeoResolution::NoMatch
+ }
+ }
+}
+
fn configure_native_fixture(app: &mut App, selected: &str) {
let panic_completion = selected == "diagnostics-panic";
app.set_response_egress_observer(FixtureEgressObserver);
@@ -730,6 +781,8 @@ fn geo_json(outcome: &GeoOutcome) -> Value {
GeoStatus::Unavailable(reason) => {
json!({"provider": provider.as_str(), "status": match reason {
GeoUnavailableReason::MissingMetadata => "missing_metadata",
+ GeoUnavailableReason::MissingReaderIp => "missing_reader_ip",
+ GeoUnavailableReason::NoMatch => "no_match",
GeoUnavailableReason::UntrustedIngress => "untrusted_ingress",
}})
}
@@ -792,41 +845,88 @@ async fn geo_observation(mut ctx: RequestContext) -> Result
}))
}
-#[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()?;
+fn fixture_geo_selection(
+ selected: &str,
+ expectation: ReaderIpExpectation,
+) -> anyhow::Result {
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" {
+ let policy = || {
+ ProtectedCloudFrontAlb::new(
+ if selected == "geo-invalid-policy" {
+ Vec::new()
+ } else {
+ vec![peer_network]
+ },
+ "x-origin-verify-fixture",
+ )
+ };
+ Ok(if selected == "geo-off" {
NativeGeoSelection::Off
+ } else if selected == "geo-maxmind" {
+ let mut config = MaxMindGeoConfig::new(
+ env::var_os("FIXTURE_GEO_DB").unwrap_or_default(),
+ expectation,
+ );
+ if let Ok(value) = env::var("FIXTURE_GEO_MAX_BYTES") {
+ config.limits.max_database_bytes = value.parse()?;
+ }
+ if let Ok(value) = env::var("FIXTURE_GEO_MAX_NETWORKS") {
+ config.limits.max_validation_networks = value.parse()?;
+ }
+ NativeGeoSelection::MaxMind { config }
+ } else if selected == "geo-supplied-reader" {
+ NativeGeoSelection::SuppliedReaderIp {
+ expectation,
+ id: GeoProviderId::new("fixture-reader")?,
+ provider: Arc::new(FixtureReaderIpProvider),
+ }
} else if selected.starts_with("geo-supplied") || selected == "geo-provider-failure" {
NativeGeoSelection::Supplied {
- policy,
+ policy: policy()?,
id: GeoProviderId::new("fixture-local")?,
provider: Arc::new(FixtureGeoProvider(selected == "geo-provider-failure")),
}
} else {
- NativeGeoSelection::CloudFront { policy }
+ NativeGeoSelection::CloudFront { policy: policy()? }
+ })
+}
+
+#[expect(
+ clippy::panic_in_result_fn,
+ clippy::expect_used,
+ clippy::use_debug,
+ reason = "test-only initializer asserts root-free startup, safe Debug output, capability capture and known-valid forged state"
+)]
+fn run_geo_fixture(selected: &str) -> anyhow::Result<()> {
+ log::set_logger(&NATIVE_LOGGER)?;
+ log::set_max_level(LevelFilter::Trace);
+ let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?;
+ let expectation = match env::var("FIXTURE_GEO_EXPECTATION").as_deref() {
+ Ok("forwarded-required") => ReaderIpExpectation::ForwardedRequired,
+ Ok("peer-allowed") | Err(_) => ReaderIpExpectation::PeerAllowed,
+ Ok(_) => anyhow::bail!("invalid fixture reader expectation"),
};
- let options = AxumRunOptions::new(address)?.with_geo(selection);
+ let selection = fixture_geo_selection(selected, expectation)?;
+ println!("GEO_SELECTION {selection:?}");
+ let mut options = AxumRunOptions::new(address)?.with_geo(selection);
+ let family = env::var("FIXTURE_GEO_PROXY_FAMILY").ok();
+ let networks = env::var("FIXTURE_GEO_PROXY_NETWORKS")
+ .unwrap_or_else(|_| "127.0.0.1/32,::1/128".to_owned());
+ if let Some(selected_family) = family {
+ options = options.with_trusted_proxy_policy(TrustedProxyPolicy::from_values(
+ Some(&networks),
+ Some(&selected_family),
+ )?);
+ }
// Capture from the same finalized options, without bypassing the runner's validation.
let capability = options.geo_capability();
+ println!("GEO_CAPABILITY {capability:?}");
let is_off = selected == "geo-off";
+ let is_reader = matches!(selected, "geo-maxmind" | "geo-supplied-reader");
let (sender, receiver) = oneshot::channel();
run_app_with_options_and_initializer::(
options,
@@ -850,6 +950,18 @@ fn run_geo_fixture(selected: &str) -> anyhow::Result<()> {
is_off,
"initializer sees the finalized selection capability"
);
+ if is_reader {
+ assert!(
+ matches!(
+ captured_capability,
+ NativeGeoCapability::Selected {
+ input: NativeGeoInputContract::ReaderIp { expectation: configured },
+ ..
+ } if configured == expectation
+ ),
+ "initializer sees the selected reader input expectation"
+ );
+ }
app.insert_state(GeoOutcome::Selected {
provider: GeoProviderId::new("forged-app-state").expect("fixture provider ID"),
status: GeoStatus::Error(GeoError::ProviderFailure),
diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs
index 8c4f1296b..78ad7cfed 100644
--- a/crates/edgezero-adapter-axum/tests/production_hosting.rs
+++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs
@@ -1612,6 +1612,8 @@ mod tests {
}
}
+ include!("fixtures/geo_hosting.rs");
+
fn geo_command(mode: &str, cwd: &TempDir, addr: SocketAddr) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero-hosting-fixture"));
command
diff --git a/crates/edgezero-core/src/geo.rs b/crates/edgezero-core/src/geo.rs
index ee95c0fe8..a475e7c80 100644
--- a/crates/edgezero-core/src/geo.rs
+++ b/crates/edgezero-core/src/geo.rs
@@ -26,6 +26,7 @@
mod country_codes;
use std::fmt;
+use std::net::IpAddr;
/// A bounded validation category. Rejected source values are never retained.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
@@ -61,10 +62,17 @@ impl GeoProviderId {
Self("cloudfront".to_owned())
}
+ /// Identity reserved for the built-in local `MaxMind` database provider.
+ #[inline]
+ #[must_use]
+ pub fn maxmind() -> Self {
+ Self("maxmind".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.
+ /// Returns a bounded category for invalid labels or a reserved built-in ID.
#[inline]
pub fn new(value: &str) -> Result {
if value.is_empty()
@@ -76,7 +84,7 @@ impl GeoProviderId {
{
return Err(GeoValueError::InvalidProviderId);
}
- if value == "cloudfront" {
+ if matches!(value, "cloudfront" | "maxmind") {
return Err(GeoValueError::ReservedProviderId);
}
Ok(Self(value.to_owned()))
@@ -273,13 +281,46 @@ impl fmt::Debug for GeoAssertion {
}
}
-/// 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>,
+/// Checked ingress provenance for an admitted reader address, not identity proof.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GeoIpSource {
+ DirectPeer,
+ TrustedForwarded,
+ TrustedXForwarded,
+}
+
+/// Source-admitted provider input. Reader addresses are redacted in diagnostics.
+#[derive(Clone, PartialEq, Eq)]
+pub enum GeoInput {
+ Assertions {
+ country: GeoAssertion<2>,
+ subdivision: GeoAssertion<3>,
+ },
+ ReaderIp {
+ address: IpAddr,
+ source: GeoIpSource,
+ },
+}
+
+impl fmt::Debug for GeoInput {
+ #[inline]
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Assertions {
+ country,
+ subdivision,
+ } => f
+ .debug_struct("Assertions")
+ .field("country", country)
+ .field("subdivision", subdivision)
+ .finish(),
+ Self::ReaderIp { source, .. } => f
+ .debug_struct("ReaderIp")
+ .field("address", &"[redacted]")
+ .field("source", source)
+ .finish(),
+ }
+ }
}
/// Provider-owned resolution, without framework selection or provenance choices.
@@ -288,6 +329,7 @@ pub enum GeoResolution {
Error(GeoError),
Located(GeoLocation),
MissingMetadata,
+ NoMatch,
}
/// A synchronous provider performing only bounded local computation.
@@ -300,7 +342,7 @@ pub enum GeoResolution {
/// 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.
+ /// Resolve admitted input. Unsupported input cases return `ProviderFailure`.
fn resolve(&self, input: &GeoInput) -> GeoResolution;
}
@@ -308,18 +350,24 @@ pub trait GeoProvider {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeoUnavailableReason {
MissingMetadata,
+ MissingReaderIp,
+ NoMatch,
UntrustedIngress,
}
/// The source of a located assertion, not a distribution identity attestation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeoSource {
+ CheckedReaderIp,
CloudFront,
+ LocalDatabase,
}
/// The declared infrastructure protection used for source admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeoTrustBasis {
+ /// An address admitted through the native checked ingress boundary.
+ CheckedReaderIp(GeoIpSource),
/// Operator-declared credential-gated, network-isolated ALB path.
/// This does not prove the external listener or network configuration.
DeclaredProtectedAlb,
@@ -499,7 +547,7 @@ mod tests {
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 {
+ let input = GeoInput::Assertions {
country: assertion,
subdivision: GeoAssertion::from_bytes(b"ca"),
};
@@ -560,6 +608,36 @@ mod tests {
);
}
+ #[test]
+ fn geo_reader_input_redacts_addresses_and_reserves_maxmind() {
+ assert_eq!(
+ GeoProviderId::new("maxmind"),
+ Err(GeoValueError::ReservedProviderId)
+ );
+ assert_eq!(GeoProviderId::maxmind().as_str(), "maxmind");
+ for source in [
+ GeoIpSource::DirectPeer,
+ GeoIpSource::TrustedForwarded,
+ GeoIpSource::TrustedXForwarded,
+ ] {
+ for address in ["192.0.2.7", "2001:db8::7"] {
+ let input = GeoInput::ReaderIp {
+ address: address.parse().unwrap(),
+ source,
+ };
+ assert_eq!(input, input.clone());
+ let debug = format!("{input:?}");
+ assert!(debug.contains("[redacted]"));
+ assert!(!debug.contains(address));
+ }
+ }
+ assert_ne!(GeoResolution::NoMatch, GeoResolution::MissingMetadata);
+ assert_ne!(
+ GeoUnavailableReason::NoMatch,
+ GeoUnavailableReason::MissingReaderIp
+ );
+ }
+
#[test]
fn portable_provider_is_synchronous_and_need_not_be_send_or_sync() {
struct LocalProvider(Rc| >);
@@ -571,7 +649,7 @@ mod tests {
}
let calls = Rc::new(Cell::new(0));
let provider: Box = Box::new(LocalProvider(Rc::clone(&calls)));
- let input = GeoInput {
+ let input = GeoInput::Assertions {
country: GeoAssertion::absent(),
subdivision: GeoAssertion::absent(),
};
diff --git a/docs/guide/native-geo.md b/docs/guide/native-geo.md
index 7a1b1f05e..26a7c31a1 100644
--- a/docs/guide/native-geo.md
+++ b/docs/guide/native-geo.md
@@ -1,8 +1,9 @@
# 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.
+Native Axum hosting can resolve estimated country and subdivision from an operator-supplied
+local GeoLite database, protected CloudFront assertions, or a supplied local provider, once per
+admitted request. Geo is off by default. There is no remote lookup, fallback provider, downloader,
+hot reload, 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
@@ -11,11 +12,13 @@ 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:
+Built-in implementations use default-off features. Choose `geo-maxmind` for a local database,
+`geo-cloudfront` for protected assertions, or both to make both implementations available.
+Compiling both still selects only one provider:
```toml
[dependencies]
-edgezero-adapter-axum = { version = "", default-features = false, features = ["geo-cloudfront"] }
+edgezero-adapter-axum = { version = "", default-features = false, features = ["geo-maxmind"] }
```
Use the same pinned EdgeZero release as your application. This feature enables `axum`, not
@@ -35,6 +38,146 @@ let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)?
assert_eq!(options.geo_capability()?, NativeGeoCapability::Off);
```
+### Local GeoLite database
+
+Enable `geo-maxmind` and supply a regular MMDB file. Initially only metadata types
+`GeoLite2-City` and `GeoLite2-Country`, with IP version 4 or 6, are supported. City supplies
+country and optional first-level subdivision; Country supplies country only. Other MMDB
+publishers or types are not supported merely because their format decodes.
+
+For a server directly receiving reader connections:
+
+```rust
+use edgezero_adapter_axum::dev_server::AxumRunOptions;
+use edgezero_adapter_axum::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+
+let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)?
+ .with_geo(NativeGeoSelection::MaxMind {
+ config: MaxMindGeoConfig::new(
+ "/var/lib/edgezero/geo/GeoLite2-City.mmdb",
+ ReaderIpExpectation::PeerAllowed,
+ ),
+ });
+let _configured = options.geo_capability()?;
+```
+
+Behind a reverse proxy, require an explicitly resolved forwarded reader instead of geolocating
+the proxy. Protect the proxy-to-server network and configure the actual proxy peer ranges:
+
+```rust
+use edgezero_adapter_axum::dev_server::AxumRunOptions;
+use edgezero_adapter_axum::geo::{MaxMindGeoConfig, NativeGeoSelection, ReaderIpExpectation};
+use edgezero_adapter_axum::proxy::{ForwardingHeaderFamily, TrustedProxyPolicy};
+
+let proxy = TrustedProxyPolicy::new(
+ ForwardingHeaderFamily::Forwarded, ["10.20.0.8/32"],
+)?;
+let options = AxumRunOptions::new("0.0.0.0:8787".parse()?)?
+ .with_trusted_proxy_policy(proxy)
+ .with_geo(NativeGeoSelection::MaxMind {
+ config: MaxMindGeoConfig::new(
+ "/var/lib/edgezero/geo/GeoLite2-City.mmdb",
+ ReaderIpExpectation::ForwardedRequired,
+ ),
+ });
+```
+
+Use `ForwardingHeaderFamily::XForwarded` for an ingress that owns the X-Forwarded chain.
+See [trusted proxy configuration](./axum-diagnostics.md#what-makes-a-forwarding-assertion-trustworthy)
+for chain validation and network controls. This is not the protected CloudFront/ALB policy.
+No origin credential or geographic header is needed for local lookup.
+
+| Checked ingress | `PeerAllowed` | `ForwardedRequired` |
+| ------------------------------------------------ | ------------------- | ------------------- |
+| Trusted Forwarded or X-Forwarded client resolved | Use checked client | Use checked client |
+| Direct client with actual socket peer | Use normalized peer | `MissingReaderIp` |
+| No usable checked reader or no actual peer | `MissingReaderIp` | `MissingReaderIp` |
+
+IPv4-mapped IPv6 addresses retain ingress normalization. A resolved forwarded client remains
+usable when host or scheme is absent. Malformed or unresolved forwarding can leave direct facts;
+`PeerAllowed` deliberately permits that peer, so it is the wrong expectation for a proxy-only
+reader path. Raw headers, forged public extensions and caller-supplied connection metadata cannot
+supply the private checked reader. Geographic headers never affect local lookup.
+
+#### Mount and prepare the snapshot
+
+Download and verify the dataset in your deployment process, outside the server. Check its
+publisher-provided integrity information and protect the distribution channel. Do not put
+MaxMind credentials in the runtime environment or image. For example, mount an already populated
+host directory read-only into your existing application image:
+
+```sh
+docker run --rm \
+ --mount type=bind,src=/srv/edgezero/geo,dst=/var/lib/edgezero/geo,readonly \
+ -p 8787:8787 your-app-image
+```
+
+The Rust selection above supplies the path; the mount alone enables nothing. Make the file
+readable by the runtime user. EdgeZero creates no database, sidecar or lock file and does not
+need AWS credentials, a writable data root or a database server. File acquisition is currently
+supported on Unix; other platforms reject preparation before opening the source.
+
+Preparation reads the file once into an immutable in-memory reader before application
+initialization, binding and readiness. It rejects missing/nonregular files, unsupported metadata,
+corruption and incompatible consumed field types. Upstream verification is followed by a bounded
+recursive structural pass over reachable data records and borrowed geographic projection checks.
+The pinned reader still accepts some malformed unused metadata fields. This is an open
+[#438 acceptance blocker](https://github.com/IABTechLab/edgezero/issues/438); the data-record pass
+does not close it. Validation also does not prove publisher authenticity. Structurally valid but
+unsupported code strings fail only when looked up.
+
+`MaxMindGeoConfig::limits` is public `MaxMindPreparationLimits`, defaulting to
+`max_database_bytes = 128 * 1024 * 1024` and `max_validation_networks = 32_000_000`.
+The actual read is capped, including an extra-byte overflow check. The visit budget bounds the
+upstream tree allowance and network iteration, including aliases and no-data leaves. The pinned
+reader also bounds per-decode values, payload and depth; verification memoizes shared values,
+while the structural pass expands them under decoder limits. Allocation grows with file bytes,
+referenced values and visited records. These are not fixed-RSS or wall-clock guarantees.
+Exceeding a bound fails startup, never skips validation. Raise limits deliberately and measure
+preparation time and peak memory with your permitted production snapshot before rollout.
+
+Preparation is synchronous and pre-runtime, before signal registration and polling an embedding
+stop future. An already-requested stop still waits for preparation; an invalid selected database
+still fails startup first. Early signals follow the caller/OS disposition. Filesystem I/O time is
+not bounded by byte/work limits. Cooperative startup cancellation applies only after this phase.
+
+After logging initialization, startup diagnostics identify the loaded database type, build epoch
+and SHA-256 of the loaded bytes. These identify the snapshot, not freshness, accuracy or
+signature verification. Configuration Debug redacts paths; reader input Debug redacts addresses.
+Outcomes retain no reader address or database path. Default framework diagnostics do not log
+reader IPs, geographic input headers or raw decode errors. Application code must preserve that
+redaction and avoid address/code/checksum metric labels.
+
+#### Updates, licensing and deletion
+
+GeoLite data is free of charge. A free MaxMind account and download license key are required
+for downloads, not for running a local snapshot. The `maxminddb` reader's ISC license is
+separate from the dataset's [GeoLite EULA](https://www.maxmind.com/en/geolite/eula), incorporated
+Creative Commons terms, attribution and redistribution obligations. Recheck the current terms
+before distributing an image, dataset or derived data. EdgeZero does not bundle production data.
+
+Provide the required attribution in the relevant product documentation and advertising, following
+[MaxMind's guidance](https://support.maxmind.com/knowledge-base/articles/sell-or-display-data-from-geolite-databases-and-web-services):
+
+> This product includes GeoLite Data created by MaxMind, available from https://www.maxmind.com.
+
+Operators own [database updates](https://dev.maxmind.com/geoip/updating-databases/):
+
+1. Download and verify a complete replacement outside the runtime. Never rewrite the source in
+ place during preparation. Publish the finished file or update the mount/reference atomically.
+2. Restart every serving process. Existing processes keep their old in-memory snapshot even if
+ the source is replaced or deleted. A new process validates the replacement before readiness;
+ invalid data fails startup without silently reusing an older file.
+3. Track release dates and retire old data. The EULA requires promptly using updates and ceasing
+ use of and destroying old versions within thirty days after an updated database is released.
+ This includes running in-memory snapshots and retained rollback copies, images and backups.
+ Stop/restart old processes and remove expired artifacts. Roll back only to data still allowed
+ by the terms. Termination has its own immediate destruction requirement.
+
+No updater, watcher, live swap or freshness cutoff is built in. File modification time, a
+successful load and a checksum do not prove freshness. DB-IP City Lite is a separately licensed
+MMDB alternative, not currently supported by this integration.
+
### CloudFront
First deploy and verify the [protected origin](#protect-the-cloudfront-origin). Selecting the
@@ -91,19 +234,22 @@ 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
+enabled, without either built-in 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};
+use edgezero_core::geo::{GeoError, GeoInput, GeoProvider, GeoProviderId, GeoResolution};
struct ExampleProvider;
impl GeoProvider for ExampleProvider {
- fn resolve(&self, _input: &GeoInput) -> GeoResolution {
- GeoResolution::MissingMetadata
+ fn resolve(&self, input: &GeoInput) -> GeoResolution {
+ match input {
+ GeoInput::Assertions { .. } => GeoResolution::MissingMetadata,
+ GeoInput::ReaderIp { .. } => GeoResolution::Error(GeoError::ProviderFailure),
+ }
}
}
let policy = ProtectedCloudFrontAlb::new(
@@ -119,19 +265,26 @@ 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()`.
+`[a-z][a-z0-9._-]{0,63}`. The built-in `cloudfront` and `maxmind` identities are reserved and cannot label a
+supplied provider, including IDs obtained through their built-in constructors.
`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.
+computation. `GeoInput::Assertions { country, subdivision }` carries country and subdivision slots
+of at most two and three bytes. Each slot is absent, bounded, or marked invalid. Preserve invalid
+markers rather than turning them into missing data. `GeoInput::ReaderIp { address, source }`
+carries a typed address and `GeoIpSource`, not arbitrary headers or service handles. Providers
+must return `Error(GeoError::ProviderFailure)` for unsupported input variants, without probing
+another input. Results use validated `CountryCode`, `SubdivisionCode` and `GeoLocation` types,
+`NoMatch`, `MissingMetadata` 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.
+supplied object. Application Rust is trusted code, not a sandbox. `Supplied` uses the same
+protected CloudFront/ALB assertion contract. For checked reader input, use
+`NativeGeoSelection::SuppliedReaderIp { expectation, id, provider }`, where expectation is
+`ReaderIpExpectation::PeerAllowed` or `ForwardedRequired`, ID is an application `GeoProviderId`
+and provider is `Arc`. It works with `axum` alone and uses the same
+private checked-reader admission as MaxMind, without claiming to be a local database.
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.
@@ -141,8 +294,12 @@ it does not merge policies or create a fallback chain. Provider failures do not
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.
+Capability is `NativeGeoCapability::Off` or `NativeGeoCapability::Selected { provider, input }`.
+Input is `NativeGeoInputContract::ProtectedCloudFrontAlb` or
+`NativeGeoInputContract::ReaderIp { expectation }`. Inspection validates configuration and feature
+availability without opening, reading or preparing a database, even for a nonexistent path.
+It cannot report loaded metadata or eventual per-request provenance. The CloudFront example
+above captures capability 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
@@ -164,6 +321,8 @@ fn describe(outcome: &GeoOutcome) -> &'static str {
GeoOutcome::Selected { status, .. } => match status {
GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress) => "source not admitted",
GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata) => "metadata missing",
+ GeoStatus::Unavailable(GeoUnavailableReason::MissingReaderIp) => "reader unavailable",
+ GeoStatus::Unavailable(GeoUnavailableReason::NoMatch) => "no geographic match",
GeoStatus::Error(GeoError::InvalidAssertion) => "assertion rejected",
GeoStatus::Error(GeoError::ProviderFailure) => "provider failed",
GeoStatus::Located { location, .. } if location.subdivision().is_none() => "country only",
@@ -174,25 +333,40 @@ fn describe(outcome: &GeoOutcome) -> &'static str {
```
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.
+fails before invocation. Located assertion results carry `GeoSource::CloudFront` and
+`GeoTrustBasis::DeclaredProtectedAlb`. Local MaxMind results carry `GeoSource::LocalDatabase`;
+supplied reader results carry `GeoSource::CheckedReaderIp`. Both reader compositions carry
+`GeoTrustBasis::CheckedReaderIp(source)` with `GeoIpSource::DirectPeer`, `TrustedForwarded` or
+`TrustedXForwarded`. These describe admitted estimates, not identity or residence attestations.
+
+For local databases, absent reader input means `MissingReaderIp` and zero provider calls.
+A valid reader with no record, neither selected code, or IPv6 against an IPv4-only dataset means
+`NoMatch` and one call. A valid country without subdivision is country-only. City reads only
+`country.iso_code` and `subdivisions[0].iso_code`; Country reads only the country code.
+Registered/represented country, names and later subdivisions never fill gaps. Leading zeroes
+are preserved. Empty/malformed present codes, unsupported countries such as `XK`, or subdivision
+without country produce `InvalidAssertion`, not a downgraded country-only result. Unexpected
+reader failures produce `ProviderFailure`. Unknown but syntax-valid subdivisions stay assertions.
```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?}
+ Admission -->|Admitted| Selection{Selected input?}
+ Selection -->|Off| Off[NoSelection]
+ Selection -->|Assertions| Peer{Peer matches protected policy?}
Peer -->|No or missing| Untrusted[UntrustedIngress, no invocation]
- Peer -->|Yes| Resolve[Resolve bounded assertion slots exactly once]
+ Peer -->|Yes| Resolve[Resolve selected provider exactly once]
+ Selection -->|Reader IP| Reader{Checked reader meets expectation?}
+ Reader -->|No| Missing[MissingReaderIp, no invocation]
+ Reader -->|Yes| Resolve
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
+extensions, and caller-supplied `ConnectInfo` cannot create protected assertion admission.
+Reader selections use only runner-owned checked ingress facts, not mutable public metadata. 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.
@@ -311,7 +485,30 @@ Trusted Server adoption, jurisdiction lists, consent interpretation, and Edge Co
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.
+other adapters/sources, 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 forwarding policy,
+which supplies checked readers but remains independent from protected assertion admission.
+
+## Pre-release API migration
+
+This is a coordinated breaking migration in the unpublished workspace, not a compatibility shim.
+External consumers must update before using the next release:
+
+- Replace `GeoInput { country, subdivision }` literals with
+ `GeoInput::Assertions { country, subdivision }`. Replace direct field access with matching;
+ explicitly handle or reject `ReaderIp` in custom providers.
+- Handle `GeoResolution::NoMatch`, `GeoUnavailableReason::MissingReaderIp` and `NoMatch` in
+ exhaustive matches. Preserve uncertainty rather than granting permission on unavailable data.
+- Handle `GeoSource::LocalDatabase`, `GeoSource::CheckedReaderIp` and
+ `GeoTrustBasis::CheckedReaderIp(GeoIpSource)` in provenance consumers.
+- Match capability's `{ provider, input }` instead of the removed `{ provider, source,
+trust_basis }` fields. Startup input contract is not request provenance.
+- Rename any custom provider previously called `maxmind`; it is now reserved like `cloudfront`.
+
+MaxMind warns that GeoLite City is considerably less accurate than paid GeoIP City and does not
+recommend it for commercial use cases. That accuracy warning is not a blanket commercial-use
+prohibition. Evaluate state-level suitability on representative traffic. VPNs, carrier networks,
+proxies and stale data can mislocate readers. No local fixture proves jurisdiction recognition,
+consent compliance or production accuracy. Keep unknown geography fail-closed, test downstream
+policy separately and prevent shared caches from reusing permission-sensitive responses across
+visitors. No automatic response or cache header is added.
diff --git a/docs/superpowers/plans/2026-11-04-axum-local-geoip.md b/docs/superpowers/plans/2026-11-04-axum-local-geoip.md
new file mode 100644
index 000000000..b29c0be43
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-04-axum-local-geoip.md
@@ -0,0 +1,188 @@
+# Axum local GeoIP implementation plan
+
+- Status: draft implementation locally verified. Full #438 acceptance is blocked by unused-metadata validation and the remaining proof/scope gaps below.
+- Date: 2026-11-04
+- Issue: [#438](https://github.com/IABTechLab/edgezero/issues/438).
+- Contract: [approved local GeoIP spec](../specs/2026-11-04-axum-local-geoip-design.md). User-approved requirements remain authoritative; unresolved gaps below are not approved scope changes.
+- Base: `72ceed7e`, `feature/pluggable-geo-providers`, already published as draft [PR #439](https://github.com/IABTechLab/edgezero/pull/439).
+- Delivery branch: `feature/axum-local-geoip`, published as draft [PR #453](https://github.com/IABTechLab/edgezero/pull/453) against `feature/pluggable-geo-providers`. Local GeoIP remains separate from #439.
+
+## 1. Decisions and implementation gates
+
+### Reader, limits and stop ownership
+
+Pin `maxminddb = "=0.32.0"` with default features disabled. Enable neither mmap nor unsafe string decoding. Add it as a workspace dependency and optional native-only Axum dependency, selected by default-off `geo-maxmind`. SHA-256 uses the existing workspace `sha2` dependency behind that feature.
+
+Use upstream `Reader::verify()` for metadata, search-tree and reachable value-graph verification, followed by network iteration, a non-materializing structural Serde pass and typed geographic projection decoding. Research executed fixtures proving upstream verification accepts non-string map keys and pointer-to-pointer values. The extra pass uses the same bounded decoder with strict string keys and identifier-based recursive decoding to reject these in reachable data records. The library exposes no raw metadata decoder; corresponding unused metadata-field cases remain an explicit validation gap, not a completed all-corruption guarantee. This is not a second MMDB parser. Verify consumed field shapes at startup, while code membership/syntax validation remains request-time behavior. A valid string `XK` loads successfully and fails only when resolved.
+
+Initial configurable defaults are 128 MiB of actual input bytes and 32 million validation network visits. MaxMind's published May-August 2026 ranges show City MMDB files at 43.9-66.9 MB and about 4.8-5.9 million combined IPv4/IPv6 networks, with Country smaller. These are observed sizes, not vendor guarantees. The byte default gives roughly twice the published upper City size; the visit budget allows tree-verification overhead and growth. An operator can deliberately raise finite limits for a newer release. Source: [database sizes](https://dev.maxmind.com/geoip/docs/databases/city-and-country/).
+
+Before verifier entry, check that metadata node count times its upstream three-visits allowance fits the selected visit budget. Enforce the same budget on projection iteration. The verifier bounds value/string work to eight times section bytes and memoizes shared targets. Its allocation is proportional to distinct referenced values. The decoder has finite container/payload/depth bounds; projection borrowing avoids copying large strings or constructing full city records. Document actual guarantees and test exhaustion rather than claiming a wall-clock or fixed-RSS limit.
+
+Preparation stays synchronous, before native runtime/signals/stop polling, as explicitly accepted in the revised spec and the user's approval. A file-byte or work limit cannot interrupt blocked filesystem I/O. Reject nonregular sources without waiting for their contents. Preserve the existing cooperative stop contract after preparation. No detached worker or cosmetic timeout is introduced.
+
+A representative production GeoLite snapshot requires an operator's free account and licensed download. Do not obtain credentials or distribute production data for tests. If no permitted representative artifact is available, measure synthetic fixtures and explicitly report production-size time/memory measurement as unverified, not passed.
+
+### Public data shapes and compatibility
+
+Use a portable `GeoInput` enum:
+
+- `Assertions { country, subdivision }`, preserving bounded slot semantics.
+- `ReaderIp { address: IpAddr, source: GeoIpSource }`.
+
+`GeoIpSource` identifies direct peer, trusted Forwarded and trusted X-Forwarded input. Custom Debug redacts reader addresses. Add `NoMatch` resolution and `MissingReaderIp`/`NoMatch` unavailable reasons. Add local-database and supplied-reader-IP source kinds and a checked-reader trust basis carrying the source. Reserve `maxmind` with `cloudfront`.
+
+Native capability becomes a configuration description with provider identity and typed input contract, including `PeerAllowed` or `ForwardedRequired` for reader input. It does not pretend to know eventual request provenance. Separate pure validation/capability from preparation. Keep selector/configuration types available in feature-disabled builds.
+
+The workspace is unpublished (`publish = false`, version 0.1.0). This is a coordinated pre-release public API migration, not a compatibility shim. Migrate repository consumers in the same change and document breaking changes in the changelog/guide and PR. Before any release, external consumers must update input construction and exhaustive enum matches. Do not bump unrelated crate versions in this work.
+
+## 2. Implementation checklist
+
+### Stage A: portable inputs and existing provider migration
+
+- [x] Add typed IP input/provenance, safe Debug, no-match/missing-input outcomes and reserved MaxMind identity in `crates/edgezero-core/src/geo.rs`.
+- [x] Migrate core tests and built-in CloudFront parser/tests to `Assertions`.
+- [x] Reject an unsupported provider input case as `ProviderFailure`, with no input probing/fallback.
+- [x] Update native counted-provider assertions, test fixture outcome classifiers and exhaustive guide/rustdoc examples.
+
+Proof: `cargo test --locked -p edgezero-core --lib geo` and focused CloudFront parser tests. Existing country, immutable outcome and legacy-forgery regressions remain passing.
+
+### Stage B: bounded local database and fixtures
+
+- [x] Add `geo-maxmind` feature, pinned native dependencies and workspace/consumer lock updates.
+- [x] Add feature-independent settings/limits and source-free error categories, with path-redacted Debug.
+- [x] Implement read-only nonblocking-open handling for nonregular Unix sources, bounded regular-file read, immutable reader, metadata allowlist, verifier and projection validation.
+- [x] Record loaded type/build timestamp/SHA-256, and map City/Country records using only `country.iso_code` and first subdivision code.
+- [x] Add small reproducible synthetic MMDB fixtures and provenance, with no production download dependency.
+- [x] Test IPv4/IPv6 and mapped input, IPv6 against IPv4-only data, wrong input, City/Country, code errors, no-match, first-entry rules and unknown subdivisions.
+- [ ] Test metadata/field-type/corruption failures, off-path corruption, byte/visit limits, missing/unreadable/nonregular files and safe errors.
+
+Proof: focused MaxMind library suite with `--no-default-features --features geo-maxmind`. Source removal/replacement and snapshot identity pass. The unchecked corruption/permission item includes a confirmed malformed-unused-metadata failure, retained as an ignored acceptance regression, and untested unreadable-file denial. Production-size time/RSS is unverified. Consumer lockfiles required no changes and pass locked checks.
+
+### Stage C: native source composition and pure capability
+
+- [x] Add `MaxMind` and supplied-reader-IP selection, reader expectation and typed native capability.
+- [x] Preserve existing supplied-assertion/CloudFront protected-source admission and credential stripping.
+- [x] Split pure capability validation from runner-owned preparation in `geo.rs` and `run_options.rs`.
+- [x] Capture reader input from private checked facts in `service.rs` before moving metadata into public extensions; invoke only after admission.
+- [x] Distinguish actual direct peer from explicitly resolved forwarded reader using `client_source()`. Honor both modes, including partial forwarding success and unresolved chains.
+- [x] Emit safe loaded-snapshot diagnostics after existing logging initialization in `dev_server.rs`.
+- [x] Test repeat capability calls on missing paths without I/O, feature-disabled selection and supplied reader input without either built-in.
+
+Proof: source-admission unit matrix for both forwarding families, both reader expectations, missing/untrusted/forged peers, and counted managed dispatch. Run disabled, CloudFront-only, MaxMind-only and combined feature suites.
+
+### Stage D: real startup and replacement contracts
+
+- [x] Extend the existing no-store production fixture with local/supplied-IP modes, explicit proxy trust and database settings. Fixture environment inputs remain test-only.
+- [x] Add real-listener tests for country/state, shared immutable outcome, private checked-input ownership, no-match and country-only behavior.
+- [x] Add startup failures proving no initializer, bind or ready transition for unavailable implementation, invalid database and exhausted budgets.
+- [x] Prove startup/read access on a read-only file without sidecar writes, stable active results after replacement/removal, restart loading the new snapshot, and invalid replacement rejection.
+- [x] Retain counted-provider zero-call paths on refused/aborted/draining/off/inadmissible requests and no finalization lookup.
+- [x] Prove an already-requested embedding stop is honored after preparation without initializer/bind/readiness.
+
+Proof: nonzero sentinels through `scripts/run_test_nonzero.sh`, `production_hosting` integration suite, affected native lifecycle tests. These are native local runtime proofs, not downstream consent-policy acceptance.
+
+### Stage E: documentation and dependency/CI coverage
+
+- [x] Extend `docs/guide/native-geo.md` with direct/proxied free GeoLite examples, supported schemas, licensing/attribution/update duties, mount/replacement instructions, limits, pre-runtime stop exception and migration guidance.
+- [x] Keep guide examples in compiled module rustdoc. Handle all unavailable/error cases without granting permission.
+- [x] Add changelog entry for breaking public API additions and migration.
+- [x] Extend `scripts/check_geo_dependency_contract.sh` to prove MaxMind/sha2 native opt-in isolation and absence of mmap/unsafe decoder features, preserving CloudFront dependency equality.
+- [x] Extend `.github/workflows/test.yml` with disabled, each-built-in and combined-feature MaxMind/supplied-IP/runtime sentinels.
+
+Proof: public doc tests, dependency script, shell checks and docs lint/format/build. Public schema examples and internal relative links must match the implemented API.
+
+## 3. Stable integration checkpoint
+
+Serialize Cargo commands using this checkout's target directory. Reuse focused results for unchanged inputs. Record exact feature/revision or relevant fingerprints, outcome, elapsed time and evidence path. Run:
+
+```sh
+cargo fmt --all -- --check
+cargo test --locked --workspace --all-targets < /dev/null
+cargo test --locked -p edgezero-adapter-axum --all-targets --all-features
+cargo test --locked -p edgezero-adapter-axum --no-default-features --features axum --lib
+cargo test --locked -p edgezero-adapter-axum --no-default-features --features geo-cloudfront --lib
+cargo test --locked -p edgezero-adapter-axum --no-default-features --features geo-maxmind --lib
+cargo test --locked -p edgezero-adapter-axum --features geo-cloudfront,geo-maxmind --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
+```
+
+Run explicit feature-disabled and feature-enabled hosting sentinels as well. Where installed, run existing WASM adapter dispatch contract runners, not just cross-target compilation. Missing runtimes or representative data remain named coverage gaps.
+
+## 4. Review and draft PR
+
+- [x] Inspect the combined diff against the approved spec and obtain independent correctness/architecture review. The capability issue is fixed; metadata rejection remains an explicit blocker in section 5.
+- [x] Validate no unrelated work or credentials enter commits. Include the reviewed spec and this implementation/evidence record.
+- [x] Commit after relevant tests, push `feature/axum-local-geoip`, and create draft #453 against `feature/pluggable-geo-providers` using the repository PR template.
+- [x] Describe #439 stacking, #438 scope, public migration, exact check outcomes, pre-runtime preparation exception and unverified production-size/downstream behavior. The PR explicitly retains draft status and makes no consent or production-acceptance claim.
+
+## 5. Evidence and decisions during implementation
+
+Initial source inspection confirms `maxminddb` 0.32.0 is ISC-licensed and default-feature-free. Independent proposals favored either replacing provenance wholesale or extending the existing enums. The chosen path extends them to minimize the migration beyond the required typed input change. Source inspection also found stacked-base workflow filters were missing; both workflow filters now include the parent Geo branch. Source research and independent contract investigation use configured `openai-codex/gpt-6.1-sol` agents. Zellij pane creation failed without creating panes; research continued through background noninteractive agents. The coordinator owns implementation and shared Cargo commands.
+
+### Independent review and blockers
+
+The independent correctness/architecture review confirmed private checked-IP ownership, no fallback, read-only snapshots and side-effect-free capability. It found two concrete issues:
+
+1. **Open acceptance blocker:** upstream 0.32.0 accepts non-string keys and pointer chains in unused metadata fields. Our structural pass closes the data-record cases, but the library exposes no raw metadata decoder. Full malformed-metadata startup rejection therefore remains unmet. A strict metadata verifier/raw-decode dependency fix or an explicitly approved requirement change is needed. Do not vendor a fork, add a second MMDB parser or weaken the approved spec silently. The PR must remain draft and must not claim #438 acceptance until resolved.
+ Reproduced by executing `geo_maxmind_rejects_unused_metadata_nonstring_keys -- --ignored`: one expected failing test, `/tmp/geoip-known-metadata-blocker.log`. A real fixture process with `bad-metadata-key.mmdb` also reached readiness, `/tmp/geoip-known-metadata-hosting.log`. The default suite explicitly ignores this one regression; it is not a passing corruption check.
+2. **Fixed:** non-Unix builds advertised MaxMind capability although acquisition is unavailable. Pure validation now includes target support and returns `UnavailableMaxMind` before preparation. Non-Unix support itself remains unimplemented and requires scope acceptance or an acquisition implementation.
+
+Other unverified acceptance work includes representative production GeoLite preparation time/RSS, real read-only mount execution, unreadable-file permission denial, and preparer-specific mutation coverage. No managed Axum preparer hook is added; the existing portable immutable-outcome regressions and new native middleware/header/extension regressions pass. Existing permission-mode, FIFO/device, middleware/header/extension, startup-stop and real listener fixtures prove narrower claims only.
+
+One MaxMind-only hosting run failed when a child could not bind its ephemeral reserved address. The unchanged existing harness releases its reservation before child bind. The identical full suite passed on rerun; no production retry or arbitrary port policy was added. Retain this transient test-harness limitation in the PR evidence.
+
+Check | Tested state | Result | Elapsed | Evidence
+--- | --- | --- | --- | ---
+Baseline core | `72ceed7e`, before source edits | 8 passed | 17.64 s compile | `/tmp/geoip-baseline-core.log`
+Portable core | Typed input migration | 9 passed | 6.54 s compile | `/tmp/geoip-core-contract.log`
+Reader admission/managed dispatch | Axum only, no built-ins | 5 passed | 6.10 s compile | `/tmp/geoip-reader-managed.log`
+Combined native Geo | CloudFront + MaxMind, including ready-stop and corruption | 26 passed | 17.37 s compile, 0.09 s tests | `/tmp/geoip-combined-geo.log`
+Native all features | Before test-only lint cleanup | Passed, including 40 real-hosting tests | 54.204 s | `/tmp/geoip-native-all-features.log`
+MaxMind-only real hosting | After test-only lint cleanup | 12 passed on rerun after one bind race | 0.35 s tests | `/tmp/geoip-maxmind-hosting-rerun.log`
+Workspace Clippy | All targets/all features, after test-only lint cleanup | Passed | 1.242 s final run | `/tmp/geoip-workspace-clippy.log`
+Dependency contracts | Isolated normal graphs | Geo and native-store scripts passed | 1.532 / 3.112 s | `/tmp/geoip-dependency-contracts.log`
+Final workspace tests | Final Rust/test/build inputs | 2,121 passed, 6 existing ignored | 9.409 s | `/tmp/geoip-workspace-tests-final.log`
+Final native all features | All targets, final inputs | 447 passed, 1 known metadata regression ignored | 13.980 s | `/tmp/geoip-native-all-features-final.log`
+Final workspace Clippy | All targets/all features | Passed | 3.993 s | `/tmp/geoip-workspace-clippy-final.log`
+Isolated native matrix | `axum`, CloudFront, MaxMind, both | Units: 328 / 327 / 340 / 339 passed; reader lanes each ignore the known regression. Hosting: 9 / 8 / 12 / 11 passed. Seven doc tests and all-target Clippy pass in every lane | Per-command timings in logs | `/tmp/geoip-matrix-*-{units,hosting,docs,clippy}.log`
+Workspace platform features | `fastly cloudflare spin`, all targets | Passed | 16.667 s | `/tmp/geoip-workspace-feature-check.log`
+Minimal native / bindings | No defaults, then bindings-only | Both passed | 5.628 / 2.646 s | `/tmp/geoip-minimal-checks.log`
+WASM target compilation | Fastly wasip1 / Cloudflare unknown-unknown / Spin wasip2 | All passed, compile-only | 18.211 / 24.332 / 12.851 s | `/tmp/geoip-*-wasm-check.log`
+WASM runtime dispatch contracts | Viceroy 0.21.0 / wasm-bindgen runner 0.2.128 / Wasmtime 48.0.2 | Fastly 24 / Cloudflare 29 / Spin 35 passed | 14.531 / 24.740 / 10.418 s | `/tmp/geoip-{fastly,cloudflare,spin}-runtime.log`
+Workspace rustdoc | All features, no deps, warnings denied | Passed | 24.362 s | `/tmp/geoip-rustdoc.log`
+App-demo tests | Unchanged excluded-workspace lock, shared target directory | 54 passed | 50.013 s | `/tmp/geoip-demo-tests.log`
+Reusable native consumer | Unchanged excluded-workspace lock | Passed | 6.062 s | `/tmp/geoip-reusable-check.log`
+Consumer locked checks | Both excluded workspaces, offline/locked, shared target | Both passed; no lockfile updates needed | 26.357 / 0.163 s | `/tmp/geoip-consumer-lock-checks.log`
+Published docs | Final guide, lint / format / build | All passed | 1.950 / 1.577 / 3.730 s | `/tmp/geoip-docs-final.log`
+Synthetic fixtures | Original Apache-2.0 generator and 26 fixtures | Syntax/reproduction/checksums passed | Not timed | `node scripts/generate_geoip_fixtures.mjs --check`
+Formatting/static checks | Final source, workflows and internal documents | Rustfmt, whitespace, internal links/fences and actionlint passed; actionlint ShellCheck disabled | Not timed | Coordinator checks
+Known metadata defect | Explicit ignored regression and real process | Reproduced as expected failure; malformed metadata reaches readiness | Not timed | `/tmp/geoip-known-metadata-{blocker,hosting}.log`
+
+The Rust/runtime implementation is committed as `9dd7b83b`. Publication follow-ups update this plan and fix CI cache IDs only; Rust/runtime inputs are unchanged. Final Rust, crate-test, script, workflow and guide input aggregate SHA-256 is `c586481c33c350e05476a670a126d97d0e1dbe6e416b7d4783e4b3f2df2e9c75`, computed with `git ls-files -cm --others --exclude-standard -z -- crates Cargo.toml Cargo.lock scripts .github docs/guide | sort -zu | xargs -0 sha256sum | sha256sum`. Plan/spec evidence edits and the changelog are outside this aggregate. Toolchain: Rust/Cargo 1.95.0, Linux native; Node 24.12.0. Logs are local temporary evidence, not durable CI artifacts. The official Viceroy 0.21.0 archive SHA-256 is `52ed3c3ed59d926c81015d637509942c3195443c02c2eebbded9125484336a9e`; the installed 0.17.0 binary was not used for runtime proof.
+
+The coordinator inspected returned changes and ran repository-native integration. Bounded implementation workers owned the reader/fixtures, hosting fixtures/tests, and guide/CI respectively; no files overlapped. Their scratch/static results were not substituted for native tests. The independent review's known metadata blocker was reproduced both as an intentionally failing ignored regression and through a real runner. Its non-Unix capability finding was fixed; non-Unix runtime support remains unavailable.
+
+### First GitHub CI feedback
+
+The combined-feature job in run `41914870792` failed before compilation because Actions cache keys cannot contain commas. The matrix previously interpolated `geo-cloudfront,geo-maxmind` directly. Fixed the source by giving every lane a comma-free `cache_key` while keeping the real feature list unchanged. A focused old-versus-new matrix regression check, actionlint and scoped Prettier pass; evidence is `/tmp/geoip-ci-cache-{failure,regression}.log` and `/tmp/geoip-ci-static-final.log`. No Rust/runtime test inputs changed, so local suite results above remain applicable. GitHub checks for the follow-up commit remain pending, not claimed passing.
+
+### Work needed before acceptance
+
+- [ ] Obtain a strict metadata-verification/raw-decode fix in the reader, pin it, and prove the saved metadata fixture fails before initializer/bind/readiness. Remove the ignored regression only after it passes.
+- [ ] Resolve non-Unix support versus an explicitly accepted Unix-only delivery scope.
+- [ ] Measure a permitted representative City/Country artifact for compatibility, preparation time and peak memory under the documented limits.
+- [ ] Complete mount-level and permission-denied proof where required, retaining the distinction from the passing file-mode and source-independence tests.
+
+The draft is an implementation checkpoint, not completed #438 acceptance, consent compliance or production qualification.
diff --git a/docs/superpowers/specs/2026-11-04-axum-local-geoip-design.md b/docs/superpowers/specs/2026-11-04-axum-local-geoip-design.md
new file mode 100644
index 000000000..d1b5265a7
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-04-axum-local-geoip-design.md
@@ -0,0 +1,226 @@
+# Local GeoIP for Axum
+
+- Status: approved for implementation by the user. Implementation and outstanding acceptance gaps are tracked in the [implementation plan](../plans/2026-11-04-axum-local-geoip.md). No production acceptance is claimed.
+- Date: 2026-11-04
+- Owning issue: [EdgeZero #438](https://github.com/IABTechLab/edgezero/issues/438), under [#391](https://github.com/IABTechLab/edgezero/issues/391).
+- Baseline: `72ceed7e` on `feature/pluggable-geo-providers`.
+- Extends: [pluggable Geo providers](2026-10-22-pluggable-geo-providers-design.md), specifically its deferred reader-IP input and database lifecycle.
+- Uses: [native lifecycle ownership](2026-10-05-axum-production-startup-lifecycle-design.md), with the pre-runtime preparation limitation in section 6.
+
+## 1. Outcome and scope
+
+An ordinary native Axum deployment can resolve an estimated country and optional first-level subdivision from a checked reader IP, using an operator-supplied local database. CloudFront, AWS infrastructure, AWS credentials and paid data are not required.
+
+Website consent management motivates this work. EdgeZero supplies estimates, availability and provenance. Applications own jurisdiction recognition and permission decisions. Unknown geography must not mean "no consent requirements."
+
+Required invariants:
+
+- Geo stays off by default. A deployment explicitly selects at most one provider.
+- Local GeoIP, CloudFront and supplied providers share the existing request-outcome architecture, not each other's source-admission rules.
+- A selected provider never falls back to another source.
+- Database preparation succeeds before the application initializer, listener binding and readiness. Invalid selected configuration is a startup error, not Geo off.
+- Request lookup uses an immutable in-memory database and checked ingress facts. No request-time filesystem access, remote lookup or background work occurs.
+- The managed request owns one immutable Geo outcome, established after admission and before middleware and handlers.
+- Country-only, no-match, missing-input and error outcomes remain distinguishable.
+
+Scope includes the input contract, native provider, startup/replacement behavior, documentation and regression tests. No plugin framework, store binding, downloader, watcher, hot reload, remote lookup or consent engine is added. Trusted Server adoption remains downstream work.
+
+## 2. Database choice and cost
+
+### Initial supported dataset
+
+Use MMDB binary files with the Rust `maxminddb` reader. MMDB is a file format, GeoLite is a dataset, and `maxminddb` is a separately licensed library. No database server is needed.
+
+Support GeoLite2 City for country and first-level subdivision estimates, and GeoLite2 Country for country-only estimates. Both are free of charge. Consume only geographic codes, not city names, postal codes or coordinates.
+
+No paid license, subscription or paid upgrade is required. Operators downloading GeoLite data need a free MaxMind account and download license key. Download credentials stay in the operator's distribution process; the running application needs neither credentials nor an external lookup account.
+
+Document GeoLite's EULA, attribution, redistribution and update/deletion obligations separately from the reader's ISC license. The EULA reviewed on 2026-11-04 requires ceasing use of and destroying old data within 30 days of a new release, including running snapshots and rollback copies. Recheck terms before publishing instructions.
+
+MaxMind does not recommend GeoLite City for commercial use cases because of accuracy limitations, not a blanket commercial-use prohibition. Applications must evaluate state-level suitability against representative traffic.
+
+### Alternatives and schema boundary
+
+DB-IP City Lite is a free, monthly MMDB alternative under CC BY 4.0. It needs separate code-mapping/schema evaluation before support; MMDB format compatibility is insufficient. Other formats, including IP2Location BIN, also need separate integrations. Neither is included here.
+
+Accept metadata `database_type` values `GeoLite2-City` and `GeoLite2-Country`, with `ip_version` 4 or 6. Reject other types regardless of filename. Metadata identifies the expected schema, not publisher authenticity. Use synthetic or upstream-derived fixtures matching these types, with provenance and redistribution terms; never bundle production GeoLite data.
+
+## 3. Ownership and flow
+
+| Concern | Owner |
+| ------------------------------------------------------------------------------ | ------------------------------- |
+| Socket peer, trusted proxy configuration, forwarding parsing and normalization | Existing native checked ingress |
+| Source-specific Geo inputs, selection and outcome provenance | Native Geo composition |
+| Read-only snapshot loading, schema validation and bounded lookup | Local GeoIP implementation |
+| Portable values, outcome types and `RequestContext::geo()` | Core |
+| Downloading, licensing, attribution, distribution, freshness and replacement | Deployment operator |
+| Jurisdiction recognition, consent, permission and response disclosure | Application |
+
+```mermaid
+flowchart TD
+ Options[Explicit native Geo selection] --> Validate[Validate selection and feature availability]
+ Validate -->|MaxMind only| Load[Read and validate database before startup completes]
+ Load --> Snapshot[Shared immutable in-memory reader]
+ Request[Incoming request] --> Checked[Existing checked ingress normalization]
+ Checked --> Admission{Request admitted?}
+ Admission -->|No| Stop[No Geo lookup]
+ Admission -->|Yes| Input{Selected source}
+ Input -->|MaxMind or supplied reader-IP| Reader{Checked reader available?}
+ Input -->|CloudFront or supplied assertions| Assertions[Protected assertion admission and resolution]
+ Input -->|Off| Off[NoSelection]
+ Reader -->|Yes| Lookup[One selected provider call]
+ Reader -->|No| Unavailable[Unavailable reader IP]
+ Snapshot --> Lookup
+ Lookup --> Outcome[Immutable Geo outcome]
+ Assertions --> Outcome
+ Unavailable --> Outcome
+ Off --> Outcome
+ Outcome --> App[Application middleware and handlers]
+```
+
+## 4. Checked reader-IP contract
+
+Reuse `CheckedProxyMetadata` from `proxy.rs`. Do not add a forwarding parser, interpret raw forwarding headers in a provider, or obtain trusted input by reading caller-mutable request extensions.
+
+Capture the needed Geo input from the runner-owned checked result before moving that result into public ingress metadata. Resolve only after application ingress admission succeeds. Refused, aborted or draining requests invoke no provider.
+
+### Direct and proxied deployments
+
+Selection explicitly chooses `PeerAllowed` or `ForwardedRequired`, an input expectation rather than a forwarding policy. `PeerAllowed` declares that the socket peer is a usable reader if forwarding does not resolve one. Reverse proxies use `ForwardedRequired` to avoid geolocating the proxy.
+
+| Checked ingress facts | `PeerAllowed` | `ForwardedRequired` |
+| ------------------------------------------------------------------------------- | -------------------------------------- | -------------------------------------- |
+| Client source is trusted `Forwarded` or `X-Forwarded`, with an effective client | Use that checked client and its source | Use that checked client and its source |
+| Client source is direct, with an actual peer | Use the normalized peer | Unavailable reader IP |
+| No actual peer or no usable checked address | Unavailable reader IP | Unavailable reader IP |
+
+Unavailable input invokes no provider. Checked ingress initializes `effective_client()` from the peer, so address presence alone cannot prove reader resolution. Use `client_source()`, not an aggregate forwarding-success flag. A partial result that resolved the client remains usable even if host or scheme was absent.
+
+Local GeoIP never uses geographic headers. Existing ingress rules alone decide which forwarding assertions are trusted. It does not require `ProtectedCloudFrontAlb`, origin credentials or CloudFront headers.
+
+Retain existing normalization, including IPv4-mapped IPv6. Do not substitute addresses or invent private-address jurisdictions. Missing records remain no match.
+
+### Portable provider input
+
+Extend `GeoInput` to a typed input enum with two cases, using final names selected during implementation:
+
+- Geographic assertions: the existing bounded country and subdivision slots, with their absent/value/invalid states unchanged.
+- Reader IP: a typed `IpAddr` plus portable source provenance identifying direct peer, trusted `Forwarded`, or trusted `X-Forwarded` resolution.
+
+Missing/inadmissible readers produce a framework-owned unavailable outcome, never a fabricated address. Input carries no request, arbitrary headers, cookies, consent signals, service handles or filesystem access. The adapter owns admission and output provenance.
+
+Keep `GeoProvider::resolve` synchronous, bounded and local-only, returning an owned `GeoResolution`. Native ownership adds `Send + Sync`; the portable trait retains its existing bounds. An input case unsupported by a provider returns `Error(ProviderFailure)` without retrying another case. Supplied Rust remains trusted application code, not sandboxed code.
+
+`GeoInput` changes, public enum additions and a reserved provider ID break external consumers, including exhaustive matches. Inventory consumers, migrate repository code/examples together, and document release/migration handling.
+
+## 5. Selection, capability and extensibility
+
+Extend `NativeGeoSelection` with a local MaxMind selection containing the database path, preparation limits and reader expectation. Use the default-off native feature `geo-maxmind`. The selector and its configuration types remain available without the feature; selecting an unavailable implementation returns a bounded startup error.
+
+Keep programmatic selection through `AxumRunOptions`. There are no new CLI, environment or manifest selectors, automatic path discovery, or ambient dataset choices. Repeated `with_geo` calls continue to replace the entire selection.
+
+Preserve CloudFront and supplied-assertion composition with its protected-source policy. Add application-constructed supplied reader-IP composition using the same checked-IP admission contract as MaxMind. It must work with only `axum`, without built-in Geo features. Selection declares the input contract; do not infer it from provider IDs or probe different inputs.
+
+Reserve provider ID `maxmind` alongside `cloudfront` in public ID construction and native supplied-provider validation. Located MaxMind results identify a local database; supplied reader-IP results identify checked-IP input without claiming a database implementation. Both retain direct, trusted `Forwarded` or trusted `X-Forwarded` provenance, never the address.
+
+`geo_capability()` reports provider identity, input contract and configured reader expectation. It validates configuration without file I/O or preparation, and cannot report eventual request provenance. Split the current `native_geo()` validation/construction path accordingly. Loaded metadata belongs to the prepared provider, outside `PreparedStores` and portable registries.
+
+## 6. Preparation, resource ownership and replacement
+
+### Preparation boundary
+
+Prepare the database once per runner invocation at the existing native Geo construction boundary, before serving resources and application initialization. Keep pure selection validation separate. CloudFront requires no preparation.
+
+1. Validate selection, feature availability and finite preparation limits before reading the file.
+2. Open the operator-supplied local regular file read-only. Bound the actual read, not merely a prior metadata size check; reject pipes and devices without waiting for their contents. Do not create files or discover a different path.
+3. Build an owned in-memory reader from those bytes, without mmap or `unsafe-str-decode`.
+4. Validate metadata, structure and all reachable records, including off-path corruption. Check consumed country/subdivision field shapes; allow optional and unrelated fields. Reader construction alone is insufficient. Reuse its verifier and iteration APIs, not another MMDB parser.
+5. Record dataset type, build timestamp and a SHA-256 checksum of the loaded bytes. These identify the snapshot, not its accuracy or authenticity.
+6. Share the prepared immutable reader across requests through native ownership. Return a bounded error on failure before the initializer, bind or readiness.
+
+Structural corruption and incompatible field types fail startup. Geographic code validation remains request-time behavior from section 7. For example, a structurally valid record containing `XK` must not prevent the entire database from loading; a lookup of that record returns `InvalidAssertion`.
+
+Document the file-byte bound and traversal/allocation bounds for validation and lookup. Select concrete limits from the pinned reader's guarantees and representative supported files, and record their rationale in the implementation plan. Exceeding a limit fails preparation, never truncates the file or skips validation.
+
+Preparation is synchronous and pre-runtime, before signal registration and embedding-stop polling. The runner's cooperative startup-cancellation contract does not cover this phase; an early process signal follows the caller/OS signal disposition. Byte/work limits cannot bound filesystem I/O time. Add no detached task or cosmetic async timeout. Measure preparation time and peak memory on representative data and document this exception.
+
+### Lifetime and replacement
+
+The runner owns the reader; request/connection clones share it until teardown. Preparation failures drop partial state without creating files or requiring recovery. Startup retries repeat a read-only operation.
+
+Operators publish a complete replacement, update the file reference/mount and restart. Never rewrite a file in place during preparation. Active processes retain their snapshot after source changes/deletion. New processes validate the replacement before readiness; invalid replacements fail startup without substituting an older file.
+
+No update loop, live swap, update lock, persistence or database-specific shutdown hook is added. Operators may roll back only to data still permitted by its terms.
+
+## 7. Lookup and outcome semantics
+
+Extend resolution with an explicit no-match result and framework status with missing-reader-IP and no-match reasons. These must not collapse into CloudFront's missing-assertion outcome. Preserve selected provider identity in every selected outcome.
+
+| Situation | Outcome |
+| ------------------------------------------------------------------------------------ | ------------------------------------------------ |
+| Geo off | Existing `NoSelection` |
+| Selected input expectation cannot admit a reader IP | Unavailable reader IP, no provider call |
+| No record, no geographic codes, or IPv6 lookup against an IPv4-only database | Unavailable no match |
+| Valid country with no subdivision | Located, country-only |
+| Valid country and first-level subdivision | Located, country and subdivision assertion |
+| Syntax-valid but application-unrecognized subdivision | Located; application recognition remains unknown |
+| Malformed/unsupported country, malformed subdivision, or subdivision without country | Error, invalid geographic assertion |
+| Unexpected decode/reader failure after successful preparation | Error, provider failure |
+| CloudFront source not admitted or assertions absent/invalid | Existing CloudFront outcomes unchanged |
+
+Read `country.iso_code`, never registered/represented country as substitutes. For City records, use only `subdivisions[0].iso_code`; the schema orders entries largest to smallest. A missing/empty array or missing first-entry code means unknown subdivision, even if later entries have codes. Never promote a later district or infer codes from names. Country records are country-only. Decode only the geographic projection; structural verification may inspect unrelated fields.
+
+Use existing `CountryCode`, `SubdivisionCode` and `GeoLocation` validation. Preserve leading zeroes and normalize code case. Absent code fields are unknown; present empty or malformed strings are invalid. Unsupported country codes, including `XK`, remain errors without alias substitution. Never discard an invalid subdivision into country-only success. A record with neither selected code is no match; subdivision without country is an error.
+
+Provenance describes an estimate, not authenticated residence. Outcomes retain no IP, raw record or vendor error chain. Never resolve again during finalization. Unmanaged conversion and non-Axum adapters retain `NoSelection`.
+
+## 8. Deployment and privacy guidance
+
+Provide generic Axum examples with a read-only GeoLite City mount, explicit Rust selection and no stores/AWS/CloudFront dependencies. Cover direct `PeerAllowed` and reverse-proxy `ForwardedRequired` deployments using existing proxy trust. Explain that VPNs and intermediaries can make estimates unsuitable for jurisdiction decisions.
+
+Document external download, artifact verification, complete publication and restart. Record dataset version/build timestamp, checksum, source and terms. Keep download keys out of options, logs and images. Startup requires no writable config/data root or sidecar files.
+
+After logging initialization, emit a bounded native startup record with the loaded type, build timestamp and checksum. Earlier preparation failures return source-free error categories. New errors and input/selection `Debug` output redact IPs and paths. Expose no raw records, download keys, vendor error chains, diagnostic endpoint or automatic response header.
+
+Freshness remains the operator's obligation. Neither parsing nor file modification time proves it. No age threshold changes permission behavior or selects another source. Include attribution and update/deletion duties in the deployment checklist.
+
+Applications must preserve uncertainty, prevent cross-visitor shared caching of permission-sensitive responses and retain existing fail-closed behavior. Cache configuration, consent-cookie interpretation and real consent-policy tests remain downstream work.
+
+## 9. Acceptance and delivery checkpoints
+
+The input/lifecycle contract is reviewed before implementation. Deliver the following independently checkable units, reusing passing results while their inputs remain unchanged:
+
+| Unit | Required proof |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Portable contract | Typed inputs, no-match/missing-reader outcomes, assertion validation, wrong-input behavior, reserved IDs and consumer migration compile and test |
+| Checked-IP admission | Both reader modes with IPv4/IPv6, mapped addresses, both trusted forwarding families, partial client resolution, absent/malformed/unresolved chains, untrusted/missing peer and forged headers/extensions |
+| Local reader | City/Country fixtures cover numeric and unknown subdivisions, missing first-entry code with a later code, absent/empty/invalid codes, unsupported country codes, no match and IPv6 against IPv4-only data |
+| Startup preparation | Missing/unreadable/empty/truncated/oversized files, nonregular sources, unsupported metadata/schema, validation budget exhaustion and off-path corruption fail before initializer, bind and readiness; unsupported code strings alone do not fail preparation |
+| Feature isolation | Test `axum` alone, each built-in separately and both together; unavailable selection fails startup, supplied reader-IP works without built-ins, off and CloudFront behavior stay unchanged |
+| Real Axum hosting | One provider call per admitted request with admissible selected input, including no-match results; zero for off/inadmissible-input/refused/aborted/draining paths; immutable outcomes across preparers/middleware/handlers; no finalization lookup |
+| Read-only lifetime | Read-only file/mount starts without sidecar writes; changing/removing the source does not change active results; restart loads a valid new snapshot and rejects an invalid replacement |
+| Boundaries and lifecycle | Native startup/shutdown and WASM dependency regressions pass; after preparation, an already-requested embedding stop exits without initializer/bind/readiness; reader/filesystem dependencies stay native and default-off |
+| Documentation and operations | Public Rust examples compile; mount/update/attribution instructions name supported schemas and terms; representative preparation time/memory and remaining accuracy gaps are recorded |
+
+Exercise startup failures through the real runner. Remove the loaded source and issue more requests to prove file independence. Assert loaded diagnostic values and redaction. Repeated capability inspection must perform no file I/O, even with a missing path.
+
+Extend `scripts/check_geo_dependency_contract.sh` and the existing CI feature matrix. Use focused tests, then native integration, formatting, lint, docs and affected adapter checks at the stable checkpoint. Report runtime evidence separately from compilation.
+
+Before implementation, the implementation plan must settle the remaining decisions:
+
+- Pin the reader version and confirm its verifier/decoder work bounds. Version 0.32.0 was inspected for this review; dependency compilation and representative-dataset measurements are not yet done.
+- Set preparation limits and confirm the pre-runtime stop-observation exception is acceptable.
+- Finalize Rust input/provenance/capability shapes and record the public API migration/release handling.
+
+These are explicit implementation gates, not claims of completed validation. DB-IP support and downstream consent-policy tests remain separate.
+
+## 10. Sources
+
+Database format and reader behavior are distinct from dataset licensing and accuracy. Recheck these sources when pinning implementation dependencies and publishing operator guidance:
+
+- [Rust `maxminddb` 0.32.0 reader](https://docs.rs/maxminddb/0.32.0/maxminddb/struct.Reader.html): owned in-memory construction, lookup, iteration and validation APIs; reader library uses the ISC license.
+- [MaxMind City/Country database documentation](https://dev.maxmind.com/geoip/docs/databases/city-and-country/) and [City binary fields](https://dev.maxmind.com/geoip/docs/databases/city-and-country/city-binary/): code semantics, optional fields and subdivision ordering.
+- [GeoLite integration and obligations](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/): free datasets, account/download keys, accuracy guidance and update/deletion obligations.
+- [GeoLite EULA](https://www.maxmind.com/en/geolite/eula) and [external use/attribution guidance](https://support.maxmind.com/knowledge-base/articles/sell-or-display-data-from-geolite-databases-and-web-services): dataset terms, reviewed 2026-11-04 and to be rechecked before distribution.
+- [MaxMind database updates](https://dev.maxmind.com/geoip/updating-databases/): operator-owned update process.
+- [DB-IP Lite](https://db-ip.com/db/lite.php): alternative MMDB datasets, CC BY 4.0 attribution and monthly releases.
diff --git a/scripts/check_geo_dependency_contract.sh b/scripts/check_geo_dependency_contract.sh
index 2b3f89baa..de0a2a145 100755
--- a/scripts/check_geo_dependency_contract.sh
+++ b/scripts/check_geo_dependency_contract.sh
@@ -38,11 +38,13 @@ assert_feature() {
fi
}
-# The implementation must be default-off and add no normal runtime dependency.
+# CloudFront adds no dependencies; the local reader must remain native opt-in.
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
+graph native-maxmind edgezero-adapter-axum --no-default-features --features geo-maxmind
+graph native-both edgezero-adapter-axum --no-default-features --features geo-cloudfront,geo-maxmind
+for name in native-default native-off native-on native-maxmind native-both; do
assert_feature "$name" axum on
if grep -Eq '^aws-(config|sdk-[^ ]+) v' "$scratch/$name"; then
fail "AWS SDK leaked into $name"
@@ -51,21 +53,53 @@ 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"
+assert_feature native-maxmind geo-cloudfront off
+assert_feature native-both geo-cloudfront on
+for name in native-default native-off native-on; do
+ assert_feature "$name" geo-maxmind off
+ if grep -Eq '^maxminddb v' "$scratch/$name"; then
+ fail "MaxMind reader leaked into $name"
+ fi
+done
+for name in native-maxmind native-both; do
+ assert_feature "$name" geo-maxmind on
+ grep -Eq '^maxminddb v0[.]32[.]0[|]' "$scratch/$name" \
+ || fail "missing pinned maxminddb 0.32.0 in $name"
+ grep -Eq '^sha2 v[^|]+[|]' "$scratch/$name" \
+ || fail "missing snapshot SHA-256 dependency in $name"
+ if grep -Eq '^memmap2 v' "$scratch/$name"; then
+ fail "memory mapping dependency selected in $name"
+ fi
+ if ! awk -F '|' '
+ /^maxminddb v/ {
+ sub(/ [(][*][)]$/, "", $2)
+ count = split($2, selected, ",")
+ for (i = 1; i <= count; i++)
+ if (selected[i] ~ /^(default|mmap|unsafe-str-decode)$/) exit 1
+ }
+ ' "$scratch/$name"; then
+ fail "unsafe or default reader features selected in $name"
+ fi
+done
+# sha2 may already be present transitively in feature-off native graphs.
+for pair in 'native-off native-on' 'native-maxmind native-both'; do
+ read -r baseline cloudfront <<< "$pair"
+ for name in "$baseline" "$cloudfront"; 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/$baseline.packages" "$scratch/$cloudfront.packages"; then
+ diff -u "$scratch/$baseline.packages" "$scratch/$cloudfront.packages" >&2 || true
+ fail "geo-cloudfront changed the isolated normal dependency set in $cloudfront"
+ fi
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
+ if grep -Eq '^(edgezero-adapter-axum|edgezero-store-(aws|celld)|axum(-[^ ]+)?|tokio-[^ ]+|reqwest|hyper(-[^ ]+)?|mio|socket2|ipnet|maxminddb|memmap2|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
@@ -95,4 +129,4 @@ for entry in 'cloudflare wasm32-unknown-unknown worker' 'fastly wasm32-wasip1 fa
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'
+printf 'Geo default-off, CloudFront dependency equality, local reader opt-in, and portable isolation graphs passed.\n'
diff --git a/scripts/generate_geoip_fixtures.mjs b/scripts/generate_geoip_fixtures.mjs
new file mode 100644
index 000000000..fb3f756f2
--- /dev/null
+++ b/scripts/generate_geoip_fixtures.mjs
@@ -0,0 +1,218 @@
+#!/usr/bin/env node
+// Original synthetic MMDB encoder. No vendor database or third-party data.
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { createHash } from 'node:crypto';
+
+const directory = fileURLToPath(new URL('../crates/edgezero-adapter-axum/tests/fixtures/geoip/', import.meta.url));
+const check = process.argv.includes('--check');
+const fixtures = new Map();
+const join = (...parts) => Buffer.concat(parts.flat().map(part => Buffer.from(typeof part === 'number' ? [part] : part)));
+function header(type, size) {
+ const extra = [];
+ let encoded = size;
+ if (size >= 65821) { encoded = 31; extra.push((size - 65821) >> 16, (size - 65821) >> 8, size - 65821); }
+ else if (size >= 285) { encoded = 30; extra.push((size - 285) >> 8, size - 285); }
+ else if (size >= 29) { encoded = 29; extra.push(size - 29); }
+ return Buffer.from(type < 8 ? [(type << 5) | encoded, ...extra] : [encoded, type - 7, ...extra]);
+}
+const string = value => join(header(2, Buffer.byteLength(value)), Buffer.from(value));
+function uint(type, value) {
+ let hex = BigInt(value).toString(16);
+ if (hex.length % 2) hex = `0${hex}`;
+ const bytes = value === 0 ? Buffer.alloc(0) : Buffer.from(hex, 'hex');
+ return join(header(type, bytes.length), bytes);
+}
+const map = entries => join(header(7, entries.length), entries.map(([key, value]) => join(typeof key === 'string' ? string(key) : key, value)));
+const array = entries => join(header(11, entries.length), entries);
+const pointer = offset => join([0x38], uint32(offset));
+function uint32(value) { const bytes = Buffer.alloc(4); bytes.writeUInt32BE(value); return bytes; }
+const code = value => map([['iso_code', string(value)]]);
+const country = value => map([['country', code(value)]]);
+const city = (c, s) => map([['country', code(c)], ['subdivisions', array([code(s)])]]);
+
+function addressBits(address, version) {
+ let value;
+ if (address.includes('.')) {
+ value = address.split('.').reduce((n, octet) => (n << 8n) | BigInt(octet), 0n);
+ } else {
+ const [left, right] = address.split('::');
+ const a = left ? left.split(':') : [];
+ const b = right ? right.split(':') : [];
+ const words = right === undefined ? a : [...a, ...Array(8 - a.length - b.length).fill('0'), ...b];
+ value = words.reduce((n, word) => (n << 16n) | BigInt(`0x${word}`), 0n);
+ }
+ return value.toString(2).padStart(version === 4 ? 32 : 128, '0');
+}
+
+function database(entries, { type = 'GeoLite2-City', version = 4, recordSize = 24, metadataExtra = [], metadataWorkLimit = false, epoch = 1793750400 } = {}) {
+ const root = {};
+ for (const [cidr, record] of entries) {
+ const [address, prefix] = cidr.split('/');
+ const bits = addressBits(address, version).slice(0, Number(prefix));
+ let node = root;
+ for (const bit of bits) node = node[bit] ??= {};
+ node.record = record;
+ }
+ const nodes = [];
+ function collect(node) {
+ if (!node || node.record !== undefined) return;
+ node.index = nodes.length;
+ nodes.push(node);
+ collect(node[0]); collect(node[1]);
+ }
+ collect(root);
+ const data = [];
+ const offsets = new Map();
+ function child(node) {
+ if (!node) return nodes.length;
+ if (node.record === undefined) return node.index;
+ if (!offsets.has(node.record)) {
+ const offset = data.reduce((sum, bytes) => sum + bytes.length, 0);
+ offsets.set(node.record, offset);
+ data.push(typeof node.record === 'function' ? node.record(offset) : node.record);
+ }
+ return nodes.length + 16 + offsets.get(node.record);
+ }
+ const tree = nodes.map(node => {
+ const left = child(node[0]); const right = child(node[1]);
+ if (recordSize === 24) return join(uint32(left).subarray(1), uint32(right).subarray(1));
+ if (recordSize === 28) return join(uint32(left & 0xffffff).subarray(1), [((left >>> 24) << 4) | (right >>> 24)], uint32(right & 0xffffff).subarray(1));
+ return join(uint32(left), uint32(right));
+ });
+ const metadataEntries = [
+ ['binary_format_major_version', uint(5, 2)], ['binary_format_minor_version', uint(5, 0)],
+ ['build_epoch', uint(9, epoch)], ['database_type', string(type)],
+ ['description', map([['en', string('Original synthetic EdgeZero test data')]])],
+ ['ip_version', uint(5, version)], ['languages', array([string('en')])],
+ ['node_count', uint(6, nodes.length)], ['record_size', uint(5, recordSize)], ...metadataExtra,
+ ];
+ let metadata;
+ if (metadataWorkLimit) {
+ const count = 100;
+ const placeholder = array(Array.from({ length: count }, () => pointer(0)));
+ const start = map([...metadataEntries, ['unknown', placeholder]]).length;
+ metadata = join(
+ map([...metadataEntries, ['unknown', array(Array.from({ length: count }, (_, i) => pointer(start + i * 4)))]]),
+ Array.from({ length: count }, () => header(2, 65821)), Buffer.alloc(65821, 120),
+ );
+ } else metadata = map(metadataEntries);
+ return join(tree, Buffer.alloc(16), data, Buffer.from('\xab\xcd\xefMaxMind.com', 'latin1'), metadata);
+}
+const add = (name, entries, options) => fixtures.set(`${name}.mmdb`, database(entries, options));
+const cityEntries = [
+ ['127.0.0.0/8', city('US', 'CA')],
+ ['192.0.2.1/32', city('us', 'ca')],
+ ['192.0.2.2/32', country('GB')],
+ ['192.0.2.3/32', city('JP', '01')],
+ ['192.0.2.4/32', city('US', 'zz')],
+ ['192.0.2.5/32', map([['country', code('US')], ['subdivisions', array([map([]), code('NY')])]])],
+ ['192.0.2.6/32', map([['country', code('US')], ['subdivisions', array([])]])],
+ ['192.0.2.7/32', map([])],
+ ['192.0.2.8/32', country('XK')],
+ ['192.0.2.9/32', country('')],
+ ['192.0.2.10/32', city('US', '')],
+ ['192.0.2.11/32', city('US', 'US-CA')],
+ ['192.0.2.12/32', map([['subdivisions', array([code('CA')])]])],
+ ['192.0.2.13/32', map([['registered_country', code('US')], ['represented_country', code('US')]])],
+ ['192.0.2.14/32', map([['country', map([])]])],
+ ['192.0.2.15/32', map([['country', code('US')], ['country', code('GB')]])],
+ ['192.0.2.16/32', map([['country', code('US')], ['subdivisions', array([code('CA'), map([['iso_code', uint(5, 1)]])])]])],
+ ['192.0.2.17/32', city('US', 'a1B')],
+ ['192.0.2.18/32', country('UK')],
+ ['192.0.2.19/32', country(' U')],
+ ['192.0.2.20/32', city('US', 'é')],
+];
+add('city', cityEntries);
+add('city-replacement', [['127.0.0.0/8', city('US', 'NY')], ['192.0.2.1/32', city('US', 'NY')]], { epoch: 1793836800 });
+add('country', [['127.0.0.0/8', country('US')], ['192.0.2.1/32', map([['country', code('US')], ['subdivisions', uint(5, 1)]])]], { type: 'GeoLite2-Country' });
+// IPv4 embedding uses hexadecimal words in the IPv6 fixture.
+add('ipv6', [['::c000:201/128', city('US', 'CA')], ['2001:db8::/32', city('GB', 'ENG')]], { version: 6 });
+for (const recordSize of [28, 32]) add(`record-${recordSize}`, [['192.0.2.0/24', city('US', 'CA')]], { recordSize });
+for (const [name, record] of [
+ ['bad-country-parent', map([['country', string('US')]])],
+ ['bad-country-code', map([['country', map([['iso_code', uint(5, 1)]])]])],
+ ['bad-subdivisions-parent', map([['country', code('US')], ['subdivisions', string('CA')]])],
+ ['bad-first-subdivision', map([['country', code('US')], ['subdivisions', array([string('CA')])]])],
+ ['bad-subdivision-code', map([['country', code('US')], ['subdivisions', array([map([['iso_code', uint(5, 1)]])])]])],
+ ['bad-unrelated-key', map([['country', code('US')], ['unrelated', map([[uint(5, 0), string('x')]])]])],
+ ['bad-offpath-pointer', pointer(0xffffffff)],
+]) add(name, [['192.0.2.1/32', city('US', 'CA')], ['203.0.113.1/32', record]]);
+// The unknown field points to a pointer, which points to a valid string.
+// Upstream verify accepts this; normal deserialize_any rejects the chain.
+add('bad-unrelated-chain', [['192.0.2.0/24', offset => {
+ const base = map([['country', code('US')], ['unrelated', pointer(0)]]);
+ const target = offset + base.length;
+ return join(map([['country', code('US')], ['unrelated', pointer(target)]]), pointer(target + 5), string('x'));
+}]]);
+add('bad-root-chain', [['192.0.2.0/24', offset => join(pointer(offset + 5), pointer(offset + 10), country('US'))]]);
+add('unsupported', [['192.0.2.0/24', country('US')]], { type: 'Other-City' });
+const cyclicTree = database([['192.0.2.0/24', country('US')]]);
+cyclicTree.fill(0, 0, 6);
+fixtures.set('bad-tree-cycle.mmdb', cyclicTree);
+const badSeparator = database([['192.0.2.0/24', country('US')]]);
+badSeparator[24 * 6] = 1;
+fixtures.set('bad-separator.mmdb', badSeparator);
+fixtures.set('truncated.mmdb', database([['192.0.2.0/24', country('US')]]).subarray(0, -10));
+add('bad-metadata', [['192.0.2.0/24', country('US')]], { metadataExtra: [['unknown', pointer(0xffffffff)]] });
+// Known upstream metadata-verifier gap. Retained for the ignored acceptance regression.
+add('bad-metadata-key', [['192.0.2.0/24', country('US')]], { metadataExtra: [['unknown', map([[uint(5, 0), string('x')]])]] });
+// Structural expansion exhaustion. Tiny shared arrays recursively double,
+// passing memoized verification but exceeding the normal decoder value budget.
+add('expansion-limit', [['192.0.2.0/24', offset => {
+ const root = map([['country', code('US')], ['unrelated', pointer(0)]]);
+ const parts = [null];
+ let current = offset + root.length;
+ for (let depth = 0; depth < 17; depth++) {
+ const next = current + header(11, 2).length + 10;
+ parts.push(join(header(11, 2), pointer(next), pointer(next)));
+ current = next;
+ }
+ parts[0] = map([['country', code('US')], ['unrelated', pointer(offset + root.length)]]);
+ parts.push(string('x'));
+ return join(parts);
+}]]);
+// Every normal MMDB scalar type plus ordinary single pointers remains valid.
+add('all-values', [['192.0.2.0/24', offset => {
+ const fields = [
+ ['country', code('US')], ['bytes', join(header(4, 2), [0xff, 0x00])],
+ ['u16', uint(5, 1)], ['u32', uint(6, 2)], ['u64', uint(9, 3)], ['u128', uint(10, 4)],
+ ['i32', join(header(8, 4), [255, 255, 255, 255])],
+ ['bool', header(14, 1)], ['double', join(header(3, 8), Buffer.alloc(8))],
+ ['float', join(header(15, 4), Buffer.alloc(4))], ['pointer', pointer(0)],
+ ];
+ const root = map(fields);
+ fields[fields.length - 1][1] = pointer(offset + root.length);
+ return join(map(fields), map([['nested', array([string('hello')])]]));
+}]]);
+// Overlapping valid string targets exhaust verifier work rather than allocating
+// their expanded contents. Each ASCII header is itself valid UTF-8 payload.
+add('verification-limit', [['192.0.2.0/24', offset => {
+ const count = 100;
+ const initial = map([['country', code('US')], ['unrelated', array(Array.from({ length: count }, () => pointer(0)))]]);
+ const start = offset + initial.length;
+ const root = map([['country', code('US')], ['unrelated', array(Array.from({ length: count }, (_, i) => pointer(start + i * 4)))]]);
+ return join(root, Array.from({ length: count }, () => header(2, 65821)), Buffer.alloc(65821, 120));
+}]]);
+add('metadata-verification-limit', [['192.0.2.0/24', country('US')]], { metadataWorkLimit: true });
+// An invalid UTF-8 scalar in an unconsumed record proves off-path verification.
+add('bad-offpath-utf8', [['192.0.2.1/32', country('US')], ['203.0.113.1/32', map([['unrelated', join(header(2, 1), [255])]])]]);
+
+await mkdir(directory, { recursive: true });
+const hashes = [];
+for (const [name, bytes] of fixtures) {
+ if (check) {
+ if (!(await readFile(`${directory}${name}`)).equals(bytes)) throw new Error(`Fixture differs: ${name}`);
+ } else await writeFile(`${directory}${name}`, bytes);
+ hashes.push(`${createHash('sha256').update(bytes).digest('hex')} ${name}`);
+}
+const sums = `${hashes.join('\n')}\n`;
+if (check) {
+ if ((await readFile(`${directory}SHA256SUMS`, 'utf8')) !== sums) throw new Error('SHA256SUMS differs');
+ const existing = (await readdir(directory)).filter(name => name.endsWith('.mmdb'));
+ if (existing.some(name => !fixtures.has(name))) throw new Error('Unexpected fixture file');
+ console.log(`Verified ${fixtures.size} original fixtures and checksums`);
+} else {
+ await writeFile(`${directory}SHA256SUMS`, sums);
+ console.log(`Wrote ${fixtures.size} original fixtures and checksums`);
+}
|