From 3bfa77c63573cf42514fd9b7001990f6a4ec7fb6 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 18:44:44 -0600 Subject: [PATCH] Initialize native TS from prepared immutable stores --- Cargo.lock | 475 ++++++++++- crates/trusted-server-adapter-axum/Cargo.toml | 8 + crates/trusted-server-adapter-axum/src/app.rs | 247 +++--- crates/trusted-server-adapter-axum/src/lib.rs | 11 +- .../trusted-server-adapter-axum/src/main.rs | 51 +- .../src/middleware.rs | 65 +- .../src/platform.rs | 462 ++++------- .../src/startup.rs | 425 ++++++++++ .../tests/fixtures/native/README.md | 83 ++ .../fixtures/native/nextjs/app/actions.js | 10 + .../native/nextjs/app/api/data/route.js | 3 + .../fixtures/native/nextjs/app/layout.jsx | 7 + .../tests/fixtures/native/nextjs/app/page.jsx | 13 + .../fixtures/native/nextjs/next.config.mjs | 6 + .../tests/fixtures/native/origin.rs | 148 ++++ .../tests/fixtures/native/proxy.rs | 137 ++++ .../tests/native_acceptance.rs | 735 ++++++++++++++++++ .../tests/native_http.rs | 415 ++++++++++ .../tests/startup.rs | 319 ++++++++ .../trusted-server-core/src/config_payload.rs | 154 +++- crates/trusted-server-core/src/http_util.rs | 15 +- .../src/platform/outbound.rs | 28 +- .../src/platform/outbound_native_tests.rs | 4 +- .../src/secret_resolution.rs | 149 ++-- crates/trusted-server-core/src/settings.rs | 8 + docs/guide/native-rc-demo.md | 144 ++++ ...10-20-edgezero-431-compatibility-design.md | 230 ++++++ scripts/test-native-providers.mjs | 638 +++++++++++++++ scripts/tests/test-native-providers.test.mjs | 121 +++ 29 files changed, 4528 insertions(+), 583 deletions(-) create mode 100644 crates/trusted-server-adapter-axum/src/startup.rs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/README.md create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs create mode 100644 crates/trusted-server-adapter-axum/tests/native_acceptance.rs create mode 100644 crates/trusted-server-adapter-axum/tests/native_http.rs create mode 100644 crates/trusted-server-adapter-axum/tests/startup.rs create mode 100644 docs/guide/native-rc-demo.md create mode 100644 docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md create mode 100644 scripts/test-native-providers.mjs create mode 100644 scripts/tests/test-native-providers.test.mjs diff --git a/Cargo.lock b/Cargo.lock index fe78f03fd3..5ac97a1795 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -311,6 +354,355 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832dc9d5dbd19c0ac6b24e21ef9f3e553695d0f3928de7134567407b3b7a447d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http", + "percent-encoding", + "sha2 0.11.1", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http", +] + +[[package]] +name = "aws-smithy-types" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http", + "http-body", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -381,6 +773,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -583,6 +985,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cast" version = "0.3.0" @@ -1601,7 +2013,7 @@ dependencies = [ "tempfile", "toml_edit 0.25.12+spec-1.1.0", "walkdir", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker", ] @@ -1753,9 +2165,19 @@ name = "edgezero-store-aws" version = "0.1.0" source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ + "async-trait", + "aws-config", + "aws-sdk-secretsmanager", + "aws-smithy-runtime-api", + "aws-types", + "bytes", + "edgezero-core", + "futures-util", "percent-encoding", + "reqwest 0.13.4", "serde", "thiserror 2.0.18", + "tokio", "url", ] @@ -3422,6 +3844,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -4384,6 +4807,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -4658,6 +5087,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + [[package]] name = "pkcs1" version = "0.7.5" @@ -5178,6 +5613,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -5256,12 +5697,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -6624,8 +7067,11 @@ dependencies = [ "error-stack", "futures", "log", + "reqwest 0.12.28", + "serde_json", "simple_logger", "temp-env", + "tempfile", "tokio", "tower 0.4.13", "trusted-server-core", @@ -7104,6 +7550,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -7268,6 +7720,19 @@ dependencies = [ "wasmparser 0.262.0", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.6.0" @@ -7854,7 +8319,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker-macros", "worker-sys", @@ -7922,6 +8387,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yaml-rust2" version = "0.11.0" diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 4611b23307..cddabcd01a 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,6 +18,11 @@ path = "src/lib.rs" name = "trusted-server-axum" path = "src/main.rs" +[features] +default = [] +aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] +aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] + [dependencies] async-trait = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } @@ -31,6 +36,9 @@ trusted-server-core = { workspace = true } web-time = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 88b0934f60..df356ce315 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -1,20 +1,25 @@ use core::future::Future; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use async_trait::async_trait; use edgezero_adapter_axum::context::AxumRequestContext; -use edgezero_core::app::Hooks; +use edgezero_adapter_axum::diagnostics::NativeIngressMetadata; +use edgezero_core::PreDispatchRequest; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, }; -use edgezero_core::router::RouterService; +use edgezero_core::middleware::{Middleware, Next}; +use edgezero_core::router::{PreDispatchHook, RouterService}; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::config_payload::{DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, @@ -34,15 +39,12 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::{ - default_config_key, default_config_store_name, get_settings_from_config_store, -}; use trusted_server_core::platform::{ClientInfo, RuntimeServices}; use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; -use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; +use crate::platform::{apply_native_ingress, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -50,38 +52,19 @@ use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_ru /// Application state built once at startup and shared across all requests. pub struct AppState { - settings: Arc, + pub(crate) settings: Arc, orchestrator: Arc, registry: Arc, services: Option, } -/// Build the application state, loading settings and constructing all per-application components. -/// -/// # Errors -/// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. -fn build_state() -> Result, Report> { - let store_name = default_config_store_name(); - let config_key = default_config_key(); - let settings = get_settings_from_config_store( - &AxumPlatformConfigStore, - &AxumPlatformSecretStore, - &store_name, - &config_key, - &trusted_server_core::settings_data::default_secret_store_name(), - )?; - build_state_with_settings(settings) -} - /// Build the application state from explicit settings. /// /// # Errors /// /// Returns an error when the auction orchestrator or the integration /// registry fail to initialise. -fn build_state_with_settings( +pub(crate) fn build_state_with_settings( settings: Settings, ) -> Result, Report> { build_state_with_services(settings, None) @@ -105,10 +88,20 @@ fn build_state_with_services( } impl AppState { - fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { - self.services - .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + pub(crate) fn from_request(ctx: &RequestContext) -> Result, EdgeError> { + ctx.extensions().get::>().cloned().ok_or_else(|| { + EdgeError::service_unavailable("Trusted Server application state is not initialized") + }) + } + + fn services_for_request(&self, ctx: &RequestContext) -> Result { + if let Some(services) = &self.services { + return Ok(services.clone()); + } + build_runtime_services(ctx).map_err(|error| { + log::error!("native runtime services unavailable: {error:?}"); + EdgeError::service_unavailable("initialized native runtime services are unavailable") + }) } } @@ -153,7 +146,6 @@ fn legacy_admin_alias_denied() -> Response { // --------------------------------------------------------------------------- async fn execute_handler( - state: Arc, ctx: RequestContext, body_limit: Option, handler: F, @@ -162,13 +154,15 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = state.services_for_request(&ctx); + let state = AppState::from_request(&ctx)?; + let services = state.services_for_request(&ctx)?; if ctx.method() == Method::POST && let Some(limit) = body_limit { ctx.body_bytes(limit).await?; } let mut req = ctx.into_request()?; + apply_native_ingress(&mut req)?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, &mut req, @@ -282,13 +276,9 @@ async fn dispatch_fallback( .await } -fn fallback_handler( - state: Arc, -) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { +fn fallback_handler() -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { move |ctx: RequestContext| { - let state = Arc::clone(&state); Box::pin(execute_handler( - state, ctx, None, |state, services, req| async move { dispatch_fallback(&state, &services, req).await }, @@ -452,13 +442,10 @@ fn named_routes() -> [NamedRoute; 17] { } fn named_route_handler( - state: Arc, handler: NamedRouteHandler, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { move |ctx: RequestContext| { - let state = Arc::clone(&state); Box::pin(execute_handler( - state, ctx, match handler { NamedRouteHandler::VerifySignature => { @@ -500,9 +487,8 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::AdminNotSupported => { - // Config/secret-store writes are backed by read-only env vars on the - // Axum dev server. Returning 501 is clearer than failing on the first - // store write. + // Native snapshots are immutable. Returning 501 is clearer + // than failing on the first management write. let body = edgezero_core::body::Body::from( "Admin key management is not supported on the Axum dev server.\n\ Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", @@ -601,32 +587,16 @@ fn publisher_fallback_methods() -> [Method; 7] { ] } -/// Returns a [`RouterService`] that responds to every route with the startup error. -fn startup_error_router(e: &Report) -> RouterService { - let message = Arc::new(format!("{}\n", e.current_context().user_message())); - let status = e.current_context().status_code(); - - let make_handler = |msg: Arc| { - move |_ctx: RequestContext| { - let body = edgezero_core::body::Body::from((*msg).clone()); - let mut resp = Response::new(body); - *resp.status_mut() = status; - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - async move { Ok::(resp) } - } - }; +/// Only explicit-settings test routers capture state. Production state is +/// inserted into the framework App by the prepared-store initializer. +struct ExplicitStateMiddleware(Arc); - let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( - Arc::new(Settings::default()), - )); - for method in publisher_fallback_methods() { - router = router.route("/", method.clone(), make_handler(Arc::clone(&message))); - router = router.route("/{*rest}", method, make_handler(Arc::clone(&message))); +#[async_trait(?Send)] +impl Middleware for ExplicitStateMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + ctx.extensions_mut().insert(Arc::clone(&self.0)); + next.run(ctx).await } - router.build() } // --------------------------------------------------------------------------- @@ -636,30 +606,76 @@ fn startup_error_router(e: &Report) -> RouterService { /// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. pub struct TrustedServerApp; +type TraceBinding = Arc>; + +struct NativeTraceHook(TraceBinding); + +#[async_trait(?Send)] +impl PreDispatchHook for NativeTraceHook { + async fn handle( + &self, + request: &mut PreDispatchRequest, + ) -> Result, EdgeError> { + let hook = self.0.get().ok_or_else(|| { + EdgeError::service_unavailable("native trace settings are not initialized") + })?; + hook.handle(request).await + } +} + +fn configured_trace(state: &Arc) -> TracePreDispatchHook { + let metadata_state = Arc::clone(state); + TracePreDispatchHook::new( + Arc::clone(&state.settings), + Arc::new(move |request| trace_metadata(&metadata_state, request)), + ) +} + +pub(crate) fn install_native_state(app: &mut App, state: Arc) -> Result<(), EdgeError> { + let binding = app.state::().ok_or_else(|| { + EdgeError::validation("native trace binding is missing from the application router") + })?; + binding + .set(configured_trace(&state)) + .map_err(|_| EdgeError::validation("native trace settings are already initialized"))?; + app.insert_state(state); + Ok(()) +} + impl Hooks for TrustedServerApp { fn name() -> &'static str { "TrustedServer" } fn routes() -> Result { - let state = match build_state() { - Ok(s) => s, - Err(ref e) => { - log::error!("failed to build application state: {:?}", e); - return Ok(startup_error_router(e)); - } - }; + Ok(build_router(None, None)) + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: DEFAULT_CONFIG_STORE_ID, + ids: &[DEFAULT_CONFIG_STORE_ID], + }), + secrets: Some(StoreMetadata { + default: DEFAULT_SECRET_STORE_ID, + ids: &[DEFAULT_SECRET_STORE_ID], + }), + kv: None, + } + } - Ok(build_router(&state)) + fn owns_logging() -> bool { + true } } impl TrustedServerApp { /// Build the full application router from explicit settings. /// - /// Testing seam: integration tests use this to drive the router with - /// known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// Testing seam with unavailable network/store dependencies. Tests needing + /// those dependencies must use [`Self::routes_with_settings_and_services`]. + /// This does not load prepared stores or read legacy environment settings. /// /// # Errors /// @@ -668,8 +684,13 @@ impl TrustedServerApp { pub fn routes_with_settings( settings: Settings, ) -> Result> { - let state = build_state_with_settings(settings)?; - Ok(build_router(&state)) + let state = build_state_with_services( + settings, + Some(crate::startup::explicit_settings_services()), + )?; + let trace = + TracePreDispatchHook::new(Arc::clone(&state.settings), Arc::new(native_trace_metadata)); + Ok(build_router(Some(state), Some(trace))) } /// Build the full router with explicit settings and runtime services. @@ -693,26 +714,31 @@ impl TrustedServerApp { services: RuntimeServices, ) -> Result> { let state = build_state_with_services(settings, Some(services))?; - Ok(build_router(&state)) + let trace = configured_trace(&state); + Ok(build_router(Some(state), Some(trace))) } } -fn build_router(state: &Arc) -> RouterService { - let fallback = fallback_handler(Arc::clone(state)); - let trace_state = Arc::clone(state); - - let mut router = RouterService::builder() - .pre_dispatch_hook_arc(Arc::new(TracePreDispatchHook::new( - Arc::clone(&state.settings), - Arc::new(move |request| trace_metadata(&trace_state, request)), - ))) - // Outermost middleware: strips the configured trusted-client-IP - // headers before anything else sees the request. Must stay first — - // any middleware registered ahead of it would observe the - // shared-secret authentication header. - .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) - .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); +fn build_router( + state: Option>, + trace_hook: Option, +) -> RouterService { + let fallback = fallback_handler(); + let trace = Arc::new(match trace_hook { + Some(hook) => OnceLock::from(hook), + None => OnceLock::new(), + }); + let mut router = + RouterService::builder().pre_dispatch_hook(NativeTraceHook(Arc::clone(&trace))); + if let Some(state) = state { + router = router.middleware(ExplicitStateMiddleware(state)); + } + // The test-only injector observes no headers. Sanitation remains the first + // request-processing middleware so trust credentials cannot leak inward. + router = router + .middleware(SanitizeRequestMiddleware::from_request()) + .middleware(FinalizeResponseMiddleware::from_request()) + .middleware(AuthMiddleware::from_request()); router = router.route("/health", Method::GET, |_ctx: RequestContext| async { Ok::( @@ -729,7 +755,7 @@ fn build_router(state: &Arc) -> RouterService { router = router.route( route.path, method.clone(), - named_route_handler(Arc::clone(state), route.handler), + named_route_handler(route.handler), ); } for method in publisher_fallback_methods() { @@ -744,7 +770,7 @@ fn build_router(state: &Arc) -> RouterService { router = router.route("/{*rest}", method, fallback.clone()); } - router.build() + router.with_state(trace).build() } fn trace_metadata(state: &AppState, request: &edgezero_core::http::RequestParts) -> TraceMetadata { @@ -758,13 +784,24 @@ fn trace_metadata(state: &AppState, request: &edgezero_core::http::RequestParts) }); return TraceMetadata { client_info, geo }; } - TraceMetadata { - client_info: ClientInfo { - client_ip: request + native_trace_metadata(request) +} + +fn native_trace_metadata(request: &edgezero_core::http::RequestParts) -> TraceMetadata { + let client_ip = request + .extensions + .get::() + .map(NativeIngressMetadata::effective_client) + .unwrap_or_else(|| { + request .extensions .get::() .and_then(|context| context.remote_addr) - .map(|peer| peer.ip()), + .map(|peer| peer.ip()) + }); + TraceMetadata { + client_info: ClientInfo { + client_ip, ..ClientInfo::default() }, geo: None, diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 569e2e502f..8ff5b1984b 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -1,12 +1,13 @@ -//! Native Axum adapter for local Trusted Server development. +//! Native Axum adapter with strict prepared-store startup. //! -//! Runs a full Axum HTTP server on `localhost` as a drop-in dev alternative to -//! the Fastly Compute adapter (via Viceroy). All routes and middleware mirror -//! the Fastly adapter; store and geo primitives fall back to env vars and no-ops. +//! Runtime settings and immutable store snapshots are validated before the RC +//! listener becomes ready. Reader geography remains unavailable on native. /// Application routing and handler registration for the Axum dev server. pub mod app; /// Request middleware (auth, response finalisation). pub mod middleware; -/// Platform services backed by environment values and the request's `EdgeZero` client. +/// Native platform services using the incoming request's `EdgeZero` client. pub mod platform; +/// Prepared-store initialization and immutable native snapshots. +pub mod startup; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index e439753365..bb705c01a3 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,7 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::app::{App, Hooks as _}; +use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::startup::initialize_native; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -26,27 +26,15 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { - // When PORT is set, bind to a specific address so integration tests - // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. - Some(port) => AxumDevServerConfig { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), - enable_ctrl_c: true, - ..AxumDevServerConfig::default() - }, - // Normal development path: read bind address from axum.toml. - None => AxumDevServerConfig::default(), - }; - - log::info!("Listening on http://{}", config.addr); - let router = match TrustedServerApp::routes() { - Ok(router) => router, - Err(error) => { - log::error!("trusted-server-adapter-axum initialization fails: {error}"); - std::process::exit(1); - } - }; - if let Err(err) = AxumDevServer::with_config(App::new(router), config).run() { + if let Err(err) = run_production_app_with_initializer::(|app, stores| { + Box::pin(async move { + initialize_native(app, stores).await.inspect_err(|error| { + // RC classifies callback errors without their details. This safe + // application diagnostic tells operators which input to fix. + log::error!("Trusted Server initialization failed: {error}"); + }) + }) + }) { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } @@ -72,23 +60,6 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Read a port number from the `PORT` environment variable. -/// -/// Returns `None` when the variable is unset. Exits non-zero if the value -/// is set but cannot be parsed — silently falling back to a different port -/// would surprise tooling that expects the server at the requested address. -#[allow(clippy::print_stderr)] -fn port_from_env() -> Option { - let raw = std::env::var("PORT").ok()?; - match raw.parse() { - Ok(port) => Some(port), - Err(e) => { - eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); - std::process::exit(1); - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 9c7c93dd21..61226b9f39 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -10,6 +10,18 @@ use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; +use crate::app::AppState; + +fn request_settings( + explicit: &Option>, + ctx: &RequestContext, +) -> Result, EdgeError> { + match explicit { + Some(settings) => Ok(Arc::clone(settings)), + None => Ok(Arc::clone(&AppState::from_request(ctx)?.settings)), + } +} + // --------------------------------------------------------------------------- // SanitizeRequestMiddleware // --------------------------------------------------------------------------- @@ -24,23 +36,32 @@ use trusted_server_core::settings::Settings; /// a shared configuration cannot leak the secret into publisher or integration /// request handling. pub struct SanitizeRequestMiddleware { - settings: Arc, + settings: Option>, } impl SanitizeRequestMiddleware { - /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + let settings = request_settings(&self.settings, &ctx)?; sanitize_trusted_client_ip_header_map( ctx.headers_mut(), - self.settings.trusted_client_ip.as_ref(), + settings.trusted_client_ip.as_ref(), ); next.run(ctx).await } @@ -61,22 +82,31 @@ impl Middleware for SanitizeRequestMiddleware { /// [`AuthMiddleware`] so that every outgoing response — including auth-rejected /// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { - settings: Arc, + settings: Option>, } impl FinalizeResponseMiddleware { - /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let settings = request_settings(&self.settings, &ctx)?; let mut response = next.run(ctx).await?; - apply_finalize_headers(&self.settings, &mut response); + apply_finalize_headers(&settings, &mut response); Ok(response) } } @@ -87,26 +117,35 @@ impl Middleware for FinalizeResponseMiddleware { /// Inner middleware: enforces basic-auth before the handler runs. /// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// - `Ok(Some(response))` from [`enforce_basic_auth_parts`] → auth failed; return the /// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). /// - `Ok(None)` → no auth required or credentials accepted; continue the chain. /// - `Err(report)` → internal error; log and convert to a 500 HTTP response. pub struct AuthMiddleware { - settings: Arc, + settings: Option>, } impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { + let settings = request_settings(&self.settings, &ctx)?; + match enforce_basic_auth_parts(&settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index d7aa859076..9baaaf937d 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,152 +1,20 @@ use std::net::IpAddr; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use edgezero_adapter_axum::diagnostics::NativeIngressMetadata; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Request, Uri, header}; +use edgezero_core::outbound::ResponseDecoding; use error_stack::{Report, ResultExt as _}; +use trusted_server_core::http_util::RequestInfo; use trusted_server_core::platform::{ AuctionTargetId, BackendNamingPolicy, ClientInfo, EdgeZeroHttpClient, GeoInfo, PlatformBackend, - PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, + PlatformBackendSpec, PlatformError, PlatformGeo, PlatformKvStore, RuntimeServices, + UnavailableKvStore, }; -// --------------------------------------------------------------------------- -// Env-var naming helpers -// --------------------------------------------------------------------------- - -/// Normalize a store name or key for use as an environment-variable segment. -/// -/// Uppercases and replaces hyphens, dots, and spaces with underscores. -fn normalize_env_segment(s: &str) -> String { - s.to_uppercase().replace(['-', '.', ' '], "_") -} - -/// Returns the environment-variable name for a config store entry. -#[must_use] -pub fn config_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_CONFIG_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -fn secret_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_SECRET_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -// --------------------------------------------------------------------------- -// PlatformConfigStore -// --------------------------------------------------------------------------- - -/// Environment-variable–backed config store for the Axum dev server. -/// -/// Reads from `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` (uppercased, hyphens→underscores). -/// Write operations are unsupported in local development. -pub struct AxumPlatformConfigStore; - -impl PlatformConfigStore for AxumPlatformConfigStore { - fn get(&self, store_name: &StoreName, key: &str) -> Result> { - let var_name = config_env_var(store_name.as_ref(), key); - std::env::var(&var_name).map_err(|_| { - Report::new(PlatformError::ConfigStore).attach(format!( - "env var '{var_name}' not set — export it to supply this config value" - )) - }) - } - - fn put( - &self, - store_id: &StoreId, - key: &str, - _value: &str, - ) -> Result<(), Report> { - log::warn!( - "AxumPlatformConfigStore: write to store '{}' key '{}' ignored \ - (config store writes are not supported on the Axum dev server)", - store_id.as_ref(), - key - ); - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported on the Axum dev server")) - } - - fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { - log::warn!( - "AxumPlatformConfigStore: delete from store '{}' key '{}' ignored \ - (config store deletes are not supported on the Axum dev server)", - store_id.as_ref(), - key - ); - Err(Report::new(PlatformError::ConfigStore) - .attach("config store deletes are not supported on the Axum dev server")) - } -} - -// --------------------------------------------------------------------------- -// PlatformSecretStore -// --------------------------------------------------------------------------- - -/// Environment-variable–backed secret store for the Axum dev server. -/// -/// Reads from `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` as raw UTF-8 bytes. -/// Write operations are unsupported in local development. -pub struct AxumPlatformSecretStore; - -impl PlatformSecretStore for AxumPlatformSecretStore { - fn get_bytes( - &self, - store_name: &StoreName, - key: &str, - ) -> Result, Report> { - let var_name = secret_env_var(store_name.as_ref(), key); - std::env::var(&var_name) - .map(String::into_bytes) - .map_err(|_| { - Report::new(PlatformError::SecretStore).attach(format!( - "env var '{var_name}' not set — export it to supply this secret value" - )) - }) - } - - fn create( - &self, - store_id: &StoreId, - name: &str, - _value: &str, - ) -> Result<(), Report> { - log::warn!( - "AxumPlatformSecretStore: create '{}' in store '{}' ignored \ - (secret store writes are not supported on the Axum dev server)", - name, - store_id.as_ref() - ); - Err(Report::new(PlatformError::SecretStore) - .attach("secret store writes are not supported on the Axum dev server")) - } - - fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { - log::warn!( - "AxumPlatformSecretStore: delete '{}' from store '{}' ignored \ - (secret store deletes are not supported on the Axum dev server)", - name, - store_id.as_ref() - ); - Err(Report::new(PlatformError::SecretStore) - .attach("secret store deletes are not supported on the Axum dev server")) - } -} - -// --------------------------------------------------------------------------- -// PlatformBackend -// --------------------------------------------------------------------------- - -/// No-op backend for the Axum dev server. -/// -/// Returns a deterministic name; `ensure` is a no-op returning the same name. -/// The Axum HTTP client sends directly to URIs and ignores backend names. +/// Backend naming for native requests, whose RC transport connects directly to the URI. pub struct AxumPlatformBackend; impl PlatformBackend for AxumPlatformBackend { @@ -166,11 +34,7 @@ impl PlatformBackend for AxumPlatformBackend { } } -// --------------------------------------------------------------------------- -// PlatformGeo -// --------------------------------------------------------------------------- - -/// No-op geo implementation — geographic lookup is unavailable in local development. +/// Reader geography is unavailable on native, regardless of deployment region. pub struct AxumPlatformGeo; impl PlatformGeo for AxumPlatformGeo { @@ -179,172 +43,141 @@ impl PlatformGeo for AxumPlatformGeo { } } -// --------------------------------------------------------------------------- -// build_runtime_services -// --------------------------------------------------------------------------- - -/// Construct [`RuntimeServices`] for an incoming Axum request. +/// Install checked public URL facts without consuming or replacing the request body. /// -/// # Degraded features in dev +/// Raw forwarding headers are never consulted. Managed ingress validates proxy +/// trust and syntax before creating this metadata. Requests outside the native +/// runner remain unchanged, for explicit-services router tests and other adapters. /// -/// The generic runtime KV slot uses -/// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is -/// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { - static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); - KV_WARNED.get_or_init(|| { - log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); - }); +/// # Errors +/// Returns a bad-request error when checked public URI/Host facts cannot be represented. +pub(crate) fn apply_native_ingress(request: &mut Request) -> Result<(), EdgeError> { + let Some(metadata) = request.extensions().get::() else { + return Ok(()); + }; + let info = RequestInfo { + host: metadata + .effective_host() + .authority() + .unwrap_or_default() + .to_owned(), + scheme: metadata.effective_scheme().as_str().to_owned(), + }; + // Some core consumers read URI/Host directly rather than RequestInfo. + // Normalize all three representations from the same checked facts so an + // absolute request target cannot override the selected public scheme. + let uri = Uri::builder() + .scheme(info.scheme.as_str()) + .authority(info.host.as_str()) + .path_and_query( + request + .uri() + .path_and_query() + .map_or("/", |path| path.as_str()), + ) + .build() + .map_err(|_| { + EdgeError::bad_request("native request has no representable checked public URI") + })?; + let host = HeaderValue::from_str(&info.host).map_err(|_| { + EdgeError::bad_request("native request has no representable checked public host") + })?; + *request.uri_mut() = uri; + request.headers_mut().insert(header::HOST, host); + request.extensions_mut().insert(info); + Ok(()) +} - let client_ip = ctx +/// Construct request-local runtime services from initialized immutable stores. +/// +/// The RC client and application clock come from the incoming request. Only +/// dependency handles are shared; checked visitor metadata is read afresh. +/// Generic KV and reader geography are intentionally unavailable in this demo. +/// +/// # Errors +/// Returns an error when startup has not installed the store snapshot or the +/// native runner has not supplied its HTTP client. There is no environment fallback. +pub fn build_runtime_services( + context: &RequestContext, +) -> Result> { + let snapshot = context .extensions() - .get::() - .and_then(|c| c.remote_addr) - .map(|addr| addr.ip()); - - // Stateless shims are promoted to process-wide statics so callers clone - // an existing Arc instead of allocating a new one per request. - static CONFIG_STORE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - static SECRET_STORE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - static KV_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); - static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); - static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); - - static TIMER: std::sync::OnceLock> = - std::sync::OnceLock::new(); - - RuntimeServices::builder() - .timer(Arc::clone(TIMER.get_or_init(|| Arc::new(AxumTimer)))) - .config_store(Arc::clone(CONFIG_STORE.get_or_init(|| { - Arc::new(AxumPlatformConfigStore) as Arc - }))) - .secret_store(Arc::clone(SECRET_STORE.get_or_init(|| { - Arc::new(AxumPlatformSecretStore) as Arc - }))) - .kv_store(Arc::clone(KV_STORE.get_or_init(|| { - Arc::new(trusted_server_core::platform::UnavailableKvStore) as Arc - }))) - .backend(Arc::clone(BACKEND.get_or_init(|| { - Arc::new(AxumPlatformBackend) as Arc - }))) - .http_client(EdgeZeroHttpClient::for_request(ctx, AuctionTargetId::Axum)) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + .get::>() + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach("native prepared-store snapshot is missing; initialize the application before serving") + })?; + let client = context.http_client().ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("request-scoped native HTTP client is missing") + })?; + let client_ip = context + .extensions() + .get::() + .and_then(NativeIngressMetadata::effective_client); + + static KV: OnceLock> = OnceLock::new(); + static BACKEND: OnceLock> = OnceLock::new(); + static GEO: OnceLock> = OnceLock::new(); + + Ok(RuntimeServices::builder() + .timer(native_timer()) + .config_store(Arc::clone(&snapshot.config_store)) + .secret_store(Arc::clone(&snapshot.secret_store)) + .kv_store(Arc::clone(KV.get_or_init(|| Arc::new(UnavailableKvStore)))) + .backend(Arc::clone( + BACKEND.get_or_init(|| Arc::new(AxumPlatformBackend)), + )) + .http_client(Arc::new( + EdgeZeroHttpClient::new(client, AuctionTargetId::Axum) + .response_decoding(ResponseDecoding::Decode), + )) + .geo(Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo)))) .client_info(ClientInfo { client_ip, - tls_protocol: None, - tls_cipher: None, ..ClientInfo::default() }) - .build() + .build()) } struct AxumTimer; impl trusted_server_core::platform::PlatformTimer for AxumTimer { fn delay(&self, duration: std::time::Duration) -> futures::future::LocalBoxFuture<'static, ()> { - Box::pin(async move { - tokio::time::sleep(duration).await; - }) + Box::pin(async move { tokio::time::sleep(duration).await }) } } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +fn native_timer() -> Arc { + static TIMER: OnceLock> = OnceLock::new(); + Arc::clone(TIMER.get_or_init(|| Arc::new(AxumTimer))) +} #[cfg(test)] mod tests { - use super::*; use std::time::Duration; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + + use super::*; + #[test] fn html_finalization_timer_is_shared_without_an_executor() { - let request = edgezero_core::http::request_builder() - .body(edgezero_core::body::Body::empty()) - .expect("should build a request"); - let context = edgezero_core::context::RequestContext::new(request, Default::default()); - let services = build_runtime_services(&context); - let next_services = build_runtime_services(&context); + let first = native_timer(); + let second = native_timer(); assert!( - std::ptr::eq( - services - .timer() - .expect("should install a cooperative timer"), - next_services - .timer() - .expect("should reuse the cooperative timer"), - ), + std::ptr::eq(first.as_ref(), second.as_ref()), "should share the stateless timer without requiring an executor" ); } #[test] - fn auction_http_capabilities_are_explicit() { - let capabilities = trusted_server_core::platform::AuctionTargetId::Axum - .descriptor() - .capabilities(); - assert!(capabilities.supports_concurrent_provider_fanout()); - } - - #[test] - fn config_env_var_normalizes_store_and_key() { - assert_eq!( - config_env_var("my-store.name", "my key"), - "TRUSTED_SERVER_CONFIG_MY_STORE_NAME_MY_KEY", - "should normalize environment-variable segments" - ); - } - - #[test] - fn config_store_reads_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_CONFIG_MY_STORE_MY_KEY", - Some("test-value"), - || { - let store = AxumPlatformConfigStore; - let result = store - .get(&StoreName::from("my-store"), "my-key") - .expect("should read env var"); - assert_eq!(result, "test-value", "should return env var value"); - }, - ); - } - - #[test] - fn config_store_returns_error_for_missing_env_var() { - let store = AxumPlatformConfigStore; - let result = store.get( - &StoreName::from("nonexistent-store-zzz"), - "nonexistent-key-zzz", - ); - assert!(result.is_err(), "should error for missing env var"); - } - - #[test] - fn secret_store_reads_bytes_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_SECRET_MY_SECRETS_MY_SECRET", - Some("hello"), - || { - let store = AxumPlatformSecretStore; - let result = store - .get_bytes(&StoreName::from("my-secrets"), "my-secret") - .expect("should read env var as bytes"); - assert_eq!(result, b"hello", "should return raw bytes"); - }, - ); - } - - #[test] - fn backend_predict_name_returns_deterministic_string() { + fn backend_ensure_matches_prediction() { let backend = AxumPlatformBackend; let spec = PlatformBackendSpec { - scheme: "https".to_string(), - host: "example.com".to_string(), + scheme: "https".to_owned(), + host: "example.com".to_owned(), port: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), @@ -352,42 +185,49 @@ mod tests { host_header_override: None, discriminator: None, }; - let name1 = backend.predict_name(&spec).expect("should return a name"); - let name2 = backend - .predict_name(&spec) - .expect("should return same name"); - assert!(!name1.is_empty(), "should return a non-empty name"); - assert_eq!(name1, name2, "should be deterministic"); + assert_eq!( + backend.predict_name(&spec).expect("should predict backend"), + backend.ensure(&spec).expect("should ensure backend"), + "should preserve deterministic native backend names" + ); } #[test] - fn backend_ensure_returns_same_name_as_predict() { - let backend = AxumPlatformBackend; - let spec = PlatformBackendSpec { - scheme: "https".to_string(), - host: "example.com".to_string(), - port: None, - certificate_check: true, - first_byte_timeout: Duration::from_secs(15), - between_bytes_timeout: Duration::from_secs(15), - host_header_override: None, - discriminator: None, - }; - assert_eq!( - backend.predict_name(&spec).expect("should return name"), - backend.ensure(&spec).expect("should return name"), - "ensure should equal predict_name" + fn reader_geography_remains_unavailable() { + assert!( + AxumPlatformGeo + .lookup(None) + .expect("should look up geography") + .is_none() + ); + assert!( + AxumPlatformGeo + .lookup(Some("192.0.2.1".parse().expect("should parse client IP"))) + .expect("should look up geography") + .is_none() + ); + } + + #[test] + fn missing_prepared_snapshot_fails_without_environment_fallback() { + let request = request_builder() + .body(Body::empty()) + .expect("should build request"); + let context = RequestContext::new(request, edgezero_core::params::PathParams::default()); + assert!( + build_runtime_services(&context).is_err(), + "should require prepared stores" ); } #[test] - fn geo_always_returns_none() { - let geo = AxumPlatformGeo; - let no_ip = geo.lookup(None).expect("should not error"); - assert!(no_ip.is_none(), "should return None for no IP"); - let with_ip = geo - .lookup(Some("127.0.0.1".parse().expect("should parse IP"))) - .expect("should not error"); - assert!(with_ip.is_none(), "should return None for any IP"); + fn unmanaged_test_request_is_not_rewritten_from_raw_forwarding_headers() { + let mut request = request_builder() + .header("forwarded", "host=spoof.example.com;proto=https") + .body(Body::from("body")) + .expect("should build request"); + apply_native_ingress(&mut request).expect("should retain unmanaged test request"); + assert!(request.extensions().get::().is_none()); + assert_eq!(request.body().as_bytes(), Some(b"body".as_slice())); } } diff --git a/crates/trusted-server-adapter-axum/src/startup.rs b/crates/trusted-server-adapter-axum/src/startup.rs new file mode 100644 index 0000000000..4d3f460ab5 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/startup.rs @@ -0,0 +1,425 @@ +//! Strict native initialization from prepared stores. +//! +//! This finite snapshot bridge exists until core's platform store traits become +//! asynchronous. No provider is polled by synchronous request consumers. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use edgezero_adapter_axum::dev_server::PreparedStores; +use edgezero_core::app::App; +use edgezero_core::error::EdgeError; +use error_stack::Report; +use trusted_server_core::config_payload::{ + DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID, VerifiedConfigBlob, +}; +use trusted_server_core::integrations::datadome::{DataDomeConfig, ProtectionMatcherConfig}; +use trusted_server_core::platform::{ + PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableHttpClient, UnavailableKvStore, +}; +use trusted_server_core::settings::Settings; + +use crate::app::{build_state_with_settings, install_native_state}; +use crate::platform::{AxumPlatformBackend, AxumPlatformGeo}; + +/// Immutable inputs shared by per-request native runtime services. +/// +/// Only declared stores and explicitly loaded keys exist in these snapshots. +/// Missing keys and all management writes fail rather than consulting legacy +/// environment variables or opening another provider. +pub struct NativeStoreSnapshot { + pub(crate) config_store: Arc, + pub(crate) secret_store: Arc, +} + +struct ConfigSnapshot { + key: String, + envelope: String, +} + +impl PlatformConfigStore for ConfigSnapshot { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + if store_name.as_ref() == DEFAULT_CONFIG_STORE_ID && !self.key.is_empty() && key == self.key + { + Ok(self.envelope.clone()) + } else { + Err(Report::new(PlatformError::ConfigStore) + .attach("config key is not present in the immutable native startup snapshot")) + } + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(read_only_config()) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(read_only_config()) + } +} + +struct SecretSnapshot(BTreeMap>); + +impl PlatformSecretStore for SecretSnapshot { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + if store_name.as_ref() == DEFAULT_SECRET_STORE_ID + && let Some(value) = self.0.get(key) + { + return Ok(value.clone()); + } + Err(Report::new(PlatformError::SecretStore) + .attach("secret key is not present in the immutable native startup snapshot")) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(read_only_secret()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(read_only_secret()) + } +} + +/// Explicit-settings router seam. Network/store-dependent tests must supply +/// services through `routes_with_settings_and_services` instead. No env fallback. +pub(crate) fn explicit_settings_services() -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(ConfigSnapshot { + key: String::new(), + envelope: String::new(), + })) + .secret_store(Arc::new(SecretSnapshot(BTreeMap::new()))) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(AxumPlatformGeo)) + .client_info(Default::default()) + .build() +} + +fn read_only_config() -> Report { + Report::new(PlatformError::ConfigStore) + .attach("native startup snapshots are read-only; publish inputs and restart") +} + +fn read_only_secret() -> Report { + Report::new(PlatformError::SecretStore) + .attach("native startup snapshots are read-only; publish inputs and restart") +} + +fn configuration_error(message: &str) -> EdgeError { + EdgeError::validation(message) +} + +fn settings_error(error: Report) -> EdgeError { + // Core configuration errors deliberately omit secret values. Do not attach + // provider errors or a serialized config to native startup diagnostics. + let message = error.current_context().to_string(); + drop(error); + configuration_error(&message) +} + +fn validate_snapshot_consumers(settings: &Settings) -> Result<(), EdgeError> { + let origin = settings + .publisher + .origin_url + .parse::() + .map_err(|_| { + configuration_error("publisher.origin_url must be an absolute HTTP or HTTPS URI") + })?; + if !matches!(origin.scheme_str(), Some("http" | "https")) || origin.authority().is_none() { + return Err(configuration_error( + "publisher.origin_url must be an absolute HTTP or HTTPS URI", + )); + } + if settings + .request_signing + .as_ref() + .is_some_and(|signing| signing.enabled) + { + return Err(configuration_error( + "native snapshots do not support request_signing.enabled; disable signing or add declared jwks_store/signing_keys snapshots with KID and key-pair validation before readiness", + )); + } + if let Some(datadome) = settings + .integrations + .get_typed::("datadome") + .map_err(settings_error)? + && datadome.enable_protection + && (!datadome.protection_excluded_ip_cidr_sources.is_empty() + || datadome.protection_exclusion_rules.iter().any(|rule| { + rule.enabled && matches!(rule.matcher, ProtectionMatcherConfig::IpCidrSource { .. }) + })) + { + return Err(configuration_error( + "native snapshots do not support DataDome dynamic Config Store CIDR sources; use inline protection_excluded_ip_cidrs/ip_cidr rules or disable those sources before starting", + )); + } + Ok(()) +} + +/// Load and validate the finite native inputs before the framework listens. +/// +/// The framework retains ownership of the App, clock, policies, listener, +/// readiness, and signal/drain lifecycle. Selected-provider failures are fatal. +/// +/// # Errors +/// Returns an error for missing bindings/values, bounded-read failures, invalid +/// envelopes or runtime settings, unsupported store consumers, or invalid plans. +pub async fn initialize_native(app: &mut App, stores: &PreparedStores) -> Result<(), EdgeError> { + let limits = app.config_extraction_limits().validate()?; + let clock = app.monotonic_clock(); + let deadline = clock.deadline_after(limits.timeout); + let binding = stores + .config() + .and_then(|registry| registry.named_ref(DEFAULT_CONFIG_STORE_ID)) + .ok_or_else(|| { + configuration_error( + "required declared config store trusted_server_config is not prepared", + ) + })?; + let secrets = stores + .secrets() + .and_then(|registry| registry.named_ref(DEFAULT_SECRET_STORE_ID)) + .ok_or_else(|| { + configuration_error( + "required declared secret store trusted_server_secrets is not prepared", + ) + })?; + + let config_read = binding.handle.get_bounded( + &binding.default_key, + &clock, + deadline, + limits.max_backend_bytes.min(limits.max_total_bytes), + limits.max_blob_bytes.min(limits.max_total_bytes), + ).await.map_err(|_| configuration_error( + "failed bounded read of prepared Trusted Server config; check the selected provider, binding key, extraction limits and deadline; legacy environment fallback is disabled", + ))?; + let mut remaining = limits + .max_total_bytes + .checked_sub(config_read.backend_bytes) + .ok_or_else(|| { + configuration_error("native config extraction exceeds the total byte budget") + })?; + let envelope = config_read.value.ok_or_else(|| configuration_error( + "Trusted Server config envelope is missing at the prepared binding key; publish a TS BlobEnvelope before starting", + ))?; + let verified = VerifiedConfigBlob::parse(envelope.as_ref()).map_err(settings_error)?; + verified + .check_structure(limits.max_json_depth, limits.max_json_nodes) + .map_err(settings_error)?; + let keys = verified.required_secret_keys().map_err(settings_error)?; + let mut values = BTreeMap::new(); + for key in keys { + if remaining == 0 { + return Err(configuration_error( + "native secret extraction exhausts the total byte budget", + )); + } + let read = secrets.get_bytes_bounded( + &key, + &clock, + deadline, + limits.max_backend_bytes.min(remaining), + limits.max_secret_bytes.min(remaining), + ).await.map_err(|_| configuration_error( + "failed bounded read of a required app-config secret; check the selected trusted_server_secrets provider, required references, extraction limits and deadline; legacy environment fallback is disabled", + ))?; + remaining = remaining.checked_sub(read.backend_bytes).ok_or_else(|| { + configuration_error("native secret extraction exceeds the total byte budget") + })?; + let value = read.value.ok_or_else(|| configuration_error( + "a required app-config secret is missing in trusted_server_secrets; publish every active reference before starting", + ))?; + values.insert(key, value.to_vec()); + } + let secret_store = Arc::new(SecretSnapshot(values)); + let settings = verified + .into_settings( + secret_store.as_ref(), + &StoreName::from(DEFAULT_SECRET_STORE_ID), + ) + .map_err(settings_error)?; + validate_snapshot_consumers(&settings)?; + let state = build_state_with_settings(settings).map_err(settings_error)?; + if deadline.is_expired_at(clock.now()) { + return Err(configuration_error( + "native application initialization exceeds its extraction deadline", + )); + } + let snapshot = Arc::new(NativeStoreSnapshot { + config_store: Arc::new(ConfigSnapshot { + key: binding.default_key.clone(), + envelope: envelope.to_string(), + }), + secret_store, + }); + app.insert_state(snapshot); + install_native_state(app, state)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::app::Hooks as _; + + #[test] + fn signing_is_rejected_with_an_actionable_startup_error() { + let settings = Settings { + publisher: trusted_server_core::settings::Publisher { + origin_url: "https://origin.publisher.example.com".to_owned(), + ..Default::default() + }, + request_signing: Some(trusted_server_core::settings::RequestSigning { + enabled: true, + config_store_id: "fixture-config".to_owned(), + secret_store_id: "fixture-secrets".to_owned(), + }), + ..Settings::default() + }; + let error = validate_snapshot_consumers(&settings) + .expect_err("should reject signing before readiness"); + assert!(error.to_string().contains("disable signing")); + assert!(error.to_string().contains("KID")); + } + + #[test] + fn native_origin_requires_an_absolute_http_transport_uri() { + for origin in [ + "not-a-valid-url", + "ftp://origin.publisher.example.com", + "/relative", + ] { + let mut settings = datadome_settings(""); + settings.publisher.origin_url = origin.to_owned(); + let error = validate_snapshot_consumers(&settings) + .expect_err("should reject an unusable native origin before readiness"); + assert!(error.to_string().contains("publisher.origin_url")); + } + } + + fn datadome_settings(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "fixture-admin-password" + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.example.com" + proxy_secret = "fixture-proxy-secret-32-bytes-minimum" + [ec] + passphrase = "fixture-passphrase-32-bytes-minimum" + [integrations.datadome] + enabled = true + enable_protection = true + server_side_key_secret_name = "fixture-datadome-key" + {extra} + "# + )) + .expect("should parse consumer validation fixture") + } + + #[test] + fn dynamic_datadome_sources_are_rejected_but_inline_sources_are_supported() { + for extra in [ + "protection_excluded_ip_cidr_sources = [{ config_store = 'extra-store', key = 'cidrs' }]", + "protection_exclusion_rules = [{ id = 'fixture-rule', type = 'ip_cidr_source', config_store = 'extra-store', key = 'cidrs' }]", + ] { + let error = validate_snapshot_consumers(&datadome_settings(extra)) + .expect_err("should reject dynamic config reads before readiness"); + assert!(error.to_string().contains("use inline")); + } + validate_snapshot_consumers(&datadome_settings( + "protection_excluded_ip_cidrs = ['192.0.2.0/24']", + )) + .expect("should allow self-contained inline CIDRs"); + } + + #[test] + fn snapshots_reject_missing_keys_wrong_stores_and_management_writes() { + let config = ConfigSnapshot { + key: "config".to_owned(), + envelope: "envelope".to_owned(), + }; + let config_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); + assert_eq!( + config + .get(&config_name, "config") + .expect("should read loaded envelope"), + "envelope" + ); + assert!(config.get(&config_name, "missing").is_err()); + assert!(config.get(&StoreName::from("other"), "config").is_err()); + assert!( + config + .put(&StoreId::from(DEFAULT_CONFIG_STORE_ID), "config", "new") + .is_err() + ); + assert!( + config + .delete(&StoreId::from(DEFAULT_CONFIG_STORE_ID), "config") + .is_err() + ); + let secrets = SecretSnapshot(BTreeMap::from([( + "token".to_owned(), + b"fixture-value".to_vec(), + )])); + let secret_name = StoreName::from(DEFAULT_SECRET_STORE_ID); + let mut returned = secrets + .get_bytes(&secret_name, "token") + .expect("should read loaded secret"); + returned.clear(); + assert_eq!( + secrets + .get_bytes(&secret_name, "token") + .expect("should retain immutable value"), + b"fixture-value" + ); + assert!(secrets.get_bytes(&secret_name, "missing").is_err()); + assert!( + secrets + .get_bytes(&StoreName::from("other"), "token") + .is_err() + ); + assert!( + secrets + .create(&StoreId::from(DEFAULT_SECRET_STORE_ID), "token", "new") + .is_err() + ); + assert!( + secrets + .delete(&StoreId::from(DEFAULT_SECRET_STORE_ID), "token") + .is_err() + ); + } + + #[tokio::test] + async fn missing_prepared_stores_fail_initialization() { + let mut app = App::new( + crate::app::TrustedServerApp::routes().expect("should construct native routes"), + ); + let error = initialize_native(&mut app, &PreparedStores::default()) + .await + .expect_err("should reject absent prepared stores"); + assert!(error.to_string().contains("not prepared")); + } +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md new file mode 100644 index 0000000000..aaa06124a5 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md @@ -0,0 +1,83 @@ +# Native acceptance fixtures + +`origin.rs` serves core's existing Next.js Flight fixture over local TCP. It also +records POST bodies, serves a deterministic API, and deliberately breaks one +POST response. `proxy.rs` is a real local gateway that replaces visitor +forwarding fields with fixed synthetic assertions. Neither needs AWS or DNS. + +Run the ordinary subprocess tests with: + +```sh +cargo test -p trusted-server-adapter-axum --target x86_64-unknown-linux-gnu \ + --test native_acceptance +``` + +The transport-sequence test aborts an inbound POST and also receives a truncated +origin POST response, then forwards three action-header POST/API pairs through +the same TS process and origin port. Its action origin is synthetic. This test +alone does not establish real Next.js server-action compatibility. + +## Real Next.js regression + +The existing integration Next.js fixture has no real server actions. Its unknown +ID scenario accepts 404/405, or a soft 404. That cannot prove that an action +executed after a truncated POST. + +The `nextjs/app` files add a real server action that checks its input and +redirects to `/?action=accepted`. The ignored acceptance test extracts the +compiled form action ID from the proxied page, aborts an upload, submits that +real action and requires its 303 redirect, then requires exact API JSON. The +origin explicitly allows only `publisher.example.com` for server actions, and +the test sends that public Host and Origin. This is normal reverse-proxy origin +configuration, not disabled CSRF protection. + +Prepare an isolated fixture with the existing integration fixture's lockfile. +Use the repository's pinned Node version. From the repository root: + +```sh +NEXT_FIXTURE_ROOT=$(mktemp -d /tmp/ts-native-nextjs.XXXXXX) +cp crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/package{,-lock}.json \ + "$NEXT_FIXTURE_ROOT/" +cp -R crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app \ + "$NEXT_FIXTURE_ROOT/" +cp crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs \ + "$NEXT_FIXTURE_ROOT/" +npm ci --prefix "$NEXT_FIXTURE_ROOT" --ignore-scripts +NATIVE_NEXTJS_PORT=$(node -e \ + 'const s=require("node:net").createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') +export NATIVE_NEXTJS_ORIGIN="http://127.0.0.1:$NATIVE_NEXTJS_PORT" +( + cd "$NEXT_FIXTURE_ROOT" + NEXT_TELEMETRY_DISABLED=1 timeout --signal=TERM --kill-after=3s 180s \ + node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \ + --port "$NATIVE_NEXTJS_PORT" +) >"$NEXT_FIXTURE_ROOT/next.log" 2>&1 & +NEXT_FIXTURE_PID=$! +``` + +Prewarm `/` and `/api/data` directly before the two-second acceptance request +budget. These commands must return 200 before starting the Rust test: + +```sh +curl --fail --retry 20 --retry-connrefused --retry-delay 1 --max-time 60 \ + "$NATIVE_NEXTJS_ORIGIN/" >"$NEXT_FIXTURE_ROOT/page.html" +curl --fail --max-time 60 "$NATIVE_NEXTJS_ORIGIN/api/data" +cargo test -p trusted-server-adapter-axum --target x86_64-unknown-linux-gnu \ + --test native_acceptance real_nextjs_action_and_api_after_truncated_post \ + -- --ignored --exact --nocapture +``` + +The 180-second fixture limit includes compilation and the test. Start it again +if Cargo compilation consumed that budget. To clean up, allow `timeout` to drain +and kill the process group, then remove the temporary directory: + +```sh +wait "$NEXT_FIXTURE_PID" || true +rm -rf -- "$NEXT_FIXTURE_ROOT" +``` + +This opt-in test fails if setup is absent. A default-suite ignored result is not +proof of the required real Next.js regression. It covers an aborted inbound +POST before a real progressively enhanced action/API sequence. The ordinary +synthetic test separately covers a truncated upstream POST response. Neither +claims to reproduce the historical failure until executed and compared. diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js new file mode 100644 index 0000000000..c143c008f6 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js @@ -0,0 +1,10 @@ +'use server' + +import { redirect } from 'next/navigation' + +export async function acceptMessage(formData) { + if (formData.get('message') !== 'native-acceptance') { + throw new Error('Unexpected synthetic acceptance message') + } + redirect('/?action=accepted') +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js new file mode 100644 index 0000000000..06c255dc24 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ native: 'real-nextjs-api' }) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx new file mode 100644 index 0000000000..0ea378c6fc --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx new file mode 100644 index 0000000000..84481db839 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx @@ -0,0 +1,13 @@ +import { acceptMessage } from './actions' + +export default function Page() { + return ( +
+

Native real Next.js acceptance

+
+ + +
+
+ ) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs new file mode 100644 index 0000000000..234915ea0f --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs @@ -0,0 +1,6 @@ +// The local origin explicitly trusts only this fixture publisher for actions. +export default { + experimental: { + serverActions: { allowedOrigins: ['publisher.example.com'] }, + }, +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs b/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs new file mode 100644 index 0000000000..5ad9611924 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs @@ -0,0 +1,148 @@ +use std::io; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use axum::Router; +use axum::body::{Body, Bytes, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::Response; +use tokio::sync::oneshot; +use trusted_server_core::test_support::nextjs_auction; + +#[derive(Clone, Debug)] +pub struct Received { + pub path: String, + pub body: Vec, + pub next_action: Option, +} + +#[derive(Clone)] +struct OriginState { + base_url: String, + label: String, + received: Arc>>, +} + +pub struct Origin { + pub base_url: String, + pub received: Arc>>, + stop: Option>, + worker: Option>, +} + +impl Origin { + pub fn start(label: &str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind fixture origin"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("should get origin address") + ); + listener + .set_nonblocking(true) + .expect("should set nonblocking listener"); + let received = Arc::new(Mutex::new(Vec::new())); + let state = OriginState { + base_url: base_url.clone(), + label: label.to_owned(), + received: Arc::clone(&received), + }; + let (stop, stopped) = oneshot::channel(); + let worker = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build fixture runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener) + .expect("should adopt fixture listener"); + let router = Router::new().fallback(origin).with_state(state); + tokio::select! { + result = axum::serve(listener, router) => result.expect("should serve fixture"), + _ = stopped => {} + } + }); + }); + Self { + base_url, + received, + stop: Some(stop), + worker: Some(worker), + } + } +} + +impl Drop for Origin { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(worker) = self.worker.take() { + worker.join().expect("should stop fixture runtime"); + } + } +} + +async fn origin(State(state): State, request: Request) -> Response { + let path = request.uri().path().to_owned(); + let next_action = request.headers().get("next-action").map(|value| { + value + .to_str() + .expect("should read action header") + .to_owned() + }); + let body = to_bytes(request.into_body(), 65536).await; + let Ok(body) = body else { + return Response::builder() + .status(400) + .body(Body::empty()) + .expect("should reject bad upload"); + }; + state + .received + .lock() + .expect("should lock origin observations") + .push(Received { + path: path.clone(), + body: body.to_vec(), + next_action: next_action.clone(), + }); + match path.as_str() { + // Force a response-framing failure after a fully received POST. The + // origin stays alive on the same port for the following action/API flow. + "/truncate-response" => Response::builder() + .header("content-type", "application/json") + .header("content-length", "1024") + .body(Body::from_stream(futures::stream::iter([ + Ok(Bytes::from_static(b"{\"partial\":")), + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "synthetic truncated POST response", + )), + ]))) + .expect("should build truncated response"), + "/api/data" => Response::builder() + .header("content-type", "application/json") + .body(Body::from(format!("{{\"native\":\"{}\"}}", state.label))) + .expect("should build API response"), + "/action" if next_action.is_some() => Response::builder() + .header("content-type", "text/x-component") + .body(Body::from("0:{\"accepted\":true}\n")) + .expect("should build action response"), + _ => { + let html = nextjs_auction::origin_html() + .replace("https://origin.test-publisher.example.com", &state.base_url) + .replace( + "

prefix

", + &format!( + "

{}

asset", + state.label, state.base_url, + ), + ); + Response::builder() + .header("content-type", "text/html") + .body(Body::from(html)) + .expect("should build publisher page") + } + } +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs b/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs new file mode 100644 index 0000000000..2406339358 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs @@ -0,0 +1,137 @@ +use std::net::TcpListener; +use std::thread::JoinHandle; +use std::time::Duration; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{Response, header}; +use tokio::sync::oneshot; + +#[derive(Clone)] +struct ProxyState { + upstream: String, + public_host: &'static str, + client: &'static str, +} + +/// A local gateway that replaces visitor assertions with its own fixed facts. +pub struct Proxy { + pub base_url: String, + stop: Option>, + worker: Option>, +} + +impl Proxy { + pub fn start(upstream: &str, public_host: &'static str, client: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind test gateway"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("should get gateway address") + ); + listener + .set_nonblocking(true) + .expect("should make gateway nonblocking"); + let state = ProxyState { + upstream: upstream.to_owned(), + public_host, + client, + }; + let (stop, stopped) = oneshot::channel(); + let worker = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build gateway runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener) + .expect("should adopt gateway listener"); + let router = Router::new().fallback(relay).with_state(state); + tokio::select! { + result = axum::serve(listener, router) => result.expect("should serve gateway"), + _ = stopped => {} + } + }); + }); + Self { + base_url, + stop: Some(stop), + worker: Some(worker), + } + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(worker) = self.worker.take() { + worker.join().expect("should stop gateway"); + } + } +} + +async fn relay(State(state): State, request: Request) -> Response { + let (parts, body) = request.into_parts(); + let body = to_bytes(body, 65536) + .await + .expect("should read bounded gateway request"); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(3)) + .build() + .expect("should build gateway HTTP client"); + let mut headers = parts.headers; + headers.remove("forwarded"); + let forwarding_names = headers + .keys() + .filter(|name| name.as_str().starts_with("x-forwarded-")) + .cloned() + .collect::>(); + for name in forwarding_names { + headers.remove(name); + } + headers.remove(header::HOST); + headers.remove(header::CONNECTION); + headers.insert( + "forwarded", + format!( + "for={};host=\"{}\";proto=https", + state.client, state.public_host, + ) + .parse() + .expect("should construct gateway assertion"), + ); + let url = format!( + "{}{}", + state.upstream, + parts + .uri + .path_and_query() + .map_or("/", |value| value.as_str()) + ); + let response = client + .request(parts.method, url) + .headers(headers) + .body(body) + .send() + .await + .expect("should relay request through real TS listener"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .bytes() + .await + .expect("should read bounded test page"); + assert!( + body.len() < 2_000_000, + "should keep gateway fixture responses small" + ); + let mut response = Response::builder() + .status(status) + .body(Body::from(body)) + .expect("should construct relayed page"); + *response.headers_mut() = headers; + response +} diff --git a/crates/trusted-server-adapter-axum/tests/native_acceptance.rs b/crates/trusted-server-adapter-axum/tests/native_acceptance.rs new file mode 100644 index 0000000000..8f39717749 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_acceptance.rs @@ -0,0 +1,735 @@ +//! Subprocess acceptance for strict native startup and managed TS requests. + +#[path = "fixtures/native/origin.rs"] +mod origin; +#[path = "fixtures/native/proxy.rs"] +mod proxy; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use edgezero_core::blob_envelope::BlobEnvelope; +use reqwest::blocking::{Client, Response}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::config_payload::{ + CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID, VerifiedConfigBlob, +}; +use trusted_server_core::test_support::nextjs_auction; + +const ADMIN_PASSWORD: &str = "native-admin-password-32-bytes-example"; +const PROXY_SECRET: &str = "native-proxy-secret-32-bytes-example"; +const EC_PASSPHRASE: &str = "native-ec-passphrase-32-bytes-example"; + +struct Prepared { + workspace: TempDir, + data: Value, + secrets: BTreeMap, +} + +impl Prepared { + fn new(origin: &str) -> Self { + // Reuse core's typed Next.js fixture and the production BlobEnvelope. + // Only the existing TS shape is customized, never another config format. + let mut settings = nextjs_auction::settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.publisher.cookie_domain = ".publisher.example.com".to_owned(); + settings.publisher.origin_url = origin.to_owned(); + settings.auction.enabled = false; + settings.auction.providers.clear(); + settings.creative_opportunities = None; + let config = + TrustedServerAppConfig::new(settings).expect("should validate fixture settings"); + let mut data = serde_json::to_value(config).expect("should serialize typed TS config"); + data["handlers"][0]["password"] = json!("native_admin"); + let mut protected_handler = data["handlers"][0].clone(); + protected_handler["path"] = json!("^/protected"); + data["handlers"] + .as_array_mut() + .expect("should have fixture handlers") + .push(protected_handler); + data["publisher"]["proxy_secret"] = json!("native_proxy"); + data["ec"]["passphrase"] = json!("native_ec"); + let fixture = Self { + workspace: tempfile::tempdir().expect("should create isolated prepared-store root"), + data, + secrets: BTreeMap::from([ + ("native_admin".to_owned(), ADMIN_PASSWORD.to_owned()), + ("native_proxy".to_owned(), PROXY_SECRET.to_owned()), + ("native_ec".to_owned(), EC_PASSPHRASE.to_owned()), + ]), + }; + let verified = VerifiedConfigBlob::parse(&fixture.envelope()) + .expect("should verify production envelope"); + assert_eq!( + verified + .required_secret_keys() + .expect("should read active references"), + fixture.secrets.keys().cloned().collect::>(), + "should prepare exactly active fixture secrets" + ); + fixture.publish(); + fixture + } + + fn envelope(&self) -> String { + serde_json::to_string(&BlobEnvelope::new( + self.data.clone(), + "2026-10-20T00:00:00Z".to_owned(), + )) + .expect("should serialize production envelope") + } + + fn config_path(&self) -> PathBuf { + self.workspace + .path() + .join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")) + } + + fn publish(&self) { + fs::write( + self.config_path(), + serde_json::to_vec(&json!({ CONFIG_BLOB_KEY: self.envelope() })) + .expect("should serialize local store map"), + ) + .expect("should publish local prepared config"); + } + + fn spawn(&self, trusted_proxy: bool) -> Running { + let port = TcpListener::bind("127.0.0.1:0") + .expect("should reserve isolated port") + .local_addr() + .expect("should read reserved port") + .port(); + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let root = self + .workspace + .path() + .canonicalize() + .expect("should use absolute store roots"); + let log_path = root.join(format!("native-{port}.log")); + let log = File::create(&log_path).expect("should create child log"); + let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); + command + .env_clear() + .current_dir(&root) + .env("RUST_LOG", "info") + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", port.to_string()) + .env("EDGEZERO__ADAPTER__CONFIG_DIR", &root) + .env("EDGEZERO__ADAPTER__DATA_DIR", &root) + .env("EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", "1") + // A valid old bridge cannot rescue missing prepared inputs. An + // invalid settings override cannot poison a valid prepared input. + .env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + self.envelope(), + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_ADMIN", + ADMIN_PASSWORD, + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_PROXY", + PROXY_SECRET, + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_EC", + EC_PASSPHRASE, + ) + .env( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + "http://poison.example.com", + ) + .env("TRUSTED_SERVER__EC__PASSPHRASE", "poison") + .stdout(Stdio::from( + log.try_clone().expect("should clone child log"), + )) + .stderr(Stdio::from(log)); + for (key, value) in &self.secrets { + command.env( + format!("EDGEZERO__SECRETS__{}", key.to_ascii_uppercase()), + value, + ); + } + if trusted_proxy { + command + .env("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "127.0.0.1/32") + .env("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "forwarded"); + } + Running { + child: command + .spawn() + .expect("should launch real native TS binary"), + address, + log_path, + } + } +} + +struct Running { + child: Child, + address: SocketAddr, + log_path: PathBuf, +} + +impl Running { + fn base_url(&self) -> String { + format!("http://{}", self.address) + } + + fn log(&self) -> String { + fs::read_to_string(&self.log_path).unwrap_or_default() + } + + fn ready(&mut self) { + let client = client(None); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + assert!( + self.child + .try_wait() + .expect("should poll child exit") + .is_none(), + "should not exit before readiness: {}", + self.log() + ); + if let Ok(response) = client.get(format!("{}/health", self.base_url())).send() + && response.status().as_u16() == 200 + && response.text().is_ok_and(|body| body == "ok") + { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + Instant::now() < deadline, + "should reach exact HTTP 200 health body ok: {}", + self.log() + ); + } + + fn rejected(&mut self, diagnostic: &str) { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + assert!( + TcpStream::connect_timeout(&self.address, Duration::from_millis(30)).is_err(), + "should fail before opening listener: {}", + self.log() + ); + if let Some(status) = self.child.try_wait().expect("should poll rejected child") { + assert!(!status.success(), "should exit nonzero for invalid startup"); + let log = self.log(); + assert!( + log.contains(diagnostic), + "should report {diagnostic}: {log}" + ); + for secret in [ADMIN_PASSWORD, PROXY_SECRET, EC_PASSPHRASE] { + assert!(!log.contains(secret), "should not log secret values"); + } + return; + } + assert!( + Instant::now() < deadline, + "should reject startup within deadline: {}", + self.log() + ); + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.child.kill(); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + log::error!( + "native child did not exit within bounded teardown: {}", + self.log() + ); + } +} + +fn client(source: Option) -> Client { + Client::builder() + .no_proxy() + .local_address(source) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + .expect("should build bounded local client") +} + +fn successful_body(response: Response) -> String { + assert_eq!( + response.status().as_u16(), + 200, + "should serve real TS request" + ); + response.text().expect("should collect TS response") +} + +fn page(client: &Client, base_url: &str) -> String { + successful_body( + client + .get(format!("{base_url}/")) + .send() + .expect("should fetch TS publisher page"), + ) +} + +fn assert_rewrite(body: &str, public_origin: &str, origin: &str) { + assert!( + body.contains(&format!("href=\"{public_origin}/asset\"")), + "should rewrite using request-local checked public URL: {body}" + ); + assert!( + !body.contains(&format!("href=\"{origin}/asset\"")), + "should not leak origin href" + ); +} + +#[test] +fn prepared_envelope_serves_real_health_tsjs_and_rewritten_page() { + let origin = origin::Origin::start("native-page"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + let body = page(&client, &app.base_url()); + assert!( + body.contains("native-page"), + "should reach actual configured origin" + ); + assert!( + body.contains("self.__next_f.push"), + "should retain core Next.js fixture Flight data" + ); + assert_rewrite(&body, &app.base_url(), &origin.base_url); + let start = body + .find("/static/tsjs=") + .expect("should inject real TSJS URL"); + let end = body[start..] + .find('"') + .expect("should terminate TSJS source attribute"); + let script = body[start..start + end].replace("&", "&"); + let response = client + .get(format!("{}{script}", app.base_url())) + .send() + .expect("should fetch injected TSJS URL"); + assert!( + response + .headers() + .get("content-type") + .expect("should serve script content type") + .to_str() + .expect("should read content type") + .contains("javascript") + ); + assert!( + successful_body(response).len() > 100, + "should serve embedded bundle, not an empty router" + ); +} + +#[test] +fn missing_prepared_inputs_and_invalid_runtime_config_exit_before_listen() { + let origin = origin::Origin::start("unused"); + let fixture = Prepared::new(&origin.base_url); + fs::remove_file(fixture.config_path()).expect("should remove required config snapshot"); + fixture.spawn(false).rejected("required snapshot missing"); + fs::write(fixture.config_path(), "{}").expect("should publish empty config map"); + fixture.spawn(false).rejected("config envelope is missing"); + fixture.publish(); + for key in ["native_admin", "native_proxy", "native_ec"] { + let mut incomplete = Prepared::new(&origin.base_url); + incomplete.secrets.remove(key); + incomplete + .spawn(false) + .rejected("a required app-config secret is missing"); + } + let mut invalid = Prepared::new(&origin.base_url); + invalid.data["publisher"]["origin_url"] = json!("not-a-valid-url"); + invalid.publish(); + invalid + .spawn(false) + .rejected("Trusted Server initialization failed"); + let mut invalid = Prepared::new(&origin.base_url); + invalid + .secrets + .insert("native_ec".to_owned(), "short".to_owned()); + invalid + .spawn(false) + .rejected("Trusted Server initialization failed"); +} + +#[test] +fn absolute_request_target_cannot_override_checked_public_scheme() { + let origin = origin::Origin::start("checked-target"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let mut socket = + TcpStream::connect(app.address).expect("should connect directly to native ingress"); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound the response read"); + socket.write_all(b"GET https://publisher.example.com/first-party/sign?url=%2F%2Fads.example.com%2Fcreative HTTP/1.1\r\nHost: publisher.example.com\r\nX-Forwarded-Proto: https\r\nConnection: close\r\n\r\n") + .expect("should submit an absolute request target with a spoofed scheme"); + let mut response = String::new(); + socket + .take(64 * 1024) + .read_to_string(&mut response) + .expect("should read a bounded native response"); + assert!( + response.starts_with("HTTP/1.1 200"), + "should execute the local signing route: {response}" + ); + assert!( + response.contains("http://ads.example.com"), + "should use checked direct HTTP, including URI-based consumers: {response}" + ); + assert!( + !response.contains("https://ads.example.com"), + "should not adopt raw target or forwarding scheme" + ); +} + +#[test] +fn managed_post_bodies_reach_buffered_api_handlers_and_keep_their_limits() { + let origin = origin::Origin::start("buffered-api"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + let auction = client + .post(format!("{}/auction", app.base_url())) + .json(&json!({"adUnits": []})) + .send() + .expect("should submit a lazy managed auction body"); + assert_eq!(auction.status().as_u16(), 200); + let auction: Value = auction + .json() + .expect("should receive a real OpenRTB no-bid response"); + assert!( + auction + .get("seatbid") + .is_none_or(|bids| bids.as_array().is_some_and(Vec::is_empty)) + ); + let signed = client + .post(format!("{}/first-party/sign", app.base_url())) + .json(&json!({"url": "https://ads.example.com/creative"})) + .send() + .expect("should submit first-party signing JSON"); + assert_eq!( + signed.status().as_u16(), + 200, + "should buffer the lazy request before the existing synchronous parser" + ); + let verified = client + .post(format!("{}/verify-signature", app.base_url())) + .json(&json!({"payload": "fixture", "signature": "invalid", "kid": "fixture"})) + .send() + .expect("should submit verification JSON"); + assert_eq!( + verified.status().as_u16(), + 200, + "missing key is a verification result, not a discarded-body parse failure" + ); + let oversized = client + .post(format!("{}/verify-signature", app.base_url())) + .json(&json!({"payload": "x".repeat(4096), "signature": "invalid", "kid": "fixture"})) + .send() + .expect("should reject the bounded API upload"); + assert_eq!(oversized.status().as_u16(), 413); +} + +#[test] +fn running_snapshot_is_immutable_and_restart_adopts_config_and_secret() { + let first = origin::Origin::start("snapshot-first"); + let second = origin::Origin::start("snapshot-second"); + let mut fixture = Prepared::new(&first.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + assert!(page(&client, &app.base_url()).contains("snapshot-first")); + fixture.data["publisher"]["origin_url"] = json!(second.base_url); + fixture.secrets.insert( + "native_admin".to_owned(), + "replacement-admin-password-32-bytes-example".to_owned(), + ); + fixture.publish(); + assert!( + page(&client, &app.base_url()).contains("snapshot-first"), + "should not reload edited config under a running app" + ); + let protected = format!("{}/protected", app.base_url()); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", Some(ADMIN_PASSWORD)) + .send() + .expect("should authorize old snapshot password") + .status() + .as_u16(), + 200 + ); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", fixture.secrets.get("native_admin")) + .send() + .expect("should reject unpublished child secret change") + .status() + .as_u16(), + 401 + ); + drop(app); + let mut app = fixture.spawn(false); + app.ready(); + assert!( + page(&client, &app.base_url()).contains("snapshot-second"), + "should adopt config on restart" + ); + let protected = format!("{}/protected", app.base_url()); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", Some(ADMIN_PASSWORD)) + .send() + .expect("should reject superseded password") + .status() + .as_u16(), + 401 + ); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", fixture.secrets.get("native_admin")) + .send() + .expect("should authorize restarted secret") + .status() + .as_u16(), + 200 + ); +} + +#[test] +fn managed_ingress_ignores_direct_spoof_and_isolates_two_gateway_clients() { + let origin = origin::Origin::start("ingress-page"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let visitor = client(None); + let response = visitor + .get(app.base_url()) + .header( + "forwarded", + "for=192.0.2.99;host=spoof.example.com;proto=https", + ) + .header("x-forwarded-host", "spoof.example.com") + .header("x-forwarded-proto", "https") + .header("x-forwarded-for", "192.0.2.99") + .send() + .expect("should send untrusted forwarding assertions"); + let body = successful_body(response); + assert_rewrite(&body, &app.base_url(), &origin.base_url); + assert!(!body.contains("https://spoof.example.com/asset")); + drop(app); + + let mut app = fixture.spawn(true); + app.ready(); + // Only the gateways connect from the declared trusted 127.0.0.1 address. + // Visitors use distinct source addresses and independent client pools. + let alpha_client = client(Some( + "127.0.0.2".parse().expect("should parse alpha source"), + )); + let beta_client = client(Some("127.0.0.3".parse().expect("should parse beta source"))); + let direct = alpha_client + .get(app.base_url()) + .header( + "forwarded", + "for=192.0.2.99;host=spoof.example.com;proto=https", + ) + .send() + .expect("should bypass gateway from an untrusted peer"); + assert_rewrite(&successful_body(direct), &app.base_url(), &origin.base_url); + let alpha = proxy::Proxy::start(&app.base_url(), "alpha.example.com", "192.0.2.10"); + let beta = proxy::Proxy::start(&app.base_url(), "beta.example.com", "192.0.2.20"); + std::thread::scope(|scope| { + let alpha_request = scope.spawn(|| page(&alpha_client, &alpha.base_url)); + let beta_request = scope.spawn(|| page(&beta_client, &beta.base_url)); + let alpha_body = alpha_request.join().expect("should complete alpha request"); + let beta_body = beta_request.join().expect("should complete beta request"); + assert_rewrite(&alpha_body, "https://alpha.example.com", &origin.base_url); + assert_rewrite(&beta_body, "https://beta.example.com", &origin.base_url); + assert!(!alpha_body.contains("https://beta.example.com/asset")); + assert!(!beta_body.contains("https://alpha.example.com/asset")); + }); + assert_rewrite( + &page(&alpha_client, &alpha.base_url), + "https://alpha.example.com", + &origin.base_url, + ); +} + +fn truncate_inbound_post(app: &Running) { + let mut socket = TcpStream::connect_timeout(&app.address, Duration::from_secs(1)) + .expect("should connect partial POST"); + socket + .set_write_timeout(Some(Duration::from_secs(1))) + .expect("should bound partial write"); + write!(socket, + "POST /action HTTP/1.1\r\nHost: {}\r\nNext-Action: native-action\r\nContent-Type: text/plain\r\nContent-Length: 128\r\n\r\n[", + app.address, + ).expect("should write deliberately incomplete POST"); + // Closing without the declared bytes reproduces an aborted upload. + drop(socket); +} + +#[test] +fn truncated_post_then_action_and_api_keep_using_the_same_live_origin() { + let origin = origin::Origin::start("transport-sequence"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + truncate_inbound_post(&app); + let broken = client + .post(format!("{}/truncate-response", app.base_url())) + .body("[]") + .send(); + match broken { + Err(_) => {} + Ok(response) if response.status().is_server_error() => {} + Ok(response) => assert!( + response.bytes().is_err(), + "should expose truncated origin response" + ), + } + for _ in 0..3 { + let response = client + .post(format!("{}/action", app.base_url())) + .header("next-action", "native-action") + .header("content-type", "text/plain;charset=UTF-8") + .body("[\"synthetic-action\"]") + .send() + .expect("should forward action after truncated POST"); + assert_eq!(successful_body(response), "0:{\"accepted\":true}\n"); + let body = successful_body( + client + .get(format!("{}/api/data", app.base_url())) + .send() + .expect("should forward API after action"), + ); + assert_eq!( + serde_json::from_str::(&body).expect("should retain API JSON"), + json!({"native": "transport-sequence"}) + ); + } + let received = origin + .received + .lock() + .expect("should read origin observations"); + assert_eq!( + received + .iter() + .filter(|request| request.path == "/action" + && request.body == b"[\"synthetic-action\"]" + && request.next_action.as_deref() == Some("native-action")) + .count(), + 3, + "should deliver three complete action bodies despite preceding truncated POST" + ); +} + +/// The ordinary origin above checks wire contracts, not a real Next.js runtime. +/// Run this separately against the real server-action fixture described in +/// tests/fixtures/native/README.md. Missing external setup is never a pass. +#[test] +#[ignore = "requires the local real Next.js fixture and NATIVE_NEXTJS_ORIGIN"] +fn real_nextjs_action_and_api_after_truncated_post() { + let origin = std::env::var("NATIVE_NEXTJS_ORIGIN") + .expect("should explicitly supply local real Next.js origin"); + let url = reqwest::Url::parse(&origin).expect("should parse local Next.js URL"); + assert_eq!( + url.scheme(), + "http", + "should use account-free local HTTP fixture" + ); + assert!( + matches!(url.host_str(), Some("127.0.0.1" | "localhost")), + "should never contact external origin" + ); + let fixture = Prepared::new(&origin); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + // The fixture instructions prewarm Next's compiler before this test. + let body = page(&client, &app.base_url()); + let marker = "$ACTION_ID_"; + let start = body + .find(marker) + .expect("should find actual compiled server-action form") + + marker.len(); + let end = body[start..] + .find('"') + .expect("should terminate compiled action identifier"); + let action = &body[start..start + end]; + assert!( + !action.is_empty(), + "should extract nonempty real server-action ID" + ); + truncate_inbound_post(&app); + let boundary = "native-acceptance-boundary"; + let form = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"$ACTION_ID_{action}\"\r\n\r\n\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\nnative-acceptance\r\n--{boundary}--\r\n" + ); + let response = client + .post(app.base_url()) + .header("origin", "http://publisher.example.com") + .header("host", "publisher.example.com") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(form) + .send() + .expect("should invoke real Next.js server action after truncated upload"); + assert_eq!( + response.status().as_u16(), + 303, + "should execute real action redirect, not accept unknown-ID 404" + ); + assert!( + response + .headers() + .get("location") + .expect("should redirect from action") + .to_str() + .expect("should read action redirect") + .contains("action=accepted") + ); + let body = successful_body( + client + .get(format!("{}/api/data", app.base_url())) + .send() + .expect("should reach real Next.js API after real action"), + ); + assert_eq!( + serde_json::from_str::(&body).expect("should parse real API response"), + json!({"native": "real-nextjs-api"}) + ); +} diff --git a/crates/trusted-server-adapter-axum/tests/native_http.rs b/crates/trusted-server-adapter-axum/tests/native_http.rs new file mode 100644 index 0000000000..1c5e56bd91 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_http.rs @@ -0,0 +1,415 @@ +//! Existing native HTTP regressions through the shared request-scoped SDK client. +//! The retired batch-driver protocol remains an SDK responsibility. + +use std::time::Duration; + +use edgezero_adapter_axum::outbound::AxumOutboundClient; +use edgezero_core::body::Body; +use edgezero_core::http::{Method, StatusCode, header, request_builder}; +use edgezero_core::outbound::{HttpClient, ResponseDecoding}; +use futures::{StreamExt as _, stream}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::task::LocalSet; +use trusted_server_core::platform::{ + AuctionTargetId, EdgeZeroHttpClient, PlatformHttpClient as _, PlatformHttpRequest, +}; + +fn bridge() -> EdgeZeroHttpClient { + let client = AxumOutboundClient::try_new().expect("should construct native RC client"); + EdgeZeroHttpClient::new(HttpClient::with_client(client), AuctionTargetId::Axum) + .response_decoding(ResponseDecoding::Decode) +} + +fn request(url: &str, backend: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + request_builder() + .uri(url) + .body(Body::empty()) + .expect("should build request"), + backend, + ) +} + +async fn body_bytes(body: Body) -> Vec { + body.into_bytes_bounded(16 * 1024 * 1024) + .await + .expect("should collect the complete fixture response with its owner") + .to_vec() +} + +async fn read_request_head(socket: &mut tokio::net::TcpStream) { + let mut head = Vec::new(); + let mut chunk = [0; 2048]; + loop { + let count = socket + .read(&mut chunk) + .await + .expect("should read request head"); + assert!(count > 0, "should not close before the request head"); + head.extend_from_slice(&chunk[..count]); + assert!( + head.len() <= 64 * 1024, + "should bound fixture request headers" + ); + if head.windows(4).any(|window| window == b"\r\n\r\n") { + return; + } + } +} + +async fn raw_server(response: &'static [u8]) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind raw origin"); + let address = listener.local_addr().expect("should read origin address"); + tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("should accept origin request"); + read_request_head(&mut socket).await; + socket + .write_all(response) + .await + .expect("should send origin response"); + }); + format!("http://{address}/") +} + +#[tokio::test(flavor = "current_thread")] +async fn redirects_are_exposed_for_core_per_hop_authorization() { + LocalSet::new().run_until(async { + + let url = raw_server( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: https://example.com/next\r\nContent-Length: 0\r\n\r\n", + ).await; + let response = bridge() + .send(request(&url, "redirect")) + .await + .expect("should return redirect without following it") + .response; + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers()[header::LOCATION], + "https://example.com/next" + ); + + }).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn duplicate_cookies_survive_and_hop_headers_do_not() { + LocalSet::new().run_until(async { + + let url = raw_server( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nSet-Cookie: first=1\r\nSet-Cookie: second=2\r\nConnection: close, x-remove\r\nX-Remove: hidden\r\n\r\nok", + ).await; + let response = bridge() + .send(request(&url, "cookies")) + .await + .expect("should return response") + .response; + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert!(!response.headers().contains_key(header::CONNECTION)); + assert!(!response.headers().contains_key("x-remove")); + assert_eq!(body_bytes(response.into_body()).await, b"ok"); + + }).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn rc_owns_gzip_decoding_and_ts_receives_matching_identity_metadata() { + LocalSet::new().run_until(async { + + let url = raw_server( + b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 22\r\n\r\n\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\xcb\xcf\x06\x00\x47\xdd\xdc\x79\x02\x00\x00\x00", + ).await; + let response = bridge() + .send(request(&url, "gzip")) + .await + .expect("should decode gzip once in RC") + .response; + assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); + assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); + assert_eq!(body_bytes(response.into_body()).await, b"ok"); + + }).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dropping_streamed_response_releases_the_origin_connection() { + LocalSet::new() + .run_until(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind streaming origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + let mut buffer = [0; 4096]; + read_request_head(&mut socket).await; + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\n\r\nx") + .await + .expect("should write response head"); + tokio::time::timeout(Duration::from_secs(1), socket.read(&mut buffer)).await + }); + let response = bridge() + .send( + request(&format!("http://{address}/"), "dropped-stream").with_stream_response(), + ) + .await + .expect("should receive stream"); + drop(response); + let closed = origin + .await + .expect("should join origin") + .expect("should release origin promptly"); + assert!( + matches!(closed, Ok(0)) || closed.is_err(), + "should close rather than leak the unread response" + ); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn caller_response_bound_applies_to_send_and_pending() { + LocalSet::new() + .run_until(async { + for pending in [false, true] { + let url = raw_server(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody").await; + let mut outbound = request(&url, "bounded"); + outbound.max_response_body_bytes = Some(3); + let client = bridge(); + let result = if pending { + let handle = client + .send_async(outbound) + .await + .expect("should start bounded request"); + client.wait(handle).await + } else { + client.send(outbound).await + }; + assert!( + result.is_err(), + "should reject a response over the caller's bound" + ); + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn streamed_upload_is_bounded_before_network_work() { + LocalSet::new() + .run_until(async { + for pending in [false, true] { + let source = stream::iter([Ok(Body::from("oversized") + .into_bytes() + .expect("should have bytes"))]); + let edge_request = request_builder() + .method(Method::POST) + .uri("http://example.com/") + .body(Body::from_stream(source)) + .expect("should build streaming upload"); + let mut outbound = PlatformHttpRequest::new(edge_request, "upload"); + outbound.max_request_body_bytes = 3; + let rejected = if pending { + bridge().send_async(outbound).await.is_err() + } else { + bridge().send(outbound).await.is_err() + }; + assert!(rejected, "should bound upload before network work"); + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn pending_post_preserves_streamed_upload_and_duplicate_request_headers() { + LocalSet::new() + .run_until(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind POST origin"); + let address = listener.local_addr().expect("should read origin address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept POST"); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let read = socket + .read(&mut buffer) + .await + .expect("should read POST bytes"); + assert_ne!(read, 0, "should not truncate the uploaded body"); + request.extend_from_slice(&buffer[..read]); + if let Some(head_end) = + request.windows(4).position(|window| window == b"\r\n\r\n") + && request.len() >= head_end + 4 + 7 + { + break; + } + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .expect("should respond to POST"); + request + }); + let source = stream::iter([ + Ok(Body::from("one") + .into_bytes() + .expect("should have first chunk")), + Ok(Body::from("-two") + .into_bytes() + .expect("should have second chunk")), + ]); + let outbound = request_builder() + .method(Method::POST) + .uri(format!("http://{address}/")) + .header(header::HOST, "publisher.example.com") + .header("x-example", "first") + .header("x-example", "second") + .body(Body::from_stream(source)) + .expect("should build streamed POST"); + let client = bridge(); + let pending = client + .send_async(PlatformHttpRequest::new(outbound, "post")) + .await + .expect("should start POST batch"); + tokio::time::timeout(Duration::from_secs(1), client.wait(pending)) + .await + .expect("should finish POST promptly") + .expect("should complete POST"); + let received = origin.await.expect("should join POST origin"); + let received = String::from_utf8(received).expect("should have textual fixture bytes"); + assert!(received.starts_with("POST / HTTP/1.1\r\n")); + assert!(received.ends_with("\r\n\r\none-two")); + assert!(received.contains("host: publisher.example.com\r\n")); + assert!(received.contains("x-example: first\r\n")); + assert!(received.contains("x-example: second\r\n")); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn expired_caller_deadline_is_rejected_before_network_work() { + LocalSet::new() + .run_until(async { + for pending in [false, true] { + let transport = + AxumOutboundClient::try_new().expect("should construct native transport"); + let sdk = HttpClient::with_client(transport); + let clock = sdk.time().clock().clone(); + let client = EdgeZeroHttpClient::new(sdk, AuctionTargetId::Axum); + let mut outbound = request("http://example.com/", "expired"); + outbound.deadline = Some(clock.deadline_after(Duration::ZERO)); + let rejected = if pending { + client.send_async(outbound).await.is_err() + } else { + client.send(outbound).await.is_err() + }; + assert!(rejected, "should reject expired absolute caller deadline"); + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn bodyless_streaming_response_keeps_head_representation_length() { + LocalSet::new() + .run_until(async { + let url = raw_server(b"HTTP/1.1 200 OK\r\nContent-Length: 9000\r\n\r\n").await; + let edge_request = request_builder() + .method(Method::HEAD) + .uri(url) + .body(Body::empty()) + .expect("should build HEAD request"); + let response = bridge() + .send(PlatformHttpRequest::new(edge_request, "head").with_stream_response()) + .await + .expect("should finish bodyless response") + .response; + assert_eq!(response.headers()[header::CONTENT_LENGTH], "9000"); + assert!(body_bytes(response.into_body()).await.is_empty()); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn total_timeout_covers_waiting_for_response_headers() { + LocalSet::new() + .run_until(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind stalled origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + read_request_head(&mut socket).await; + tokio::time::sleep(Duration::from_secs(5)).await; + }); + let mut outbound = request(&format!("http://{address}/"), "timeout"); + outbound.timeout = Some(Duration::from_millis(30)); + let result = tokio::time::timeout(Duration::from_secs(1), bridge().send(outbound)) + .await + .expect("should enforce total transport timeout"); + origin.abort(); + assert!(result.is_err(), "should reject stalled origin"); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn streamed_body_retains_total_timeout_after_response_head() { + LocalSet::new() + .run_until(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind streaming origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + read_request_head(&mut socket).await; + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nx") + .await + .expect("should write partial response"); + tokio::time::sleep(Duration::from_secs(5)).await; + }); + let mut outbound = + request(&format!("http://{address}/"), "stream-timeout").with_stream_response(); + outbound.timeout = Some(Duration::from_millis(100)); + let response = bridge() + .send(outbound) + .await + .expect("should receive response head") + .response; + let mut stream = response + .into_body() + .into_stream() + .expect("should preserve streamed response body"); + let result = tokio::time::timeout(Duration::from_secs(1), async { + while let Some(chunk) = stream.next().await { + if chunk.is_err() { + return true; + } + } + false + }) + .await + .expect("should enforce total streamed-body timeout"); + origin.abort(); + assert!(result, "should surface a body timeout instead of clean EOF"); + }) + .await; +} diff --git a/crates/trusted-server-adapter-axum/tests/startup.rs b/crates/trusted-server-adapter-axum/tests/startup.rs new file mode 100644 index 0000000000..c381d65794 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -0,0 +1,319 @@ +//! Native startup contracts exercised through the real production binary. + +use std::fs; +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use edgezero_core::app::{Hooks as _, StoreMetadata}; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_core::config_payload::{DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID}; + +// The hash covers the sorted, compact data object, using the existing TS +// BlobEnvelope v1 format. All values are synthetic local fixture inputs. +const ENVELOPE: &str = r#"{"data":{"ec":{"passphrase":"ec_key"},"handlers":[{"password":"admin_key","path":"^/_ts/admin","username":"admin"}],"publisher":{"cookie_domain":".publisher.example.com","domain":"publisher.example.com","origin_url":"https://origin.publisher.example.com","proxy_secret":"proxy_key"},"request_signing":null},"sha256":"f4dc03017deb24bfd589efde7dce0f9f8a20d952c28ebcbdb6141b08661cd137","version":1,"generated_at":"2026-10-20T00:00:00Z"}"#; +static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +struct ServerFixture { + root: PathBuf, + addr: SocketAddr, + child: Option, +} + +impl ServerFixture { + fn new(envelope: Option<&str>) -> Self { + let root = std::env::temp_dir().join(format!( + "ts-native-startup-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&root).expect("should create isolated native config directory"); + if let Some(envelope) = envelope { + // The fixed fixture is ASCII; Debug escaping yields the JSON string + // value required by the local provider's outer key-to-string map. + fs::write( + root.join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")), + format!("{{{DEFAULT_CONFIG_STORE_ID:?}:{envelope:?}}}"), + ) + .expect("should write a local config envelope map"); + } + let listener = TcpListener::bind("127.0.0.1:0").expect("should reserve a fresh test port"); + let addr = listener.local_addr().expect("should read the test address"); + drop(listener); + Self { + root, + addr, + child: None, + } + } + + fn spawn(&mut self, missing_secret: bool, short_passphrase: bool) { + let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); + command + .env_clear() + .env("RUST_LOG", "info") + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", self.addr.port().to_string()) + .env("EDGEZERO__ADAPTER__CONFIG_DIR", &self.root) + .env( + "EDGEZERO__SECRETS__PROXY_KEY", + "native-fixture-proxy-secret-32-bytes", + ) + .env( + "EDGEZERO__SECRETS__ADMIN_KEY", + "native-fixture-admin-password", + ) + // These poisoned legacy values must never become native inputs. + .env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + "poisoned-legacy-config", + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_KEY", + "poisoned-legacy-secret", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if !missing_secret { + command.env( + "EDGEZERO__SECRETS__EC_KEY", + if short_passphrase { + "short" + } else { + "native-fixture-passphrase-32-bytes-minimum" + }, + ); + } + self.child = Some( + command + .spawn() + .expect("should spawn production native binary"), + ); + } + + fn assert_failed_before_listen(&mut self, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + TcpStream::connect_timeout(&self.addr, Duration::from_millis(20)).is_err(), + "should not listen while required startup inputs are invalid" + ); + if let Some(status) = self + .child + .as_mut() + .expect("should own child") + .try_wait() + .expect("should poll production child") + { + assert!( + !status.success(), + "should exit nonzero on initialization failure" + ); + let output = self + .child + .take() + .expect("should own child output") + .wait_with_output() + .expect("should collect startup diagnostics"); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + diagnostic.contains(expected), + "should explain startup failure: {diagnostic}" + ); + assert!( + !diagnostic.contains("poisoned-legacy"), + "should not expose legacy inputs" + ); + assert!( + !diagnostic.contains("native-fixture-passphrase"), + "should not expose resolved secrets" + ); + return; + } + assert!( + Instant::now() < deadline, + "should fail startup within the test bound" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn wait_ready(&mut self) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(response) = self.get("/health") { + assert!( + response.starts_with("HTTP/1.1 200"), + "should become healthy only after successful initialization: {response}" + ); + return response; + } + let status = self + .child + .as_mut() + .expect("should own child") + .try_wait() + .expect("should poll child"); + let diagnostic = if status.is_some() { + let output = self + .child + .take() + .expect("should own failed child") + .wait_with_output() + .expect("should collect startup diagnostics"); + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } else { + String::new() + }; + assert!( + status.is_none(), + "should keep running after valid prepared-store initialization: {diagnostic}" + ); + assert!( + Instant::now() < deadline, + "should become ready within the test bound" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn get(&self, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect_timeout(&self.addr, Duration::from_millis(100))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: publisher.example.com\r\nConnection: close\r\n\r\n" + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) + } +} + +impl Drop for ServerFixture { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn missing_config_fails_before_listen_without_legacy_fallback() { + let mut server = ServerFixture::new(None); + server.spawn(false, false); + // Local preparation itself rejects an absent required file. + server.assert_failed_before_listen("startup"); +} + +#[test] +fn missing_secret_fails_before_listen_without_legacy_fallback() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(true, false); + server.assert_failed_before_listen("required app-config secret is missing"); +} + +#[test] +fn runtime_secret_validation_fails_before_listen() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(false, true); + server.assert_failed_before_listen("passphrase"); +} + +#[test] +fn tampered_envelope_fails_before_listen() { + let tampered = ENVELOPE.replace("f4dc0301", "00000000"); + let mut server = ServerFixture::new(Some(&tampered)); + server.spawn(false, false); + server.assert_failed_before_listen("integrity verification"); +} + +#[test] +fn valid_prepared_inputs_serve_real_routes_and_ignore_changed_config_until_restart() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(false, false); + let health = server.wait_ready(); + assert!(health.to_ascii_lowercase().contains("x-ts-version:")); + let tsjs = server + .get("/static/tsjs=0000000000000000") + .expect("should reach TSJS route"); + assert!( + tsjs.starts_with("HTTP/1.1 404"), + "should execute unknown-hash TSJS handler: {tsjs}" + ); + assert!( + tsjs.to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + fs::write( + server + .root + .join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")), + "invalid-changed-config", + ) + .expect("should replace fixture publication input"); + assert!( + server + .get("/health") + .expect("should retain initialized state") + .starts_with("HTTP/1.1 200") + ); +} + +#[test] +fn handwritten_store_metadata_matches_the_manifest() { + let manifest = include_str!("../../../edgezero.toml"); + let stores = TrustedServerApp::stores(); + for (kind, metadata) in [("config", stores.config), ("secrets", stores.secrets)] { + let StoreMetadata { default, ids } = + metadata.expect("should declare required native store"); + let section = manifest + .split(&format!("[stores.{kind}]\n")) + .nth(1) + .expect("should declare the same store kind in the manifest") + .split("\n[") + .next() + .expect("should read store section"); + let quoted_values = |name: &str| -> Vec<&str> { + section + .lines() + .find_map(|line| line.trim().strip_prefix(&format!("{name} = "))) + .expect("should contain the portable store field") + .split('"') + .enumerate() + .filter_map(|(index, part)| (index % 2 == 1).then_some(part)) + .collect() + }; + assert_eq!(quoted_values("default"), vec![default]); + assert_eq!(quoted_values("ids"), ids); + } + assert_eq!( + stores.config.expect("should declare config").default, + DEFAULT_CONFIG_STORE_ID + ); + assert_eq!( + stores.secrets.expect("should declare secrets").default, + DEFAULT_SECRET_STORE_ID + ); + assert!(stores.kv.is_none(), "should not prepare unused generic KV"); + // The portable manifest is the union of adapter stores. Fastly still + // declares generic KV; native deliberately prepares only config/secrets. + assert!( + manifest.contains("\n[stores.kv]"), + "should preserve the existing Fastly KV declaration in the portable manifest" + ); +} diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index e0615a7e7a..0e8127e554 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -5,13 +5,15 @@ //! stored [`edgezero_core::blob_envelope::BlobEnvelope`] and reconstruct //! [`Settings`] from its data value. +use std::collections::BTreeSet; + use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; use crate::platform::{PlatformSecretStore, StoreName}; -use crate::secret_resolution::resolve_secret_references; +use crate::secret_resolution::{resolve_secret_references, secret_reference_keys}; use crate::settings::Settings; /// Canonical logical secret store used by Trusted Server app-config secrets. @@ -46,29 +48,98 @@ pub fn settings_from_config_blob( secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, ) -> Result> { - let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to parse Trusted Server app-config blob envelope".to_string(), - }) - .attach(error.to_string()) - })?; - envelope.verify().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "Trusted Server app-config blob failed integrity verification".to_string(), - }) - .attach(error.to_string()) - })?; - - let mut data = envelope.into_data(); - remove_inactive_secret_references(&mut data); - resolve_secret_references::( - &mut data, - secret_store, - default_secret_store_name, - )?; - let settings = Settings::from_json_value(data)?; - crate::config::validate_settings_for_runtime(&settings)?; - Ok(settings) + VerifiedConfigBlob::parse(envelope_json)?.into_settings(secret_store, default_secret_store_name) +} + +/// Verified app-config with inactive secret references removed. +/// +/// Native startup can collect required keys before awaiting providers. Remove +/// this bridge when platform config and secret consumers become asynchronous. +pub struct VerifiedConfigBlob { + data: serde_json::Value, +} + +impl VerifiedConfigBlob { + /// Verify an envelope and remove references belonging to inactive features. + /// + /// # Errors + /// Returns a configuration error for malformed or tampered envelopes. + pub fn parse(envelope_json: &str) -> Result> { + let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to parse Trusted Server app-config blob envelope".to_string(), + }) + .attach(error.to_string()) + })?; + envelope.verify().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "Trusted Server app-config blob failed integrity verification".to_string(), + }) + .attach(error.to_string()) + })?; + + let mut data = envelope.into_data(); + remove_inactive_secret_references(&mut data); + Ok(Self { data }) + } + + /// Return the distinct required secret keys without consulting a provider. + /// + /// # Errors + /// Returns a configuration error for malformed secret paths or references. + pub fn required_secret_keys(&self) -> Result, Report> { + secret_reference_keys::(&self.data) + } + + /// Enforce finite JSON structure limits before traversing secret metadata. + /// + /// # Errors + /// Returns a configuration error when depth or node count exceeds the limits. + pub fn check_structure( + &self, + max_depth: u32, + max_nodes: u32, + ) -> Result<(), Report> { + let mut pending = vec![(&self.data, 1_u32)]; + let mut nodes = 0_u32; + while let Some((value, depth)) = pending.pop() { + nodes = nodes.saturating_add(1); + if depth > max_depth || nodes > max_nodes { + return Err(Report::new(TrustedServerError::Configuration { + message: "Trusted Server app-config exceeds JSON structure limits".to_owned(), + })); + } + match value { + serde_json::Value::Array(items) => { + pending.extend(items.iter().map(|item| (item, depth + 1))) + } + serde_json::Value::Object(items) => { + pending.extend(items.values().map(|item| (item, depth + 1))) + } + _ => {} + } + } + Ok(()) + } + + /// Resolve references from a finite store and validate runtime settings. + /// + /// # Errors + /// Returns a configuration error for missing secrets or invalid resolved settings. + pub fn into_settings( + mut self, + secret_store: &dyn PlatformSecretStore, + default_secret_store_name: &StoreName, + ) -> Result> { + resolve_secret_references::( + &mut self.data, + secret_store, + default_secret_store_name, + )?; + let settings = Settings::from_json_value(self.data)?; + crate::config::validate_settings_for_runtime(&settings)?; + Ok(settings) + } } fn remove_inactive_secret_references(data: &mut serde_json::Value) { @@ -723,12 +794,19 @@ mod tests { .partners .push(partner_with_pull_sync(false, "unused-partner-pull-token")); - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should skip inactive optional feature references"); + let blob = VerifiedConfigBlob::parse(&envelope_json(&original)) + .expect("should verify before collecting required references"); + let keys = blob + .required_secret_keys() + .expect("should collect active secret keys"); + assert!(keys.contains("partner-api-token-key")); + assert!( + !keys.iter().any(|key| key.starts_with("unused-")), + "should remove inactive references before awaiting a provider" + ); + let reconstructed = blob + .into_settings(&UnifiedSecretStore, &StoreName::from("ts_secrets")) + .expect("should skip inactive optional feature references"); assert!(reconstructed.tinybird.auction_token_secret.is_none()); assert!(reconstructed.ec.partners[0].ts_pull_token.is_none()); @@ -998,6 +1076,22 @@ mod tests { .expect("runtime should accept disabled browser bidder ownership overlap"); } + #[test] + fn verified_blob_enforces_structure_limits_before_key_collection() { + let blob = VerifiedConfigBlob::parse(&envelope_json(&test_settings())) + .expect("should verify valid envelope"); + blob.check_structure(64, 8192) + .expect("should accept bounded fixture structure"); + assert!( + blob.check_structure(1, 8192).is_err(), + "should reject excessive depth" + ); + assert!( + blob.check_structure(64, 1).is_err(), + "should reject excessive node count" + ); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index e9c8486e49..1f03a61995 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -192,9 +192,10 @@ impl RequestInfo { /// /// Host selection order: /// 1. Authenticated trusted-forwarder public origin - /// 2. Immutable ingress origin, then adapter runtime origin - /// 3. Nonempty `Host` header - /// 4. Request URI authority (host and port only, excluding userinfo) + /// 2. Prepared native public-URL metadata + /// 3. Immutable ingress origin, then adapter runtime origin + /// 4. Nonempty `Host` header + /// 5. Request URI authority (host and port only, excluding userinfo) /// /// Every selected host except the authenticated forwarder origin, which is /// validated more strictly when it is parsed, must be an ASCII hostname @@ -224,6 +225,14 @@ impl RequestInfo { scheme: origin.scheme.clone(), }); } + if let Some(info) = req.extensions().get::() { + if !is_valid_request_host(&info.host) + || !matches!(info.scheme.as_str(), "http" | "https") + { + return Err(invalid_request_host()); + } + return Ok(info.clone()); + } let runtime_origin = req .extensions() .get::() diff --git a/crates/trusted-server-core/src/platform/outbound.rs b/crates/trusted-server-core/src/platform/outbound.rs index c692f0c47d..c8a56f2870 100644 --- a/crates/trusted-server-core/src/platform/outbound.rs +++ b/crates/trusted-server-core/src/platform/outbound.rs @@ -32,6 +32,7 @@ pub struct EdgeZeroHttpClient { client: HttpClient, time: OutboundTime, target: AuctionTargetId, + response_decoding: Option, } impl EdgeZeroHttpClient { @@ -49,9 +50,26 @@ impl EdgeZeroHttpClient { client, time, target, + response_decoding: None, } } + /// Select the existing SDK decoding policy for an explicit adapter profile. + /// + /// This changes no transport, executor, body owner or deadline source. + /// + /// # Examples + /// + /// ```ignore + /// let client = EdgeZeroHttpClient::new(request_client, AuctionTargetId::Axum) + /// .response_decoding(ResponseDecoding::Decode); + /// ``` + #[must_use] + pub fn response_decoding(mut self, policy: ResponseDecoding) -> Self { + self.response_decoding = Some(policy); + self + } + /// Uses only the incoming request's transport, with no process-wide fallback. /// /// # Examples @@ -143,12 +161,12 @@ impl EdgeZeroHttpClient { } } }; - outbound = outbound - .cache_policy(cache_policy) - .response_decoding(match self.target { + outbound = outbound.cache_policy(cache_policy).response_decoding( + self.response_decoding.unwrap_or(match self.target { AuctionTargetId::Fastly | AuctionTargetId::Axum => ResponseDecoding::Original, AuctionTargetId::Cloudflare | AuctionTargetId::Spin => ResponseDecoding::Decode, - }); + }), + ); if let Some(options) = request.image_optimizer { outbound = outbound.image_optimizer(image_options(options)?); } @@ -385,7 +403,7 @@ impl PlatformHttpClient for EdgeZeroHttpClient { } fn supports_streaming_responses(&self) -> bool { - self.target == AuctionTargetId::Fastly + matches!(self.target, AuctionTargetId::Fastly | AuctionTargetId::Axum) } fn supports_pending_streaming_responses(&self) -> bool { diff --git a/crates/trusted-server-core/src/platform/outbound_native_tests.rs b/crates/trusted-server-core/src/platform/outbound_native_tests.rs index 40fc234280..94de9859f1 100644 --- a/crates/trusted-server-core/src/platform/outbound_native_tests.rs +++ b/crates/trusted-server-core/src/platform/outbound_native_tests.rs @@ -447,8 +447,8 @@ async fn collection_capabilities_and_invalid_handle_correlation_remain_explicit( ); assert_eq!( client.supports_streaming_responses(), - target == AuctionTargetId::Fastly, - "should preserve all three non-Fastly buffered-only contracts" + matches!(target, AuctionTargetId::Fastly | AuctionTargetId::Axum), + "should expose native SDK streaming while CF and Spin stay buffered-only" ); assert_eq!( client.supports_pending_streaming_responses(), diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs index f362a0b7b2..e45079ff07 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -4,6 +4,8 @@ //! public `EdgeZero` metadata contract and replaces those names only in the //! in-memory value used to build runtime [`crate::settings::Settings`]. +use std::collections::BTreeSet; + use edgezero_core::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde_json::Value; @@ -27,20 +29,57 @@ pub fn resolve_secret_references( default_store_name: &StoreName, ) -> Result<(), Report> { let mut resolved_data = data.clone(); + visit_secret_references::(&mut resolved_data, &mut |key, path| { + let resolved = secret_store + .get_string(default_store_name, key) + .map_err(|_| { + configuration_error(format!( + "failed to resolve secret reference at `{path}` from secret store \ + `{default_store_name}`" + )) + })?; + if resolved.is_empty() { + return Err(configuration_error(format!( + "resolved secret at `{path}` must not be empty" + ))); + } + Ok(resolved) + })?; + *data = resolved_data; + Ok(()) +} + +/// Collect the finite, deduplicated keys required by an app-config value. +/// +/// Uses the same metadata and structural checks as runtime resolution without +/// reading a store or changing the supplied value. +/// +/// # Errors +/// Returns a configuration error for missing or malformed secret references. +pub fn secret_reference_keys( + data: &Value, +) -> Result, Report> { + let mut keys = BTreeSet::new(); + visit_secret_references::(&mut data.clone(), &mut |key, _path| { + keys.insert(key.to_owned()); + Ok(key.to_owned()) + })?; + Ok(keys) +} + +type ResolveReference<'a> = + dyn FnMut(&str, &str) -> Result> + 'a; + +fn visit_secret_references( + data: &mut Value, + resolve: &mut ResolveReference<'_>, +) -> Result<(), Report> { for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_field( - &mut resolved_data, - &field, - &field.path, - "", - secret_store, - default_store_name, - )?; + resolve_field(data, &field, &field.path, "", resolve)?; } - *data = resolved_data; Ok(()) } @@ -49,30 +88,17 @@ fn resolve_field( field: &SecretField, remaining: &[SecretPathSegment], rendered_path: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, + resolve: &mut ResolveReference<'_>, ) -> Result<(), Report> { match remaining.split_first() { - Some((SecretPathSegment::Field(name), [])) => resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ), + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf(node, field, name.as_ref(), rendered_path, resolve) + } Some((SecretPathSegment::OptionalField(name), [])) => { if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { return Ok(()); } - resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ) + resolve_leaf(node, field, name.as_ref(), rendered_path, resolve) } Some((SecretPathSegment::Field(name), rest)) => { let next_path = join_field(rendered_path, name.as_ref()); @@ -83,14 +109,7 @@ fn resolve_field( if child.is_null() { return Err(missing_path(&next_path)); } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) + resolve_field(child, field, rest, &next_path, resolve) } Some((SecretPathSegment::OptionalField(name), rest)) => { let next_path = join_field(rendered_path, name.as_ref()); @@ -103,14 +122,7 @@ fn resolve_field( if child.is_null() { return Ok(()); } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) + resolve_field(child, field, rest, &next_path, resolve) } Some((SecretPathSegment::ArrayEach, rest)) => { let items = node.as_array_mut().ok_or_else(|| { @@ -118,14 +130,7 @@ fn resolve_field( })?; for (index, item) in items.iter_mut().enumerate() { let indexed_path = format!("{rendered_path}[{index}]"); - resolve_field( - item, - field, - rest, - &indexed_path, - secret_store, - default_store_name, - )?; + resolve_field(item, field, rest, &indexed_path, resolve)?; } Ok(()) } @@ -142,8 +147,7 @@ fn resolve_leaf( field: &SecretField, key: &str, rendered_parent: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, + resolve: &mut ResolveReference<'_>, ) -> Result<(), Report> { let leaf_path = join_field(rendered_parent, key); let object = parent.as_object_mut().ok_or_else(|| { @@ -166,20 +170,7 @@ fn resolve_leaf( } }; - let resolved = secret_store - .get_string(default_store_name, &key_name) - .map_err(|_| { - configuration_error(format!( - "failed to resolve secret reference at `{leaf_path}` from secret store \ - `{default_store_name}`" - )) - })?; - if resolved.is_empty() { - return Err(configuration_error(format!( - "resolved secret at `{leaf_path}` must not be empty" - ))); - } - + let resolved = resolve(&key_name, &leaf_path)?; object.insert(key.to_owned(), Value::String(resolved)); Ok(()) } @@ -281,6 +272,30 @@ mod tests { } } + #[test] + fn collects_deduplicated_keys_without_mutating_data() { + let data = serde_json::json!({ + "outer": [{"token": "token-a"}, {"token": "token-a", "optional": "token-b"}] + }); + let original = data.clone(); + let keys = + secret_reference_keys::(&data).expect("should collect finite references"); + assert_eq!( + keys, + BTreeSet::from(["token-a".to_owned(), "token-b".to_owned()]) + ); + assert_eq!( + data, original, + "should not mutate references during collection" + ); + } + + #[test] + fn collection_rejects_malformed_required_paths() { + let data = serde_json::json!({"outer": [{"token": false}]}); + assert!(secret_reference_keys::(&data).is_err()); + } + #[test] fn resolves_nested_array_values_and_skips_optional_nulls() { let mut data = serde_json::json!({ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 65179c5294..ba3af0fd6b 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -7239,6 +7239,14 @@ source_domain = "partner.example.com" ); } + #[test] + fn legacy_settings_loader_ignores_compiled_git_metadata() { + temp_env::with_var("TRUSTED_SERVER__GIT_VERSION", Some("fixture-build"), || { + Settings::from_toml_and_env(&crate_test_settings_str()) + .expect("should ignore build metadata in the test-only legacy settings loader"); + }); + } + #[test] fn test_set_env() { temp_env::with_var( diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md new file mode 100644 index 0000000000..a39e70ed94 --- /dev/null +++ b/docs/guide/native-rc-demo.md @@ -0,0 +1,144 @@ +# Native prepared-store startup + +This extracts the existing native demo onto the reviewed EdgeZero stack. It is +not production certification or an AWS deployment. The extraction starts from TS +main `6fbe64aa056a17d32b272d67df3dd89b21ce895f` and uses one exact public SDK revision, +`456313675b6a85e11acea870240f6a6d869f1137`. The original demo RC remains preserved. + +The binary is `trusted-server-axum` in package +`trusted-server-adapter-axum`. It uses the framework production initializer, +listener, admission and signal/drain lifecycle. It does not load the former +legacy environment-backed development settings. Required configuration and +secret reads finish before listening; selected-provider failures are fatal. +Running snapshots are immutable. Restart to adopt changed inputs. + +## Startup contract + +```mermaid +flowchart TD + A[Build routes and declare stores] --> B[Prepare selected providers] + B --> C[Read bounded config envelope] + C --> D[Verify envelope and collect active secret references] + D --> E[Read required secrets and validate settings] + E --> F[Install immutable state and trace settings] + F --> G[Listen and become ready] + B --> X[Fail before listening] + C --> X + D --> X + E --> X +``` + +Only `trusted_server_config` and `trusted_server_secrets` are declared by native +application metadata. The configuration key is `trusted_server_config`. The +metadata/manifest regression checks these identities. Generic runtime KV and +reader geography remain unavailable. This unit adds no native EC graph, +DynamoDB persistence, signing, automatic key rotation or management writes. +Configurations requiring unsupported native signing or dynamic external config +stores fail before readiness with an actionable error. + +The SDK retains the application clock, extraction limits, listener and response +owner. The startup callback installs read-only configuration/secret snapshots and +application state. Trace settings are bound during that same callback, before +serving, because the pre-dispatch hook intentionally runs before router state +injection. Explicit-settings and explicit-services constructors remain test +seams, not alternate production provider fallbacks. + +## Prepare the existing envelope + +Use a disposable project directory containing `edgezero.toml` and an ordinary +`trusted-server.toml`. Keep secrets as references. The source-controlled +`trusted-server.example.toml` documents the schema; do not copy private operator +inputs into fixtures or build contexts. + +The existing host CLI exports the local envelope: + +```sh +ts config validate +ts config push --adapter axum --local --no-diff --yes +``` + +Its default output, `.edgezero/local-config-trusted_server_config.json`, is an +outer key-to-string JSON map. The `trusted_server_config` value is the serialized +TS BlobEnvelope with its version and digest. AppConfig Agent supplies that +envelope document itself, not the outer local map. Secrets Manager supplies each +whole secret value, not a selected JSON field. This is not a new config format +or an AWS publication command. + +## Native inputs + +| Input | Meaning | +| ------------------------------------------- | ----------------------------------------------------------- | +| `EDGEZERO__ADAPTER__CONFIG_DIR` | Directory containing the local configuration map | +| `EDGEZERO__ADAPTER__HOST` | Explicit listen address | +| `EDGEZERO__ADAPTER__PORT` | Listen port | +| `EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS` | Existing framework in-flight drain grace | +| `EDGEZERO__STORE_BINDINGS_FILE` | Optional runtime-only TOML selecting providers and mappings | +| `EDGEZERO__SECRETS__` | Local secret provider value, key uppercased | + +Legacy `TRUSTED_SERVER_CONFIG_*`, `TRUSTED_SERVER_SECRET_*` and runtime +`TRUSTED_SERVER__*` settings cannot rescue missing prepared inputs. Typed +`TRUSTED_SERVER__*` overlays still belong to the CLI configuration workflow. +Native features `aws-appconfig-agent` and `aws-secrets-manager` independently +forward the existing SDK providers and default off. CLI features do not enable +server providers. AWS dependencies remain native-only. + +The local secret provider reads environment values. Runtime wrapping, image +production and sanitized infrastructure examples are separate review units. +This startup change neither parses an untrusted secret file nor provisions AWS. + +## Request and transport contracts + +Checked `NativeIngressMetadata` supplies the request-local client address and +public host/scheme. The adapter prepares the URL view, URI and Host from those +same checked facts, including absolute-form request targets. The original SDK +ingress record remains intact. Prepared URL metadata does not grant trace-origin +privileges. Raw Forwarded families never grant trust; only explicitly configured +trusted proxies can supply checked forwarding facts. Reader geography stays +unknown and consent remains fail-closed. + +All network work uses the incoming request's shared `EdgeZeroHttpClient`. The +native profile selects the SDK's existing decoding policy and streamed-response +support, retaining the original native demo contract. The shared client's +standalone/default byte policy remains separate. There is no raw HTTP fallback, +second transport, custom batch driver, new timer or retry layer. Explicit caller +deadlines remain supported; unset total timeouts use the SDK default. + +The retained native HTTP regressions cover redirects, duplicate headers/cookies, +SDK gzip decoding, request/response limits, POST replay, expired deadlines, +stream cancellation, bodyless representation length and total body deadlines. +The shared Core suite covers current pending/select correlation and cancellation. +The retired batch-driver event/index protocol belongs to SDK conformance rather +than a duplicate TS driver implementation. + +## Checks and limits + +```sh +cargo test --locked -p trusted-server-adapter-axum +cargo test --locked -p trusted-server-core --lib +node --test scripts/tests/test-native-providers.test.mjs +``` + +The native subprocess tests exercise verified envelopes, missing/tampered inputs, +secret validation, no legacy fallback, health/TSJS/page routes, immutable running +snapshots, restart adoption, guarded POST bodies and two-client checked-ingress +isolation. The real Next.js fixture remains an explicitly selected acceptance +test requiring `NATIVE_NEXTJS_ORIGIN`; an ignored test is not a pass. + +Independent provider acceptance uses an explicitly built binary with both AWS +features and disposable local provider fixtures: + +```sh +cargo build --locked -p trusted-server-adapter-axum \ + --features aws-appconfig-agent,aws-secrets-manager +node scripts/test-native-providers.mjs /absolute/path/to/trusted-server-axum +``` + +These checks use synthetic inputs, not an AWS account. They do not establish +cloud, publisher, auction, fleet, production security or image acceptance. +Historical qualification reports are not packaged as current approval. + +Keep `RUST_LOG` at `info` or quieter around operator inputs. AWS dependency +DEBUG/TRACE events can expose credential-source metadata, ARNs or response +bodies; AppConfig Agent diagnostics can include the request URL. This adapter +does not suppress those dependency events. No deployment or live input switch is +part of this change. diff --git a/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md b/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md new file mode 100644 index 0000000000..ef76905904 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md @@ -0,0 +1,230 @@ +> Historical reviewed contract from the original native demo, preserved for the extracted feature. Its SDK identity, API spellings, Spin migration and delivery commands describe that original checkpoint, not instructions to execute now. The current extraction uses SDK `456313675b6a85e11acea870240f6a6d869f1137`; current callers, proof and scope are described in [native prepared-store startup](../../guide/native-rc-demo.md). The new PR sequence, reviewer requests and no-deployment boundary supersede the old delivery section. The two original review rounds below are not a review of later combined source. + +# EdgeZero RC431 compatibility and native demo + +## Contract + +Start from Trusted Server main `6ef07ae52bec3e695ec57fe090243b1a281c314f`. +Use `https://github.com/IABTechLab/edgezero.git` at +`61bfe3b931fd97f5300287e4a91e33f32aa0fbee` for all six workspace declarations. +This is a temporary integration candidate, not production certification or an +implementation of the broader production-support epic #1236. + +The approved handoff supplies the requirements and implementation authority. +PRs #1410, #1411, #1412 and their #777 module/configuration/permission stack are +excluded. No code or architecture from those branches is a base for this work. +Existing page rewriting, TSJS, consent and auction behavior remains the product. + +## Choices + +A dependency-only update leaves native startup and outbound traffic outside the +new framework guarantees. A wholesale module/configuration rewrite exceeds the +approved scope. Use narrow compatibility adapters instead, with strict native +initialization and immutable prepared-store snapshots. Remove the synchronous +snapshot bridge when TS adopts asynchronous config/secret read traits. + +Keep cryptographic algorithms and serialized bytes unchanged. Align only the +shared SDK and trait identities required by RC431, including validator, Fastly +and Spin. Pin Rust to the RC toolchain. Review the resolved graph and retain one +EdgeZero Git source. An ESI dependency patch may be needed for Fastly SDK identity; +use the existing reviewed dependency-only patch from #1123, not its EdgeZero tag. + +## Native startup and state + +Production routes must not load environment-backed TS settings or capture a +startup-error state. Define the route graph independently of initialization. +Resolve typed application state from the request for handlers and middleware. +Native preflight also requires an absolute HTTP/HTTPS publisher origin URI. +Keep explicit-settings/services builders as test seams. + +```mermaid +flowchart TD + A[Build app and routes] --> B[Prepare declared stores] + B --> C[Read bounded config envelope] + C --> D[Verify envelope and remove inactive secret references] + D --> E[Await required secrets and validate runtime settings] + E --> F[Compile auction and integration plans] + F --> G[Insert immutable typed state] + G --> H[Bind listener and become ready] + B --> X[Startup error before listening] + C --> X + D --> X + E --> X + F --> X +``` + +Use `run_production_app_with_initializer`, retaining the framework App, platform, +clock and policies. Only declared logical IDs may be read. Metadata and manifest +must agree, enforced by a test. A selected provider error is fatal; no legacy +`TRUSTED_SERVER_*` fallback. Local config uses a key-to-string map containing the +existing TS BlobEnvelope. AppConfig supplies the envelope document itself. + +Await finite required inputs at startup and preserve TS's envelope verification, +inactive-reference cleanup and resolved-runtime validation. Generic deploy +validation is not a substitute. Synchronous request consumers receive immutable +read-only maps. No arbitrary provider `block_on` is allowed in handlers. Missing +snapshot values are errors, and management writes remain unsupported. + +The minimum demo omits signing and mutable integration-specific external stores. +If a selected configuration requires signing or another undeclared store, reject +it before listening with an actionable error. Supporting signing requires a +separate bounded addition declaring `jwks_store` and `signing_keys`, validating +all KIDs and matching public/private material before ready. Do not silently +serve incomplete signing or automatic rotation. + +Reuse dependency handles, but derive client metadata per request. Do not install +one startup RuntimeServices value with a fixed client IP in production. Native +readiness follows successful initialization and framework listener readiness; +liveness does not imply valid application configuration. Use RC signal/drain +ownership and prove bounded SIGTERM behavior. + +## Bodies, request heads and adapters + +Consume owned bodies through exhaustive `BodyContent`; preserve managed inbound +limits and provenance during reconstruction. External-error streams use +`from_external_stream`. Chunk polling must remain cancellation-safe. Propagate +fallible body/request extraction. Head-only sanitation/auth helpers must work +without extracting or replacing managed bodies. Existing buffered JSON API +handlers receive bounded context-cached bodies before reconstruction, under +limits exported by their core handlers. Publisher/proxy streaming bodies remain +lazy. Raw streams cannot silently become empty auction or batch-sync input. + +Axum receives the full strict startup migration. Fastly, Cloudflare and Spin +must use the same RC and preserve current behavior, subject to the explicit +Spin host-version migration below. An upstream API gap or a required wider +redesign is a blocker to report, not permission to weaken the contract. + +Fastly retains the SDK serving loop and sandbox retirement policy. Replace its +routed custom dispatcher and writer with +`send_request_with_registries_and_hooks`. Preparation sanitizes trust headers +and inserts checked client/TLS/device metadata without reading the body. The +borrowed-response finalizer preserves TS's header, EC, asset-policy, terminal +privacy and sandbox-counter order. It returns only owned pull-sync inputs. +The RC owns actual transmission and completion. Never send another response +after it returns an error. Pull sync requires an observed Completed Application +egress report, not merely `Ok(Some(state))`, which can also mean a fallback was +sent. A bounded retained-app observer may record that terminal fact because the +SDK sandbox callback exclusively borrows the sandbox and runs serially; reset +it before each dispatch and retain no body or native resource. Detached refusal, +fallback and delivery failure must not run pull sync. + +The existing `/health`, `/_ts/debug/ja4` and feature-gated sandbox metrics probes, +including their early errors, remain raw SDK exceptions outside RC admission +and egress accounting to preserve app-build-free probes. App construction must +install FASTLY_PLATFORM before fallible configure, propagate errors, and reject +replacement of that metadata. + +Spin selects exact SDK 7.0.0, Rust `wasm32-wasip2`, the P3 `wasi:http` 0.3.0 +interface and Spin >=4.1.0. Fastly stays on wasip1. Update Spin aliases, component +artifact paths, manifests, CI and operator docs together. Return the concrete RC +SpinResponse so the framework's response ownership is visible. Inspect the +emitted component interface and run it under the selected Spin host; compile +and native tests do not establish host compatibility. + +Spin SDK7 config/variable reads are asynchronous. Do not keep provider block_on +in routes or handlers. Use invocation-local async bootstrap with bounded finite +reads and immutable snapshots for synchronous TS consumers. Preserve the +existing encoded store/key variable naming, not the RC flat lowercase secret +convention. An initial pre-dispatch bootstrap may fail without routed admission; +name and test that exception rather than implying it has the RC request-body +lifecycle. Preserve currently supported signing/JWKS reads when configured on +Spin, independently of the native demo's signing exclusion. No global cache of +live SDK resources or pending provider work is allowed. + +## Native HTTP and ingress + +Bridge TS's native HTTP abstraction to the request-scoped RC client. Retain TS +allowlists and per-hop redirect policy, POST replay, duplicate Set-Cookie, +bodyless responses, codec ownership and caller byte limits. RC batch indices, +cutoffs and driver failure need explicit TS pending/select semantics. One layer +owns cancellation and one owns transmission completion; returning a handler is +not delivery completion. Use the app clock for deadlines. + +Before accepting pooled transport, reproduce truncated POST followed by the +Next.js server-action/API sequence. Test real native requests for redirects, +disallowed origins, response limits, timeout, disconnect and streaming cleanup. + +Use checked `NativeIngressMetadata` for client IP and public host/scheme. +Normalize neutral RequestInfo, URI and Host together so URI-based consumers +cannot prefer a spoofed absolute-form scheme over checked ingress facts. Do not +trust raw Forwarded families, which the runner strips. Direct spoofing must not +change public URL or cookie behavior. Explicitly trusted proxies may supply that +metadata. Test two different clients for request-local isolation. Reader geo +remains unavailable on native; AWS region does not change consent, which stays +fail-closed. + +## Providers and publication input + +Add default-off native server features forwarding `aws-appconfig-agent`, +`aws-secrets-manager`, and independent `aws-dynamodb` only when KV is wired. +CLI features do not compile server providers. Keep AWS dependencies out of WASM +builds. Use recognized manifest build/run shapes or explicit Docker features. + +Exercise real TS envelopes and secret references with local stores, then +account-free independent provider fixtures using synthetic credentials and +isolated state. Reject selected-provider failures without fallback. Running +snapshots do not change until restart. Secrets Manager returns whole values, +not selected JSON fields. Existing `ts config push` exports a local envelope; +it is not an AWS publisher. Document extraction of that same envelope as the +operator publication input, not a new format. + +If generic KV is included, verify read/write, TTL, pagination and persistence. +Do not claim DynamoDB ordinary KV implements an EC graph, CAS, generations or +exactly-once semantics. + +## Container and rollout + +After native acceptance, build fresh embedded JS bundles with pinned Node and +locked dependencies, then a locked Rust release binary for the actual TS bin. +Use the RC hardened runtime recipe with CA roots, numeric non-root identity, +read-only root and explicit runtime-only configuration and writable mounts. +No Rust, Node, credentials or operator configuration belongs in the runtime. +Validate non-loopback reachability, invalid-input failure before listen and +in-flight bounded SIGTERM. Record tested architecture. + +This changes native launch configuration from the old development environment +bridge to prepared stores. Update launchers and smoke expectations explicitly; +accepting any HTTP response as readiness would hide initialization failures. +Keep cloud provisioning, AWS deployment, registry publication, release automation, +new identity endpoints, module frameworks, cookie issuance, alternate asset +routing and live vendor integrations outside this PR. + +## Proof and delivery + +Maintain an exact-command ledger with tested revision/input fingerprint, outcome +and log. First reach a core compiler checkpoint, then native API compilation and +focused body/proxy/auth tests. Prove fail-before-listen and real TS routes using +subprocesses with poisoned legacy environment values. Add provider and transport +regressions at their behavioral checkpoints. + +At integration checkpoints run applicable repository gates in AGENTS.md, +including all target checks/lints/tests, parity, JS, build-digest, docs and static +checks. Unavailable tooling and pre-existing failures remain named coverage gaps. +Framework RC evidence is not TS acceptance evidence. + +Fastly proof includes preparation/finalization order, duplicate Set-Cookie, +stream success/failure and exactly one terminal completion, detached refusal, +late privacy effects, post-send gating, failed-build retry, probe laziness and +default/reusable sandbox modes. Spin proof includes the emitted component +interface, real config envelope and encoded variables, missing required secret, +pending read/deadline behavior, configured signing reads and two isolated +invocations. Unavailable compatible runtime tooling remains a named gap. + +Review the complete diff, commit only this task's files, push +`rc/edgezero-431-demo`, and open a draft PR against main. Include exact SHAs, +excluded scope, observed outcomes and unresolved checks. Use Refs rather than +closing broad production issues. Leave reviewers and auto-merge unset. + +## Review log + +- Round 1, independent Pi agent using openai-codex/gpt-6.1-sol. Found ambiguous + Fastly transmission ownership and fallback/post-send behavior, and missing + Spin target/host/async-read contracts. Spec now selects closed RC transmission, + Completed Application gating, named raw-probe exceptions, and the SDK7 + wasip2/P3/Spin 4.1 migration with invocation-local async bootstrap. No upstream + API defect was established. Source/path review only, not runtime evidence. +- Round 2, fresh independent Pi agent using openai-codex/gpt-6.1-sol. Verified + the revised Fastly observer gate, serial callback assumption, probe exceptions, + and Spin SDK7 target/async bootstrap contracts against the exact RC and SDK + sources. No remaining design blocker or demonstrated upstream API gap. Runtime + and implementation acceptance remain separate required checks. diff --git a/scripts/test-native-providers.mjs b/scripts/test-native-providers.mjs new file mode 100644 index 0000000000..afdf378cf3 --- /dev/null +++ b/scripts/test-native-providers.mjs @@ -0,0 +1,638 @@ +#!/usr/bin/env node +// Usage: node scripts/test-native-providers.mjs /absolute/path/to/trusted-server-axum +// Requires a prebuilt production binary with aws-appconfig-agent,aws-secrets-manager. +// No builds, external AWS, DynamoDB, or CLI-export acceptance are performed here. +// The ASCII fixture uses the existing BlobEnvelope v1 sorted compact JSON hash. +// CLI export/publication input acceptance remains a separate check. +import { spawn, execFile } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { access, mkdtemp, mkdir, writeFile, rename, rm } from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +export const images = { + moto: 'ghcr.io/getmoto/motoserver@sha256:d8ae5edc2bf080e7e4c13f9bd4b29b53ac3b4427e92956318db3dbe23ec43eb7', + agent: + 'public.ecr.aws/aws-appconfig/aws-appconfig-agent@sha256:cd604064652c56d9d6dc8435aa0d4bf3e90aa348830fa3b8a7b8650e3500ec3e', +} +const configId = 'trusted_server_config' +const secretId = 'trusted_server_secrets' +const token = 'synthetic-agent-token' +const keys = ['ec_key', 'proxy_key', 'admin_key'] +const secretValues = (phase) => ({ + ec_key: `synthetic-ec-passphrase-32-bytes-minimum-${phase}`, + proxy_key: `synthetic-proxy-secret-32-bytes-minimum-${phase}`, + // A whole JSON document, including whitespace, must be the password. + admin_key: ` { "password": "synthetic-admin-${phase}" }\n`, +}) +// Only these application-owned assertions may reach diagnostics. Never print +// arbitrary subprocess, environment, SDK or HTTP error messages. +class FixtureAssertion extends Error {} +function requireCheck(condition, stage) { + if (!condition) throw new FixtureAssertion(stage) +} +export function publishedEndpoint(address) { + const match = /^127\.0\.0\.1:([0-9]+)$/.exec(address.trim()) + requireCheck( + match && Number(match[1]) > 0 && Number(match[1]) <= 65535, + 'invalid published port' + ) + return `http://127.0.0.1:${Number(match[1])}` +} +export function sorted(value) { + if (Array.isArray(value)) return value.map(sorted) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]) + ) + } + return value +} +export function envelope(data) { + const canonical = sorted(data) + return JSON.stringify({ + data: canonical, + sha256: createHash('sha256') + .update(JSON.stringify(canonical)) + .digest('hex'), + version: 1, + generated_at: '2026-10-20T00:00:00Z', + }) +} +export function fixtureEnvelope(phase) { + return envelope({ + ec: { passphrase: 'ec_key' }, + handlers: [ + { path: '^/_ts/admin', username: 'admin', password: 'admin_key' }, + { path: '^/health$', username: 'admin', password: 'admin_key' }, + ], + publisher: { + domain: 'publisher.example.com', + cookie_domain: '.publisher.example.com', + origin_url: 'https://origin.publisher.example.com', + proxy_secret: 'proxy_key', + }, + request_signing: null, + response_headers: { 'x-provider-snapshot': phase }, + }) +} +export function isolatedEnvironment(work, port, endpoints, bindings, phase) { + const env = { + HOME: work, + AWS_CONFIG_FILE: join(work, 'aws-config'), + AWS_SHARED_CREDENTIALS_FILE: join(work, 'aws-credentials'), + AWS_EC2_METADATA_DISABLED: 'true', + AWS_ACCESS_KEY_ID: 'DUMMYLOCALACCESS123', + AWS_SECRET_ACCESS_KEY: 'DUMMYLOCALSECRET456', + AWS_REGION: 'us-east-1', + AWS_DEFAULT_REGION: 'us-east-1', + AWS_ENDPOINT_URL: endpoints.moto, + AWS_ENDPOINT_URL_SECRETS_MANAGER: endpoints.moto, + AWS_IGNORE_CONFIGURED_ENDPOINT_URLS: 'false', + APPCONFIG_AGENT_TOKEN: token, + EDGEZERO__STORE_BINDINGS_FILE: bindings, + EDGEZERO__ADAPTER__HOST: '127.0.0.1', + EDGEZERO__ADAPTER__PORT: String(port), + EDGEZERO__ADAPTER__CONFIG_DIR: work, + EDGEZERO__ADAPTER__DATA_DIR: work, + EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS: '1', + EDGEZERO__LOGGING__LEVEL: 'off', + RUST_LOG: 'off', + TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG: + fixtureEnvelope(phase), + TRUSTED_SERVER__EC__PASSPHRASE: 'poison', + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: 'https://poison.example.com', + } + for (const [key, value] of Object.entries(secretValues(phase))) { + // Valid local and legacy values deliberately cannot rescue selected providers. + env[`EDGEZERO__SECRETS__${key.toUpperCase()}`] = value + env[`TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_${key.toUpperCase()}`] = + value + } + return env +} + +// Direct HTTP only: no environment proxy lookup, redirects, or credential lookup. +async function request( + endpoint, + path, + { body, headers = {}, method = 'GET' } = {} +) { + return new Promise((resolve, reject) => { + const req = http.request( + `${endpoint}${path}`, + { method, headers }, + (res) => { + const chunks = [] + let size = 0 + res.on('data', (chunk) => { + size += chunk.length + if (size > 1024 * 1024) req.destroy(new Error('response limit')) + else chunks.push(chunk) + }) + res.on('error', () => reject(new Error('response read'))) + res.on('end', () => + resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + ) + } + ) + const timer = setTimeout( + () => req.destroy(new Error('request deadline')), + 2500 + ) + req.on('close', () => clearTimeout(timer)) + req.on('error', () => reject(new Error('local request'))) + req.end(body) + }) +} +async function secrets(endpoint, operation, payload = {}) { + const body = JSON.stringify(payload) + const date = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '') + const response = await request(endpoint, '/', { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + 'Content-Length': Buffer.byteLength(body), + 'X-Amz-Target': `secretsmanager.${operation}`, + 'X-Amz-Date': date, + Authorization: `AWS4-HMAC-SHA256 Credential=DUMMYLOCAL/${date.slice(0, 8)}/us-east-1/secretsmanager/aws4_request, SignedHeaders=host;x-amz-date, Signature=${'0'.repeat(64)}`, + }, + }) + requireCheck(response.status === 200, 'fixture administration') + try { + return JSON.parse(response.body) + } catch { + throw new Error('fixture administration response') + } +} +async function freePort() { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const port = server.address().port + await new Promise((resolve) => server.close(resolve)) + return port +} +async function listening(port) { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }) + const finish = (value) => { + socket.destroy() + resolve(value) + } + socket.setTimeout(100, () => finish(false)) + socket.once('connect', () => finish(true)) + socket.once('error', () => finish(false)) + }) +} +export async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + const deadline = Date.now() + 2000 + while ( + child.exitCode === null && + child.signalCode === null && + Date.now() < deadline + ) + await delay(25) + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + const killed = Date.now() + 2000 + while ( + child.exitCode === null && + child.signalCode === null && + Date.now() < killed + ) + await delay(25) + requireCheck( + child.exitCode !== null || child.signalCode !== null, + 'child teardown deadline' + ) +} +function bindingsText( + mode, + endpoints, + profile, + secretArns, + missingSecret = false +) { + let text = 'version = 1\n' + if (mode !== 'secrets') + text += `[config.${configId}]\nprovider = "aws-appconfig-agent"\ndefault_key = "${configId}"\nendpoint = "${endpoints.agent}"\naccess_token_env = "APPCONFIG_AGENT_TOKEN"\n[config.${configId}.documents.${configId}]\napplication = "ts"\nenvironment = "test"\nprofile = "${profile}"\n` + if (mode !== 'config') { + text += `[secrets.${secretId}]\nprovider = "aws-secrets-manager"\nregion = "us-east-1"\n` + for (const key of keys) { + const arn = secretArns[key] + requireCheck( + typeof arn === 'string' && + arn.startsWith('arn:aws:secretsmanager:us-east-1:'), + 'synthetic secret ARN mapping' + ) + const target = + missingSecret && key === 'admin_key' + ? arn.replace(/:secret:.+$/, ':secret:missing-A1b2c3') + : arn + text += `[secrets.${secretId}.entries.${key}]\nsecret_id = "${target}"\n` + } + } + return text +} +async function publish(path, contents) { + await writeFile(`${path}.next`, contents) + await rename(`${path}.next`, path) +} + +export async function main(binary) { + requireCheck( + typeof binary === 'string' && isAbsolute(binary), + 'supply absolute prebuilt binary path' + ) + await access(binary) + const work = await mkdtemp(join(tmpdir(), 'ts-native-providers-')) + const prefix = `ts-providers-${randomUUID()}` + const names = { moto: `${prefix}-moto`, agent: `${prefix}-agent` } + const configs = join(work, 'configs') + const children = new Set() + const commands = new Set() + const created = new Set() + let interrupted = false + let stage = 'setup' + let failed = false + let cleaning = false + let interruptTimer + const interrupt = () => { + if (interrupted) return + interrupted = true + if (!cleaning) for (const command of commands) command.abort() + for (const child of children) child.kill('SIGTERM') + interruptTimer = setTimeout(() => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + } + }, 2000) + } + process.on('SIGINT', interrupt) + process.on('SIGTERM', interrupt) + const check = () => requireCheck(!interrupted, 'interrupted') + // Do not inherit AWS, proxies, Docker config credentials, or arbitrary endpoints. + const dockerEnv = { PATH: process.env.PATH, HOME: work } + const docker = async (args, timeout = 30_000) => { + const controller = new AbortController() + commands.add(controller) + try { + const { stdout } = await exec('docker', args, { + env: dockerEnv, + timeout, + signal: controller.signal, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }) + return stdout.trim() + } catch { + throw new Error('container command') + } finally { + commands.delete(controller) + } + } + const launch = async (directory, endpoints, bindingPath, phase) => { + check() + const port = await freePort() + const child = spawn(binary, [], { + cwd: directory, + env: isolatedEnvironment(directory, port, endpoints, bindingPath, phase), + stdio: 'ignore', + }) + let spawnFailed = false + child.on('error', () => { + spawnFailed = true + }) + children.add(child) + return { + child, + port, + endpoint: `http://127.0.0.1:${port}`, + spawnFailed: () => spawnFailed, + } + } + const stop = async (app) => { + await stopChild(app.child) + children.delete(app.child) + } + const health = (app, password) => + request(app.endpoint, '/health', { + headers: { + Authorization: `Basic ${Buffer.from(`admin:${password}`).toString('base64')}`, + }, + }) + const ready = async (app, phase) => { + const until = Date.now() + 20_000 + while (Date.now() < until) { + check() + requireCheck( + !app.spawnFailed() && + app.child.exitCode === null && + app.child.signalCode === null, + 'startup exited' + ) + let response + try { + response = await health(app, secretValues(phase).admin_key) + } catch { + /* Retry connection refusal, not a wrong HTTP response. */ + } + if (response) { + requireCheck( + response.status === 200 && + response.body === 'ok' && + response.headers['x-ts-version'] && + response.headers['x-provider-snapshot'] === phase, + 'health snapshot readiness' + ) + return + } + await delay(100) + } + throw new Error('startup deadline') + } + const snapshot = async (app, configPhase, secretPhase, rejectedPhase) => { + const response = await health(app, secretValues(secretPhase).admin_key) + requireCheck( + response.status === 200 && + response.body === 'ok' && + response.headers['x-provider-snapshot'] === configPhase, + 'snapshot observation' + ) + requireCheck( + (await health(app, secretValues(rejectedPhase).admin_key)).status === 401, + 'old or future secret rejection' + ) + requireCheck( + (await health(app, `synthetic-admin-${secretPhase}`)).status === 401, + 'whole secret semantics' + ) + const js = await request(app.endpoint, '/static/tsjs=0000000000000000') + requireCheck( + js.status === 404 && + js.headers['x-geo-info-available'] === 'false' && + js.headers['x-provider-snapshot'] === configPhase, + 'TSJS route observation' + ) + } + const rejected = async (app) => { + const until = Date.now() + 20_000 + while (Date.now() < until) { + check() + requireCheck(!(await listening(app.port)), 'invalid startup listened') + requireCheck(!app.spawnFailed(), 'binary launch') + if (app.child.exitCode !== null || app.child.signalCode !== null) { + requireCheck( + app.child.exitCode !== 0 && app.child.signalCode === null, + 'invalid startup exit' + ) + return + } + await delay(25) + } + throw new Error('invalid startup deadline') + } + try { + await mkdir(configs) + await writeFile(join(work, 'aws-config'), '') + await writeFile(join(work, 'aws-credentials'), '') + await docker(['info'], 20_000) + for (const image of Object.values(images)) { + check() + await docker(['pull', image], 180_000) + } + for (const service of ['moto', 'agent']) { + check() + created.add(names[service]) + const args = [ + 'run', + '-d', + '--name', + names[service], + '--publish', + `127.0.0.1::${service === 'moto' ? 5000 : 2772}`, + ] + if (service === 'agent') + args.push( + '--mount', + `type=bind,source=${configs},target=/configs,readonly`, + '--env', + 'LOCAL_DEVELOPMENT_DIRECTORY=/configs', + '--env', + 'SERVICE_REGION=us-east-1', + '--env', + 'AWS_ACCESS_KEY_ID=DUMMYLOCAL', + '--env', + 'AWS_SECRET_ACCESS_KEY=DUMMYLOCAL', + '--env', + 'AWS_EC2_METADATA_DISABLED=true', + '--env', + `ACCESS_TOKEN=${token}`, + '--env', + 'HTTP_HOST=0.0.0.0', + '--env', + 'POLL_INTERVAL=1s' + ) + args.push(images[service]) + await docker(args) + } + const endpoints = {} + for (const [service, port] of [ + ['moto', 5000], + ['agent', 2772], + ]) + endpoints[service] = publishedEndpoint( + await docker(['port', names[service], `${port}/tcp`]) + ) + stage = 'service readiness' + await publish(join(configs, 'probe:test:ready'), 'agent-ready\n') + let servicesReady = false + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + check() + try { + await secrets(endpoints.moto, 'ListSecrets') + const probe = await request( + endpoints.agent, + '/applications/probe/environments/test/configurations/ready', + { headers: { Authorization: `Bearer ${token}` } } + ) + servicesReady = probe.status === 200 && probe.body === 'agent-ready\n' + if (servicesReady) break + } catch { + /* Bounded service startup retry. */ + } + await delay(200) + } + requireCheck(servicesReady, 'service readiness deadline') + const secretArns = {} + for (const [key, value] of Object.entries(secretValues('A'))) { + const createdSecret = await secrets(endpoints.moto, 'CreateSecret', { + Name: key, + SecretString: value, + }) + secretArns[key] = createdSecret.ARN + } + for (const mode of ['config', 'secrets', 'combined']) { + stage = `${mode} snapshots` + check() + const directory = join(work, mode) + await mkdir(directory) + await writeFile(join(directory, 'aws-config'), '') + await writeFile(join(directory, 'aws-credentials'), '') + const bindingPath = join(directory, 'bindings.toml') + const localPath = join(directory, `local-config-${configId}.json`) + const document = join(configs, `ts:test:${mode}`) + const publishPhase = async (phase) => { + await publish(document, fixtureEnvelope(phase)) + await publish( + localPath, + JSON.stringify({ [configId]: fixtureEnvelope(phase) }) + ) + for (const [key, value] of Object.entries(secretValues(phase))) { + await secrets(endpoints.moto, 'PutSecretValue', { + SecretId: key, + SecretString: value, + ClientRequestToken: randomUUID(), + }) + const actual = await secrets(endpoints.moto, 'GetSecretValue', { + SecretId: key, + }) + requireCheck( + actual.SecretString === value, + 'independent secret readback' + ) + } + } + await publishPhase('A') + await writeFile( + bindingPath, + bindingsText(mode, endpoints, mode, secretArns) + ) + const app = await launch(directory, endpoints, bindingPath, 'A') + await ready(app, 'A') + await snapshot(app, 'A', 'A', 'B') + await publishPhase('B') + // Wait for the independent Agent cache, not only the mounted file write. + const until = Date.now() + 15_000 + let adopted = false + while (Date.now() < until) { + check() + const response = await request( + endpoints.agent, + `/applications/ts/environments/test/configurations/${mode}`, + { headers: { Authorization: `Bearer ${token}` } } + ) + adopted = + response.status === 200 && response.body === fixtureEnvelope('B') + if (adopted) break + await delay(200) + } + requireCheck(adopted, 'independent config readback') + await snapshot(app, 'A', 'A', 'B') + await stop(app) + const restarted = await launch(directory, endpoints, bindingPath, 'B') + await ready(restarted, 'B') + await snapshot(restarted, 'B', 'B', 'A') + await stop(restarted) + console.log( + `PASS ${mode}: real TS health/auth/TSJS; immutable snapshots; restart adoption` + ) + if (mode !== 'secrets') { + stage = `${mode} config failure` + await writeFile( + bindingPath, + bindingsText(mode, endpoints, 'missing', secretArns) + ) + const invalid = await launch(directory, endpoints, bindingPath, 'B') + await rejected(invalid) + await stop(invalid) + console.log( + `PASS ${mode}: selected config failure before listen, no local/legacy fallback` + ) + } + if (mode !== 'config') { + stage = `${mode} secret failure` + await writeFile( + bindingPath, + bindingsText(mode, endpoints, mode, secretArns, true) + ) + const invalid = await launch(directory, endpoints, bindingPath, 'B') + await rejected(invalid) + await stop(invalid) + console.log( + `PASS ${mode}: selected secret failure before listen, no local/legacy fallback` + ) + } + } + } catch (error) { + // stage and assertion messages are application-owned bounded labels. + const reason = + error instanceof FixtureAssertion ? error.message : 'operation failed' + console.error(`FAIL provider acceptance stage: ${stage}; ${reason}`) + failed = true + } finally { + cleaning = true + for (const child of children) { + try { + await stopChild(child) + } catch { + failed = true + } + } + for (const name of created) { + try { + await docker(['rm', '-f', name], 10_000) + } catch { + failed = true + } + } + try { + await rm(work, { recursive: true, force: true }) + } catch { + failed = true + } + clearTimeout(interruptTimer) + process.off('SIGINT', interrupt) + process.off('SIGTERM', interrupt) + } + // Never print child output, arbitrary caught errors, endpoint bytes, or values. + requireCheck( + !failed && !interrupted, + `provider acceptance failed during ${stage}; child/provider diagnostics suppressed` + ) + console.log( + 'PASS provider acceptance; CLI-export acceptance and DynamoDB are not covered' + ) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(process.argv[2]).catch(() => { + console.error( + 'Native provider acceptance failed; check binary/features, Docker availability, and bounded stage output. Raw diagnostics suppressed.' + ) + process.exitCode = 1 + }) +} diff --git a/scripts/tests/test-native-providers.test.mjs b/scripts/tests/test-native-providers.test.mjs new file mode 100644 index 0000000000..82402d45c0 --- /dev/null +++ b/scripts/tests/test-native-providers.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { test } from 'node:test' +import { + envelope, + fixtureEnvelope, + images, + isolatedEnvironment, + publishedEndpoint, + sorted, + stopChild, +} from '../test-native-providers.mjs' + +test('published ports must be exclusively literal loopback and errors omit input', () => { + assert.equal(publishedEndpoint('127.0.0.1:12345\n'), 'http://127.0.0.1:12345') + for (const input of [ + '127.0.0.1:0', + '127.0.0.1:65536', + '0.0.0.0:12345', + '[::]:12345', + 'https://secret.example.com', + '127.0.0.1:80\n[::]:80', + ]) { + assert.throws(() => publishedEndpoint(input), { + message: 'invalid published port', + }) + } +}) + +test("existing TS startup fixture's BlobEnvelope hash matches Node serialization", () => { + const data = { + ec: { passphrase: 'ec_key' }, + handlers: [ + { password: 'admin_key', path: '^/_ts/admin', username: 'admin' }, + ], + publisher: { + cookie_domain: '.publisher.example.com', + domain: 'publisher.example.com', + origin_url: 'https://origin.publisher.example.com', + proxy_secret: 'proxy_key', + }, + request_signing: null, + } + // Exact golden vector from crates/trusted-server-adapter-axum/tests/startup.rs. + assert.equal( + JSON.parse(envelope(data)).sha256, + 'f4dc03017deb24bfd589efde7dce0f9f8a20d952c28ebcbdb6141b08661cd137' + ) + assert.deepEqual(sorted({ b: [{ z: 1, a: 2 }], a: null }), { + a: null, + b: [{ a: 2, z: 1 }], + }) + const fixture = JSON.parse(fixtureEnvelope('A')) + assert.equal(fixture.version, 1) + assert.equal(fixture.data.ec.passphrase, 'ec_key') + assert.equal(fixture.data.handlers[1].password, 'admin_key') + assert.equal(fixture.data.publisher.proxy_secret, 'proxy_key') + assert.equal(fixture.data.response_headers['x-provider-snapshot'], 'A') +}) + +test('binary environment never inherits real credentials, proxy settings, or endpoints', () => { + const previous = process.env.AWS_SESSION_TOKEN + process.env.AWS_SESSION_TOKEN = 'sentinel-not-to-be-inherited' + try { + const env = isolatedEnvironment( + '/tmp/synthetic', + 12345, + { moto: 'http://127.0.0.1:12346' }, + '/tmp/synthetic/bindings.toml', + 'A' + ) + assert.equal(env.AWS_SESSION_TOKEN, undefined) + assert.equal(env.HTTP_PROXY, undefined) + assert.equal(env.PATH, undefined) + assert.equal(env.AWS_EC2_METADATA_DISABLED, 'true') + assert.equal(env.AWS_ACCESS_KEY_ID, 'DUMMYLOCALACCESS123') + assert.equal(env.AWS_ENDPOINT_URL_SECRETS_MANAGER, 'http://127.0.0.1:12346') + assert.equal(env.HOME, '/tmp/synthetic') + assert.equal( + env.AWS_SHARED_CREDENTIALS_FILE, + '/tmp/synthetic/aws-credentials' + ) + assert.ok(env.EDGEZERO__SECRETS__EC_KEY.length >= 32) + assert.ok(env.EDGEZERO__SECRETS__ADMIN_KEY.startsWith(' { "password":')) + assert.ok(!JSON.stringify(env).includes('sentinel-not-to-be-inherited')) + } finally { + if (previous === undefined) delete process.env.AWS_SESSION_TOKEN + else process.env.AWS_SESSION_TOKEN = previous + } +}) + +test('only the two independently pinned provider services are selected', () => { + assert.deepEqual(Object.keys(images).sort(), ['agent', 'moto']) + for (const image of Object.values(images)) + assert.match(image, /@sha256:[a-f0-9]{64}$/) +}) + +test( + 'child teardown is idempotent and escalates for an ignored SIGTERM', + { timeout: 7000 }, + async () => { + const child = spawn( + process.execPath, + [ + '-e', + 'process.on("SIGTERM", () => {}); console.log("ready"); setInterval(() => {}, 1000);', + ], + { env: {}, stdio: ['ignore', 'pipe', 'ignore'] } + ) + try { + await once(child.stdout, 'data') + await stopChild(child) + assert.equal(child.signalCode, 'SIGKILL') + await stopChild(child) + } finally { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + } + } +)