diff --git a/Cargo.lock b/Cargo.lock index 4991d92bd..0cc4b8316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -778,6 +778,7 @@ dependencies = [ "async-stream", "async-trait", "base16ct", + "base64", "brotli", "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cde6c0c69..8372f808d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-stream = "0.3" async-trait = "0.1" axum = { version = "0.8", default-features = true } base16ct = { version = "1", features = ["alloc"] } +base64 = "0.22" # Held at 8 while `async-compression` still resolves brotli 8: taking 9 # directly does not replace that copy, it adds a second complete # allocator/decompressor stack to every adapter graph (~40 KB of extra diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index cc7b910fb..daa2ffce1 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -362,8 +362,25 @@ impl Adapter for AxumCliAdapter { fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { let mut claimed: BTreeMap> = BTreeMap::new(); for entry in entries { - let var = secret_env_var(entry.key_value) - .map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))?; + let var = if let Some(native_name) = &entry.native_name { + if !native_name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_uppercase() || byte == b'_') + || !native_name.bytes().all(|byte| { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' + }) + { + return Err(format!( + "invalid native environment name for `{}`", + entry.field_name + )); + } + native_name.clone() + } else { + secret_env_var(entry.key_value) + .map_err(|err| format!("`#[secret]` field `{}`: {err}", entry.field_name))? + }; if let Some(prev) = claimed.get(&var) { if prev.key_value != entry.key_value { return Err(format!( @@ -1525,6 +1542,32 @@ mod tests { assert!(err.contains("`EDGEZERO__SECRETS__UPSTREAM_TOKEN`"), "{err}"); } + #[test] + fn validate_typed_secrets_checks_exact_native_environment_names() { + for name in ["A", "A1", "_TOKEN", "_"] { + let entries = [TypedSecretEntry::new("default", "owner", "obsolete/key") + .with_native_name(name.to_owned())]; + AXUM_ADAPTER + .validate_typed_secrets(&entries) + .expect("portable native name"); + } + for name in [ + "", + "1TOKEN", + "lowercase", + "WITH-HYPHEN", + "WITH SPACE", + "CAF\u{c9}", + ] { + let entries = [TypedSecretEntry::new("default", "owner", "obsolete/key") + .with_native_name(name.to_owned())]; + let error = AXUM_ADAPTER + .validate_typed_secrets(&entries) + .expect_err("invalid native environment name"); + assert_eq!(error, "invalid native environment name for `owner`"); + } + } + #[test] fn validate_typed_secrets_accepts_same_key_in_two_fields() { let entries = [ diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index f080586fe..02d5c1569 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [features] default = [] cli = [ + "dep:base64", "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", @@ -29,6 +30,7 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ ] } async-stream = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true, optional = true } brotli = { workspace = true } bytes = { workspace = true } ctor = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 01efe05cc..e3d3bd157 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,8 +1,15 @@ +#![expect( + clippy::self_named_module_files, + reason = "Workspace also denies mod_module_files; follow Spin's cli.rs plus cli/ submodule convention instead of contradictory module-file lints" +)] #![expect( clippy::arbitrary_source_item_ordering, reason = "the managed deployment planning and execution state machine is kept as one cohesive block" )] +/// Narrow host operations for reviewed secret provisioning. +pub mod secrets; + use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::env; @@ -30,7 +37,8 @@ use edgezero_adapter::cli_support::{ }; use edgezero_adapter::registry::{ Adapter, AdapterAction, AdapterDeployContext, AdapterPushContext, DeployOwnership, - DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, + DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, + register_adapter, }; use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; use edgezero_adapter::scaffold::{ @@ -385,7 +393,7 @@ struct LinkReconciliation { delete_link_ids: Vec, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] struct StoreInventory { by_name: BTreeMap, } @@ -471,14 +479,14 @@ impl ResourceInventories { }) } - fn resolve(&self, kind: ResourceKind, name: &str) -> Result { + fn resolve(&self, kind: ResourceKind, logical_id: &str, name: &str) -> Result { self.for_kind(kind) .by_name .get(name) .cloned() .ok_or_else(|| { format!( - "selected Fastly {} `{name}` does not exist in the complete provider inventory", + "selected Fastly {} `{name}` (logical `{logical_id}`) does not exist in the complete provider inventory", kind.display_name() ) }) @@ -637,7 +645,7 @@ fn desired_resource_links( let selected_name = environment .store_name_checked(kind.selector_kind(), logical_id) .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; - let resource_id = inventories.resolve(kind, &selected_name)?; + let resource_id = inventories.resolve(kind, logical_id, &selected_name)?; let desired = DesiredResourceLink { kind, alias: logical_id.clone(), @@ -2013,8 +2021,8 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { // spin requires them (variable-name regex, `[component.*]` // discovery, flat-namespace collision). The trait surface is typed // generically so any future adapter with similar constraints can -// override — but fastly has no equivalent platform requirements, -// so the no-op defaults are correct: +// override. Fastly inherits the first two defaults but validates explicit +// native secret names: // // - `validate_app_config_keys`: Fastly Config Store keys accept // alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable @@ -2023,15 +2031,15 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { // `fastly compute validate` at validate-time. We keep // `config validate` pure-Rust so it stays fast and // tool-independent. -// - `validate_typed_secrets`: Fastly's KV / Config / Secret -// stores are independent namespaces — no spin-style flat- -// namespace collision risk to detect. +// - `validate_typed_secrets`: explicit native names must use Fastly's secret +// name alphabet. Legacy entries retain their existing behavior. The CLI +// checks collisions within each selected physical Secret Store. // // `single_store_kinds` IS overridden below — explicitly returns // `&[]` for documentation, matching the inherited default. #[expect( clippy::missing_trait_methods, - reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." + reason = "see the explanatory block comment immediately above; Fastly inherits validate_app_config_keys and validate_adapter_manifest. validate_typed_secrets, read_config_entry, read_config_entry_local and single_store_kinds are overridden below." )] impl Adapter for FastlyCliAdapter { fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { @@ -2726,6 +2734,24 @@ impl Adapter for FastlyCliAdapter { // unlike spin's flat-namespace single-store model. &[] } + + fn secret_names_scoped_per_store(&self) -> bool { + true + } + + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + for entry in entries { + if let Some(name) = entry.native_name.as_deref() { + secrets::validate_key(name).map_err(|_error| { + format!( + "invalid native Fastly secret name for `{}`", + entry.field_name + ) + })?; + } + } + Ok(()) + } } impl ManifestLock { @@ -6771,7 +6797,7 @@ mod tests { use edgezero_adapter::cli_support::read_package_name; use edgezero_core::env_config::EnvConfig; #[cfg(unix)] - use edgezero_core::test_env::{EnvOverride, PathPrepend}; + use edgezero_core::test_env::{EnvOverride, PathPrepend, env_lock}; #[cfg(unix)] use std::collections::BTreeMap; use std::collections::HashSet; @@ -6780,6 +6806,8 @@ mod tests { #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; #[cfg(unix)] + use std::ptr; + #[cfg(unix)] use std::sync::Mutex; use tempfile::tempdir; @@ -6797,6 +6825,37 @@ mod tests { // `edgezero_core::test_env`; the merge with edition-2024 main replaced our // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). + #[test] + fn typed_secrets_validate_native_names_without_changing_legacy_mapping() { + let entries = [ + TypedSecretEntry::new("default", "legacy", "legacy/key"), + TypedSecretEntry::new("default", "pull", "obsolete/key") + .with_native_name("Native-secret_123.name".to_owned()), + ]; + FASTLY_ADAPTER + .validate_typed_secrets(&entries) + .expect("native names use Fastly's alphabet, not the obsolete key"); + } + + #[test] + fn typed_secrets_reject_invalid_native_names_without_echoing_them() { + for name in [ + "", + "invalid/name", + "invalid name", + "invalid\nname", + "invalid:name", + "invalid\u{540d}\u{5b57}", + ] { + let entry = TypedSecretEntry::new("default", "subjects[0]", "ts1_fixture") + .with_native_name(name.to_owned()); + let error = FASTLY_ADAPTER + .validate_typed_secrets(&[entry]) + .expect_err("invalid native name"); + assert_eq!(error, "invalid native Fastly secret name for `subjects[0]`"); + } + } + // ── Fastly staging lifecycle helpers ────────────────────────────── #[test] @@ -10310,9 +10369,18 @@ exit 1 /// test threads don't race on the environment variable. #[cfg(unix)] fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::OnceLock; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) + env_lock() + } + + #[cfg(unix)] + #[test] + fn path_mutation_guard_uses_the_shared_environment_lock() { + assert!(ptr::eq(path_mutation_guard(), env_lock())); + } + + #[test] + fn native_secret_names_are_scoped_per_physical_store() { + assert!(FastlyCliAdapter.secret_names_scoped_per_store()); } #[cfg(unix)] diff --git a/crates/edgezero-adapter-fastly/src/cli/secrets.rs b/crates/edgezero-adapter-fastly/src/cli/secrets.rs new file mode 100644 index 000000000..1acfb53bc --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/secrets.rs @@ -0,0 +1,841 @@ +//! Explicit host-side secret operations. Writes do not deploy, activate, create +//! stores or links, or imply that retained runtime state has refreshed. + +use super::{ + ResourceInventories, ResourceKind, RuntimeStoreIds, StoreInventory, + collect_paginated_store_inventory, curl_config_capture, curl_quote, desired_resource_links, + effective_deploy_environment, effective_fastly_service_id, require_token, + resolve_deploy_manifest_path, validate_service_id, +}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use edgezero_adapter::registry::AdapterDeployContext; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fmt::Write as _; + +/// Local maximum raw bytes, before Base64 encoding. Fastly documents "64KB" +/// without specifying whether it means 64,000 or 65,536 bytes; provider boundary +/// acceptance remains unverified. +pub const FASTLY_SECRET_MAX_BYTES: usize = 0x0001_0000; + +/// Exact physical resources selected by deployment configuration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FastlySecretResources { + /// Selected service, not a version or activation request. + pub service_id: String, + /// Sorted by resource kind and logical ID. + pub stores: Vec, +} + +/// One selected logical-to-physical resource mapping. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FastlyStoreResource { + /// Existing selector kind: config, kv or secrets. + pub kind: String, + /// Runtime resource-link alias. + pub logical_id: String, + /// Checked physical name from the deployment environment or logical default. + pub physical_name: String, + /// Exact provider resource ID resolved from the complete inventory. + pub resource_id: String, +} + +/// Intended write action, chosen before accepting a credential. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretWriteAction { + /// POST requires that no secret with this name exists. + Create, + /// PATCH requires that a secret with this name exists. + Replace, +} + +/// Provider response. It deliberately has no Debug implementation because a +/// misbehaving provider may reflect request data. +pub struct FastlySecretResponse { + /// HTTP status. + pub status: u16, + /// Response body, consumed internally without echoing it in diagnostics. + pub body: String, +} + +/// Injectable provider boundary for fictional tests. Implementations must not +/// log request bodies or authentication and must not follow redirects. +/// Public helpers discard transport error text. +pub trait FastlySecretTransport { + /// Send an API request. Paths are generated by these helpers, never by a plan. + /// + /// # Errors + /// Returns an operational failure. Callers sanitize this text. + fn request( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result; +} + +struct CurlTransport { + token: String, +} + +impl CurlTransport { + fn from_env() -> Result { + Ok(Self { + token: require_token()?, + }) + } +} + +impl FastlySecretTransport for CurlTransport { + fn request( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + let header = curl_quote(&format!("Fastly-Key: {}", self.token)); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let mut config = format!( + "request = {}\nheader = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n", + curl_quote(method), + ); + if let Some(payload) = body { + config.push_str("header = \"Content-Type: application/json\"\n"); + writeln!(config, "data-binary = {}", curl_quote(payload)) + .map_err(|_error| "failed to encode Fastly secret request".to_owned())?; + } + // Existing transport keeps authentication and body off argv, disables + // curlrc and does not follow redirects. Never expose stderr or body. + let output = curl_config_capture(&config) + .map_err(|_error| "Fastly secret API transport failed".to_owned())?; + let (response_body, status_line) = output + .rsplit_once('\n') + .ok_or_else(|| "Fastly secret API returned no HTTP status".to_owned())?; + let status = status_line + .trim() + .parse::() + .map_err(|_error| "Fastly secret API returned an invalid HTTP status".to_owned())?; + Ok(FastlySecretResponse { + status, + body: response_body.to_owned(), + }) + } +} + +/// Resolve existing stores and the service selected by existing deployment inputs. +/// No provider mutation is performed. Every declared Config, KV and Secret Store +/// must already exist. The result describes deployment-selected resources, not +/// only the stores needed for one secret write. +/// +/// # Errors +/// Returns an error for invalid/conflicting inputs, authentication, incomplete +/// provider inventory or missing resources. +#[inline] +pub fn resolve_resources(context: &AdapterDeployContext) -> Result { + resolve_resources_with_transport(context, &CurlTransport::from_env()?) +} + +/// Injectable form of [`resolve_resources`]. +/// +/// # Errors +/// Returns the same sanitized preflight errors as [`resolve_resources`]. +#[inline] +pub fn resolve_resources_with_transport( + context: &AdapterDeployContext, + transport: &dyn FastlySecretTransport, +) -> Result { + let manifest_path = resolve_deploy_manifest_path(context)?; + let service_id = if let Some(id) = &context.service_id { + validate_service_id(id)?; + id.clone() + } else { + effective_fastly_service_id(&manifest_path)? + .ok_or_else(|| "Fastly secret operations require a selected service".to_owned())? + .id + }; + let environment = effective_deploy_environment(context)?; + let mut config = StoreInventory::default(); + let mut kv = StoreInventory::default(); + let mut secret = StoreInventory::default(); + for (kind, ids, endpoint) in [ + ( + ResourceKind::Config, + &context.stores.config, + "/resources/stores/config", + ), + (ResourceKind::Kv, &context.stores.kv, "/resources/stores/kv"), + ( + ResourceKind::Secret, + &context.stores.secrets, + "/resources/stores/secret", + ), + ] { + if ids.is_empty() { + continue; + } + let inventory = if kind == ResourceKind::Config { + let response = request(transport, "GET", endpoint, None)?; + super::parse_store_inventory(kind, &require_success(response)?) + } else { + collect_paginated_store_inventory(kind, &format!("{endpoint}?limit=100"), |path| { + let response = request(transport, "GET", path, None)?; + require_success(response) + }) + } + .map_err(|_error| { + format!( + "unable to resolve complete Fastly {} inventory", + kind.display_name() + ) + })?; + match kind { + ResourceKind::Config => config = inventory, + ResourceKind::Kv => kv = inventory, + ResourceKind::Secret => secret = inventory, + } + } + // Inventory errors can contain provider-supplied identifiers. Do not expose + // them. Desired-link errors only name requested manifest/environment selectors. + let inventories = ResourceInventories::from_inventories(config, kv, secret) + .map_err(|_error| "ambiguous Fastly resource inventories".to_owned())?; + let stores = desired_resource_links( + &RuntimeStoreIds::from(&context.stores), + &environment, + &inventories, + )? + .into_iter() + .map(|link| FastlyStoreResource { + kind: link.kind.selector_kind().to_owned(), + logical_id: link.alias, + physical_name: link.selected_name, + resource_id: link.resource_id, + }) + .collect(); + Ok(FastlySecretResources { service_id, stores }) +} + +fn validate_resource_id(id: &str) -> Result<(), String> { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("invalid Fastly secret resource ID".to_owned()); + } + Ok(()) +} + +pub(super) fn validate_key(key: &str) -> Result<(), String> { + if key.is_empty() + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("invalid Fastly secret key representation".to_owned()); + } + Ok(()) +} + +fn request( + transport: &dyn FastlySecretTransport, + method: &str, + path: &str, + body: Option<&str>, +) -> Result { + transport.request(method, path, body).map_err(|_error| { + if method == "GET" { + "Fastly secret API transport failed".to_owned() + } else { + "Fastly secret API transport failed; mutation outcome may be unknown".to_owned() + } + }) +} + +fn require_success(response: FastlySecretResponse) -> Result { + if !(200..300).contains(&response.status) { + return Err(format!( + "Fastly secret API returned HTTP {}", + response.status + )); + } + Ok(response.body) +} + +/// Check metadata existence only, without reading a credential. +/// +/// # Errors +/// Returns an operational error distinct from confirmed absence. +#[inline] +pub fn secret_exists(resource_id: &str, key: &str) -> Result { + secret_exists_with_transport(resource_id, key, &CurlTransport::from_env()?) +} + +/// Injectable form of [`secret_exists`]. +/// +/// # Errors +/// Returns sanitized provider errors, never response bodies. +#[inline] +pub fn secret_exists_with_transport( + resource_id: &str, + key: &str, + transport: &dyn FastlySecretTransport, +) -> Result { + validate_resource_id(resource_id)?; + validate_key(key)?; + // Listing metadata avoids interpreting an ambiguous per-key 404 as absence. + // Completeness and store visibility are required before returning false. + let first = format!("/resources/stores/secret/{resource_id}/secrets?limit=100"); + let mut path = first.clone(); + let mut seen = BTreeSet::new(); + let mut found = false; + loop { + let body = require_success(request(transport, "GET", &path, None)?)?; + let value: serde_json::Value = serde_json::from_str(&body) + .map_err(|_error| "invalid Fastly secret metadata response".to_owned())?; + let data = value + .get("data") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "invalid Fastly secret metadata page".to_owned())?; + for row in data { + let name = row + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "invalid Fastly secret metadata entry".to_owned())?; + found |= name == key; + } + let meta = value + .get("meta") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "missing Fastly secret metadata pagination".to_owned())?; + match meta.get("next_cursor") { + None | Some(serde_json::Value::Null) => return Ok(found), + Some(serde_json::Value::String(cursor)) if cursor.is_empty() => return Ok(found), + Some(serde_json::Value::String(cursor)) => { + if !seen.insert(cursor.clone()) { + return Err("Fastly secret metadata repeated a pagination cursor".to_owned()); + } + path = format!( + "{first}&cursor={}", + super::percent_encode_query_value(cursor) + .map_err(|_error| "invalid Fastly secret metadata cursor".to_owned())? + ); + } + Some(_) => return Err("invalid Fastly secret metadata cursor".to_owned()), + } + } +} + +/// Create or replace exact raw bytes at the already-resolved physical ID. +/// POST/PATCH enforce the reviewed action without deleting the former value. +/// There is no retry after an uncertain result and no fleet-refresh guarantee. +/// +/// # Errors +/// Rejects invalid keys/limits before mutation and sanitizes provider failures. +#[inline] +pub fn write_secret( + resource_id: &str, + key: &str, + value: &[u8], + action: SecretWriteAction, +) -> Result<(), String> { + write_secret_with_transport(resource_id, key, value, action, &CurlTransport::from_env()?) +} + +/// Injectable form of [`write_secret`]. +/// +/// # Errors +/// Returns sanitized validation/provider errors with no credential representation. +#[inline] +pub fn write_secret_with_transport( + resource_id: &str, + key: &str, + value: &[u8], + action: SecretWriteAction, + transport: &dyn FastlySecretTransport, +) -> Result<(), String> { + validate_resource_id(resource_id)?; + validate_key(key)?; + if value.is_empty() || value.len() > FASTLY_SECRET_MAX_BYTES { + return Err("Fastly secret value must contain 1 through 65536 raw bytes".to_owned()); + } + let body = serde_json::json!({ + "name": key, + "secret": STANDARD.encode(value), + }) + .to_string(); + let method = match action { + SecretWriteAction::Create => "POST", + SecretWriteAction::Replace => "PATCH", + }; + let path = format!("/resources/stores/secret/{resource_id}/secrets"); + let response = request(transport, method, &path, Some(&body))?; + // Explicit HTTP rejections per RFC 9110 section 15.5 and Fastly's documented + // 429 rate limiting. Do not assume every 4xx proves no write occurred. + // Neither rejected nor uncertain writes are retried here. + match response.status { + 200..=299 => Ok(()), + status @ (400 | 401 | 403 | 404 | 409 | 413 | 429) => { + Err(format!("Fastly rejected the secret write (HTTP {status})")) + } + status => Err(format!( + "Fastly secret write not confirmed; outcome may be unknown (HTTP {status})" + )), + } +} +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::registry::DeployStoreIds; + use std::cell::RefCell; + use std::collections::{BTreeMap, VecDeque}; + use std::fs; + + struct Fake { + replies: RefCell>, + requests: RefCell)>>, + } + + impl Fake { + fn new(replies: &[(u16, &str)]) -> Self { + Self { + replies: RefCell::new( + replies + .iter() + .map(|(status, body)| FastlySecretResponse { + status: *status, + body: (*body).to_owned(), + }) + .collect(), + ), + requests: RefCell::new(Vec::new()), + } + } + } + + impl FastlySecretTransport for Fake { + fn request( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + self.requests.borrow_mut().push(( + method.to_owned(), + path.to_owned(), + body.map(str::to_owned), + )); + self.replies + .borrow_mut() + .pop_front() + .ok_or_else(|| "fictional reflected secret".to_owned()) + } + } + + #[test] + fn writes_preserve_exact_raw_bytes_and_enforce_create_replace_methods() { + let fake = Fake::new(&[(201, "{}"), (200, "{}")]); + let bytes = b" fictional\r\n\0\xff "; + write_secret_with_transport( + "store-123", + "ts1_fixture", + bytes, + SecretWriteAction::Create, + &fake, + ) + .expect("create"); + write_secret_with_transport( + "store-123", + "ts1_fixture", + bytes, + SecretWriteAction::Replace, + &fake, + ) + .expect("replace"); + let requests = fake.requests.borrow(); + assert_eq!(requests[0].0, "POST"); + assert_eq!(requests[1].0, "PATCH"); + assert_eq!(requests[0].1, "/resources/stores/secret/store-123/secrets"); + for request in requests.iter() { + let body: serde_json::Value = + serde_json::from_str(request.2.as_deref().expect("body")).expect("json"); + let decoded = STANDARD + .decode(body["secret"].as_str().expect("encoded")) + .expect("base64"); + assert!(decoded == bytes, "credential byte preservation"); + } + } + + #[test] + fn preflight_errors_do_not_mutate_and_provider_errors_are_redacted() { + let fake = Fake::new(&[(409, "fictional reflected secret")]); + for (id, key, value) in [ + ("bad/id", "ts1_fixture", b"value".as_slice()), + ("store-123", "bad/key", b"value".as_slice()), + ("store-123", "ts1_fixture", b"".as_slice()), + ] { + assert!( + write_secret_with_transport(id, key, value, SecretWriteAction::Create, &fake) + .is_err() + ); + } + let over = vec![b'x'; FASTLY_SECRET_MAX_BYTES + 1]; + assert!( + write_secret_with_transport( + "store-123", + "ts1_fixture", + &over, + SecretWriteAction::Create, + &fake + ) + .is_err() + ); + assert!(fake.requests.borrow().is_empty()); + let error = write_secret_with_transport( + "store-123", + "ts1_fixture", + b"value", + SecretWriteAction::Create, + &fake, + ) + .expect_err("conflict"); + assert!(error.contains("409")); + assert!(!error.contains("fictional")); + let transport_error = write_secret_with_transport( + "store-123", + "ts1_fixture", + b"value", + SecretWriteAction::Replace, + &fake, + ) + .expect_err("transport"); + assert!(!transport_error.contains("fictional")); + } + + #[test] + fn presence_check_requires_complete_metadata_and_never_reads_values() { + let fake = Fake::new(&[ + ( + 200, + r#"{"data":[{"name":"unrelated"}],"meta":{"next_cursor":"next/+"}}"#, + ), + ( + 200, + r#"{"data":[{"name":"ts1_fixture"}],"meta":{"next_cursor":null}}"#, + ), + (200, r#"{"data":[],"meta":{"next_cursor":null}}"#), + (404, "fictional reflected secret"), + (200, r#"{"data":[]}"#), + ]); + assert!(secret_exists_with_transport("store-123", "ts1_fixture", &fake).expect("present")); + assert!(!secret_exists_with_transport("store-123", "ts1_fixture", &fake).expect("absent")); + secret_exists_with_transport("store-123", "ts1_fixture", &fake) + .expect_err("404 is not proof of absence"); + secret_exists_with_transport("store-123", "ts1_fixture", &fake) + .expect_err("missing pagination is not proof of absence"); + let requests = fake.requests.borrow(); + assert!(requests.iter().all(|(method, path, body)| method == "GET" + && path.contains("/secrets?") + && body.is_none())); + assert!(requests[1].1.ends_with("cursor=next%2F%2B")); + } + + #[test] + fn resource_resolution_reuses_checked_defaults_and_all_store_inventories() { + let dir = tempfile::tempdir().expect("dir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = \"fictional\"\n").expect("manifest"); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + service_id: Some("FictionalService1".to_owned()), + stores: DeployStoreIds { + config: vec!["fictional_config".to_owned()], + kv: vec!["fictional_kv".to_owned()], + secrets: vec!["fictional_secrets".to_owned()], + }, + variable_defaults: BTreeMap::from([( + "EDGEZERO__STORES__SECRETS__FICTIONAL_SECRETS__NAME".to_owned(), + "selected-secrets".to_owned(), + )]), + ..Default::default() + }; + let fake = Fake::new(&[ + (200, r#"[{"id":"config-1","name":"fictional_config"}]"#), + ( + 200, + r#"{"data":[{"id":"kv-1","name":"fictional_kv"}],"meta":{"next_cursor":null}}"#, + ), + ( + 200, + r#"{"data":[{"id":"secret-1","name":"selected-secrets"}],"meta":{"next_cursor":null}}"#, + ), + ]); + let resources = resolve_resources_with_transport(&context, &fake).expect("resources"); + assert_eq!(fake.requests.borrow()[0].1, "/resources/stores/config"); + assert_eq!( + fake.requests.borrow()[1].1, + "/resources/stores/kv?limit=100" + ); + assert_eq!( + fake.requests.borrow()[2].1, + "/resources/stores/secret?limit=100" + ); + assert_eq!(resources.service_id, "FictionalService1"); + assert_eq!(resources.stores.len(), 3); + assert_eq!(resources.stores[0].physical_name, "fictional_config"); + assert_eq!(resources.stores[2].physical_name, "selected-secrets"); + assert_eq!(resources.stores[2].resource_id, "secret-1"); + assert_eq!(resources.stores[2].kind, "secrets"); + let serialized = serde_json::to_value(&resources).expect("serialize"); + assert_eq!( + resources, + serde_json::from_value(serialized).expect("comparable metadata") + ); + assert!( + fake.requests + .borrow() + .iter() + .all(|(method, _, body)| method == "GET" && body.is_none()) + ); + } + + #[test] + fn resource_resolution_rejects_invalid_config_inventories_without_echoing_payloads() { + let context = AdapterDeployContext { + adapter_manifest_path: Some("fastly.toml".into()), + service_id: Some("FictionalService1".to_owned()), + stores: DeployStoreIds { + config: vec!["fictional_config".to_owned()], + ..Default::default() + }, + ..Default::default() + }; + for body in [ + "fictional reflected secret", + r#"{"data":[{"id":"config-1","name":"fictional_config"}],"meta":{"next_cursor":null}}"#, + r#"[{"id":"config-1","name":"fictional_config"},{"id":"config-2","name":"fictional_config"}]"#, + ] { + let fake = Fake::new(&[(200, body)]); + let error = resolve_resources_with_transport(&context, &fake) + .expect_err("invalid Config Store inventory"); + assert_eq!( + error, + "unable to resolve complete Fastly Config Store inventory" + ); + assert_eq!(fake.requests.borrow().len(), 1); + } + } + + #[test] + fn resource_resolution_rejects_missing_and_duplicate_declared_stores() { + for duplicate in [false, true] { + let context = AdapterDeployContext { + adapter_manifest_path: Some("fastly.toml".into()), + service_id: Some("FictionalService1".to_owned()), + stores: DeployStoreIds { + secrets: if duplicate { + vec!["vault".to_owned(); 2] + } else { + vec!["vault".to_owned()] + }, + ..Default::default() + }, + variable_defaults: BTreeMap::from([( + "EDGEZERO__STORES__SECRETS__VAULT__NAME".to_owned(), + "selected-vault".to_owned(), + )]), + ..Default::default() + }; + let body = if duplicate { + r#"{"data":[{"id":"secret-1","name":"selected-vault"}],"meta":{"next_cursor":null}}"# + } else { + r#"{"data":[],"meta":{"next_cursor":null}}"# + }; + let fake = Fake::new(&[(200, body)]); + let error = + resolve_resources_with_transport(&context, &fake).expect_err("invalid declaration"); + assert!(error.contains("vault"), "{error}"); + if duplicate { + assert!(error.contains("declared more than once"), "{error}"); + } else { + assert!( + error.contains("selected-vault") && error.contains("does not exist"), + "{error}" + ); + } + assert_eq!(fake.requests.borrow().len(), 1); + } + } + + #[test] + fn resource_resolution_reuses_cross_kind_ambiguity_checks_without_provider_details() { + let context = AdapterDeployContext { + adapter_manifest_path: Some("fastly.toml".into()), + service_id: Some("FictionalService1".to_owned()), + stores: DeployStoreIds { + config: vec!["config".to_owned()], + secrets: vec!["vault".to_owned()], + ..Default::default() + }, + ..Default::default() + }; + let fake = Fake::new(&[ + (200, r#"[{"id":"reflected-provider-data","name":"config"}]"#), + ( + 200, + r#"{"data":[{"id":"reflected-provider-data","name":"vault"}],"meta":{"next_cursor":null}}"#, + ), + ]); + let error = + resolve_resources_with_transport(&context, &fake).expect_err("ambiguous resource kind"); + assert_eq!(error, "ambiguous Fastly resource inventories"); + assert_eq!(fake.requests.borrow().len(), 2); + + let missing_config = Fake::new(&[ + (200, "[]"), + ( + 200, + r#"{"data":[{"id":"secret-1","name":"vault"}],"meta":{"next_cursor":null}}"#, + ), + ]); + let missing_error = resolve_resources_with_transport(&context, &missing_config) + .expect_err("all declared kinds must exist"); + assert!( + missing_error.contains("Config Store") && missing_error.contains("logical `config`"), + "{missing_error}" + ); + } + + #[test] + fn resource_resolution_names_invalid_selectors_without_their_values() { + let context = AdapterDeployContext { + adapter_manifest_path: Some("fastly.toml".into()), + service_id: Some("FictionalService1".to_owned()), + stores: DeployStoreIds { + secrets: vec!["vault".to_owned()], + ..Default::default() + }, + variable_defaults: BTreeMap::from([( + "EDGEZERO__STORES__SECRETS__VAULT__NAME".to_owned(), + "private-canary\n".to_owned(), + )]), + ..Default::default() + }; + let fake = Fake::new(&[]); + let error = + resolve_resources_with_transport(&context, &fake).expect_err("invalid selector"); + assert!( + error.contains("EDGEZERO__STORES__SECRETS__VAULT__NAME"), + "{error}" + ); + assert!(!error.contains("private-canary")); + assert!(fake.requests.borrow().is_empty()); + } + + #[test] + fn write_errors_distinguish_explicit_rejection_from_uncertainty_without_retrying() { + for action in [SecretWriteAction::Create, SecretWriteAction::Replace] { + for status in [400, 401, 403, 404, 409, 413, 429, 302, 408, 422, 500, 503] { + let fake = Fake::new(&[(status, "private-canary-response")]); + let error = + write_secret_with_transport("store-123", "fixture", b"value", action, &fake) + .expect_err("provider failure"); + let rejected = matches!(status, 400 | 401 | 403 | 404 | 409 | 413 | 429); + assert_eq!(error.contains("rejected"), rejected, "{error}"); + assert_eq!(error.contains("unknown"), !rejected, "{error}"); + assert!(error.contains(&status.to_string())); + assert!(!error.contains("private-canary")); + assert_eq!(fake.requests.borrow().len(), 1, "writes are never retried"); + } + } + } + + #[test] + fn transport_errors_only_report_mutation_uncertainty_for_writes() { + let fake = Fake::new(&[]); + let error = + secret_exists_with_transport("store-123", "fixture", &fake).expect_err("GET failed"); + assert_eq!(error, "Fastly secret API transport failed"); + let write_error = write_secret_with_transport( + "store-123", + "fixture", + b"value", + SecretWriteAction::Create, + &fake, + ) + .expect_err("write failed"); + assert!(write_error.contains("unknown") && !write_error.contains("fictional")); + assert_eq!(fake.requests.borrow().len(), 2); + } + + #[test] + fn local_raw_byte_limit_accepts_exact_boundary_and_rejects_larger_values() { + let fake = Fake::new(&[(201, "{}")]); + let mut value = vec![b'x'; FASTLY_SECRET_MAX_BYTES]; + write_secret_with_transport( + "store-123", + "fixture", + &value, + SecretWriteAction::Create, + &fake, + ) + .expect("local maximum"); + value.push(b'x'); + let error = write_secret_with_transport( + "store-123", + "fixture", + &value, + SecretWriteAction::Create, + &fake, + ) + .expect_err("over local maximum"); + assert!(error.contains("65536")); + assert_eq!(fake.requests.borrow().len(), 1); + } + + #[cfg(unix)] + #[test] + fn production_transport_keeps_credentials_off_argv_and_sanitizes_stderr() { + use edgezero_core::test_env::{EnvOverride, PathPrepend, env_lock}; + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt as _; + let _lock = env_lock().lock().expect("env lock"); + let dir = tempfile::tempdir().expect("dir"); + let arguments_path = dir.path().join("args"); + let input = dir.path().join("input"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\ncat > '{}'\nprintf '\\n201'\n", + arguments_path.display(), + input.display() + ); + let curl = dir.path().join("curl"); + fs::write(&curl, script).expect("fake curl"); + fs::set_permissions(&curl, Permissions::from_mode(0o755)).expect("mode"); + let _path = PathPrepend::new(dir.path()); + let _token = EnvOverride::set("FASTLY_API_TOKEN", "fictional-api-token"); + write_secret( + "store-123", + "ts1_fixture", + b"fictional-credential", + SecretWriteAction::Create, + ) + .expect("fake production transport"); + let argv = fs::read_to_string(&arguments_path).expect("argv"); + assert!(argv.starts_with("-q\n")); + assert!(!argv.contains("fictional")); + let stdin = fs::read_to_string(&input).expect("stdin"); + assert!(stdin.contains("fictional-api-token")); + assert!(stdin.contains("data-binary")); + assert!(!stdin.contains("location")); + fs::write(&curl, "#!/bin/sh\ncat >/dev/null\nprintf 'fictional-credential fictional-api-token' >&2\nexit 1\n").expect("failing curl"); + let error = write_secret( + "store-123", + "ts1_fixture", + b"fictional-credential", + SecretWriteAction::Replace, + ) + .expect_err("uncertain failure"); + assert!(!error.contains("fictional")); + assert!(error.contains("unknown")); + } +} diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 4e14c8322..fa389254d 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -536,17 +536,20 @@ impl Adapter for SpinCliAdapter { // tells us which field already claimed it. let mut seen: HashMap = HashMap::with_capacity(entries.len()); for entry in entries { - let spin_var = entry.key_value.to_ascii_lowercase(); + let spin_var = entry + .native_name + .clone() + .unwrap_or_else(|| entry.key_value.to_ascii_lowercase()); if !is_valid_spin_key(&spin_var) { let reason = spin_key_rule_violation(&spin_var); return Err(format!( - "`#[secret]` field `{field}` does not reference a valid Spin variable name. {reason}. Pick a secret reference that conforms; its value is redacted.", + "secret lookup at `{field}` does not reference a valid Spin variable name. {reason}. Use a conforming native name; the name is redacted.", field = entry.field_name, )); } if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( - "`#[secret]` fields `{prev_field}` and `{this_field}` reference the same Spin variable after lowercasing; Spin's flat variable namespace cannot disambiguate them. Pick distinct secret references; their values are redacted.", + "secret lookups at `{prev_field}` and `{this_field}` reference the same Spin variable; Spin's flat variable namespace cannot disambiguate them. Use distinct native names; the names are redacted.", this_field = entry.field_name, )); } @@ -942,7 +945,12 @@ fn read_sqlite_entry(db_path: &Path, store: &str, key: &str) -> Result, ) -> Result { diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 28cc78140..8db577529 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -245,6 +245,9 @@ pub struct TypedSecretEntry<'entry> { pub field_name: String, /// Blob value — i.e. the secret-store KEY NAME. pub key_value: &'entry str, + /// Exact native identity supplied by an opt-in application's runtime renderer. + /// `None` retains this adapter's generic key mapping. + pub native_name: Option, /// Logical secret-store id this key targets. pub store_id: &'entry str, } @@ -261,8 +264,17 @@ impl<'entry> TypedSecretEntry<'entry> { field_name: field_name.into(), key_value, store_id, + native_name: None, } } + + /// Supply the exact native identity used by an application's runtime bridge. + #[must_use] + #[inline] + pub fn with_native_name(mut self, native_name: String) -> Self { + self.native_name = Some(native_name); + self + } } /// Outcome of a single-key read. @@ -710,6 +722,14 @@ pub trait Adapter: Sync + Send { Ok(()) } + /// Whether native secret names have separate namespaces per physical store. + /// Flat-namespace adapters retain the default. The CLI resolves store aliases + /// before collision checks when this is true. + #[inline] + fn secret_names_scoped_per_store(&self) -> bool { + false + } + /// Typed-only check that needs `#[secret]` field values — the /// CLI calls this only from the typed validation flow. /// `entries` carries both `KeyInDefault` and `KeyInNamedStore` @@ -901,6 +921,7 @@ mod tests { // method here exercises the trait's default impl. assert!(FIRST.merged_id_kinds().is_empty()); assert!(FIRST.single_store_kinds().is_empty()); + assert!(!FIRST.secret_names_scoped_per_store()); assert_eq!( FIRST.validate_adapter_manifest(Path::new("/tmp"), None, None), Ok(()) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 438db18bb..c40c12e55 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -28,8 +28,8 @@ use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; use edgezero_core::app_config::{ - self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, SecretField, SecretKind, - SecretPathSegment, + self, AppConfigError, AppConfigLoadOptions, AppConfigMeta, ImplicitSecretLookup, SecretField, + SecretKind, SecretPathSegment, }; use edgezero_core::blob_envelope::{BlobEnvelope, BlobEnvelopeError, ENVELOPE_VERSION_V1}; use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; @@ -264,8 +264,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx, AdapterValidationScope::All)?; + typed_secret_checks(&typed, &ctx, AdapterValidationScope::All)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -467,8 +466,7 @@ where .map_err(|err| format_app_config_error(&err))?; app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation, validation_scope)?; + typed_secret_checks(&typed, &ctx.validation, validation_scope)?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -610,9 +608,8 @@ pub fn run_config_diff_typed(args: &ConfigDiffArgs) -> Result(&ctx, validation_scope)?; + typed_secret_checks(&typed, &ctx, validation_scope)?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -1389,7 +1385,11 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { }) } -pub(crate) fn effective_manifest_environment( +/// Merge adapter-scoped manifest variable defaults with parent environment +/// overrides using the same rules as deployment and typed config commands. +#[must_use] +#[inline] +pub fn effective_manifest_environment( manifest: &Manifest, adapter: &str, parent: I, @@ -1833,18 +1833,40 @@ fn collect_secret_leaves<'raw>( Ok(out) } -/// Typed-only adapter dispatch: feed each adapter in the requested scope 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( +/// Compile and validate implicit lookup metadata once before adapter checks. +/// Applications that do not opt in retain the legacy leaf and store checks. +fn typed_secret_checks( + typed: &C, ctx: &ValidationContext, adapter_scope: AdapterValidationScope, ) -> Result<(), String> where C: AppConfigMeta, { + if let Some(lookups) = typed.implicit_secret_lookups()? { + check_implicit_metadata(&lookups, ctx)?; + return match adapter_scope { + AdapterValidationScope::All => { + for name in ctx.manifest().adapters.keys() { + if let Some(adapter) = adapter_registry::get_adapter(name) { + let environment = effective_manifest_environment( + ctx.manifest(), + adapter.name(), + env::vars(), + ); + check_implicit_adapter(typed, &lookups, adapter, &environment)?; + } + } + Ok(()) + } + AdapterValidationScope::Selected(adapter) => { + let environment = + effective_manifest_environment(ctx.manifest(), adapter.name(), env::vars()); + check_implicit_adapter(typed, &lookups, adapter, &environment) + } + }; + } + check_legacy_typed_secrets::(ctx)?; let default_store_id = ctx .manifest() .stores @@ -1894,7 +1916,108 @@ where // Typed secret checks // ------------------------------------------------------------------- -fn typed_secret_checks(_typed: &C, ctx: &ValidationContext) -> Result<(), String> +fn check_implicit_metadata( + lookups: &[ImplicitSecretLookup], + ctx: &ValidationContext, +) -> Result<(), String> { + let mut owners = BTreeMap::new(); + for lookup in lookups { + if lookup.descriptor_id.is_empty() + || lookup.field_path.is_empty() + || lookup.store_id.is_empty() + || lookup.key.is_empty() + { + return Err( + "implicit secret lookup has incomplete descriptor/path/store/key metadata" + .to_owned(), + ); + } + let declared = ctx + .manifest() + .stores + .secrets + .as_ref() + .is_some_and(|stores| stores.ids.iter().any(|id| id == &lookup.store_id)); + if !declared { + return Err(format!( + "implicit secret lookup `{}` at `{}` requires a declared logical secret store", + lookup.descriptor_id, lookup.field_path + )); + } + let owner = (&lookup.descriptor_id, &lookup.field_path); + if let Some(previous) = owners.insert(owner, (&lookup.store_id, &lookup.key)) + && previous != (&lookup.store_id, &lookup.key) + { + return Err(format!( + "implicit secret lookup `{}` has conflicting owning metadata", + lookup.descriptor_id + )); + } + } + Ok(()) +} + +fn check_implicit_adapter( + typed: &C, + lookups: &[ImplicitSecretLookup], + adapter: &dyn adapter_registry::Adapter, + environment: &EnvConfig, +) -> Result<(), String> +where + C: AppConfigMeta, +{ + let mut entries = Vec::new(); + let mut seen_requests = BTreeMap::new(); + let mut native_owners = BTreeMap::new(); + for lookup in lookups { + let name = typed.render_implicit_secret_name(adapter.name(), lookup, environment)?; + if name.is_empty() { + return Err(format!( + "empty native secret identity for `{}`", + lookup.descriptor_id + )); + } + // Compare physical namespaces, not logical aliases. Distinct requests + // must never merge merely because their rendered names happen to match. + let namespace = if adapter.secret_names_scoped_per_store() { + environment + .store_name_checked("secrets", &lookup.store_id) + .map_err(|error| { + format!( + "invalid implicit {} secret store selector: {error}", + adapter.name() + ) + })? + } else { + String::new() + }; + let identity = (&lookup.descriptor_id, &lookup.store_id, &lookup.key); + if let Some(previous) = native_owners.insert((namespace, name.clone()), identity) + && previous != identity + { + return Err(format!( + "native secret identity collision at `{}` for adapter `{}`", + lookup.field_path, + adapter.name() + )); + } + // Repeated identical requests can have different owning array positions. + // Rendering still occurs before deduplication so inconsistent renderers fail. + if let Some(previous) = seen_requests.insert(identity, name.clone()) { + if previous != name { + return Err("identical secret request has inconsistent native rendering".to_owned()); + } + continue; + } + entries.push( + TypedSecretEntry::new(&lookup.store_id, &lookup.field_path, &lookup.key) + .with_native_name(name), + ); + } + adapter.validate_typed_secrets(&entries) +} + +fn check_legacy_typed_secrets(ctx: &ValidationContext) -> Result<(), String> where C: AppConfigMeta, { @@ -2087,6 +2210,10 @@ fn format_app_config_error(err: &AppConfigError) -> String { clippy::default_numeric_fallback, reason = "test module groups items by subject (PathPrepend co-located with its fake-spin consumers, fixtures with their tests) rather than by item kind; `range(min = 100, max = 60_000)` bounds default to the field's int type" )] +#[expect( + clippy::missing_trait_methods, + reason = "legacy app-config fixtures intentionally exercise the default-compatible implicit lookup hooks" +)] mod tests { use super::*; use crate::test_support::{EnvOverride, manifest_guard}; @@ -2094,6 +2221,8 @@ mod tests { use edgezero_core::test_env::PathPrepend; use serde::{Deserialize, Serialize}; use std::borrow::Cow; + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::fs; use tempfile::TempDir; @@ -3198,7 +3327,7 @@ api_key = "" #[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 + // before `typed_secret_checks` ever runs — so // this is deserialize-path coverage, NOT proof of the path-aware // collector. The direct collector test below covers that. let app_config = r#" @@ -5320,7 +5449,7 @@ ids = ["default"] } // ------------------------------------------------------------------- - // diff runs typed_secret_checks + adapter_typed_checks + // diff runs the shared typed secret preflight // ------------------------------------------------------------------- /// A NESTED `#[secret]` that is present but empty must be caught by the @@ -5400,6 +5529,482 @@ ids = ["default"] ); } + #[derive(Debug, Deserialize, Serialize, Validate)] + #[serde(deny_unknown_fields)] + struct ImplicitConfig { + #[serde(skip)] + lookup_compiled: Cell, + #[serde(default)] + inactive: bool, + #[serde(default)] + subjects: Vec, + #[serde(default)] + bad: String, + } + + impl AppConfigMeta for ImplicitConfig { + fn secret_fields() -> Vec { + Vec::new() + } + + fn implicit_secret_lookups(&self) -> Result>, String> { + if self.lookup_compiled.replace(true) { + return Err("implicit secret plan compiled more than once".to_owned()); + } + if self.inactive { + return Ok(Some(Vec::new())); + } + // An omitted subject uses the application's effective ordinary default. + let subjects = if self.subjects.is_empty() { + vec!["default".to_owned()] + } else { + self.subjects.clone() + }; + Ok(Some( + subjects + .iter() + .enumerate() + .map(|(index, subject)| { + ImplicitSecretLookup::new( + if self.bad == "metadata" { + "" + } else { + "fictional.pull" + }, + format!("subjects[{index}]"), + if self.bad == "store" { + "undeclared" + } else { + "default" + }, + format!("ts1_{subject}"), + ) + }) + .collect(), + )) + } + + fn render_implicit_secret_name( + &self, + adapter: &str, + lookup: &ImplicitSecretLookup, + environment: &EnvConfig, + ) -> Result { + if self.bad == "collision" { + return Ok("native_collision".to_owned()); + } + if self.bad == "native" { + return Ok("native-hyphen".to_owned()); + } + if self.bad == "native_slash" { + return Ok("invalid/native/name".to_owned()); + } + if self.bad == "descriptor_name" { + return Ok(lookup.descriptor_id.replace('.', "_").to_ascii_uppercase()); + } + if self.bad == "path_name" { + return Ok(lookup.field_path.clone()); + } + let store = environment + .store_name_checked("secrets", &lookup.store_id) + .map_err(|_error| "invalid secret store selector".to_owned())?; + if self.bad == "manifest_store" && store != "fictional_manifest_store" { + return Err("renderer did not receive effective manifest defaults".to_owned()); + } + Ok(match adapter { + "spin" => format!("secret_{}_{}", store, lookup.key), + "axum" => format!("TS_SECRET_{}_{}", store, lookup.key).to_ascii_uppercase(), + _ => lookup.key.clone(), + }) + } + } + + fn implicit_diff_args(manifest: &Path) -> ConfigDiffArgs { + ConfigDiffArgs { + adapter: "axum".to_owned(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: false, + manifest: manifest.to_path_buf(), + no_env: true, + runtime_config: None, + store: None, + staging: false, + } + } + + #[test] + fn implicit_validate_push_diff_compile_once_without_reference_leaves() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let (dir, manifest, _) = setup_project(PUSH_MANIFEST, ""); + run_config_validate_typed::(&args_for(&manifest)) + .expect("default role validates"); + let mut push = push_args(&manifest, "axum"); + push.yes = true; + run_config_push_typed::(&push).expect("implicit push"); + run_config_diff_typed::(&implicit_diff_args(&manifest)) + .expect("implicit diff"); + let body = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) + .expect("local envelope"); + let stored: serde_json::Value = serde_json::from_str(&body).expect("store"); + let encoded = stored["app_config"].as_str().expect("encoded envelope"); + let envelope: BlobEnvelope = serde_json::from_str(encoded).expect("envelope"); + assert_eq!( + envelope.data, + serde_json::json!({"inactive":false,"subjects":[],"bad":""}) + ); + assert!(!body.contains("ts1_")); + assert!(!body.contains("fictional.pull")); + } + + #[test] + fn implicit_consumers_pass_effective_manifest_environment_to_native_renderer() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let source = format!( + "{PUSH_MANIFEST}\n[[environment.variables]]\nname = \"SECRET_STORE\"\nenv = \"EDGEZERO__STORES__SECRETS__DEFAULT__NAME\"\nvalue = \"fictional_manifest_store\"\nadapters = [\"axum\"]\n" + ); + let (_dir, manifest, _) = setup_project(&source, "bad = \"manifest_store\""); + run_config_validate_typed::(&args_for(&manifest)) + .expect("manifest environment in validate"); + let mut push = push_args(&manifest, "axum"); + push.yes = true; + run_config_push_typed::(&push).expect("manifest environment in push"); + run_config_diff_typed::(&implicit_diff_args(&manifest)) + .expect("manifest environment in diff"); + } + + struct CountingImplicitAdapter { + count: AtomicUsize, + scoped: bool, + } + + impl adapter_registry::Adapter for CountingImplicitAdapter { + fn name(&self) -> &'static str { + "counting_implicit" + } + + fn execute( + &self, + _action: adapter_registry::AdapterAction, + _args: &[String], + ) -> Result<(), String> { + Ok(()) + } + + fn secret_names_scoped_per_store(&self) -> bool { + self.scoped + } + + fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { + self.count.store(entries.len(), Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn implicit_deduplication_uses_complete_request_identity() { + let mut typed = ImplicitConfig { + lookup_compiled: Cell::default(), + inactive: false, + subjects: Vec::new(), + bad: "descriptor_name".to_owned(), + }; + let adapter = CountingImplicitAdapter { + count: AtomicUsize::new(0), + scoped: false, + }; + let first = ImplicitSecretLookup::new("purpose.first", "owners[0]", "default", "shared"); + let mut repeated = first.clone(); + repeated.field_path = "owners[1]".to_owned(); + check_implicit_adapter( + &typed, + &[first.clone(), repeated.clone()], + &adapter, + &EnvConfig::default(), + ) + .expect("identical request renders once per owner and is deduplicated"); + assert_eq!(adapter.count.load(Ordering::SeqCst), 1); + + let second = ImplicitSecretLookup::new("purpose.second", "owners[1]", "default", "shared"); + check_implicit_adapter( + &typed, + &[first.clone(), second.clone()], + &adapter, + &EnvConfig::default(), + ) + .expect("different purposes can select distinct native identities"); + assert_eq!(adapter.count.load(Ordering::SeqCst), 2); + + typed.bad = "collision".to_owned(); + let error = check_implicit_adapter( + &typed, + &[first.clone(), second], + &adapter, + &EnvConfig::default(), + ) + .expect_err("different purposes cannot share one physical credential"); + assert!( + error.contains("native secret identity collision"), + "{error}" + ); + + typed.bad = "path_name".to_owned(); + let inconsistent_error = + check_implicit_adapter(&typed, &[first, repeated], &adapter, &EnvConfig::default()) + .expect_err("one request cannot render differently by owner path"); + assert_eq!( + inconsistent_error, + "identical secret request has inconsistent native rendering" + ); + } + + #[test] + fn implicit_metadata_rejects_conflicting_owner_mappings() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let (_dir, manifest, _) = setup_project(PUSH_MANIFEST, ""); + let context = load_validation_context(&args_for(&manifest)).expect("context"); + let lookups = [ + ImplicitSecretLookup::new("purpose", "owner", "default", "first"), + ImplicitSecretLookup::new("purpose", "owner", "default", "second"), + ]; + let error = check_implicit_metadata(&lookups, &context).expect_err("conflicting owner"); + assert!(error.contains("conflicting owning metadata"), "{error}"); + } + + #[test] + fn implicit_namespace_policy_is_adapter_owned() { + let typed = ImplicitConfig { + lookup_compiled: Cell::default(), + inactive: false, + subjects: Vec::new(), + bad: "collision".to_owned(), + }; + let mut adapter = CountingImplicitAdapter { + count: AtomicUsize::new(0), + scoped: true, + }; + let lookups = [ + ImplicitSecretLookup::new("first", "first", "first", "key"), + ImplicitSecretLookup::new("second", "second", "second", "key"), + ]; + check_implicit_adapter(&typed, &lookups, &adapter, &EnvConfig::default()) + .expect("non-Fastly adapter can expose per-store namespaces"); + adapter.scoped = false; + let error = check_implicit_adapter(&typed, &lookups, &adapter, &EnvConfig::default()) + .expect_err("flat namespace collides"); + assert!(error.contains("collision"), "{error}"); + } + + #[derive(Debug, Deserialize, Serialize, Validate)] + struct ReplacingLegacyConfig { + key: String, + vault: String, + #[serde(default)] + legacy_mode: bool, + } + + impl AppConfigMeta for ReplacingLegacyConfig { + fn secret_fields() -> Vec { + vec![ + SecretField { + kind: SecretKind::KeyInDefault, + path: vec![SecretPathSegment::Field(Cow::Borrowed("key"))], + optional: false, + }, + SecretField { + kind: SecretKind::StoreRef, + path: vec![SecretPathSegment::Field(Cow::Borrowed("vault"))], + optional: false, + }, + ] + } + + fn implicit_secret_lookups(&self) -> Result>, String> { + Ok((!self.legacy_mode).then(Vec::new)) + } + } + + #[test] + fn implicit_empty_plan_replaces_legacy_leaf_checks_in_all_consumers() { + let _lock = manifest_guard().lock().expect("manifest guard"); + for source in [ + PUSH_MANIFEST.to_owned(), + PUSH_MANIFEST.replace("[stores.secrets]\nids = [\"default\"]", ""), + ] { + let (_dir, manifest, config) = + setup_project(&source, "key = \"\"\nvault = \"undeclared\""); + run_config_validate_typed::(&args_for(&manifest)) + .expect("Some([]) skips legacy leaf checks"); + let mut push = push_args(&manifest, "axum"); + push.yes = true; + run_config_push_typed::(&push).expect("replacement push"); + run_config_diff_typed::(&implicit_diff_args(&manifest)) + .expect("replacement diff"); + + for (values, expected) in [ + ("key = \"\"\nvault = \"undeclared\"", "must be non-empty"), + ( + "key = \"token\"\nvault = \"undeclared\"", + if source.contains("[stores.secrets]") { + "is not in [stores.secrets].ids" + } else { + "requires `[stores.secrets]`" + }, + ), + ] { + fs::write(&config, format!("{values}\nlegacy_mode = true")) + .expect("legacy configuration"); + let error = + run_config_validate_typed::(&args_for(&manifest)) + .expect_err("None preserves legacy checks"); + assert!(error.contains(expected), "{error}"); + } + } + } + + #[test] + fn implicit_active_plan_requires_an_application_renderer() { + struct NoRenderer; + impl AppConfigMeta for NoRenderer { + fn secret_fields() -> Vec { + Vec::new() + } + } + let lookup = ImplicitSecretLookup::new("purpose", "owner", "default", "key"); + let adapter = adapter_registry::get_adapter("axum").expect("Axum registered"); + let error = check_implicit_adapter(&NoRenderer, &[lookup], adapter, &EnvConfig::default()) + .expect_err("renderer required for active requests"); + assert_eq!( + error, + "implicit secret lookups require an application native renderer" + ); + } + + #[test] + fn implicit_fastly_detects_physical_store_alias_collisions() { + let typed = ImplicitConfig { + lookup_compiled: Cell::default(), + inactive: false, + subjects: Vec::new(), + bad: String::new(), + }; + let lookups = ["first", "second"].map(|store| { + ImplicitSecretLookup::new(format!("fictional.{store}"), store, store, "ts1_identical") + }); + let environment = EnvConfig::from_vars([ + ( + "EDGEZERO__STORES__SECRETS__FIRST__NAME", + "same-physical-store", + ), + ( + "EDGEZERO__STORES__SECRETS__SECOND__NAME", + "same-physical-store", + ), + ]); + let adapter = adapter_registry::get_adapter("fastly").expect("Fastly registered"); + let error = check_implicit_adapter(&typed, &lookups, adapter, &environment) + .expect_err("distinct logical stores share the physical namespace"); + assert!(error.contains("collision")); + check_implicit_adapter(&typed, &lookups, adapter, &EnvConfig::default()) + .expect("distinct physical stores have independent namespaces"); + } + + #[test] + fn implicit_preflight_metadata_and_collisions_reject_in_all_consumers() { + let _lock = manifest_guard().lock().expect("manifest guard"); + for (bad, expected) in [ + ("metadata", "incomplete descriptor/path/store/key metadata"), + ("store", "requires a declared logical secret store"), + ("collision", "native secret identity collision"), + ("native", "invalid native environment name"), + ] { + let subjects = if bad == "native" { + "[\"first\"]" + } else { + "[\"first\", \"second\"]" + }; + let config = format!("bad = {bad:?}\nsubjects = {subjects}\n"); + let (dir, manifest, _) = setup_project(PUSH_MANIFEST, &config); + let mut push = push_args(&manifest, "axum"); + push.yes = true; + for error in [ + run_config_validate_typed::(&args_for(&manifest)) + .expect_err("validate preflight"), + run_config_push_typed::(&push).expect_err("push preflight"), + run_config_diff_typed::(&implicit_diff_args(&manifest)) + .expect_err("diff preflight"), + ] { + assert!(error.contains(expected), "{bad}: {error}"); + } + assert!(!dir.path().join(".edgezero").exists()); + } + } + + #[test] + fn implicit_fastly_rejects_invalid_native_names_in_all_consumers() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let source = PUSH_MANIFEST + .replace("[adapters.axum.", "[adapters.fastly.") + .replace( + "crate = \"crates/demo-app-adapter-axum\"", + "crate = \"crates/demo-app-adapter-fastly\"\nmanifest = \"fastly.toml\"", + ); + let (dir, manifest, config) = setup_project(&source, "bad = \"native_slash\""); + fs::write(dir.path().join("fastly.toml"), "name = \"fictional\"\n") + .expect("Fastly manifest"); + + let mut push = push_args(&manifest, "fastly"); + push.local = true; + push.dry_run = true; + let mut diff = implicit_diff_args(&manifest); + diff.adapter = "fastly".to_owned(); + diff.local = true; + for error in [ + run_config_validate_typed::(&args_for(&manifest)) + .expect_err("validate rejects invalid native name"), + run_config_push_typed::(&push) + .expect_err("push rejects invalid native name"), + run_config_diff_typed::(&diff) + .expect_err("diff rejects invalid native name"), + ] { + assert!(error.contains("invalid native Fastly secret name")); + assert!(!error.contains("invalid/native/name")); + } + assert!(!dir.path().join(".edgezero").exists()); + + fs::write(&config, "inactive = true\nbad = \"native_slash\"").expect("inactive credential"); + run_config_validate_typed::(&args_for(&manifest)) + .expect("inactive credential is not rendered"); + } + + #[test] + fn implicit_spin_uses_native_renderer_deduplicates_and_retains_name_validation() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let manifest_source = PUSH_MANIFEST + .replace("[adapters.axum.adapter]", "[adapters.spin.adapter]") + .replace("[adapters.axum.commands]", "[adapters.spin.commands]") + .replace( + "crate = \"crates/demo-app-adapter-axum\"", + "crate = \"crates/demo-spin\"\nmanifest = \"spin.toml\"", + ); + let (dir, manifest, config) = setup_project( + &manifest_source, + "subjects = [\"identical\", \"identical\", \"distinct\"]", + ); + fs::write(dir.path().join("spin.toml"), VALID_SPIN_TOML).expect("spin manifest"); + run_config_validate_typed::(&args_for(&manifest)) + .expect("duplicate identical contexts allowed"); + fs::write(&config, "bad = \"native\"").expect("invalid native name"); + let error = run_config_validate_typed::(&args_for(&manifest)) + .expect_err("native hyphen rejected"); + assert!(error.contains("Spin variable")); + fs::write(&config, "inactive = true\nbad = \"native\"").expect("inactive cleanup"); + run_config_validate_typed::(&args_for(&manifest)) + .expect("inactive role not rendered"); + } + /// `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 diff --git a/crates/edgezero-cli/src/fastly_secrets.rs b/crates/edgezero-cli/src/fastly_secrets.rs new file mode 100644 index 000000000..77e019ea5 --- /dev/null +++ b/crates/edgezero-cli/src/fastly_secrets.rs @@ -0,0 +1,150 @@ +//! Host helpers shared with application-specific secret commands. + +pub use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds}; +pub use edgezero_adapter_fastly::cli::secrets::{ + FASTLY_SECRET_MAX_BYTES, FastlySecretResources, FastlySecretResponse, FastlySecretTransport, + FastlyStoreResource, SecretWriteAction, resolve_resources, resolve_resources_with_transport, + secret_exists, secret_exists_with_transport, write_secret, write_secret_with_transport, +}; + +use edgezero_core::manifest::ManifestLoader; +use std::path::Path; + +/// Build the same manifest-selected paths, logical stores and variable defaults +/// used by deployment. The caller supplies its effective application manifest +/// path, including any existing `EDGEZERO_MANIFEST` selection. +/// +/// # Errors +/// Returns an error for invalid manifests, missing Fastly configuration or +/// adapter manifest paths rejected by the lifecycle resolver. +#[inline] +pub fn context_from_manifest( + path: &Path, + service_id: Option<&str>, +) -> Result { + let loader = ManifestLoader::from_path(path).map_err(|error| { + // Parser errors can contain source lines, including inline values. + format!( + "failed to load application manifest {} for Fastly secret operations ({:?})", + path.display(), + error.kind() + ) + })?; + crate::ensure_adapter_defined("fastly", Some(&loader))?; + let application_manifest_path = crate::checked_application_manifest_path(path)?; + let (context, path_error) = + crate::deploy_context_from_loader(Some(&loader), "fastly", Some(application_manifest_path)); + if let Some(error) = path_error { + return Err(error); + } + Ok(AdapterDeployContext { + service_id: service_id.map(str::to_owned), + ..context + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn manifest_errors_name_the_path_without_exposing_parser_source() { + let dir = tempfile::tempdir().expect("directory"); + let manifest = dir.path().join("selected-edgezero.toml"); + let missing = context_from_manifest(&manifest, None).expect_err("missing manifest"); + assert!(missing.contains(&manifest.display().to_string())); + assert!(missing.contains("NotFound")); + fs::write( + &manifest, + "[app]\nname = \"fixture\"\nprivate = \"private-canary-source\" trailing\n", + ) + .expect("invalid manifest"); + let error = context_from_manifest(&manifest, None).expect_err("malformed manifest"); + assert!(error.contains(&manifest.display().to_string())); + assert!(error.contains("InvalidData")); + assert!(!error.contains("private-canary") && !error.contains("trailing")); + } + + #[test] + fn shared_context_defers_adapter_path_errors_for_deploy_but_secret_helpers_reject() { + let dir = tempfile::tempdir().expect("directory"); + let manifest = dir.path().join("edgezero.toml"); + fs::write(&manifest, "[app]\nname = \"fixture\"\n[adapters.fastly.adapter]\ncrate = \"fixture\"\nmanifest = \"missing-fastly.toml\"\n") + .expect("manifest"); + let loader = ManifestLoader::from_path(&manifest).expect("loaded manifest"); + let (context, path_error) = crate::deploy_context_from_loader( + Some(&loader), + "fastly", + Some(manifest.canonicalize().expect("path")), + ); + assert!(context.adapter_manifest_path.is_none()); + let expected = path_error.expect("deferred deploy error"); + assert!(expected.contains("missing-fastly.toml")); + let error = context_from_manifest(&manifest, None) + .expect_err("secret helpers require adapter path"); + assert_eq!(error, expected); + } + + #[test] + fn context_uses_selected_manifest_paths_stores_and_adapter_defaults() { + let dir = tempfile::tempdir().expect("directory"); + let adapter = dir.path().join("selected-fastly.toml"); + fs::write(&adapter, "name = \"fictional\"").expect("adapter manifest"); + let manifest = dir.path().join("selected-edgezero.toml"); + fs::write( + &manifest, + r#" +[app] +name = "fictional" +[adapters.fastly.adapter] +crate = "fictional-fastly" +manifest = "selected-fastly.toml" +[stores.config] +ids = ["fictional_config"] +[stores.kv] +ids = ["fictional_kv"] +[stores.secrets] +ids = ["fictional_secrets"] +[[environment.variables]] +name = "SECRETS" +env = "EDGEZERO__STORES__SECRETS__FICTIONAL_SECRETS__NAME" +value = "physical-secrets" +adapters = ["fastly"] +"#, + ) + .expect("application manifest"); + let context = context_from_manifest(&manifest, Some("FictionalService")).expect("context"); + assert_eq!( + context.adapter_manifest_path, + Some(adapter.canonicalize().expect("path")) + ); + assert_eq!( + context.application_manifest_path, + Some(manifest.canonicalize().expect("path")) + ); + let loader = ManifestLoader::from_path(&manifest).expect("loaded manifest"); + let (mut deploy_context, path_error) = crate::deploy_context_from_loader( + Some(&loader), + "fastly", + Some(manifest.canonicalize().expect("path")), + ); + assert!(path_error.is_none()); + deploy_context.service_id = Some("FictionalService".to_owned()); + assert_eq!( + context, deploy_context, + "secret helpers use deployment-selected context" + ); + assert_eq!(context.service_id.as_deref(), Some("FictionalService")); + assert_eq!(context.stores.secrets, ["fictional_secrets"]); + assert_eq!(context.stores.config, ["fictional_config"]); + assert_eq!(context.stores.kv, ["fictional_kv"]); + assert_eq!( + context + .variable_defaults + .get("EDGEZERO__STORES__SECRETS__FICTIONAL_SECRETS__NAME") + .map(String::as_str), + Some("physical-secrets") + ); + } +} diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 37232db75..4f5cd55cd 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -29,6 +29,9 @@ mod config; mod demo_server; #[cfg(feature = "cli")] mod diff; +/// Shared Fastly resource resolution and secure secret writes. +#[cfg(all(feature = "cli", feature = "edgezero-adapter-fastly"))] +pub mod fastly_secrets; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] @@ -48,9 +51,11 @@ pub mod args; pub use auth::run_auth; #[cfg(feature = "cli")] pub use config::{ - DiffExit, run_config_diff_typed, run_config_gc, run_config_push, run_config_push_typed, - run_config_validate, run_config_validate_typed, + DiffExit, effective_manifest_environment, run_config_diff_typed, run_config_gc, + run_config_push, run_config_push_typed, run_config_validate, run_config_validate_typed, }; +#[cfg(all(feature = "cli", feature = "edgezero-adapter-spin"))] +pub use edgezero_adapter_spin::cli::resolve_spin_component; #[cfg(feature = "cli")] pub use provision::run_provision; @@ -187,39 +192,18 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - let manifest_stores = manifest.as_ref().map(|loader| &loader.manifest().stores); - let declared_ids = |declaration: Option<&StoreDeclaration>| { - declaration - .map(|store| store.ids.clone()) - .unwrap_or_default() - }; - let deploy_stores = DeployStoreIds { - config: declared_ids(manifest_stores.and_then(|declared| declared.config.as_ref())), - kv: declared_ids(manifest_stores.and_then(|declared| declared.kv.as_ref())), - secrets: declared_ids(manifest_stores.and_then(|declared| declared.secrets.as_ref())), - }; - let variable_defaults = manifest - .as_ref() - .map(|loader| manifest_variable_defaults(loader.manifest(), &args.adapter)) - .unwrap_or_default(); - let (adapter_manifest_path, adapter_manifest_path_error) = - match resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) { - Ok(path) => (path.map(PathBuf::from), None), - Err(err) => (None, Some(err)), - }; let application_manifest_path = loaded_application_manifest_path(manifest.as_ref())?; + let (base_context, adapter_manifest_path_error) = + deploy_context_from_loader(manifest.as_ref(), &args.adapter, application_manifest_path); let application_release_root = resolve_application_release_root( args.application_release.as_deref(), - application_manifest_path.as_deref(), + base_context.application_manifest_path.as_deref(), )?; let context = AdapterDeployContext { - adapter_manifest_path, - application_manifest_path, application_release_root, service_id: args.service_id.clone(), - stores: deploy_stores, staging: args.staging, - variable_defaults, + ..base_context }; adapter::deploy( @@ -231,6 +215,43 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { ) } +/// Shared deployment-selected paths and public store/default metadata. +/// Adapter path failures stay deferred so deploy ownership can decide whether +/// that path is needed. Secret helpers require the path and return the error. +#[cfg(feature = "cli")] +fn deploy_context_from_loader( + loader: Option<&ManifestLoader>, + adapter: &str, + application_manifest_path: Option, +) -> (AdapterDeployContext, Option) { + let manifest_stores = loader.map(|loaded| &loaded.manifest().stores); + let declared_ids = |declaration: Option<&StoreDeclaration>| { + declaration + .map(|store| store.ids.clone()) + .unwrap_or_default() + }; + let (adapter_manifest_path, path_error) = match resolve_adapter_manifest_path(loader, adapter) { + Ok(path) => (path.map(PathBuf::from), None), + Err(error) => (None, Some(error)), + }; + ( + AdapterDeployContext { + adapter_manifest_path, + application_manifest_path, + stores: DeployStoreIds { + config: declared_ids(manifest_stores.and_then(|stores| stores.config.as_ref())), + kv: declared_ids(manifest_stores.and_then(|stores| stores.kv.as_ref())), + secrets: declared_ids(manifest_stores.and_then(|stores| stores.secrets.as_ref())), + }, + variable_defaults: loader + .map(|loaded| manifest_variable_defaults(loaded.manifest(), adapter)) + .unwrap_or_default(), + ..Default::default() + }, + path_error, + ) +} + #[cfg(feature = "cli")] fn manifest_variable_defaults(manifest: &Manifest, adapter: &str) -> BTreeMap { manifest @@ -250,6 +271,11 @@ fn loaded_application_manifest_path( } let path = env::var("EDGEZERO_MANIFEST") .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); + checked_application_manifest_path(&path).map(Some) +} + +#[cfg(feature = "cli")] +fn checked_application_manifest_path(path: &Path) -> Result { let canonical = path.canonicalize().map_err(|error| { format!( "could not resolve loaded application manifest {}: {error}", @@ -262,7 +288,7 @@ fn loaded_application_manifest_path( canonical.display() )); } - Ok(Some(canonical)) + Ok(canonical) } #[cfg(feature = "cli")] diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 74a17cdd1..8a05e1f96 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -13,6 +13,7 @@ //! push` flow) — [`load_app_config_raw`] / //! [`load_app_config_raw_with_options`]. +use crate::env_config::EnvConfig; use std::any; use std::borrow::Cow; use std::cell::Cell; @@ -83,10 +84,87 @@ impl SecretField { } } +/// One active credential request compiled from effective application settings. +/// +/// This is public lookup metadata, not a resolved credential or authorization. +/// The owning field may be an object or an omitted defaulted field; it need not +/// be a scalar secret-reference leaf. Applications own activation and descriptor +/// coverage and must return all active requests together. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct ImplicitSecretLookup { + /// Fixed application-owned purpose identifier. + pub descriptor_id: String, + /// Concrete owning schema path, including array indexes where applicable. + pub field_path: String, + /// Derived physical key, never a credential value. + pub key: String, + /// Logical secret-store declaration ID. + pub store_id: String, +} + +impl ImplicitSecretLookup { + /// Construct public lookup metadata, never a resolved credential. + #[must_use] + #[inline] + pub fn new( + descriptor_id: Descriptor, + field_path: FieldPath, + store_id: Store, + key: Key, + ) -> Self + where + Descriptor: Into, + FieldPath: Into, + Store: Into, + Key: Into, + { + Self { + descriptor_id: descriptor_id.into(), + field_path: field_path.into(), + key: key.into(), + store_id: store_id.into(), + } + } +} + /// 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 { + /// Opt into compiled, active credential requests instead of raw secret leaves. + /// `None` preserves the existing annotation walk; `Some(vec![])` means no + /// credentials are active. Compile from effective defaults and reject invalid + /// or incomplete application metadata before returning a plan. `Some` replaces + /// all CLI annotation-leaf checks, including non-empty values, store declarations + /// and store references, even when `secret_fields()` is non-empty. The compiler + /// must cover every active credential; this does not change runtime leaf resolution. + /// + /// # Errors + /// Returns a credential-free application compilation diagnostic. + #[inline] + fn implicit_secret_lookups(&self) -> Result>, String> { + Ok(None) + } + + /// Render the exact native identity used by the application's runtime bridge. + /// Required when opting into implicit lookups. The CLI still applies adapter + /// native-name and collision checks. `environment` merges the selected + /// adapter's manifest defaults with process overrides using deployment rules. + /// Do not re-read process environment or return a generic raw-key fallback. + /// + /// # Errors + /// Returns a credential-free diagnostic for unsupported adapters or mappings. + #[inline] + fn render_implicit_secret_name( + &self, + _adapter: &str, + _lookup: &ImplicitSecretLookup, + _environment: &EnvConfig, + ) -> Result { + Err("implicit secret lookups require an application native renderer".to_owned()) + } + /// 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. @@ -753,6 +831,10 @@ fn walk_and_overlay( match arms in `expect_err` assertions intentionally collapse all unexpected \ variants into a single panic" )] +#[expect( + clippy::missing_trait_methods, + reason = "legacy app-config fixtures intentionally exercise the default-compatible implicit lookup hooks" +)] mod tests { use super::*; use crate::test_env::{EnvOverride, env_lock}; @@ -781,6 +863,15 @@ mod tests { } } + #[test] + fn implicit_lookup_constructor_preserves_public_metadata() { + let lookup = ImplicitSecretLookup::new("purpose", "owners[0]", "vault", "derived_key"); + assert_eq!(lookup.descriptor_id, "purpose"); + assert_eq!(lookup.field_path, "owners[0]"); + assert_eq!(lookup.store_id, "vault"); + assert_eq!(lookup.key, "derived_key"); + } + fn write_fixture(contents: &str) -> NamedTempFile { let mut file = NamedTempFile::new().expect("tempfile"); file.write_all(contents.as_bytes()).expect("write"); diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 7c131e7b5..78cc09634 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1253,6 +1253,10 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option } #[cfg(test)] +#[expect( + clippy::missing_trait_methods, + reason = "legacy app-config fixtures intentionally exercise the default-compatible implicit lookup hooks" +)] mod tests { use super::*; use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4aef7303f..0c9fb4cac 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -319,6 +319,104 @@ let value = ctx .await?; ``` +### Application-compiled secret lookups + +Applications that derive credential requests from effective settings, rather than +scalar secret-reference fields, can implement `AppConfigMeta` by hand and override: + +```rust +fn implicit_secret_lookups(&self) + -> Result>, String>; + +fn render_implicit_secret_name( + &self, + adapter: &str, + lookup: &ImplicitSecretLookup, + environment: &EnvConfig, +) -> Result; +``` + +The types are `edgezero_core::app_config::{AppConfigMeta, ImplicitSecretLookup}` +and `edgezero_core::env_config::EnvConfig`. Construct a request with public, +credential-free metadata: + +```rust +ImplicitSecretLookup::new( + "partner.pull", // application-owned purpose + "partners[0]", // concrete owning path + "default", // declared logical secret-store ID + "app_partner_pull", // derived key, never a credential value +) +``` + +The compiler must return every active credential request together, including +requests derived from omitted/defaulted fields. It owns activation and descriptor +coverage. Typed `config validate`, `push` and `diff` compile once per preflight +and apply the same metadata, declaration, native-name and collision checks. + +- `Ok(None)` retains the existing annotation-leaf walk. +- `Ok(Some(vec![]))` opts in with no active credentials. +- Any `Some` plan **replaces all legacy CLI leaf checks**, even if + `secret_fields()` returns metadata. Empty leaves, missing store declarations + for legacy leaves and invalid legacy store references are no longer checked. + The compiler must cover any active credential still represented by those fields. + This hook does not change runtime annotation-leaf resolution. + +The renderer returns the exact name used by the application's runtime bridge, +not a generic EdgeZero mapping or a raw-key fallback. Its environment merges the +selected adapter's manifest defaults with parent-process overrides. An active +plan without a renderer fails preflight. Repeated requests with the same +purpose/store/key may have different owning paths but must render identically. +Different purposes may share store/key metadata when they render to distinct +native identities; they cannot claim the same physical credential. Fastly names +are scoped per selected physical store, including alias detection. Other built-in +adapters use flat namespaces. + +Axum exact native environment names must start with an uppercase ASCII letter or +`_`, followed by uppercase ASCII letters, digits or `_`. Spin and Fastly apply +their native variable/key rules. Cloudflare bindings are string-named properties, +not necessarily JavaScript identifiers; no guessed identifier restriction is +applied. Provisioning tools may have additional requirements that need separate +provider verification. + +The derive currently emits the entire `AppConfigMeta` implementation and cannot +forward these hooks. A handwritten implementation must maintain its own metadata +and cannot rely on the derive's compile-time annotation checks. Plans are public +metadata, not credentials or authorization. Applications remain responsible for +runtime lookup scoping and destination restrictions. + +### Shared Fastly secret operations + +Host-side application CLIs can use `edgezero_cli::fastly_secrets`: + +- `context_from_manifest(path, service_id)` uses the same manifest-selected paths, + logical stores and variable defaults as deploy. Supply the effective application + manifest path, including any `EDGEZERO_MANIFEST` selection. +- `resolve_resources(&context)` returns the selected service and comparable + logical/physical resource metadata. **Every declared Config, KV and Secret + Store must already exist**, even when the intended operation writes only one + secret. Resolution is read-only and does not provision or link resources. +- `secret_exists(resource_id, key)` checks a complete paginated metadata listing, + never the credential value. An operational error is distinct from absence. + Repeated key checks currently repeat the listing. +- `write_secret(resource_id, key, bytes, SecretWriteAction::Create)` uses POST; + `Replace` uses PATCH. The provider enforces the selected action without a + delete/recreate gap. Injectable `_with_transport` variants support fictional tests. + +Credentials travel to curl through stdin, not argv. Errors redact provider bodies, +transport text and stderr. Manifest-load errors include the path and error kind, +not parser source. Explicit HTTP rejections are distinguished from uncertain +outcomes; unclassified responses and transport failures remain uncertain. Writes +are never retried automatically. The local `FASTLY_SECRET_MAX_BYTES` is 65,536 raw +bytes before Base64; [Fastly documents the limit as "64KB"](https://www.fastly.com/documentation/reference/api/services/resources/secret-store-secret/) +without disambiguating 64,000 versus 65,536 bytes. Exact provider boundary +acceptance remains unverified. + +Successful storage does not deploy, link, activate or refresh retained runtime +state, and does not guarantee immediate fleet-wide rotation. These host helpers +do not prove provider IAM separation or the consuming application's runtime +secret-scoping guarantees. + ### Nested and array secrets `#[secret]` fields don't have to live at the config root. They can sit diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 3af3f34f7..525b0d579 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -823,6 +823,7 @@ dependencies = [ "async-stream", "async-trait", "base16ct", + "base64", "brotli", "bytes", "chrono",