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..226ffdeb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ base16ct = { version = "1", features = ["alloc"] } # wasm in the demo Spin build). We only use `Decompressor` and # `CompressorWriter`, unchanged across the two majors. brotli = "8" +base64 = "0.22" bytes = "1" chrono = "0.4" ctor = "1.0" diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index cc7b910fb..bd45ca95b 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -362,8 +362,22 @@ 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.is_empty() + || !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!( diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index f080586fe..fa9a3be1b 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", @@ -22,6 +23,7 @@ cli = [ fastly = ["dep:fastly", "dep:log-fastly"] [dependencies] +base64 = { workspace = true, optional = true } anyhow = { workspace = true } edgezero-core = { path = "../edgezero-core" } edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 01efe05cc..92e6c9e52 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -3,6 +3,10 @@ reason = "the managed deployment planning and execution state machine is kept as one cohesive block" )] +/// Narrow host operations for reviewed secret provisioning. +#[path = "secret_operations.rs"] +pub mod secrets; + use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::env; @@ -30,7 +34,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::{ @@ -2013,8 +2018,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 +2028,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 +2731,20 @@ impl Adapter for FastlyCliAdapter { // unlike spin's flat-namespace single-store model. &[] } + + 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 { @@ -6797,6 +6816,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] diff --git a/crates/edgezero-adapter-fastly/src/secret_operations.rs b/crates/edgezero-adapter-fastly/src/secret_operations.rs new file mode 100644 index 000000000..da75319e3 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/secret_operations.rs @@ -0,0 +1,665 @@ +//! Explicit host-side secret operations. Writes do not deploy, activate, create +//! stores or links, or imply that retained runtime state has refreshed. + +use super::{ + ResourceKind, collect_paginated_store_inventory, curl_config_capture, curl_quote, + 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 _; + +/// Maximum raw secret bytes documented by Fastly, before Base64 encoding. +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; mutation outcome may be unknown".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. +/// +/// # 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 stores = Vec::new(); + 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() + ) + })?; + for logical_id in ids { + let physical_name = environment + .store_name_checked(kind.selector_kind(), logical_id) + .map_err(|_error| "invalid Fastly store selector".to_owned())?; + let resource_id = inventory + .by_name + .get(&physical_name) + .ok_or_else(|| format!("selected Fastly {} does not exist", kind.display_name()))? + .clone(); + validate_resource_id(&resource_id)?; + stores.push(FastlyStoreResource { + kind: kind.selector_kind().to_owned(), + logical_id: logical_id.clone(), + physical_name, + resource_id, + }); + } + } + stores.sort_by(|left, right| { + (&left.kind, &left.logical_id).cmp(&(&right.kind, &right.logical_id)) + }); + for pair in stores.windows(2) { + if let [left, right] = pair + && left.kind == right.kind + && left.logical_id == right.logical_id + { + return Err("duplicate logical Fastly store declaration".to_owned()); + } + } + 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| { + "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))?; + if !(200..300).contains(&response.status) { + return Err(format!( + "Fastly secret write not confirmed; outcome may be unknown (HTTP {})", + response.status + )); + } + Ok(()) +} +#[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); + } + } + + #[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..b14180913 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -536,7 +536,10 @@ 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!( @@ -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..c2d57cd20 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. diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 438db18bb..cec0ad4dc 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}; @@ -37,7 +37,7 @@ use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDe use serde::Serialize; use serde::de::DeserializeOwned; use similar::TextDiff; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::io::{Error as IoError, IsTerminal as _, Write, stdin}; use std::iter; @@ -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,107 @@ 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 = BTreeSet::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 + )); + } + // Fastly stores have independent namespaces; the other built-in secret + // bridges flatten their names. Never merge different requests merely + // because their rendered names happen to match. + let namespace = if adapter.name() == "fastly" { + environment + .store_name_checked("secrets", &lookup.store_id) + .map_err(|_error| "invalid implicit Fastly secret store selector".to_owned())? + } 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) = entries.iter().find(|entry: &&TypedSecretEntry<'_>| { + entry.store_id == lookup.store_id && entry.key_value == lookup.key + }) && previous.native_name.as_ref() != Some(&name) + { + return Err("identical secret request has inconsistent native rendering".to_owned()); + } + if !seen_requests.insert(identity) { + 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 +2209,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 +2220,7 @@ mod tests { use edgezero_core::test_env::PathPrepend; use serde::{Deserialize, Serialize}; use std::borrow::Cow; + use std::cell::Cell; use std::fs; use tempfile::TempDir; @@ -3198,7 +3325,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 +5447,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 +5527,255 @@ 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 { + descriptor_id: if self.bad == "metadata" { + String::new() + } else { + "fictional.pull".to_owned() + }, + field_path: format!("subjects[{index}]"), + store_id: if self.bad == "store" { + "undeclared".to_owned() + } else { + "default".to_owned() + }, + key: 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()); + } + 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"); + } + + #[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 { + descriptor_id: format!("fictional.{store}"), + field_path: store.to_owned(), + store_id: store.to_owned(), + key: "ts1_identical".to_owned(), + }); + 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 in ["metadata", "store", "collision"] { + let config = format!("bad = {bad:?}\nsubjects = [\"first\", \"second\"]\n"); + let (dir, manifest, _) = setup_project(PUSH_MANIFEST, &config); + assert!(run_config_validate_typed::(&args_for(&manifest)).is_err()); + assert!( + run_config_push_typed::(&push_args(&manifest, "axum")).is_err() + ); + run_config_diff_typed::(&implicit_diff_args(&manifest)) + .expect_err("invalid implicit plan rejected before remote reads"); + 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..086fad150 --- /dev/null +++ b/crates/edgezero-cli/src/fastly_secrets.rs @@ -0,0 +1,110 @@ +//! Host helpers shared with application-specific secret commands. + +pub use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds}; +pub use edgezero_adapter_fastly::cli::secrets::*; + +use edgezero_core::manifest::ManifestLoader; +use std::path::{Path, PathBuf}; + +/// 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| { + "failed to load application manifest for Fastly secret operations".to_owned() + })?; + crate::ensure_adapter_defined("fastly", Some(&loader))?; + let stores = &loader.manifest().stores; + Ok(AdapterDeployContext { + adapter_manifest_path: crate::resolve_adapter_manifest_path(Some(&loader), "fastly")? + .map(PathBuf::from), + application_manifest_path: Some( + path.canonicalize() + .map_err(|_error| "failed to resolve application manifest path".to_owned())?, + ), + service_id: service_id.map(str::to_owned), + stores: DeployStoreIds { + config: stores + .config + .as_ref() + .map(|decl| decl.ids.clone()) + .unwrap_or_default(), + kv: stores + .kv + .as_ref() + .map(|decl| decl.ids.clone()) + .unwrap_or_default(), + secrets: stores + .secrets + .as_ref() + .map(|decl| decl.ids.clone()) + .unwrap_or_default(), + }, + variable_defaults: crate::manifest_variable_defaults(loader.manifest(), "fastly"), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[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")) + ); + 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..9b6eb7143 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; diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 74a17cdd1..04c9b2b1b 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,13 +84,61 @@ 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)] +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, +} + /// 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. + /// + /// # 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. + /// each carrying its full path from this struct\'s root. fn secret_fields() -> Vec; } @@ -753,6 +802,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}; 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/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",