diff --git a/Cargo.lock b/Cargo.lock index 2100fc07..1878e7f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,7 +824,9 @@ dependencies = [ name = "edgezero-macros" version = "0.1.0" dependencies = [ + "async-trait", "edgezero-core", + "futures", "log", "proc-macro2", "quote", diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 94d7c37d..19b7ddfd 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -1,9 +1,12 @@ //! Axum adapter config store: reads from a per-id local JSON file. //! //! Each declared `[stores.config].ids` id maps to a file at -//! `.edgezero/local-config-.json`. The file holds a flat object of -//! `string -> string` pairs — the same shape `edgezero config push -//! --adapter axum` writes. +//! `.edgezero/local-config-.json`. The file holds a JSON object of +//! `string -> string` pairs. Typed `config push --adapter axum` writes ONE +//! entry — the selected config key (defaults to the logical store id, +//! overridable with `--key`) keyed to a JSON-encoded `BlobEnvelope` string, +//! which the runtime `AppConfig` extractor parses; hand-seeded flat +//! key/value files also work for raw `get`. //! //! If the file is absent the store is empty (`get` returns `Ok(None)` for //! every key). This keeps `edgezero serve --adapter axum` permissive when @@ -69,20 +72,21 @@ impl AxumConfigStore { /// by `config push --adapter axum` to a tempdir, without /// changing the process CWD. /// - /// The file must contain a flat JSON object of `string -> string` - /// pairs, matching what `config push --adapter axum` writes: + /// The file must be a JSON object of `string -> string` pairs. + /// Typed `config push --adapter axum` writes ONE entry — the selected + /// config key (defaults to the logical store id, overridable with + /// `--key`) keyed to a JSON-encoded `BlobEnvelope` string: /// /// ```json /// { - /// "greeting": "hello", - /// "feature.new_checkout": "false", - /// "service.timeout_ms": "1500" + /// "app_config": "{\"version\":1,\"generated_at\":\"…\",\"sha256\":\"…\",\"data\":{}}" /// } /// ``` /// - /// Dotted keys are stored verbatim (no nesting): the runtime - /// extractors look up the dotted form as a single key. Non-string - /// values (`{"x": 42}`, nested objects, arrays) are rejected. + /// The runtime `AppConfig` extractor parses that envelope string; + /// hand-seeded flat key/value files also work for raw `get`. Values + /// must be strings — non-string values (`{"x": 42}`, nested objects, + /// arrays) are rejected. /// /// Behaviour matches `from_local_file`: a missing file yields /// an empty store; a present-but-malformed file yields diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 36486719..147e1658 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -340,7 +340,9 @@ pub fn run_app() -> anyhow::Result<()> { .and_then(|raw| LevelFilter::from_str(raw).ok()) .unwrap_or(LevelFilter::Info); - let _logger_init = SimpleLogger::new().with_level(level).init(); + if !A::owns_logging() { + let _logger_init = SimpleLogger::new().with_level(level).init(); + } let resolution = resolve_addr(&env); for warning in &resolution.warnings { diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index c630cc1c..edd9224f 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -101,8 +101,11 @@ pub async fn run_app( ctx: Context, ) -> Result { // Best-effort: if a logger is already installed, ignore the error rather - // than panicking — every Worker request re-enters this function. - drop(init_logger()); + // than panicking — every Worker request re-enters this function. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let stores = A::stores(); let env_config = env_config_from_worker(&env, stores); let app = A::build_app(); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index a05a02f6..e3c40e4a 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -25,6 +25,8 @@ use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(feature = "fastly")] use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] +use edgezero_core::http::Extensions; +#[cfg(feature = "fastly")] use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] @@ -111,15 +113,44 @@ fn logging_from_env(env: &EnvConfig) -> FastlyLogging { #[cfg(feature = "fastly")] #[inline] pub fn run_app(req: fastly::Request) -> Result { + run_app_with_request_extensions::(req, |_req, _extensions| {}) +} + +/// Like [`run_app`], but runs `extend` against a scratch +/// [`Extensions`](edgezero_core::http::Extensions) populated from the raw +/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request +/// is converted; the scratch values are merged into the core request's +/// extensions and are visible to middleware and the `State`/extractor layer. +/// +/// # Errors +/// Returns an error if logger setup fails or any required store cannot be opened. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_app_with_request_extensions( + req: fastly::Request, + extend: F, +) -> Result +where + A: Hooks, + F: FnOnce(&fastly::Request, &mut Extensions), +{ let stores = A::stores(); let env = env_config_from_runtime_dictionary(stores); let logging = logging_from_env(&env); - if logging.use_fastly_logger { + if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env) + request::dispatch_with_registries( + &app, + req, + stores.config, + stores.kv, + stores.secrets, + &env, + extend, + ) } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` @@ -202,7 +233,7 @@ pub fn run_app_with_config( req: fastly::Request, config_store_name: Option<&str>, ) -> Result { - if logging.use_fastly_logger { + if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs index 2947f33b..b65fa0e6 100644 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ b/crates/edgezero-adapter-fastly/src/proxy.rs @@ -46,11 +46,13 @@ fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> Fastl let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); fastly_request.set_method(method); + // Append (not set) so a multi-value client header survives; `Host` below is + // set explicitly as a single value. for (name, value) in headers { if name.as_str().eq_ignore_ascii_case("host") { continue; } - fastly_request.set_header(name.as_str(), value.clone()); + fastly_request.append_header(name.as_str(), value.clone()); } if let Some(host) = uri.host() { @@ -64,9 +66,13 @@ fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { let status = fastly_response.get_status(); let mut proxy_response = ProxyResponse::new(status, Body::empty()); - for header in fastly_response.get_header_names() { - if let Some(value) = fastly_response.get_header(header) { - proxy_response.headers_mut().insert(header, value.clone()); + // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL + // values per name and append, instead of first-value + insert (which + // replaced). `get_header_names()` yields `&HeaderName`, usable for both + // `get_header_all` and `append`. + for name in fastly_response.get_header_names() { + for value in fastly_response.get_header_all(name) { + proxy_response.headers_mut().append(name, value.clone()); } } @@ -212,6 +218,23 @@ mod tests { } } + #[test] + fn convert_response_preserves_multi_value_set_cookie() { + let mut fastly_response = FastlyResponse::from_status(200); + fastly_response.append_header("set-cookie", "a=1"); + fastly_response.append_header("set-cookie", "b=2"); + + let proxy_response = convert_response(&mut fastly_response); + + let cookies: Vec = proxy_response + .headers() + .get_all("set-cookie") + .into_iter() + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_handles_brotli() { let mut compressed = Vec::new(); diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index f196a2ee..b7eba428 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -8,7 +8,7 @@ use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; -use edgezero_core::http::{request_builder, Request}; +use edgezero_core::http::{request_builder, Extensions, Request}; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; @@ -151,6 +151,7 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, + |_req, _extensions| {}, ) } @@ -276,12 +277,31 @@ fn dispatch_core_request( from_core_response(response).map_err(|err| map_edge_error(&err)) } -fn dispatch_with_handles( +/// Run an app-provided closure against a scratch `Extensions` populated from the +/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes +/// the request. Returns the scratch bag to be `extend`ed into the core request. +fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + let mut scratch = Extensions::default(); + extend(req, &mut scratch); + scratch +} + +fn dispatch_with_handles( app: &App, req: FastlyRequest, stores: Stores, -) -> Result { - let core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ + // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. + let scratch = apply_request_extend(&req, extend); + let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; + core_request.extensions_mut().extend(scratch); dispatch_core_request(app, core_request, stores) } @@ -292,14 +312,18 @@ fn dispatch_with_handles( /// id default). KV failures escalate via [`resolve_kv_handle`]'s /// `kv_required=true` path; missing config / secret stores degrade silently /// with a one-time warning. -pub(crate) fn dispatch_with_registries( +pub(crate) fn dispatch_with_registries( app: &App, req: FastlyRequest, config_meta: Option, kv_meta: Option, secret_meta: Option, env: &EnvConfig, -) -> Result { + extend: F, +) -> Result +where + F: FnOnce(&FastlyRequest, &mut Extensions), +{ let kv_registry = build_kv_registry(kv_meta, env)?; let config_registry = build_config_registry(config_meta, env); let secret_registry = build_secret_registry(secret_meta, env); @@ -312,6 +336,7 @@ pub(crate) fn dispatch_with_registries( secret_registry, ..Default::default() }, + extend, ) } @@ -573,6 +598,65 @@ mod synthesis_tests { SecretHandle::new(Arc::new(NoopSecretStore)) } + #[test] + fn apply_request_extend_populates_scratch_from_raw_request() { + use edgezero_core::http::Method; + + #[derive(Clone, Debug, PartialEq)] + struct Ja4(String); + + let raw = FastlyRequest::new(Method::GET, "http://example.test/"); + let scratch = apply_request_extend(&raw, |req, extensions| { + // A real closure would call req.get_tls_ja4(); deriving from the URL + // keeps the assertion deterministic under Viceroy. + let marker = req.get_url_str().to_owned(); + extensions.insert(Ja4(marker)); + }); + + assert_eq!( + scratch.get::(), + Some(&Ja4("http://example.test/".to_owned())) + ); + } + + #[test] + fn extended_request_extensions_are_visible_to_handler() { + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + + #[derive(Clone)] + struct Ja4(String); + + async fn handler(ctx: RequestContext) -> Result { + let ja4 = ctx + .request() + .extensions() + .get::() + .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); + Ok(ja4) + } + + // Mirror what `dispatch_with_handles` does: a scratch bag built from the + // raw request is `extend`ed into the core request before dispatch. + let mut scratch = Extensions::default(); + scratch.insert(Ja4("t13d1516h2".to_owned())); + + let mut core_request = request_builder() + .method(Method::GET) + .uri("/ja4") + .body(Body::empty()) + .expect("request"); + core_request.extensions_mut().extend(scratch); + + let service = RouterService::builder().get("/ja4", handler).build(); + let response = block_on(service.oneshot(core_request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"t13d1516h2"); + } + #[test] fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { let stores = Stores { diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index 075b235a..f9eb4d02 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -25,8 +25,11 @@ pub fn from_core_response(response: Response) -> Result = fastly_response + .get_header_all("set-cookie") + .map(|value| value.to_str().expect("utf8").to_owned()) + .collect(); + assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + } + #[test] fn stream_body_is_written_to_fastly_response() { let response = response_builder() diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..dd6a8ec6 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -541,7 +541,7 @@ impl Adapter for SpinCliAdapter { value = entry.key_value, )); } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name) { + if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", this_field = entry.field_name, diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index b8234fe5..db282c05 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -111,8 +111,11 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { pub async fn run_app(req: SpinRequest) -> anyhow::Result { // Best-effort: every Spin `#[http_service]` re-enters this function, so a // second `log::set_logger` call returns Err — drop the result instead of - // `.expect()` to avoid panicking on every subsequent request. - drop(init_logger()); + // `.expect()` to avoid panicking on every subsequent request. Skipped + // entirely when the app owns logging. + if !A::owns_logging() { + drop(init_logger()); + } let env = EnvConfig::from_env(); let stores = A::stores(); let app = A::build_app(); diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce87..9cdc5da0 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -176,8 +176,8 @@ impl<'ctx> AdapterPushContext<'ctx> { /// v2 source-compat; construction goes through `new`. #[non_exhaustive] pub struct TypedSecretEntry<'entry> { - /// Rust struct field name (e.g. `"api_token"`). - pub field_name: &'entry str, + /// Dotted secret-field path label (e.g. `"partners[3].api_key"`). + pub field_name: String, /// Blob value — i.e. the secret-store KEY NAME. pub key_value: &'entry str, /// Logical secret-store id this key targets. @@ -188,9 +188,13 @@ impl<'entry> TypedSecretEntry<'entry> { /// Construct a new entry from its three components. #[must_use] #[inline] - pub fn new(store_id: &'entry str, field_name: &'entry str, key_value: &'entry str) -> Self { + pub fn new>( + store_id: &'entry str, + field_name: Name, + key_value: &'entry str, + ) -> Self { Self { - field_name, + field_name: field_name.into(), key_value, store_id, } diff --git a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs index 1aa71c42..62151e0e 100644 --- a/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs +++ b/crates/edgezero-cli/src/bin/check_no_nested_app_config.rs @@ -63,6 +63,7 @@ use walkdir::WalkDir; // Pass 1: collect struct identifiers that derive AppConfig // --------------------------------------------------------------------------- +#[derive(Default)] struct AppConfigStructCollector { app_config_structs: HashSet, } @@ -166,6 +167,9 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { if let Some(inner_name) = type_contains_app_config_struct(&field.ty, self.app_config_structs) { + if field_has_nested_optin(field) { + continue; // opted in via #[app_config(nested)] — allowed + } let span = field .ident .as_ref() @@ -177,6 +181,33 @@ impl<'ast> Visit<'ast> for NestedAppConfigVisitor<'_, '_> { } } +/// Returns `true` only for a well-formed `#[app_config(nested)]`. A malformed +/// `#[app_config(...)]` returns `false` -> the field is treated as NOT opted +/// in, so the guard still FLAGS the nesting (loud CI failure) rather than +/// silently waving it through. This is safe here (unlike the derive's +/// `nested_optin`, which must hard-error): the guard runs only over +/// already-compiling code, and the derive's strict `nested_optin` has already +/// rejected any malformed `#[app_config(...)]` before this binary ever runs. +fn field_has_nested_optin(field: &syn::Field) -> bool { + field.attrs.iter().any(|attr| { + if !attr.path().is_ident("app_config") { + return false; + } + // Must actually see `nested`. A bare `#[app_config()]` parses Ok but + // never sets `found`, so `.is_ok()` alone would wrongly report opt-in. + let mut found = false; + let parsed = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + found = true; + Ok(()) + } else { + Err(meta.error("unknown app_config option")) + } + }); + parsed.is_ok() && found + }) +} + // --------------------------------------------------------------------------- // Type-unwrapping helpers // --------------------------------------------------------------------------- @@ -339,9 +370,9 @@ fn main() { if violations > 0 { eprintln!( "\n{violations} nested-AppConfig violation(s). \ - A struct with #[derive(AppConfig)] must not contain fields whose \ - type resolves to another #[derive(AppConfig)] struct, even through \ - Option/Vec/Box wrappers (spec \u{00a7}3.3)." + A field whose type resolves to another #[derive(AppConfig)] struct \ + (even through Option/Vec/Box wrappers) must opt in with \ + #[app_config(nested)]; otherwise nesting is rejected (spec \u{00a7}3.3)." ); process::exit(1); } @@ -351,3 +382,50 @@ fn main() { println!("check_no_nested_app_config: OK"); } + +#[cfg(test)] +mod tests { + use super::*; + + const NESTED_VEC_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Vec } + "; + + const NESTED_WITHOUT_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { inner: Inner } + "; + + const NESTED_WITH_OPT_IN: &str = " + #[derive(edgezero_core::AppConfig)] struct Inner { #[secret] k: String } + #[derive(edgezero_core::AppConfig)] struct Outer { #[app_config(nested)] inner: Inner } + "; + + fn violations_in(src: &str) -> usize { + let file = syn::parse_file(src).expect("parse"); + let mut collector = AppConfigStructCollector::default(); + visit::visit_file(&mut collector, &file); + // NB: `new(source_path, app_config_structs)` — path FIRST, per the real + // signature above. + let mut visitor = + NestedAppConfigVisitor::new(Path::new("t.rs"), &collector.app_config_structs); + visit::visit_file(&mut visitor, &file); + visitor.violations + } + + #[test] + fn allows_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_WITH_OPT_IN), 0); + } + + #[test] + fn allows_vec_nesting_with_opt_in() { + assert_eq!(violations_in(NESTED_VEC_WITH_OPT_IN), 0); + } + + #[test] + fn flags_nesting_without_opt_in() { + assert_eq!(violations_in(NESTED_WITHOUT_OPT_IN), 1); + } +} diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508bd..d01be6b5 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -25,7 +25,8 @@ use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; use edgezero_core::app_config::{ - self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretKind, + self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, + SecretPathSegment, }; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::env_config::EnvConfig; @@ -167,6 +168,22 @@ enum RecheckOutcome { Write, } +/// One resolved `#[secret]` leaf located in the raw app-config TOML. +/// +/// `label` carries concrete `[n]` array indices (the runtime dotted +/// form used in CLI output and errors). `store_ref_value` is the sibling +/// store id resolved from the leaf's INNERMOST parent table — populated +/// only for `KeyInNamedStore` leaves. +#[derive(Debug)] +struct ResolvedTomlLeaf<'raw> { + /// Dotted runtime label, e.g. `partners[1].api_key`. + label: String, + /// Sibling store id for `KeyInNamedStore`; `None` otherwise. + store_ref_value: Option<&'raw str>, + /// The secret leaf's string value (a secret-store KEY NAME). + value: &'raw str, +} + /// Raw flow — no typed `C`. Runs every check the typed flow runs /// *except* the typed deserialise, the validator rules, the secret /// presence / store-ref checks, and the Spin config-vs-secret @@ -1287,17 +1304,93 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } +/// Collect every concrete secret leaf a `SecretField` resolves to in the +/// raw app-config TOML, navigating `Field` (table descent) and `ArrayEach` +/// (per-element) segments. `label` uses concrete `[n]` indices and, for a +/// `KeyInNamedStore` leaf, `store_ref_value` is resolved from the leaf's +/// innermost parent table. Absent optional leaves yield nothing; a missing +/// required leaf yields an `Err` carrying the dotted label. +fn collect_secret_leaves<'raw>( + root: &'raw Value, + field: &SecretField, +) -> Result>, String> { + fn walk<'raw>( + node: &'raw Value, + field: &SecretField, + remaining: &[SecretPathSegment], + rendered: &str, + out: &mut Vec>, + ) -> Result<(), String> { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => { + let parent = node.as_table().ok_or_else(|| { + format!("expected a table containing `{name}` at `{rendered}`") + })?; + let leaf_label = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name.as_ref()).and_then(Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(Value::as_str) + } + SecretKind::KeyInDefault | SecretKind::StoreRef => None, + }; + out.push(ResolvedTomlLeaf { + label: leaf_label, + store_ref_value, + value, + }); + Ok(()) + } + None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), + None => Err(format!( + "`#[secret]` field `{leaf_label}` is missing or not a string" + )), + } + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node + .as_table() + .ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { + name.to_string() + } else { + format!("{rendered}.{name}") + }; + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, &next_rendered, out), + None if field.optional => Ok(()), + None => Err(format!("missing `{next_rendered}`")), + } + } + Some((SecretPathSegment::ArrayEach, rest)) => { + let arr = node + .as_array() + .ok_or_else(|| format!("expected an array at `{rendered}`"))?; + for (idx, item) in arr.iter().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + walk(item, field, rest, &indexed, out)?; + } + Ok(()) + } + None => Ok(()), + } + } + let mut out = Vec::new(); + walk(root, field, &field.path, "", &mut out)?; + Ok(out) +} + /// Typed-only adapter dispatch: feed each adapter the `#[secret]` /// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - let default_store_id = ctx .manifest() .stores @@ -1305,22 +1398,25 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .as_ref() .map(StoreDeclaration::default_id); let mut entries: Vec> = Vec::new(); - for field in C::SECRET_FIELDS { - match field.kind { - SecretKind::KeyInDefault => { - let opt_value = raw_table.get(field.name).and_then(Value::as_str); - if let (Some(key_value), Some(store_id)) = (opt_value, default_store_id) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + for field in C::secret_fields() { + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + match field.kind { + SecretKind::KeyInDefault => { + if let Some(store_id) = default_store_id { + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); + } } - } - SecretKind::KeyInNamedStore { store_ref_field } => { - let opt_store = raw_table.get(store_ref_field).and_then(Value::as_str); - let opt_value = raw_table.get(field.name).and_then(Value::as_str); - if let (Some(store_id), Some(key_value)) = (opt_store, opt_value) { - entries.push(TypedSecretEntry::new(store_id, field.name, key_value)); + SecretKind::KeyInNamedStore { .. } => { + let store_id = leaf.store_ref_value.ok_or_else(|| { + format!( + "`#[secret(store_ref = \"...\")]` field `{}` is missing its store_ref sibling", + leaf.label + ) + })?; + entries.push(TypedSecretEntry::new(store_id, leaf.label, leaf.value)); } + SecretKind::StoreRef => {} } - SecretKind::StoreRef => {} } } @@ -1340,70 +1436,59 @@ fn typed_secret_checks( _typed: &C, ctx: &ValidationContext, ) -> Result<(), String> { - let raw_table = ctx - .raw_config - .as_table() - .ok_or_else(|| "raw app-config was not a TOML table after load".to_owned())?; - - for field in C::SECRET_FIELDS { - let value = raw_table - .get(field.name) - .and_then(Value::as_str) - .ok_or_else(|| { - format!( - "{}: `#[secret]` field `{}` is missing or not a string at the top level", + for field in C::secret_fields() { + for leaf in collect_secret_leaves(&ctx.raw_config, &field)? { + let label = leaf.label; + let value = leaf.value; + if value.is_empty() { + return Err(format!( + "{}: `#[secret]` field `{}` must be non-empty", ctx.app_config_path.display(), - field.name - ) - })?; - if value.is_empty() { - return Err(format!( - "{}: `#[secret]` field `{}` must be non-empty", - ctx.app_config_path.display(), - field.name - )); - } - match field.kind { - SecretKind::KeyInDefault => { - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - )); - } + label + )); } - SecretKind::KeyInNamedStore { .. } => { - // The field value is a key within a named store; the named - // store is identified by the sibling `#[secret(store_ref)]` - // field. Verify the store section is at least declared. - if ctx.manifest().stores.secrets.is_none() { - return Err(format!( - "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - )); + match field.kind { + SecretKind::KeyInDefault => { + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } } - } - SecretKind::StoreRef => { - let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { - format!( - "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", - ctx.app_config_path.display(), - field.name, - ctx.manifest_path.display() - ) - })?; - if !secrets.ids.iter().any(|id| id == value) { - return Err(format!( - "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", - ctx.app_config_path.display(), - field.name, - value, - secrets.ids - )); + SecretKind::KeyInNamedStore { .. } => { + // The field value is a key within a named store; the named + // store is identified by the sibling `#[secret(store_ref)]` + // field. Verify the store section is at least declared. + if ctx.manifest().stores.secrets.is_none() { + return Err(format!( + "{}: `#[secret(store_ref = \"...\")]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + )); + } + } + SecretKind::StoreRef => { + let secrets = ctx.manifest().stores.secrets.as_ref().ok_or_else(|| { + format!( + "{}: `#[secret(store_ref)]` field `{}` requires `[stores.secrets]` to be declared in {}", + ctx.app_config_path.display(), + label, + ctx.manifest_path.display() + ) + })?; + if !secrets.ids.iter().any(|id| id == value) { + return Err(format!( + "{}: `#[secret(store_ref)]` field `{}` = {:?} is not in [stores.secrets].ids ({:?})", + ctx.app_config_path.display(), + label, + value, + secrets.ids + )); + } } } } @@ -1543,8 +1628,8 @@ fn format_app_config_error(err: &AppConfigError) -> String { mod tests { use super::*; use crate::test_support::{manifest_guard, EnvOverride}; - use edgezero_core::app_config::SecretField; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; #[cfg(unix)] use std::ffi::OsString; use std::fs; @@ -1647,16 +1732,20 @@ source = "target/wasm32-wasip2/release/demo.wasm" } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } fn setup_project(manifest: &str, app_config: &str) -> (TempDir, PathBuf, PathBuf) { @@ -1864,10 +1953,13 @@ serve = "echo" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -1895,6 +1987,224 @@ ids = ["default"] .expect("secret-field validator must be skipped on typed validate"); } + // ---------- Task 6: path-aware nested / array secret reflection ---------- + + // Real nested derive: integrations.datadome.server_side_key (KeyInDefault), + // partners[*].api_key (KeyInDefault). + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct DataDome { + #[secret] + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Partner { + #[secret] + api_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NestedCliConfig { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + const NESTED_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn validate_typed_accepts_well_formed_nested_and_array_secrets() { + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("well-formed nested + array secret config validates"); + } + + #[test] + fn validate_typed_reports_dotted_path_for_empty_array_secret() { + // partners[1].api_key is empty -> typed_secret_checks must reject it and + // name the INDEXED dotted path. + let app_config = r#" +[integrations.datadome] +server_side_key = "dd_key" + +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("empty array secret must be rejected"); + assert!( + err.contains("partners[1].api_key"), + "error names the indexed dotted path: {err}" + ); + } + + #[test] + fn validate_typed_rejects_missing_required_nested_leaf_at_deserialize() { + // A MISSING required nested leaf fails serde DESERIALIZATION + // before `typed_secret_checks`/`run_adapter_typed_checks` ever run — so + // this is deserialize-path coverage, NOT proof of the path-aware + // collector. The direct collector test below covers that. + let app_config = r#" +[integrations.datadome] + +[[partners]] +api_key = "p0" +"#; + let (_dir, manifest_path, _) = setup_project(NESTED_MANIFEST, app_config); + let err = run_config_validate_typed::(&args_for(&manifest_path)) + .expect_err("missing nested leaf must be rejected"); + assert!( + err.contains("server_side_key"), + "error names the missing nested leaf: {err}" + ); + } + + // Direct coverage of the path-aware TOML collector (the new logic). + // Bypasses `run_config_validate_typed` so deserialization does not preempt + // it — proves the collector itself resolves array indices and reports the + // dotted label for a present-but-invalid / missing leaf. + #[test] + fn collect_secret_leaves_resolves_array_indices_and_dotted_labels() { + let raw: Value = toml::from_str( + r#" +[[partners]] +api_key = "p0" + +[[partners]] +api_key = "p1" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + let leaves = collect_secret_leaves(&raw, &field).expect("collect"); + let labels: Vec<&str> = leaves.iter().map(|leaf| leaf.label.as_str()).collect(); + assert_eq!(labels, vec!["partners[0].api_key", "partners[1].api_key"]); + let values: Vec<&str> = leaves.iter().map(|leaf| leaf.value).collect(); + assert_eq!(values, vec!["p0", "p1"]); + } + + #[test] + fn collect_secret_leaves_errors_on_missing_required_leaf_with_dotted_label() { + let raw: Value = toml::from_str( + r#" +[integrations.datadome] +other = "x" +"#, + ) + .expect("toml"); + + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + let err = collect_secret_leaves(&raw, &field).expect_err("missing required leaf"); + assert!( + err.contains("integrations.datadome.server_side_key"), + "collector error names the dotted path: {err}" + ); + } + + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, + } + #[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct NamedStoreCliConfig { + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, + } + + #[test] + fn validate_typed_accepts_nested_named_store_with_sibling() { + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default", "named"] +default = "default" +"#; + let app_config = r#" +[vaulted] +token = "tok_key" +vault = "named" +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + run_config_validate_typed::(&args_for(&manifest_path)) + .expect("nested named-store secret with a declared store validates"); + } + // ---------- Spin checks ---------- fn spin_manifest(extra_section: &str) -> String { @@ -2176,7 +2486,7 @@ ids = ["default"] // Regression: `#[secret(store_ref)]` values are logical // store ids (resolved at runtime), not Spin variable names — // they must not enter the Spin collision set. Earlier the - // walker treated every SECRET_FIELDS entry as a potential + // walker treated every secret_fields() entry as a potential // Spin var, so a perfectly valid `vault = "default"` plus a // config key whose flattened name happened to be `default` // would falsely trip a collision. @@ -2202,16 +2512,20 @@ ids = ["default"] vault: String, } impl AppConfigMeta for StoreRefRegressionConfig { - const SECRET_FIELDS: &'static [SecretField] = &[ - SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }, - SecretField { - kind: SecretKind::StoreRef, - name: "vault", - }, - ]; + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } } let manifest = r#" @@ -2474,6 +2788,70 @@ deep = true /// The runtime extractor (`secret_walk`) reads that name to look up /// the resolved value in the secret store. Stripping the field would /// cause `ConfigOutOfDate` on every request after a push. + #[test] + fn build_config_envelope_preserves_nested_and_array_secret_names() { + use edgezero_core::blob_envelope::BlobEnvelope; + + // Push serialises the typed struct verbatim, so nested + array secret + // KEY NAMES must survive into envelope.data at their full path — the + // runtime walk reads them there. (`build_config_envelope` only needs + // `Serialize`.) + #[derive(Debug, Serialize)] + struct DataDome { + server_side_key: String, + } + #[derive(Debug, Serialize)] + struct Integrations { + datadome: DataDome, + } + #[derive(Debug, Serialize)] + struct Partner { + api_key: String, + } + #[derive(Debug, Serialize)] + struct NestedPushConfig { + integrations: Integrations, + partners: Vec, + } + + let typed = NestedPushConfig { + integrations: Integrations { + datadome: DataDome { + server_side_key: "dd_key".to_owned(), + }, + }, + partners: vec![ + Partner { + api_key: "p0".to_owned(), + }, + Partner { + api_key: "p1".to_owned(), + }, + ], + }; + + let json = build_config_envelope(&typed).expect("envelope serialises"); + let envelope: BlobEnvelope = serde_json::from_str(&json).expect("envelope parses"); + assert_eq!( + envelope.data["integrations"]["datadome"]["server_side_key"].as_str(), + Some("dd_key"), + "nested secret key name must survive at its path: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][0]["api_key"].as_str(), + Some("p0"), + "array secret key name (element 0) must survive: {:?}", + envelope.data + ); + assert_eq!( + envelope.data["partners"][1]["api_key"].as_str(), + Some("p1"), + "array secret key name (element 1) must survive: {:?}", + envelope.data + ); + } + #[test] fn build_config_envelope_preserves_secret_field_values() { use edgezero_core::blob_envelope::BlobEnvelope; @@ -2744,10 +3122,13 @@ default = "one" greeting: String, } impl AppConfigMeta for SecretValidatorConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config = r#" @@ -3295,6 +3676,82 @@ ids = ["default"] // Medium 1 — diff runs typed_secret_checks + adapter_typed_checks // ------------------------------------------------------------------- + /// A NESTED `#[secret]` that is present but empty must be caught by the + /// path-aware `typed_secret_checks` on `diff` — before any remote read — + /// and the error must name the dotted path. + #[test] + fn diff_typed_rejects_empty_nested_secret() { + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffInner { + server_side_key: String, + } + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct DiffNestedConfig { + greeting: String, + #[validate(nested)] + integrations: DiffInner, + } + impl AppConfigMeta for DiffNestedConfig { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let app_config = r#" +greeting = "hello" + +[integrations] +server_side_key = "" +"#; + let manifest = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest_path, _) = setup_project(manifest, app_config); + let diff_args = ConfigDiffArgs { + adapter: "axum".to_owned(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: false, + manifest: manifest_path.clone(), + no_env: true, + runtime_config: None, + store: None, + }; + // The nested empty secret must be rejected by the path-aware + // typed_secret_checks before the remote-read step, naming the path. + let err = run_config_diff_typed::(&diff_args) + .expect_err("empty nested #[secret] must be rejected by diff typed_secret_checks"); + assert!( + err.contains("integrations.server_side_key") && err.contains("non-empty"), + "error names the nested dotted secret path: {err}" + ); + } + /// Medium 1 — spec 3.3.2: `run_config_diff_typed` must run the same /// structural checks as push, including `typed_secret_checks`. A /// `#[secret]` field that is present but empty must be rejected even @@ -3313,10 +3770,13 @@ ids = ["default"] greeting: String, } impl AppConfigMeta for DiffSecretConfig { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - kind: SecretKind::KeyInDefault, - name: "api_token", - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let app_config_empty_secret = r#" diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index ea5ac161..7fc32b50 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -127,6 +127,14 @@ pub trait Hooks { App::default_name() } + /// When `true`, an adapter's `run_app` skips its own logger initialization; + /// the app is responsible for installing a `log` backend. Default `false`. + #[must_use] + #[inline] + fn owns_logging() -> bool { + false + } + /// Build the router service for the application. fn routes() -> RouterService; @@ -240,6 +248,11 @@ mod tests { assert_eq!(app.name(), App::default_name()); } + #[test] + fn default_hooks_do_not_own_logging() { + assert!(!DefaultHooks::owns_logging()); + } + #[test] fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index de85aa15..2cd16d90 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -14,6 +14,7 @@ //! [`load_app_config_raw_with_options`]. use std::any; +use std::borrow::Cow; use std::collections::HashMap; use std::env; use std::fs; @@ -27,24 +28,61 @@ use toml::value::Datetime; use toml::Value; use validator::{Validate, ValidationErrors}; -/// Per-field metadata emitted by `#[derive(AppConfig)]`. The -/// derive enumerates every field annotated with `#[secret]` / -/// `#[secret(store_ref)]`; `config validate` and `config push` -/// reflect over this array to gate secret-aware behaviour. -pub trait AppConfigMeta { - /// Every `#[secret]` / `#[secret(store_ref)]` field on the struct. - const SECRET_FIELDS: &'static [SecretField]; +/// One segment of a [`SecretField`] path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SecretPathSegment { + /// Every element of an array/`Vec` at this position. + ArrayEach, + /// An object key — a Rust field name, verbatim (no `serde(rename)`). + Field(Cow<'static, str>), } /// One field's worth of secret-annotation metadata. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// The `path` locates the secret leaf from the config root. A top-level +/// scalar has a length-1 path `[Field("api_token")]`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SecretField { - /// Whether the field's value is a key in the default secret store - /// or the logical id of a `[stores.secrets]` entry. + /// Which secret-store resolution this field participates in. pub kind: SecretKind, - /// Rust field name verbatim (no `serde(rename)` translation — - /// `#[secret]` rejects renames at compile time). - pub name: &'static str, + /// `true` for `#[secret]` on `Option`: an absent leaf is + /// skipped by the runtime walk instead of erroring. + pub optional: bool, + /// Path from the config root to the secret leaf. + pub path: Vec, +} + +impl SecretField { + /// Human-readable dotted path for error messages and CLI output. + /// `ArrayEach` renders as `[*]` (the static form); the runtime walk + /// renders per-index `[n]` as it descends. + #[inline] + #[must_use] + pub fn dotted_path(&self) -> String { + let mut out = String::new(); + for segment in &self.path { + match segment { + SecretPathSegment::Field(name) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(name); + } + SecretPathSegment::ArrayEach => out.push_str("[*]"), + } + } + out + } +} + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. `config validate` +/// / `config push` and the runtime secret walk reflect over this to gate +/// secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` leaf on the struct, + /// including those reached through `#[app_config(nested)]` children, + /// each carrying its full path from this struct's root. + fn secret_fields() -> Vec; } /// Discriminator on a [`SecretField`] capturing which secret-store @@ -208,23 +246,75 @@ pub fn validate_excluding_secrets( let Err(mut errors) = result else { return Ok(()); }; - // validator 0.20 exposes errors_mut() -> &mut HashMap, ValidationErrorsKind>. - // `bag.remove(field.name)` works because `field.name` is `&'static str` - // and `Cow<'static, str>: Borrow` (the earlier comment cited the - // wrong key type — this is the corrected form). - let bag = errors.errors_mut(); - for field in C::SECRET_FIELDS { + for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; // store_id field; validator stays } - bag.remove(field.name); + prune_secret_leaf(&mut errors, &field.path); } - if bag.is_empty() { + if errors.errors().is_empty() { return Ok(()); } Err(errors) } +/// Remove the per-field validator error for the secret leaf at `path`, +/// descending `ValidationErrorsKind::Struct`/`List` containers, and prune any +/// container that becomes empty so a fully-cleared branch disappears. Without +/// the prune, an empty `Struct`/`List` marker would keep `errors` non-empty and +/// make `validate_excluding_secrets` wrongly return `Err`. +fn prune_secret_leaf(errors: &mut ValidationErrors, path: &[SecretPathSegment]) { + use validator::ValidationErrorsKind; + + let Some((head, rest)) = path.split_first() else { + return; + }; + let SecretPathSegment::Field(name) = head else { + // `ArrayEach` only appears immediately after a `Field` (the root is + // always a struct), so it is consumed by the peek below, never a head. + return; + }; + + // Leaf reached: drop the validator error keyed by this field name. + if rest.is_empty() { + errors.errors_mut().remove(name.as_ref()); + return; + } + + // A `Field` immediately followed by `ArrayEach` targets a `List` nested + // under the field's key; consume the `ArrayEach` here so the recursive + // descent sees each element's own remaining path. + let (kind_is_array, tail) = + if let Some((SecretPathSegment::ArrayEach, array_tail)) = rest.split_first() { + (true, array_tail) + } else { + (false, rest) + }; + + let mut clear = false; + if kind_is_array { + if let Some(ValidationErrorsKind::List(items)) = errors.errors_mut().get_mut(name.as_ref()) + { + for inner in items.values_mut() { + prune_secret_leaf(inner, tail); + } + items.retain(|_, inner| !inner.errors().is_empty()); + clear = items.is_empty(); + } + } + if !kind_is_array { + if let Some(ValidationErrorsKind::Struct(inner)) = + errors.errors_mut().get_mut(name.as_ref()) + { + prune_secret_leaf(inner, tail); + clear = inner.errors().is_empty(); + } + } + if clear { + errors.errors_mut().remove(name.as_ref()); + } +} + /// Load and validate a typed app-config from `.toml`. /// /// `env_overlay` is on by default; pass [`AppConfigLoadOptions`] @@ -618,7 +708,9 @@ mod tests { } impl AppConfigMeta for FixtureConfig { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } fn write_fixture(contents: &str) -> NamedTempFile { @@ -1104,7 +1196,9 @@ greeting = "hello" } // Hand-rolled AppConfigMeta — matches the same shape as the rest of this test. impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } impl validator::Validate for Fixture { fn validate(&self) -> Result<(), validator::ValidationErrors> { @@ -1136,7 +1230,9 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } let cfg = Fixture { greeting: "hello".into(), @@ -1154,10 +1250,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "short".into(), @@ -1179,10 +1278,13 @@ greeting = "hello" greeting: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } let cfg = Fixture { api_token: "x".into(), @@ -1199,10 +1301,13 @@ greeting = "hello" store_id: String, } impl AppConfigMeta for Fixture { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "store_id", - kind: SecretKind::StoreRef, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("store_id"))], + optional: false, + }] + } } let cfg = Fixture { store_id: "short".into(), @@ -1210,4 +1315,210 @@ greeting = "hello" // StoreRef keeps its validator — short store_id still fails. validate_excluding_secrets(&cfg).unwrap_err(); } + + #[test] + fn validate_excluding_secrets_prunes_nested_secret_leaf_validator() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + server_side_key: String, // holds a short KEY NAME at push time + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), // 6 chars < 100 + }, + }; + // The only failure is the nested secret leaf's validator -> pruned -> Ok. + validate_excluding_secrets(&cfg).unwrap(); + } + + #[test] + fn validate_excluding_secrets_keeps_nested_non_secret_failures() { + #[derive(Validate)] + struct Inner { + #[validate(length(min = 100))] + note: String, // NON-secret, must still fail + #[validate(length(min = 100))] + server_side_key: String, + } + #[derive(Validate)] + struct Outer { + #[validate(nested)] + integrations: Inner, + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + let cfg = Outer { + integrations: Inner { + server_side_key: "dd_key".to_owned(), + note: "short".to_owned(), + }, + }; + validate_excluding_secrets(&cfg).unwrap_err(); // `note` still fails + } + + #[test] + fn validate_excluding_secrets_prunes_array_secret_leaf_keeps_siblings() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // secret leaf (a key NAME at push time) + #[validate(length(min = 100))] + label: String, // NON-secret sibling + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element fails BOTH validators at push time. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + label: "s".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + label: "s".to_owned(), + }, + ], + }; + // `api_key` (secret) pruned from every List element; `label` + // (non-secret) survives in every element -> overall Err. + let err = validate_excluding_secrets(&cfg).expect_err("non-secret siblings still fail"); + let rendered = format!("{err:?}"); + assert!( + rendered.contains("label"), + "non-secret sibling must survive" + ); + assert!( + !rendered.contains("api_key"), + "secret leaf must be pruned from every array element" + ); + } + + #[test] + fn validate_excluding_secrets_prunes_array_all_secret_failures_to_ok() { + #[derive(Validate)] + struct Outer { + #[validate(nested)] + partners: Vec, + } + #[derive(Validate)] + struct Partner { + #[validate(length(min = 100))] + api_key: String, // the ONLY validated field, and it's the secret leaf + } + impl AppConfigMeta for Outer { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Every element's only failure is the secret leaf -> each List element + // clears -> the empty List is pruned -> `partners` removed -> Ok. + let cfg = Outer { + partners: vec![ + Partner { + api_key: "k0".to_owned(), + }, + Partner { + api_key: "k1".to_owned(), + }, + ], + }; + validate_excluding_secrets(&cfg).expect( + "an array branch whose only failures are secret leaves must fully prune to Ok(())", + ); + } + + #[test] + fn dotted_path_renders_nested_and_array_segments() { + use super::{SecretField, SecretKind, SecretPathSegment::*}; + use std::borrow::Cow; + + let top = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![Field(Cow::Borrowed("api_token"))], + optional: false, + }; + assert_eq!(top.dotted_path(), "api_token"); + + let nested = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("integrations")), + Field(Cow::Borrowed("datadome")), + Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }; + assert_eq!( + nested.dotted_path(), + "integrations.datadome.server_side_key" + ); + + let array = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + Field(Cow::Borrowed("partners")), + ArrayEach, + Field(Cow::Borrowed("api_key")), + ], + optional: false, + }; + assert_eq!(array.dotted_path(), "partners[*].api_key"); + } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 9bb4086f..8b3fc941 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,11 +1,14 @@ +use std::any; +use std::future::Future; use std::ops::{Deref, DerefMut}; +use std::pin::Pin; use async_trait::async_trait; use http::header; use serde::de::DeserializeOwned; use validator::Validate; -use crate::app_config::{AppConfigMeta, SecretKind}; +use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::config_store::ConfigStoreHandle; use crate::context::RequestContext; @@ -528,6 +531,66 @@ impl Kv { } } +/// Extractor for app-owned shared state registered via +/// [`RouterBuilder::with_state`]. Resolves by type from request extensions. +/// +/// Typically `T = Arc`. The registered value is cloned into every +/// request's extensions before dispatch; registering the same `T` twice is +/// last-write-wins. +/// +/// ```ignore +/// use edgezero_core::extractor::State; +/// use std::sync::Arc; +/// +/// #[edgezero_core::action] +/// async fn handle(State(state): State>) -> Result { +/// Ok(state.greeting.clone()) +/// } +/// ``` +/// +/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state +pub struct State(pub T); + +#[async_trait(?Send)] +impl FromRequest for State +where + T: Clone + Send + Sync + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::().map(State).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()", + any::type_name::() + )) + }) + } +} + +impl Deref for State { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for State { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl State { + /// Consume the extractor and return the inner value. + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + /// Extractor that yields the per-request [`SecretRegistry`]. /// /// The returned [`BoundSecretStore`] is pre-bound to a platform store name @@ -820,7 +883,7 @@ where Ok(cfg) } -/// Walk `C::SECRET_FIELDS` and replace each `#[secret]` key NAME in `data` +/// Walk `C::secret_fields()` and replace each `#[secret]` key NAME in `data` /// with the resolved secret VALUE from the appropriate secret store. /// /// `StoreRef` fields are skipped — their value is a store id, not a key. @@ -828,68 +891,159 @@ async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> R where C: AppConfigMeta, { - let data_obj = data - .as_object_mut() - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")))?; - for field in C::SECRET_FIELDS { - let key_name = data_obj - .get(field.name) - .and_then(|val| val.as_str()) - .ok_or_else(|| { + for field in C::secret_fields() { + resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + } + Ok(()) +} + +/// Recursively descend `remaining` path segments from `node`, resolving the +/// secret leaf(s). `rendered` is the dotted path so far (with concrete `[n]` +/// indices) for error hints. +fn resolve_secret_field<'walk>( + ctx: &'walk RequestContext, + node: &'walk mut serde_json::Value, + field: &'walk SecretField, + remaining: &'walk [SecretPathSegment], + rendered: String, +) -> Pin> + 'walk>> { + Box::pin(async move { + match remaining.split_first() { + // Leaf reached: `node` is the PARENT object; the last Field is the key. + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Descend into an object key. + Some((SecretPathSegment::Field(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + // Absent optional subtree: key missing OR serialized as null. + None | Some(serde_json::Value::Null) if field.optional => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + None => Err(EdgeError::config_out_of_date( + format!("missing or non-object value at `{next_rendered}`"), + next_rendered, + )), + } + } + // Iterate every array element. + Some((SecretPathSegment::ArrayEach, rest)) => { + let Some(items) = node.as_array_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an array at `{rendered}`"), + rendered, + )); + }; + for (idx, item) in items.iter_mut().enumerate() { + let indexed = format!("{rendered}[{idx}]"); + resolve_secret_field(ctx, item, field, rest, indexed).await?; + } + Ok(()) + } + None => Ok(()), + } + }) +} + +fn join_field(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +/// Resolve one leaf: `parent` is the innermost containing object; `key` is the +/// secret field name; `store_ref_field` (for `KeyInNamedStore`) is a sibling +/// within `parent`. +async fn resolve_leaf( + ctx: &RequestContext, + parent: &mut serde_json::Value, + field: &SecretField, + key: &str, + rendered_parent: &str, +) -> Result<(), EdgeError> { + if matches!(field.kind, SecretKind::StoreRef) { + return Ok(()); // store id, not a secret key + } + let leaf_path = join_field(rendered_parent, key); + + let Some(parent_obj) = parent.as_object_mut() else { + if field.optional { + return Ok(()); + } + return Err(EdgeError::config_out_of_date( + format!("expected an object containing `{key}` at `{rendered_parent}`"), + leaf_path, + )); + }; + + let key_name = match parent_obj.get(key) { + Some(serde_json::Value::String(name)) => name.clone(), + // An optional secret is absent when the key is MISSING *or* serialized + // as JSON `null`. serde emits `Option::None` as `null` (and `#[secret]` + // bans `skip_serializing_if`, so the key is never omitted), so both + // cases must skip — not just the missing-key case. + None | Some(serde_json::Value::Null) if field.optional => return Ok(()), + _ => { + return Err(EdgeError::config_out_of_date( + format!("missing or non-string value at `{leaf_path}`"), + leaf_path, + )) + } + }; + + let (bound, resolved_store_id) = match field.kind { + SecretKind::KeyInDefault => { + let bound = ctx.secret_store_default().ok_or_else(|| { EdgeError::config_out_of_date( - format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + format!( + "secret field `{leaf_path}` has kind KeyInDefault but no default secret \ + store is registered" + ), + leaf_path.clone(), ) - })? - .to_owned(); - let (bound, resolved_store_id) = match field.kind { - SecretKind::KeyInDefault => { - let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "secret field `{}` has kind KeyInDefault but no default secret \ - store is registered", - field.name, - ), - field.name.to_owned(), - ) - })?; - let id = bound.store_name().to_owned(); - (bound, id) - } - SecretKind::StoreRef => continue, - SecretKind::KeyInNamedStore { store_ref_field } => { - let store_id_str = data_obj - .get(store_ref_field) - .and_then(|val| val.as_str()) - .ok_or_else(|| { - EdgeError::config_out_of_date( - format!( - "missing store_ref `{store_ref_field}` for secret field `{}`", - field.name - ), - field.name.to_owned(), - ) - })? - .to_owned(); - let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + })?; + let id = bound.store_name().to_owned(); + (bound, id) + } + SecretKind::StoreRef => return Ok(()), + SecretKind::KeyInNamedStore { store_ref_field } => { + let store_id_str = parent_obj + .get(store_ref_field) + .and_then(|val| val.as_str()) + .ok_or_else(|| { EdgeError::config_out_of_date( format!( - "blob declared store_ref `{store_id_str}` but \ - [stores.secrets] has no such id" + "missing store_ref `{store_ref_field}` for secret field `{leaf_path}`" ), - field.name.to_owned(), + leaf_path.clone(), ) - })?; - (bound, store_id_str) - } - }; - let secret = bound - .require_str(&key_name) - .await - .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; - data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); - } + })? + .to_owned(); + let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { + EdgeError::config_out_of_date( + format!( + "blob declared store_ref `{store_id_str}` but \ + [stores.secrets] has no such id" + ), + leaf_path.clone(), + ) + })?; + (bound, store_id_str) + } + }; + + let secret = bound + .require_str(&key_name) + .await + .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); Ok(()) } @@ -976,7 +1130,7 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option #[cfg(test)] mod tests { use super::*; - use crate::app_config::{AppConfigMeta, SecretField, SecretKind}; + use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; @@ -987,10 +1141,16 @@ mod tests { use crate::store_registry::StoreRegistry; use futures::executor::block_on; use serde::{Deserialize, Serialize}; + use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use validator::Validate; + #[derive(Clone, Debug, PartialEq)] + struct AppStateFixture { + name: String, + } + #[derive(Debug, Deserialize, PartialEq)] struct FormData { age: Option, @@ -1047,7 +1207,9 @@ mod tests { } impl AppConfigMeta for FixtureCfg { - const SECRET_FIELDS: &'static [SecretField] = &[]; + fn secret_fields() -> Vec { + vec![] + } } // Fixture config type with one KeyInDefault secret field. Used by AppConfig tests. @@ -1060,10 +1222,75 @@ mod tests { } impl AppConfigMeta for SecretCfg { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } + } + + // Array leaf: partners[*].api_key + struct ArrayCfg; + impl AppConfigMeta for ArrayCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("partners")), + SecretPathSegment::ArrayEach, + SecretPathSegment::Field(Cow::Borrowed("api_key")), + ], + optional: false, + }] + } + } + + // Nested KeyInNamedStore: vaulted.token resolves against the store named by + // its SIBLING vaulted.vault (the sibling-in-innermost-parent scoping rule). + struct NamedStoreCfg; + impl AppConfigMeta for NamedStoreCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInNamedStore { + store_ref_field: "vault", + }, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("vaulted")), + SecretPathSegment::Field(Cow::Borrowed("token")), + ], + optional: false, + }] + } + } + + // Nested object leaf: integrations.datadome.server_side_key + struct NestedCfg; + impl AppConfigMeta for NestedCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("server_side_key")), + ], + optional: false, + }] + } + } + + // Optional top-level leaf: maybe_key + struct OptionalCfg; + impl AppConfigMeta for OptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("maybe_key"))], + optional: true, + }] + } } fn ctx(body: Body, params: PathParams) -> RequestContext { @@ -2224,6 +2451,126 @@ mod tests { } } + // Build a RequestContext whose default secret store maps `default/{key}` -> + // `value`, for exercising `secret_walk` directly. + fn ctx_with_default_secret_store(key: &str, value: &str) -> RequestContext { + ctx_with_default_secret_store_map(&[(key, value)]) + } + + // Multi-entry variant of `ctx_with_default_secret_store`. + fn ctx_with_default_secret_store_map(entries: &[(&str, &str)]) -> RequestContext { + let store = InMemorySecretStore::new(entries.iter().map(|(key, value)| { + ( + format!("default/{key}"), + bytes::Bytes::from((*value).to_owned()), + ) + })); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), "default".to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id("default".to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + // Build a RequestContext whose secret store `store_id` maps + // `{store_id}/{key}` -> `value`, resolvable via `ctx.secret_store(store_id)`. + fn ctx_with_named_secret_store(store_id: &str, key: &str, value: &str) -> RequestContext { + let store = InMemorySecretStore::new([( + format!("{store_id}/{key}"), + bytes::Bytes::from(value.to_owned()), + )]); + let bound = BoundSecretStore::new(SecretHandle::new(Arc::new(store)), store_id.to_owned()); + let registry: SecretRegistry = StoreRegistry::single_id(store_id.to_owned(), bound); + let mut request = request_builder() + .method(Method::GET) + .uri("/cfg") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn secret_walk_resolves_nested_object_leaf() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ + "integrations": { "datadome": { "server_side_key": "dd_key" } } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!( + data["integrations"]["datadome"]["server_side_key"], + serde_json::json!("resolved-dd") + ); + } + + #[test] + fn secret_walk_resolves_each_array_element() { + let ctx = ctx_with_default_secret_store_map(&[("k0", "v0"), ("k1", "v1")]); + let mut data = serde_json::json!({ + "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); + assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); + } + + #[test] + fn secret_walk_resolves_nested_named_store_via_sibling_in_parent() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ + "vaulted": { "token": "tok_key", "vault": "named" } + }); + block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); + // The store_ref sibling is left intact (it names a store, not a secret). + assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); + } + + #[test] + fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { + let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); + let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing store_ref sibling"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err.to_string().contains("vaulted.token")); + } + + #[test] + fn secret_walk_skips_absent_optional_leaf() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key + block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + assert!(data.get("maybe_key").is_none()); + } + + #[test] + fn secret_walk_skips_null_optional_leaf() { + // serde serializes `Option::None` as JSON `null` (the key is present, + // not omitted). The walk must skip a null optional leaf, not error it. + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "maybe_key": null }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional is skipped, not treated as non-string"); + assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched + } + + #[test] + fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { + let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); + let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("missing required nested leaf"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(err + .to_string() + .contains("integrations.datadome.server_side_key")); + } + #[test] fn app_config_named_reads_different_key() { struct KeyEchoStore; @@ -2327,10 +2674,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); @@ -2368,10 +2718,13 @@ mod tests { } impl AppConfigMeta for SecretLen { - const SECRET_FIELDS: &'static [SecretField] = &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }]; + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("api_token"))], + optional: false, + }] + } } struct BlobStore(String); @@ -2400,4 +2753,67 @@ mod tests { ); } } + + #[test] + fn state_extractor_resolves_registered_value() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(Arc::new(AppStateFixture { + name: "demo".to_owned(), + })); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = + block_on(State::>::from_request(&ctx)).expect("state present"); + + // Deref: State> -> Arc -> AppStateFixture + assert_eq!(state.name, "demo"); + } + + #[test] + fn state_extractor_missing_registration_is_internal_error() { + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + + // `.err().expect(..)` (not `expect_err`) so we don't require + // `State: Debug` — extractors here mirror Json/Path and omit it. + let err = block_on(State::>::from_request(&ctx)) + .err() + .expect("missing state must surface as an error, not a default"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn state_extractor_deref_and_into_inner() { + let mut request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(AppStateFixture { + name: "x".to_owned(), + }); + let ctx = RequestContext::new(request, PathParams::default()); + + let state = block_on(State::::from_request(&ctx)).expect("state present"); + assert_eq!( + *state, + AppStateFixture { + name: "x".to_owned() + } + ); // Deref + assert_eq!( + state.into_inner(), + AppStateFixture { + name: "x".to_owned() + } + ); + } } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index eae25a9f..5b9881de 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -8,7 +8,7 @@ use tower_service::Service; use crate::context::RequestContext; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; -use crate::http::{HandlerFuture, Method, Request, Response}; +use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -73,6 +73,9 @@ pub struct RouterBuilder { middlewares: Vec, route_info: Vec, routes: HashMap>, + /// App state registered via [`RouterBuilder::with_state`], keyed by type. + /// Cloned into every request's extensions at dispatch. + state_extensions: Extensions, } impl RouterBuilder { @@ -115,6 +118,7 @@ impl RouterBuilder { self.middlewares, route_index, self.manifest_json, + self.state_extensions, ) } @@ -193,6 +197,25 @@ impl RouterBuilder { self.manifest_json = Some(json.into()); self } + + /// Register a value cloned into every request's extensions before + /// dispatch, making it available to the [`State`] extractor and to + /// `RequestContext`-based handlers. + /// + /// Typically `T = Arc`. Registering the same `T` twice is + /// last-write-wins. Cost is one `T::clone` (an `Arc` bump for + /// `Arc`) per registered state per request. + /// + /// [`State`]: crate::extractor::State + #[must_use] + #[inline] + pub fn with_state(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.state_extensions.insert(value); + self + } } struct RouterInner { @@ -200,6 +223,7 @@ struct RouterInner { middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, + state_extensions: Extensions, } impl RouterInner { @@ -224,6 +248,12 @@ impl RouterInner { .extensions_mut() .insert(RouteTable(Arc::clone(&self.route_index))); } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; `extend` overwrites by + // TypeId, so app state wins last-write on any collision. + request + .extensions_mut() + .extend(self.state_extensions.clone()); let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -299,6 +329,7 @@ impl RouterService { middlewares: Vec, route_index: Arc<[RouteInfo]>, manifest_json: Option>, + state_extensions: Extensions, ) -> Self { Self { inner: Arc::new(RouterInner { @@ -306,6 +337,7 @@ impl RouterService { middlewares, route_index, routes, + state_extensions, }), } } @@ -772,4 +804,146 @@ mod tests { }); assert_eq!(collected, b"chunk-one\nchunk-two\n"); } + + #[test] + fn with_state_exposes_value_to_handler() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(9)) + .get("/count", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/count") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=9"); + } + + #[test] + fn with_state_last_write_wins_for_same_type() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct Counter(u32); + + async fn handler(ctx: RequestContext) -> Result { + let State(counter) = State::::from_request(&ctx).await?; + Ok(format!("count={}", counter.0)) + } + + let service = RouterService::builder() + .with_state(Counter(1)) + .with_state(Counter(2)) + .get("/c", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); + } + + #[test] + fn with_state_no_cross_request_bleed() { + use crate::extractor::{FromRequest as _, State}; + use std::future::Future as _; + + #[derive(Clone)] + struct Tag(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(tag) = State::::from_request(&ctx).await?; + Ok(tag.0.to_owned()) + } + + let service = RouterService::builder() + .with_state(Tag("shared")) + .get("/t", handler) + .build(); + + let req1 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req1"); + let req2 = request_builder() + .method(Method::GET) + .uri("/t") + .body(Body::empty()) + .expect("req2"); + + // Two independent in-flight requests, polled interleaved on one thread. + let mut f1 = Box::pin(service.oneshot(req1)); + let mut f2 = Box::pin(service.oneshot(req2)); + let mut cx = Context::from_waker(noop_waker_ref()); + + let mut r1 = None; + let mut r2 = None; + while r1.is_none() || r2.is_none() { + if r1.is_none() { + if let Poll::Ready(value) = f1.as_mut().poll(&mut cx) { + r1 = Some(value); + } + } + if r2.is_none() { + if let Poll::Ready(value) = f2.as_mut().poll(&mut cx) { + r2 = Some(value); + } + } + } + + let resp1 = r1.unwrap().expect("resp1"); + let resp2 = r2.unwrap().expect("resp2"); + assert_eq!(resp1.body().as_bytes().expect("buffered"), b"shared"); + assert_eq!(resp2.body().as_bytes().expect("buffered"), b"shared"); + } + + #[test] + fn with_state_supports_multiple_distinct_types() { + use crate::extractor::{FromRequest as _, State}; + + #[derive(Clone)] + struct First(u32); + #[derive(Clone)] + struct Second(&'static str); + + async fn handler(ctx: RequestContext) -> Result { + let State(first) = State::::from_request(&ctx).await?; + let State(second) = State::::from_request(&ctx).await?; + Ok(format!("{}-{}", first.0, second.0)) + } + + let service = RouterService::builder() + .with_state(First(7)) + .with_state(Second("hi")) + .get("/both", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/both") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.body().as_bytes().expect("buffered"), b"7-hi"); + } } diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index bb584cd5..18039b5a 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -25,7 +25,10 @@ validator = { workspace = true, features = ["derive"] } # `edgezero-core` re-exports `AppConfig`; the derive tests assert # against the trait/types over the re-export path the way downstream # users will. Cargo allows dev-dep cycles (only the main dep edge -# matters for build ordering). -edgezero-core = { workspace = true } +# matters for build ordering). `test-utils` exposes `InMemorySecretStore` +# so the end-to-end nested-secret test can drive the runtime secret walk. +async-trait = { workspace = true } +edgezero-core = { workspace = true, features = ["test-utils"] } +futures = { workspace = true } tempfile = { workspace = true } trybuild = { workspace = true } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 9d52e3a8..d9f2cc73 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -9,24 +9,82 @@ use syn::parse::{Parse, ParseStream}; use syn::{parse_macro_input, Ident, LitStr, Token}; use validator::Validate as _; +#[derive(Debug)] struct AppArgs { app_ident: Option, + owns_logging: Option, path: LitStr, + state: Option, } impl Parse for AppArgs { fn parse(input: ParseStream) -> syn::Result { let path: LitStr = input.parse()?; - let app_ident = if input.peek(Token![,]) { + let mut app_ident: Option = None; + let mut owns_logging: Option = None; + let mut state: Option = None; + let mut seen_keyword = false; + + while input.peek(Token![,]) { input.parse::()?; - Some(input.parse::()?) - } else { - None - }; + + // Keyword argument: `Ident = Value`. + if input.peek(Ident) && input.peek2(Token![=]) { + let key: Ident = input.parse()?; + input.parse::()?; + seen_keyword = true; + match key.to_string().as_str() { + "owns_logging" => { + if owns_logging.is_some() { + return Err(syn::Error::new( + key.span(), + "duplicate `owns_logging` argument", + )); + } + let value: syn::LitBool = input.parse()?; + owns_logging = Some(value.value); + } + "state" => { + if state.is_some() { + return Err(syn::Error::new(key.span(), "duplicate `state` argument")); + } + state = Some(input.parse::()?); + } + other => { + return Err(syn::Error::new( + key.span(), + format!( + "unknown `app!` argument `{other}`; expected `state` or `owns_logging`" + ), + )); + } + } + continue; + } + + // Bare identifier: the optional custom App type name, only before keywords. + if input.peek(Ident) { + if seen_keyword || app_ident.is_some() { + return Err(input.error( + "the custom App identifier must come immediately after the manifest path, before keyword arguments", + )); + } + app_ident = Some(input.parse::()?); + continue; + } + + return Err(input.error("expected a custom App identifier or `key = value` argument")); + } + if !input.is_empty() { return Err(input.error("unexpected tokens after app! macro arguments")); } - Ok(Self { app_ident, path }) + Ok(Self { + app_ident, + owns_logging, + path, + state, + }) } } @@ -150,12 +208,18 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let stores_tokens = build_stores_tokens(&manifest); let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); + let owns_logging_lit = args.owns_logging.unwrap_or(false); + // Emitted only when `state = ` is given; `Option: ToTokens` + // renders `None` as nothing, so an app without `state` is unchanged. + let state_call = args.state.as_ref().map(|state_expr| { + quote! { builder = builder.with_state(#state_expr); } + }); - // The emitted `Hooks` impl below explicitly defines `configure` and - // `build_app` even though their bodies mirror the trait defaults. This is - // required because `missing_trait_methods` (restriction = deny) forbids - // relying on trait defaults in the impl. If `Hooks::configure` or - // `Hooks::build_app` defaults change, update these emitted bodies to match. + // The emitted `Hooks` impl below explicitly defines `configure`, + // `owns_logging`, and `build_app` even though their bodies mirror the trait + // defaults. This is required because `missing_trait_methods` (restriction = + // deny) forbids relying on trait defaults in the impl. If those `Hooks` + // defaults change, update these emitted bodies to match. let output = quote! { // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). const _: &[u8] = include_bytes!(#manifest_path_lit); @@ -169,6 +233,10 @@ pub fn expand_app(input: TokenStream) -> TokenStream { fn configure(_app: &mut edgezero_core::app::App) {} + fn owns_logging() -> bool { + #owns_logging_lit + } + fn name() -> &'static str { #app_name_lit } @@ -185,6 +253,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); builder = builder.with_manifest_json(#manifest_json_lit); + #state_call #(#middleware_tokens)* #(#route_tokens)* builder.build() @@ -266,7 +335,107 @@ fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> Tok #[cfg(test)] mod tests { - use super::parse_handler_path; + use super::{parse_handler_path, AppArgs}; + use syn::parse_str; + + #[test] + fn app_args_parses_app_ident_then_keyword() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, owns_logging = false"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(false)); + } + + #[test] + fn app_args_parses_owns_logging_true() { + let args: AppArgs = parse_str(r#""edgezero.toml", owns_logging = true"#).expect("parse"); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.app_ident.is_none()); + } + + #[test] + fn app_args_parses_path_and_app_ident() { + let args: AppArgs = parse_str(r#""edgezero.toml", MyApp"#).expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_path_only() { + let args: AppArgs = parse_str(r#""edgezero.toml""#).expect("parse"); + assert_eq!(args.path.value(), "edgezero.toml"); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + assert!(args.state.is_none()); + } + + #[test] + fn app_args_parses_state_expr() { + let args: AppArgs = + parse_str(r#""edgezero.toml", state = crate::app_state()"#).expect("parse"); + let rendered = args.state.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: app_state ()".to_owned())); + assert!(args.app_ident.is_none()); + assert_eq!(args.owns_logging, None); + } + + #[test] + fn app_args_parses_state_with_app_ident_and_owns_logging() { + let args: AppArgs = + parse_str(r#""edgezero.toml", MyApp, state = crate::app_state(), owns_logging = true"#) + .expect("parse"); + assert_eq!( + args.app_ident.map(|ident| ident.to_string()), + Some("MyApp".to_owned()) + ); + assert_eq!(args.owns_logging, Some(true)); + assert!(args.state.is_some()); + } + + #[test] + fn app_args_rejects_duplicate_state() { + let err = parse_str::(r#""edgezero.toml", state = a(), state = b()"#) + .expect_err("duplicate state"); + assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); + } + + #[test] + fn app_args_rejects_duplicate_key() { + let err = + parse_str::(r#""edgezero.toml", owns_logging = true, owns_logging = false"#) + .expect_err("duplicate"); + assert!( + err.to_string().contains("duplicate `owns_logging`"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_ident_after_keyword() { + let err = parse_str::(r#""edgezero.toml", owns_logging = true, MyApp"#) + .expect_err("ident after keyword"); + assert!( + err.to_string() + .contains("must come immediately after the manifest path"), + "got: {err}" + ); + } + + #[test] + fn app_args_rejects_unknown_key() { + let err = + parse_str::(r#""edgezero.toml", bogus = true"#).expect_err("unknown key"); + assert!( + err.to_string().contains("unknown `app!` argument `bogus`"), + "got: {err}" + ); + } #[test] fn parse_handler_path_accepts_absolute_crate_path() { diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs index 3444cba9..e941d3f1 100644 --- a/crates/edgezero-macros/src/app_config.rs +++ b/crates/edgezero-macros/src/app_config.rs @@ -3,7 +3,7 @@ //! Scans the input struct for `#[secret]` / `#[secret(store_ref)]` //! field annotations, enforces the compile-time constraints, and //! emits `impl ::edgezero_core::app_config::AppConfigMeta` with the -//! `SECRET_FIELDS` array. +//! `secret_fields()` method. use std::collections::{HashMap, HashSet}; @@ -12,8 +12,8 @@ use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; use syn::punctuated::Punctuated; use syn::{ - parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, Ident, Lit, - Meta, MetaNameValue, Path, Type, + parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, + Ident, Lit, Meta, MetaNameValue, Path, PathArguments, Type, }; /// Recognised `#[secret(...)]` annotation kinds. @@ -36,10 +36,24 @@ enum SecretAnnotation { struct FieldAnnotation { kind: SecretAnnotation, name: Ident, + /// `true` when the annotated field is `Option`. + optional: bool, +} + +/// A `#[app_config(nested)]` field to recurse into when emitting +/// `secret_fields()`. +struct NestedDescriptor<'field> { + /// The element type whose `secret_fields()` are prepended: the field + /// type for an object, or the `Vec`/slice element type for an array. + child_ty: &'field Type, + /// The Rust field name, emitted verbatim as a `Field` path segment. + field_name: Ident, + /// `true` when the field is `Vec` / `[T]` (emit `Field` + `ArrayEach`). + is_array: bool, } /// Inspect the input struct, emit `impl AppConfigMeta` with the -/// `SECRET_FIELDS` array. Errors surface as `compile_error!` tokens +/// `secret_fields()` method. Errors surface as `compile_error!` tokens /// substituted in place of the impl. #[inline] pub fn derive(tokens: TokenStream) -> TokenStream { @@ -50,29 +64,21 @@ pub fn derive(tokens: TokenStream) -> TokenStream { } fn expand(input: &DeriveInput) -> Result { - let struct_ident = &input.ident; - let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); - let fields = struct_fields(input)?; // Enforce serde skip/flatten bans on EVERY field (not just secret ones). enforce_no_disallowed_serde_attrs_on_all_fields(fields)?; - let mut annotations: Vec = Vec::new(); - for field in fields { - if let Some(annotation) = scan_field(field)? { - annotations.push(annotation); - } - } + let (annotations, nested_descriptors) = classify_fields(fields)?; - // SECRET_FIELDS emits the Rust field name verbatim. A container- + // secret_fields() emits the Rust field name verbatim. A container- // level `#[serde(rename_all = ...)]` would desync that metadata // from what `config validate` (and the Spin collision check) sees - // on the wire — silently — so reject it whenever any - // secret field is present. Structs with no secret fields are - // unaffected: SECRET_FIELDS is empty and the validator never - // compares names. - if !annotations.is_empty() { + // on the wire — silently — so reject it whenever any secret field is + // present, whether direct or reached through a nested child. Structs + // with no secret paths are unaffected: secret_fields() is empty and + // the validator never compares names. + if !annotations.is_empty() || !nested_descriptors.is_empty() { enforce_no_container_rename_all(&input.attrs)?; } @@ -125,8 +131,67 @@ fn expand(input: &DeriveInput) -> Result { } } - let entries = annotations.iter().map(|annotation| { + Ok(emit_impl(input, &annotations, &nested_descriptors)) +} + +/// Classify every field as a direct `#[secret]` annotation or a +/// `#[app_config(nested)]` recursion descriptor. A field may not be both. +fn classify_fields( + fields: &Punctuated, +) -> syn::Result<(Vec, Vec>)> { + let mut annotations: Vec = Vec::new(); + let mut nested_descriptors: Vec = Vec::new(); + for field in fields { + let is_nested = nested_optin(field)?; + match scan_field(field)? { + Some(_) if is_nested => { + return Err(syn::Error::new_spanned( + field, + "a field may not be both `#[secret]` and `#[app_config(nested)]`", + )); + } + Some(annotation) => annotations.push(annotation), + None if is_nested => { + // The emitter writes `Field(field_name)` verbatim, so a + // `#[serde(rename/flatten/skip*)]` on the nested parent would + // desync the path segment from the serialized key — banned on + // any secret path. + enforce_no_disallowed_serde_attrs(field)?; + let Some(field_name) = field.ident.clone() else { + return Err(syn::Error::new_spanned( + field, + "`#[app_config(nested)]` requires a named field", + )); + }; + let (child_ty, is_array) = nested_child_type(&field.ty); + nested_descriptors.push(NestedDescriptor { + child_ty, + field_name, + is_array, + }); + } + None => {} + } + } + Ok((annotations, nested_descriptors)) +} + +/// Emit `impl AppConfigMeta` (with the `secret_fields()` body), the +/// `AppConfigRoot` marker impl, and a per-child `AppConfigRoot` bound +/// assertion. +fn emit_impl( + input: &DeriveInput, + annotations: &[FieldAnnotation], + nested_descriptors: &[NestedDescriptor<'_>], +) -> TokenStream2 { + let struct_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + // Direct `#[secret]` leaves: length-1 `Field` path, `optional` set from + // `Option`. + let direct_entries = annotations.iter().map(|annotation| { let name_lit = annotation.name.to_string(); + let optional = annotation.optional; let kind_tokens = match &annotation.kind { SecretAnnotation::KeyInDefault => { quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) @@ -143,26 +208,95 @@ fn expand(input: &DeriveInput) -> Result { }; quote! { ::edgezero_core::app_config::SecretField { - name: #name_lit, kind: #kind_tokens, + path: ::std::vec![::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#name_lit) + )], + optional: #optional, } } }); - Ok(quote! { + // Nested children: prepend `Field(field)` (object) or `Field(field)` + + // `ArrayEach` (`Vec`/slice) onto every leaf the child reports. + let nested_pushes = nested_descriptors.iter().map(|descriptor| { + let field_lit = descriptor.field_name.to_string(); + let child_ty = descriptor.child_ty; + let prefix = if descriptor.is_array { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ::edgezero_core::app_config::SecretPathSegment::ArrayEach, + ] + } + } else { + quote! { + ::std::vec![ + ::edgezero_core::app_config::SecretPathSegment::Field( + ::std::borrow::Cow::Borrowed(#field_lit) + ), + ] + } + }; + quote! { + for mut __f in <#child_ty as ::edgezero_core::app_config::AppConfigMeta>::secret_fields() { + let mut __p = #prefix; + __p.append(&mut __f.path); + __f.path = __p; + __out.push(__f); + } + } + }); + + let secret_fields_body = if nested_descriptors.is_empty() { + quote! { ::std::vec![#(#direct_entries),*] } + } else { + quote! { + let mut __out: ::std::vec::Vec<::edgezero_core::app_config::SecretField> = + ::std::vec![#(#direct_entries),*]; + #(#nested_pushes)* + __out + } + }; + + // A nested child must go through `#[derive(AppConfig)]` — the + // `AppConfigRoot` marker — not merely impl `AppConfigMeta` by hand. + // The closure is never called, but coercing it to `fn()` type-checks + // its body, enforcing the bound with a clear error span per child. + let nested_child_tys: Vec<&Type> = nested_descriptors + .iter() + .map(|descriptor| descriptor.child_ty) + .collect(); + let root_assertion = if nested_child_tys.is_empty() { + quote! {} + } else { + quote! { + const _: fn() = || { + fn __assert_app_config_root<__T: ::edgezero_core::app_config::AppConfigRoot>() {} + #( __assert_app_config_root::<#nested_child_tys>(); )* + }; + } + }; + + quote! { + #root_assertion + #[automatically_derived] impl #impl_generics ::edgezero_core::app_config::AppConfigMeta for #struct_ident #type_generics #where_clause { - const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = - &[#(#entries),*]; + fn secret_fields() -> ::std::vec::Vec<::edgezero_core::app_config::SecretField> { + #secret_fields_body + } } #[automatically_derived] impl #impl_generics ::edgezero_core::app_config::AppConfigRoot for #struct_ident #type_generics #where_clause {} - }) + } } /// Borrow the struct's named fields, or error with a clear message. @@ -212,10 +346,84 @@ fn scan_field(field: &Field) -> Result, syn::Error> { } let kind = parse_secret_kind(first)?; - enforce_scalar_string_type(field)?; + let optional = secret_string_optionality(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + &field.ty, + "`#[secret]` may only annotate `String` or `Option`", + ) + })?; + // A `#[secret(store_ref)]` value is a store id — structural, always + // present. `Option` there is undefined (an absent store cannot + // resolve its dependent `KeyInNamedStore` sibling), so reject it. + if optional && matches!(kind, SecretAnnotation::StoreRef) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present", + )); + } enforce_no_disallowed_serde_attrs(field)?; - Ok(Some(FieldAnnotation { kind, name })) + Ok(Some(FieldAnnotation { + kind, + name, + optional, + })) +} + +/// Whether `field` carries `#[app_config(nested)]`. Returns `Err` (not +/// `false`) on a malformed `#[app_config(...)]` such as `#[app_config(bogus)]` +/// or an empty `#[app_config()]`, so a typo is a hard compile error rather +/// than a silently-ignored non-recursion (which would drop the child's +/// secrets). +fn nested_optin(field: &Field) -> syn::Result { + let mut found = false; + for attr in &field.attrs { + if !attr.path().is_ident("app_config") { + continue; + } + // Track whether THIS attribute actually named `nested`. A bare + // `#[app_config]` / empty `#[app_config()]` parses Ok with the closure + // never firing; without this guard it would leave `found` false and the + // field would be silently NOT recursed, dropping the child's secrets. + let mut this_attr_nested = false; + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + this_attr_nested = true; + Ok(()) + } else { + Err(meta.error("`#[app_config(...)]` only accepts `nested`")) + } + })?; + if !this_attr_nested { + return Err(syn::Error::new_spanned( + attr, + "`#[app_config]` requires an option; the only supported one is \ + `nested` (e.g. `#[app_config(nested)]`)", + )); + } + found = true; + } + Ok(found) +} + +/// The child element type to recurse into and whether it is an array element. +/// `Vec` / `[T]` -> (T, true); otherwise (`field_ty`, false). +fn nested_child_type(ty: &Type) -> (&Type, bool) { + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Vec" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + return (inner, true); + } + } + } + } + } + if let Type::Slice(slice) = ty { + return (&slice.elem, true); + } + (ty, false) } /// Decode `#[secret]` (`KeyInDefault`), `#[secret(store_ref)]` @@ -258,18 +466,27 @@ fn parse_secret_kind(attr: &Attribute) -> Result { } } -/// `#[secret]` may only annotate a scalar string field. Per we -/// accept bare `String` only — generic or qualified forms (e.g. -/// `Option`, `Cow<'_, str>`) are intentionally rejected so -/// `cfg.api_token` resolves to a value at every call site. -fn enforce_scalar_string_type(field: &Field) -> Result<(), syn::Error> { - if !is_scalar_string_type(&field.ty) { - return Err(syn::Error::new_spanned( - &field.ty, - "`#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`)", - )); +/// Classify a `#[secret]` field's type: `String` -> `Some(false)`, +/// `Option` -> `Some(true)`, anything else (e.g. `Vec`, +/// `Cow<'_, str>`, non-string scalars) -> `None`. +fn secret_string_optionality(ty: &Type) -> Option { + if is_scalar_string_type(ty) { + return Some(false); } - Ok(()) + if let Type::Path(type_path) = ty { + if let Some(last) = type_path.path.segments.last() { + if last.ident == "Option" { + if let PathArguments::AngleBracketed(bracketed) = &last.arguments { + if let Some(GenericArgument::Type(inner)) = bracketed.args.first() { + if is_scalar_string_type(inner) { + return Some(true); + } + } + } + } + } + } + None } fn is_scalar_string_type(ty: &Type) -> bool { @@ -326,13 +543,14 @@ fn enforce_no_disallowed_serde_attrs_on_all_fields( Ok(()) } -/// Container-level guard: a struct that carries any `#[secret]` field -/// must not also carry `#[serde(rename_all = ...)]`. The derive emits -/// `SECRET_FIELDS` with Rust field names verbatim, but `rename_all` -/// would translate the on-the-wire key name (e.g. `kebab-case` → -/// `api-token`), silently desyncing the typed `config validate` secret -/// checks from what the deserialiser actually accepts. Reject this at -/// compile time so the desync can't ship. +/// Container-level guard: a struct that carries any `#[secret]` field or +/// any `#[app_config(nested)]` child must not also carry +/// `#[serde(rename_all = ...)]`. The derive emits `secret_fields()` paths +/// with Rust field names verbatim, but `rename_all` would translate the +/// on-the-wire key name (e.g. `kebab-case` → `api-token`), silently +/// desyncing the typed `config validate` secret checks from what the +/// deserialiser actually accepts. Reject this at compile time so the +/// desync can't ship. fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error> { for attr in attrs { if !attr.path().is_ident("serde") { @@ -348,7 +566,7 @@ fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error if offending { return Err(syn::Error::new_spanned( attr, - "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", + "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", )); } } @@ -380,7 +598,7 @@ fn enforce_no_disallowed_serde_attrs(field: &Field) -> Result<(), syn::Error> { "skip_serializing" => Some("skip_serializing"), // `skip_serializing_if = "..."` also omits the // field from round-trips (config push reads - // SECRET_FIELDS, then serialises the typed + // secret_fields(), then serialises the typed // struct), so reject it alongside the // unconditional skip family. "skip_serializing_if" => Some("skip_serializing_if"), diff --git a/crates/edgezero-macros/src/lib.rs b/crates/edgezero-macros/src/lib.rs index 17a572d9..3c5538b3 100644 --- a/crates/edgezero-macros/src/lib.rs +++ b/crates/edgezero-macros/src/lib.rs @@ -17,7 +17,7 @@ pub fn app(input: TokenStream) -> TokenStream { app::expand_app(input) } -#[proc_macro_derive(AppConfig, attributes(secret))] +#[proc_macro_derive(AppConfig, attributes(secret, app_config))] #[inline] pub fn app_config_derive(input: TokenStream) -> TokenStream { app_config::derive(input) diff --git a/crates/edgezero-macros/tests/action_state.rs b/crates/edgezero-macros/tests/action_state.rs new file mode 100644 index 00000000..1c999105 --- /dev/null +++ b/crates/edgezero-macros/tests/action_state.rs @@ -0,0 +1,56 @@ +//! Integration coverage: `#[action]` composes the `State` extractor with a +//! request-derived extractor (`Query`) and runs end-to-end through the +//! router. Lives in `edgezero-macros/tests` because the `#[action]` macro +//! emits absolute `::edgezero_core::…` paths that only resolve when +//! `edgezero_core` is an external crate (as it is here, via the dev-dep). + +#[cfg(test)] +mod tests { + use edgezero_core::action; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::extractor::{Query, State}; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use futures::executor::block_on; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Clone)] + struct AppState { + greeting: String, + } + + #[derive(Deserialize)] + struct Params { + n: u32, + } + + #[action] + async fn handler( + State(state): State>, + Query(params): Query, + ) -> Result { + Ok(format!("{}:{}", state.greeting, params.n)) + } + + #[test] + fn action_composes_state_and_query() { + let service = RouterService::builder() + .with_state(Arc::new(AppState { + greeting: "hi".to_owned(), + })) + .get("/h", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/h?n=5") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes().expect("buffered"), b"hi:5"); + } +} diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs index 3fe92fbc..ab40b573 100644 --- a/crates/edgezero-macros/tests/app_config_derive.rs +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -3,7 +3,8 @@ #[cfg(test)] mod tests { - use edgezero_core::app_config::{AppConfigMeta as _, AppConfigRoot, SecretField, SecretKind}; + use edgezero_core::app_config::{AppConfigMeta, AppConfigRoot, SecretKind}; + use validator::Validate as _; #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] @@ -12,14 +13,13 @@ mod tests { } // The `#[secret]`-annotated fields below are exercised only via the - // `SECRET_FIELDS` associated constant the derive emits — Rust still - // counts them as "never read", so silence the dead-code lint at the - // struct level. + // `secret_fields()` method the derive emits — Rust still counts them + // as "never read", so silence the dead-code lint at the struct level. #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInDefault { _greeting: String, @@ -31,7 +31,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigStoreRef { _greeting: String, @@ -43,7 +43,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigBothKinds { _greeting: String, @@ -57,7 +57,7 @@ mod tests { #[serde(deny_unknown_fields)] #[expect( dead_code, - reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" )] struct ConfigKeyInNamedStore { #[secret(store_ref = "vault")] @@ -66,46 +66,99 @@ mod tests { vault: String, } + // Optional secret: `#[secret]` on `Option` -> `optional: true`. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct ConfigOptionalSecret { + #[secret] + api_token: Option, + } + + // Nested object + array recursion. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct DataDome { + #[secret] + server_side_key: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Integrations { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; secret_fields() reads them via the derive, not via Rust field access" + )] + struct Partner { + #[secret] + api_key: String, + #[secret] + maybe: Option, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct Settings { + #[app_config(nested)] + #[validate(nested)] + integrations: Integrations, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + } + + /// Reflect each derived `SecretField` down to the tuple the + /// assertions compare: `(dotted_path, kind, optional)`. + fn reflect() -> Vec<(String, SecretKind, bool)> { + C::secret_fields() + .into_iter() + .map(|field| (field.dotted_path(), field.kind, field.optional)) + .collect() + } + #[test] fn no_secret_annotation_yields_empty_secret_fields() { - assert!(ConfigNoSecrets::SECRET_FIELDS.is_empty()); + assert!(ConfigNoSecrets::secret_fields().is_empty()); } #[test] fn plain_secret_attribute_yields_key_in_default() { assert_eq!( - ConfigKeyInDefault::SECRET_FIELDS, - &[SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }] + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, false)] ); } #[test] fn secret_store_ref_attribute_yields_store_ref() { assert_eq!( - ConfigStoreRef::SECRET_FIELDS, - &[SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }] + reflect::(), + vec![("vault".to_owned(), SecretKind::StoreRef, false)] ); } #[test] fn both_secret_kinds_are_collected_in_source_order() { assert_eq!( - ConfigBothKinds::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInDefault, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + reflect::(), + vec![ + ("api_token".to_owned(), SecretKind::KeyInDefault, false), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } @@ -113,22 +166,54 @@ mod tests { #[test] fn key_in_named_store_attribute_yields_correct_secret_fields() { assert_eq!( - ConfigKeyInNamedStore::SECRET_FIELDS, - &[ - SecretField { - name: "api_token", - kind: SecretKind::KeyInNamedStore { + reflect::(), + vec![ + ( + "api_token".to_owned(), + SecretKind::KeyInNamedStore { store_ref_field: "vault", }, - }, - SecretField { - name: "vault", - kind: SecretKind::StoreRef, - }, + false, + ), + ("vault".to_owned(), SecretKind::StoreRef, false), ] ); } + #[test] + fn optional_string_secret_sets_optional_flag() { + assert_eq!( + reflect::(), + vec![("api_token".to_owned(), SecretKind::KeyInDefault, true)] + ); + } + + #[test] + fn nested_and_array_paths_are_emitted() { + let mut paths = reflect::(); + paths.sort_by(|left, right| left.0.cmp(&right.0)); + assert_eq!( + paths, + vec![ + ( + "integrations.datadome.server_side_key".to_owned(), + SecretKind::KeyInDefault, + false, + ), + ( + "partners[*].api_key".to_owned(), + SecretKind::KeyInDefault, + false + ), + ( + "partners[*].maybe".to_owned(), + SecretKind::KeyInDefault, + true + ), + ], + ); + } + #[test] fn derive_emits_app_config_root_impl() { // The trait is a marker; we just need it to compile and the @@ -155,6 +240,14 @@ mod tests { cases.compile_fail("tests/ui/non_secret_with_serde_flatten.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing.rs"); cases.compile_fail("tests/ui/non_secret_with_serde_skip_serializing_if.rs"); + // `#[app_config(nested)]` recursion + `Option` secret guards. + // The `secret_*.rs` glob above already covers + // `secret_on_option_non_string.rs` and `secret_store_ref_optional.rs`. + cases.compile_fail("tests/ui/app_config_empty.rs"); + cases.compile_fail("tests/ui/app_config_nested_on_non_appconfig.rs"); + cases.compile_fail("tests/ui/app_config_unknown_option.rs"); + cases.compile_fail("tests/ui/nested_field_serde_rename.rs"); + cases.compile_fail("tests/ui/nested_parent_rename_all.rs"); cases.pass("tests/ui/secret_with_store_ref_named.rs"); } } diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs new file mode 100644 index 00000000..58185135 --- /dev/null +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -0,0 +1,21 @@ +//! Integration coverage: `app!(..., owns_logging = true)` emits a `Hooks` impl +//! whose `owns_logging()` returns `true`. The manifest path resolves against +//! this crate's `CARGO_MANIFEST_DIR`, so the fixture is `tests/fixtures/...`. + +// The macro emits `pub struct OwnedLoggingApp;`, a `Hooks` impl, and a free +// `build_router()` at this module scope. +edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + OwnedLoggingApp, + owns_logging = true +); + +#[cfg(test)] +mod tests { + use edgezero_core::app::Hooks as _; + + #[test] + fn app_macro_emits_owns_logging_true() { + assert!(super::OwnedLoggingApp::owns_logging()); + } +} diff --git a/crates/edgezero-macros/tests/fixtures/owns_logging.toml b/crates/edgezero-macros/tests/fixtures/owns_logging.toml new file mode 100644 index 00000000..2b009868 --- /dev/null +++ b/crates/edgezero-macros/tests/fixtures/owns_logging.toml @@ -0,0 +1,2 @@ +[app] +name = "owns-logging-fixture" diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs new file mode 100644 index 00000000..3176272e --- /dev/null +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -0,0 +1,171 @@ +//! End-to-end proof that nested and array `#[secret]` fields resolve +//! through the runtime secret walk of the `AppConfig` extractor. +//! +//! Unlike `app_config_derive.rs` (which only reflects over the metadata +//! `secret_fields()` emits), this test drives the WHOLE chain a downstream +//! app hits at request time: a `BlobEnvelope` holding secret-store KEY NAMES +//! is deserialized through `AppConfig::::from_request`, whose `secret_walk` +//! resolves every nested / array / named-store leaf against a live +//! `InMemorySecretStore` before the struct is materialised. The assertions +//! read the RESOLVED values off the deserialized config. + +#![cfg(test)] + +use async_trait::async_trait; +use edgezero_core::blob_envelope::BlobEnvelope; +use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as AppConfigExtractor, FromRequest as _}; +use edgezero_core::http::{request_builder, Method}; +use edgezero_core::params::PathParams; +use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, +}; +use futures::executor::block_on; +use std::collections::BTreeMap; +use std::sync::Arc; +use validator::Validate as _; + +// --- fixture config (nested objects + `Vec<_>` array + named store) -------- + +// A 2-level `KeyInDefault` nested leaf: `datadome.server_side_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct DataDome { + #[secret] + server_side_key: String, +} + +// One array element carrying a `KeyInDefault` secret: `partners[*].api_key`. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Partner { + #[secret] + api_key: String, +} + +// The root config, exercising every reachable secret shape at once. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Settings { + #[app_config(nested)] + #[validate(nested)] + datadome: DataDome, + #[app_config(nested)] + #[validate(nested)] + partners: Vec, + #[app_config(nested)] + #[validate(nested)] + vaulted: Vaulted, +} + +// A nested `KeyInNamedStore` leaf whose `store_ref` sibling (`vault`) lives in +// the SAME inner struct — the innermost-parent scoping rule. +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct Vaulted { + #[secret(store_ref = "vault")] + token: String, + #[secret(store_ref)] + vault: String, +} + +// --- wiring helpers --------------------------------------------------------- + +// A minimal `ConfigStore` that returns one fixed blob-envelope string, +// mirroring the hand-written stores in `extractor.rs`'s own tests. +struct BlobStore(String); + +#[async_trait(?Send)] +impl ConfigStore for BlobStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } +} + +// Build a `RequestContext` wired to a config store holding `envelope` plus a +// two-store secret registry: the default store and a `named` store, so both +// the `KeyInDefault` leaves and the `KeyInNamedStore` leaf resolve. +fn ctx_with_stores(envelope: String) -> RequestContext { + let binding = ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(BlobStore(envelope))), + default_key: "app_config".to_owned(), + }; + let config_registry: ConfigRegistry = + StoreRegistry::single_id("app_config".to_owned(), binding); + + // Default store resolves the `KeyInDefault` leaves (nested + array). + let default_store = InMemorySecretStore::new([ + ("default/dd_key".to_owned(), "DD"), + ("default/p0_key".to_owned(), "P0"), + ("default/p1_key".to_owned(), "P1"), + ]); + let default_bound = BoundSecretStore::new( + SecretHandle::new(Arc::new(default_store)), + "default".to_owned(), + ); + + // Named store resolves the `KeyInNamedStore` leaf via its `vault` sibling. + let named_store = InMemorySecretStore::new([("named/tok_key".to_owned(), "TOK")]); + let named_bound = + BoundSecretStore::new(SecretHandle::new(Arc::new(named_store)), "named".to_owned()); + + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("default".to_owned(), default_bound); + by_id.insert("named".to_owned(), named_bound); + let secret_registry: SecretRegistry = StoreRegistry::new(by_id, "default".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("build request"); + request.extensions_mut().insert(config_registry); + request.extensions_mut().insert(secret_registry); + RequestContext::new(request, PathParams::default()) +} + +// The blob at rest holds secret-store KEY NAMES (Model A), not resolved +// values — exactly what `config push` persists. +fn envelope_with_key_names() -> String { + let data = serde_json::json!({ + "datadome": { "server_side_key": "dd_key" }, + "partners": [ { "api_key": "p0_key" }, { "api_key": "p1_key" } ], + "vaulted": { "token": "tok_key", "vault": "named" } + }); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + serde_json::to_string(&envelope).expect("serialise envelope") +} + +// --- the end-to-end assertions --------------------------------------------- + +#[test] +fn nested_and_named_store_secrets_resolve_through_extractor() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Nested `KeyInDefault`: `datadome.server_side_key` -> default store. + assert_eq!(cfg.datadome.server_side_key, "DD"); + + // Nested `KeyInNamedStore`: `vaulted.token` -> the store named by its + // sibling `vaulted.vault` ("named"). The `store_ref` sibling is left + // verbatim (it names a store, not a secret). + assert_eq!(cfg.vaulted.token, "TOK"); + assert_eq!(cfg.vaulted.vault, "named"); +} + +#[test] +fn array_element_secrets_resolve_per_index() { + let ctx = ctx_with_stores(envelope_with_key_names()); + let AppConfigExtractor(cfg) = + block_on(AppConfigExtractor::::from_request(&ctx)).expect("extraction succeeds"); + + // Each `partners[n].api_key` resolves independently against the default + // store — proving the `ArrayEach` runtime walk. + assert_eq!(cfg.partners.len(), 2); + assert_eq!(cfg.partners[0].api_key, "P0"); + assert_eq!(cfg.partners[1].api_key, "P1"); +} diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.rs b/crates/edgezero-macros/tests/ui/app_config_empty.rs new file mode 100644 index 00000000..f8e9abed --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.rs @@ -0,0 +1,11 @@ +//! An empty `#[app_config()]` must be a hard compile error, not a silent +//! no-op — otherwise the field would not be recursed and the child's +//! `#[secret]` metadata would be dropped without any diagnostic. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config()] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_empty.stderr b/crates/edgezero-macros/tests/ui/app_config_empty.stderr new file mode 100644 index 00000000..4edda2ac --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_empty.stderr @@ -0,0 +1,5 @@ +error: `#[app_config]` requires an option; the only supported one is `nested` (e.g. `#[app_config(nested)]`) + --> tests/ui/app_config_empty.rs:7:5 + | +7 | #[app_config()] + | ^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs new file mode 100644 index 00000000..62a55b0c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.rs @@ -0,0 +1,15 @@ +//! `#[app_config(nested)]` on a field whose type does not derive +//! `AppConfig` must fail with a clear `AppConfigRoot` bound error. + +#[derive(serde::Deserialize)] +struct NotAppConfig { + _key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(nested)] + child: NotAppConfig, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr new file mode 100644 index 00000000..893b763a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_nested_on_non_appconfig.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `NotAppConfig: AppConfigRoot` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigRoot` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigRoot` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `__assert_app_config_root` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `__assert_app_config_root` + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `NotAppConfig: AppConfigMeta` is not satisfied + --> tests/ui/app_config_nested_on_non_appconfig.rs:12:12 + | +12 | child: NotAppConfig, + | ^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `AppConfigMeta` is not implemented for `NotAppConfig` + --> tests/ui/app_config_nested_on_non_appconfig.rs:5:1 + | + 5 | struct NotAppConfig { + | ^^^^^^^^^^^^^^^^^^^ +help: the trait `AppConfigMeta` is implemented for `Config` + --> tests/ui/app_config_nested_on_non_appconfig.rs:9:51 + | + 9 | #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = note: this error originates in the derive macro `edgezero_core::AppConfig` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs new file mode 100644 index 00000000..8fe0308a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.rs @@ -0,0 +1,10 @@ +//! `#[app_config(bogus)]` must be a hard compile error (a typo must not +//! be silently ignored — that would drop the child's secrets). + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[app_config(bogus)] + child: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr new file mode 100644 index 00000000..2db58821 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/app_config_unknown_option.stderr @@ -0,0 +1,5 @@ +error: `#[app_config(...)]` only accepts `nested` + --> tests/ui/app_config_unknown_option.rs:6:18 + | +6 | #[app_config(bogus)] + | ^^^^^ diff --git a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr index be76c2c4..c9e69eca 100644 --- a/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr +++ b/crates/edgezero-macros/tests/ui/key_in_named_store_sibling_not_string.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/key_in_named_store_sibling_not_string.rs:11:12 | 11 | vault: u32, diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs new file mode 100644 index 00000000..548b969f --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.rs @@ -0,0 +1,18 @@ +//! `#[serde(rename = "...")]` on a `#[app_config(nested)]` field must +//! error — the emitter writes the Rust field name verbatim as a `Field` +//! path segment, which a rename would desync from the serialized key. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[serde(rename = "x")] + #[app_config(nested)] + child: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr new file mode 100644 index 00000000..e3d21b53 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_field_serde_rename.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(rename)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/nested_field_serde_rename.rs:13:5 + | +13 | #[serde(rename = "x")] + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs new file mode 100644 index 00000000..fb89b56c --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.rs @@ -0,0 +1,18 @@ +//! A parent with only `#[app_config(nested)]` children (no direct +//! `#[secret]`) carrying `#[serde(rename_all = ...)]` must error — the +//! rename would desync the emitted `Field(parent_field)` path segment. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Child { + #[secret] + api_key: String, +} + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(rename_all = "kebab-case")] +struct Config { + #[app_config(nested)] + child_config: Child, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr new file mode 100644 index 00000000..620642a2 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/nested_parent_rename_all.stderr @@ -0,0 +1,5 @@ +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation + --> tests/ui/nested_parent_rename_all.rs:12:1 + | +12 | #[serde(rename_all = "kebab-case")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr index 817d8c55..f2df9e2e 100644 --- a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr @@ -1,4 +1,4 @@ -error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) +error: `#[secret]` may only annotate `String` or `Option` --> tests/ui/secret_on_non_scalar.rs:7:17 | 7 | api_tokens: Vec, diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs new file mode 100644 index 00000000..4ec447eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.rs @@ -0,0 +1,10 @@ +//! `#[secret]` on `Option` must error — only `String` or +//! `Option` are accepted. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + api_token: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr new file mode 100644 index 00000000..114ab8eb --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_option_non_string.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` may only annotate `String` or `Option` + --> tests/ui/secret_on_option_non_string.rs:7:16 + | +7 | api_token: Option, + | ^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs new file mode 100644 index 00000000..2ea63bff --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.rs @@ -0,0 +1,10 @@ +//! `#[secret(store_ref)]` on `Option` must error — a store id is +//! structural and must always be present. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret(store_ref)] + vault: Option, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr new file mode 100644 index 00000000..cd0e028d --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_store_ref_optional.stderr @@ -0,0 +1,5 @@ +error: `#[secret(store_ref)]` may not be `Option`: a store id is structural and must always be present + --> tests/ui/secret_store_ref_optional.rs:7:12 + | +7 | vault: Option, + | ^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs index a50d90fa..594adb5e 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs @@ -1,6 +1,6 @@ //! Container-level `#[serde(rename_all = ...)]` on a struct that has a //! `#[secret]` field must be rejected: the renamer would translate the -//! TOML key to `api-token` while `SECRET_FIELDS` keeps reporting +//! TOML key to `api-token` while `secret_fields()` keeps reporting //! `api_token`, silently desyncing the typed `config validate` secret //! checks and the Spin collision check. diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr index c94cb25d..30040917 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr @@ -1,4 +1,4 @@ -error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields or `#[app_config(nested)]` children: secret_fields() uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation --> tests/ui/secret_with_serde_container_rename_all.rs:8:1 | 8 | #[serde(rename_all = "kebab-case")] diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs index b0c088b1..9be7c3fc 100644 --- a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs @@ -1,8 +1,8 @@ //! `#[serde(skip_serializing_if = "...")]` conditionally omits the //! field from serialisation. Combined with `#[secret]`, that would -//! make `config push` (which reads `SECRET_FIELDS`, then serialises +//! make `config push` (which reads `secret_fields()`, then serialises //! the typed struct) drop the secret key under the condition — -//! desyncing the on-the-wire shape from the SECRET_FIELDS invariant +//! desyncing the on-the-wire shape from the secret_fields() invariant //! relies on. Reject at compile time. #[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)] diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 27db1295..62813d79 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -151,31 +151,38 @@ ids = ["app_config"] ``` ```jsonc -// .edgezero/local-config-app_config.json +// .edgezero/local-config-app_config.json — what ` config push` writes. +// The outer object maps the selected config key (defaults to the logical store +// id; override with `--key`, e.g. for a `staging` blob) to ONE BlobEnvelope, +// JSON-encoded as a string (see the blob migration guide for the envelope's +// fields). `data` holds the typed config verbatim; `#[secret]` fields store +// their key NAMES, which the runtime resolves at request time — never the +// secret values. { - "greeting": "hello from config store", - "feature.new_checkout": "false", - "service.timeout_ms": "1500", + "app_config": "{\"version\":1,\"generated_at\":\"…\",\"sha256\":\"…\",\"data\":{\"greeting\":\"hello\",\"feature\":{\"new_checkout\":false},\"service\":{\"timeout_ms\":1500},\"api_token\":\"demo_api_token\",\"vault\":\"default\"}}", } ``` -Handlers access stores via the `Config` extractor or `ctx.config_store(id)`: +Typed apps read the whole config in one shot with the `AppConfig` extractor, +which parses the envelope and resolves `#[secret]` fields before handing you `cfg`: ```rust -async fn handler(config: Config) -> Result { - let store = config.default().ok_or_else(|| EdgeError::service_unavailable("no default config"))?; - let greeting = store.get("greeting").await?.unwrap_or_default(); +async fn handler(AppConfig(cfg): AppConfig) -> Result { + let greeting = &cfg.greeting; // … } ``` -Do not pass raw user input straight to `store.get(…)` in production -handlers; validate or allowlist keys first. Seed the per-id JSON -files with `edgezero config push --adapter axum` (or -` config push --adapter axum` for the typed flow with -`#[secret]` stripping), which writes the same -`.edgezero/local-config-.json` files the runtime reads — -no shell-out, no server to authenticate against. +The lower-level `Config` extractor / `ctx.config_store(id)` exposes the raw +key/value store — `store.get("app_config")` returns the envelope string, and a +hand-seeded flat file returns individual values. Do not pass raw user input +straight to `store.get(…)` in production handlers; validate or allowlist keys +first. + +Seed the per-id files with ` config push --adapter axum` (the typed +flow — the bundled `edgezero config push` errors), which writes the same +`.edgezero/local-config-.json` files the runtime reads — no shell-out, no +server to authenticate against. ## Container Deployment diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 59d34c7d..f8d25739 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -61,6 +61,50 @@ store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. +### Capturing raw-request signals (JA4, H2 fingerprint) + +`run_app` converts the `fastly::Request` into a neutral core request before +dispatch. The client IP is carried across automatically — read it via +`FastlyRequestContext` (see [Context Access](#context-access) below). Other +Fastly-only signals that are readable only on the raw request +(`get_tls_ja4()`, `get_client_h2_fingerprint()`) aren't reachable from handlers +by default. Use `run_app_with_request_extensions`, which +runs an app closure against a scratch `Extensions` **before** conversion and +merges the values into the core request — so a `State`/extractor or middleware +can read them: + +```rust +#[derive(Clone)] +struct Ja4(String); + +#[fastly::main] +fn main(req: fastly::Request) -> Result { + edgezero_adapter_fastly::run_app_with_request_extensions::(req, |raw, ext| { + if let Some(ja4) = raw.get_tls_ja4() { + ext.insert(Ja4(ja4.to_owned())); + } + }) +} +``` + +`run_app` is exactly `run_app_with_request_extensions::(req, |_, _| {})`. +The closure runs once per request; insert whatever typed values your handlers +need, then read them in a handler via a custom extractor or +`ctx.request().extensions().get::()`. + +### Owning your own logging + +By default `run_app` initializes the Fastly logger. If your app already installs +a `log` backend, opt out with the platform-neutral `Hooks::owns_logging()` flag — +via the `app!` macro: + +```rust +edgezero_core::app!("edgezero.toml", owns_logging = true); +``` + +or on a hand-written `Hooks` impl (`fn owns_logging() -> bool { true }`). Every +adapter's `run_app` honors it, so the app is responsible for logger setup. + ## Building Build for Fastly's Wasm target: @@ -182,7 +226,7 @@ Access Fastly-specific APIs via the request context extensions: ```rust use edgezero_core::context::RequestContext; -use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_adapter_fastly::context::FastlyRequestContext; async fn handler(ctx: RequestContext) -> Result { // Access Fastly context from extensions diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 35909500..e9c0e57c 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -151,9 +151,11 @@ hand-edited). ### Seeding the store -`edgezero config push --adapter spin` reads `runtime-config.toml` and +` config push --adapter spin` reads `runtime-config.toml` and dispatches to the right per-backend writer — no embedded HTTP endpoint, -no token to manage. Resolution order: +no token to manage. (Typed push runs from your downstream app CLI; the +bundled `edgezero config push` errors with a pointer to it, since the +blob model needs the typed `AppConfig`.) Resolution order: 1. **`--local` set**: forces SQLite-direct against `/.spin/sqlite_key_value.db` (Spin's local KV file). @@ -200,11 +202,11 @@ the schema until the operator opts in. ```bash # Local dev: writes through to .spin/sqlite_key_value.db. -edgezero config push --adapter spin --local + config push --adapter spin --local # Production (Fermyon Cloud): shells `spin cloud key-value set`. spin cloud login # one-time -edgezero config push --adapter spin + config push --adapter spin ``` ## Secret Store diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index d8a5a9cf..2ed20964 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -57,7 +57,7 @@ problems made that untenable as projects grew: blob model writes ONE envelope per `[stores.config]` key. - **Secret resolution was per-handler.** Every handler that read a `#[secret]` field had to remember to call `require_str`. The new - `AppConfig` extractor walks `C::SECRET_FIELDS` once and replaces + `AppConfig` extractor walks `C::secret_fields()` once and replaces each key NAME with the resolved value before handing `cfg` to the handler. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index ce42d628..cd7584f7 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -221,7 +221,7 @@ each adapter crate owns its own implementation, the CLI is a thin delegate. ```bash -edgezero config push --adapter [--manifest ] [--app-config ] [--store ] [--no-env] [--local] [--runtime-config ] [--dry-run] +edgezero config push --adapter [--manifest ] [--app-config ] [--store ] [--key ] [--no-env] [--local] [--runtime-config ] [--no-diff] [--yes] [--dry-run] ``` **Arguments:** @@ -230,38 +230,80 @@ edgezero config push --adapter [--manifest ] [--app-config ] - `--manifest ` — manifest path (default: `edgezero.toml`). - `--app-config ` — typed app-config path (default: `.toml` next to the manifest). - `--store ` — logical config-store id to push to. Defaults to `[stores.config].default` (or the only declared id when `[stores.config].ids` has length 1). +- `--key ` — override the config-store key the blob is written under (spec §5.4). Defaults to the logical store id. Use it to publish a side channel (e.g. `--key app_config_staging`); the runtime selects it back via `EDGEZERO__STORES__CONFIG____KEY` (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). - `--no-env` — skip the `__…__` env-var overlay when loading the app config. By default the loader reads the overlay so the push sends the same values the runtime would. - `--local` — push into the adapter's local-emulator state instead of the live platform. Fastly edits `[local_server.config_stores]` in `fastly.toml` (Viceroy reads it on startup); Cloudflare runs `wrangler kv bulk put --local` so writes land in `.wrangler/state`; Spin forces SQLite-direct against `/.spin/sqlite_key_value.db` even when the manifest's deploy command targets Fermyon Cloud (the runtime-config `[key_value_store.