diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0d5b4fd..01c5e084 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -126,6 +126,16 @@ jobs: cargo test --offline --locked -p edgezero-store-aws --all-features --lib scripts/run_test_nonzero.sh production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,test-utils --test native_aws_startup + - name: Native celld independent provider and startup contracts (loopback only) + run: | + cargo test --offline --locked -p edgezero-store-celld --no-default-features --lib + cargo test --offline --locked -p edgezero-store-celld --no-default-features --features config --lib + cargo test --offline --locked -p edgezero-store-celld --no-default-features --features kv --lib + cargo test --offline --locked -p edgezero-store-celld --all-features --lib + cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features native-bindings --lib + cargo test --offline --locked -p edgezero-adapter-axum --features celld-config,celld-kv,test-utils --test native_celld_startup + cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,aws-dynamodb,celld-config,celld-kv,test-utils --lib --test native_aws_startup --test native_celld_startup + - name: Bundled demo logging contract run: scripts/run_test_nonzero.sh demo_respects_off_without_hiding_terminal_errors cargo test --offline --locked -p edgezero-cli --features demo-example --test demo_logging @@ -169,8 +179,13 @@ jobs: EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target run: scripts/run_test_nonzero.sh --ignored generated_cli_default_off_and_enabled_dynamodb_crud_list_ttl_restart cargo test --offline --locked -p edgezero-cli --test dynamodb_generated_proof - - name: Native AWS integration runner tests - run: node --test scripts/native_aws_test_admin.test.mjs scripts/run_native_aws_integration.test.mjs + - name: Generated native celld startup and KV (deterministic loopback fixture) + env: + EDGEZERO_GENERATED_TEST_TARGET_DIR: ${{ github.workspace }}/target + run: scripts/run_test_nonzero.sh --ignored generated_celld_default_off_kv_and_config_restart cargo test --offline --locked -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum --test celld_generated_proof generated_celld_default_off_kv_and_config_restart + + - name: Native provider integration runner tests + run: node --test scripts/native_aws_test_admin.test.mjs scripts/run_native_aws_integration.test.mjs scripts/run_native_celld_integration.test.mjs - name: Generated native AWS independent service contracts env: diff --git a/Cargo.lock b/Cargo.lock index bd55a416..42422563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,6 +1182,7 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "edgezero-store-aws", + "edgezero-store-celld", "flate2", "futures", "futures-util", @@ -1310,12 +1311,14 @@ dependencies = [ "proc-macro2", "serde", "serde_json", + "sha2 0.10.9", "similar", "simple_logger", "syn 3.0.3", "tempfile", "thiserror 2.0.20", "toml", + "url", "validator", "walkdir", ] @@ -1406,6 +1409,25 @@ dependencies = [ "url", ] +[[package]] +name = "edgezero-store-celld" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "bytes", + "edgezero-core", + "futures-util", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-rustls", + "url", +] + [[package]] name = "either" version = "1.16.0" diff --git a/Cargo.toml b/Cargo.toml index fbb37abd..f6ae307c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/edgezero-core", "crates/edgezero-macros", "crates/edgezero-store-aws", + "crates/edgezero-store-celld", ] exclude = ["examples/app-demo"] resolver = "2" @@ -60,6 +61,7 @@ edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-fea edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } edgezero-store-aws = { path = "crates/edgezero-store-aws", default-features = false } +edgezero-store-celld = { path = "crates/edgezero-store-celld", default-features = false } edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "=0.13.1" fastly-shared = "=0.13.1" @@ -109,6 +111,7 @@ tempfile = "3" toml_edit = "0.25" thiserror = "2" tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-rustls = { version = "=0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] } trybuild = "1" toml = { version = "1.1" } tower = { version = "0.5", features = ["util"] } diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 4255c4a2..38bb1a44 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -25,11 +25,13 @@ axum = [ "dep:reqwest", "dep:redb", ] -native-bindings = ["dep:toml", "dep:edgezero-store-aws"] +native-bindings = ["dep:toml", "dep:edgezero-store-aws", "dep:edgezero-store-celld"] aws-appconfig-agent = ["axum", "edgezero-store-aws/appconfig-agent"] aws-secrets-manager = ["axum", "edgezero-store-aws/secrets-manager"] aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] aws-dynamodb = ["axum", "edgezero-store-aws/dynamodb-kv"] +celld-config = ["axum", "edgezero-store-celld/config"] +celld-kv = ["axum", "edgezero-store-celld/kv"] cli = [ "native-bindings", "dep:edgezero-adapter", @@ -72,6 +74,7 @@ walkdir = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] edgezero-store-aws = { workspace = true, optional = true } +edgezero-store-celld = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } @@ -93,6 +96,17 @@ name = "native_aws_startup" path = "tests/native_aws_startup.rs" required-features = ["aws-stores", "test-utils"] +[[bin]] +name = "edgezero-native-celld-host" +path = "tests/fixtures/native_celld_host.rs" +required-features = ["celld-config", "celld-kv", "test-utils"] +test = false + +[[test]] +name = "native_celld_startup" +path = "tests/native_celld_startup.rs" +required-features = ["celld-config", "celld-kv", "test-utils"] + [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index c022aa22..30629abf 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -987,12 +987,10 @@ async fn build_production_stores( let mut kv = BTreeMap::new(); let mut secrets = BTreeMap::new(); let mut resident = 0_usize; - let mut aws = sources.session()?; + let mut sessions = sources.sessions()?; for (id, source) in sources.config { let binding = match source { - NativeStoreSource::BuiltIn(builtin) => { - native_stores::prepare_aws_config(builtin, &mut aws, id).await? - } + NativeStoreSource::BuiltIn(builtin) => sessions.prepare_config(builtin, id).await?, local @ (NativeStoreSource::Local | NativeStoreSource::Ready(_) | NativeStoreSource::Prepare(_)) => { @@ -1053,9 +1051,7 @@ async fn build_production_stores( } for (id, source) in sources.kv { let handle = match source { - NativeStoreSource::BuiltIn(builtin) => { - native_stores::prepare_aws_kv(builtin, &mut aws, id).await? - } + NativeStoreSource::BuiltIn(builtin) => sessions.prepare_kv(builtin, id).await?, local @ (NativeStoreSource::Local | NativeStoreSource::Ready(_) | NativeStoreSource::Prepare(_)) => { @@ -1081,9 +1077,7 @@ async fn build_production_stores( let environment_secrets = SecretHandle::new(Arc::new(EnvSecretStore::new())); for (id, source) in sources.secrets { let binding = match source { - NativeStoreSource::BuiltIn(builtin) => { - native_stores::prepare_aws_secrets(builtin, &mut aws, id).await? - } + NativeStoreSource::BuiltIn(builtin) => sessions.prepare_secrets(builtin, id).await?, local @ (NativeStoreSource::Local | NativeStoreSource::Ready(_) | NativeStoreSource::Prepare(_)) => { diff --git a/crates/edgezero-adapter-axum/src/native_bindings.rs b/crates/edgezero-adapter-axum/src/native_bindings.rs index 3873a41f..3a4849de 100644 --- a/crates/edgezero-adapter-axum/src/native_bindings.rs +++ b/crates/edgezero-adapter-axum/src/native_bindings.rs @@ -22,6 +22,9 @@ use edgezero_core::secret_store::MAX_NAME_LEN; use edgezero_store_aws::settings::{ AppConfigAgentSettings, AwsPreparationLimits, DynamoDbKvSettings, SecretsManagerSettings, }; +use edgezero_store_celld::settings::{ + CelldConfigSettings, CelldKvSettings, CelldPreparationLimits, +}; use serde::Deserialize; /// Closed bootstrap failures. File, TOML and provider inputs never become source chains. @@ -84,18 +87,26 @@ impl NativeBindingsLimits { /// Providers present in a resolved target, independent of the running CLI's features. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[expect( + clippy::struct_excessive_bools, + reason = "each provider feature is independent, not a state-machine flag" +)] pub struct NativeProviderCapabilities { pub appconfig_agent: bool, pub secrets_manager: bool, pub dynamodb: bool, + pub celld_config: bool, + pub celld_kv: bool, } -/// Strict configuration dispatch. Provider-specific fields are validated by AWS settings. +/// Strict configuration dispatch. Each provider validates its own settings. #[derive(Clone, Debug, Deserialize)] #[serde(tag = "provider", deny_unknown_fields)] pub enum NativeConfigBinding { #[serde(rename = "aws-appconfig-agent")] AppConfigAgent(AppConfigAgentSettings), + #[serde(rename = "celld-config")] + Celld(CelldConfigSettings), } /// Strict KV dispatch. The physical namespace is independent of the declared ID. @@ -104,6 +115,8 @@ pub enum NativeConfigBinding { pub enum NativeKvBinding { #[serde(rename = "aws-dynamodb")] DynamoDb(DynamoDbKvSettings), + #[serde(rename = "celld-kv")] + Celld(CelldKvSettings), } /// Strict secret dispatch. No arbitrary remote names or per-request discovery. @@ -114,10 +127,25 @@ pub enum NativeSecretBinding { SecretsManager(SecretsManagerSettings), } -#[derive(Clone, Debug, Deserialize)] +/// Independent explicit preparation policy sources. Same-provider sources cannot compete. +#[derive(Clone, Copy, Debug, Default)] +pub struct NativePreparationPolicySources { + pub aws: Option, + pub celld: Option, +} + +/// Resolved provider policies, including defaults for providers not selected. +#[derive(Clone, Copy, Debug)] +pub struct NativePreparationPolicies { + pub aws: AwsPreparationLimits, + pub celld: CelldPreparationLimits, +} + +#[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct LimitsTable { - aws: AwsPreparationLimits, + aws: Option, + celld: Option, } /// Version 1 native document. Logical IDs and lookup keys remain case-sensitive. @@ -177,6 +205,23 @@ impl NativeStoreBindings { input: &str, limits: NativeBindingsLimits, aws: Option, + ) -> Result { + Self::parse_with_policies( + input, + limits, + NativePreparationPolicySources { aws, celld: None }, + ) + } + + /// Parses under independent explicit provider policies, before applying mapping caps. + /// + /// # Errors + /// Rejects malformed, conflicting or over-limit inputs without source excerpts. + #[inline] + pub fn parse_with_policies( + input: &str, + limits: NativeBindingsLimits, + policies: NativePreparationPolicySources, ) -> Result { limits.validate()?; if input.len() > limits.max_file_bytes { @@ -184,7 +229,7 @@ impl NativeStoreBindings { } let bindings: Self = toml::from_str(input).map_err(|_error| NativeBindingsError::Malformed)?; - bindings.validate(limits, aws)?; + bindings.validate_with_policies(limits, policies)?; Ok(bindings) } @@ -206,6 +251,23 @@ impl NativeStoreBindings { path: &Path, limits: NativeBindingsLimits, aws: Option, + ) -> Result { + Self::read_with_policies( + path, + limits, + NativePreparationPolicySources { aws, celld: None }, + ) + } + + /// Captures one capped file using independent explicit provider policies. + /// + /// # Errors + /// Rejects unusable files, conflicting policies and invalid documents. + #[inline] + pub fn read_with_policies( + path: &Path, + limits: NativeBindingsLimits, + policies: NativePreparationPolicySources, ) -> Result { limits.validate()?; if !path.is_absolute() { @@ -239,7 +301,7 @@ impl NativeStoreBindings { return Err(NativeBindingsError::SizeLimit); } let input = String::from_utf8(bytes).map_err(|_error| NativeBindingsError::Malformed)?; - Self::parse_with_policy(&input, limits, aws) + Self::parse_with_policies(&input, limits, policies) } /// Checks the whole settings document before constructing providers. @@ -252,6 +314,26 @@ impl NativeStoreBindings { limits: NativeBindingsLimits, explicit_aws: Option, ) -> Result { + self.validate_with_policies( + limits, + NativePreparationPolicySources { + aws: explicit_aws, + celld: None, + }, + ) + .map(|policies| policies.aws) + } + + /// Validates all settings and resolves each provider policy independently. + /// + /// # Errors + /// Rejects unsupported schema, invalid settings, limits and same-provider conflicts. + #[inline] + pub fn validate_with_policies( + &self, + limits: NativeBindingsLimits, + explicit: NativePreparationPolicySources, + ) -> Result { limits.validate()?; if self.version != 1 { return Err(NativeBindingsError::Invalid); @@ -266,51 +348,74 @@ impl NativeStoreBindings { { return Err(NativeBindingsError::SizeLimit); } - if self.limits.is_some() && explicit_aws.is_some() { + let file_aws = self.limits.as_ref().and_then(|table| table.aws); + let file_celld = self.limits.as_ref().and_then(|table| table.celld); + if (file_aws.is_some() && explicit.aws.is_some()) + || (file_celld.is_some() && explicit.celld.is_some()) + { return Err(NativeBindingsError::Duplicate); } - let policy = explicit_aws - .or_else(|| self.limits.as_ref().map(|table| table.aws)) - .unwrap_or_default(); - policy + let policies = NativePreparationPolicies { + aws: explicit.aws.or(file_aws).unwrap_or_default(), + celld: explicit.celld.or(file_celld).unwrap_or_default(), + }; + policies + .aws + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + policies + .celld .validate() .map_err(|_category| NativeBindingsError::Invalid)?; - let mut mappings = - u64::try_from(self.kv.len()).map_err(|_error| NativeBindingsError::SizeLimit)?; + let mut aws_mappings = 0_u64; + let mut celld_mappings = 0_u64; for binding in self.kv.values() { - let NativeKvBinding::DynamoDb(settings) = binding; - settings - .validate() - .map_err(|_category| NativeBindingsError::Invalid)?; + let counter = match binding { + NativeKvBinding::DynamoDb(settings) => { + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + &mut aws_mappings + } + NativeKvBinding::Celld(settings) => { + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + &mut celld_mappings + } + }; + charge_mappings(counter, 1)?; } for binding in self.config.values() { - let NativeConfigBinding::AppConfigAgent(settings) = binding; - settings - .validate() - .map_err(|_category| NativeBindingsError::Invalid)?; - mappings = mappings - .checked_add( - u64::try_from(settings.documents.len()) - .map_err(|_error| NativeBindingsError::SizeLimit)?, - ) - .ok_or(NativeBindingsError::SizeLimit)?; + let (counter, count) = match binding { + NativeConfigBinding::AppConfigAgent(settings) => { + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + (&mut aws_mappings, settings.documents.len()) + } + NativeConfigBinding::Celld(settings) => { + settings + .validate() + .map_err(|_category| NativeBindingsError::Invalid)?; + (&mut celld_mappings, settings.documents.len()) + } + }; + charge_mappings(counter, count)?; } for binding in self.secrets.values() { let NativeSecretBinding::SecretsManager(settings) = binding; settings .validate() .map_err(|_category| NativeBindingsError::Invalid)?; - mappings = mappings - .checked_add( - u64::try_from(settings.entries.len()) - .map_err(|_error| NativeBindingsError::SizeLimit)?, - ) - .ok_or(NativeBindingsError::SizeLimit)?; + charge_mappings(&mut aws_mappings, settings.entries.len())?; } - if mappings > policy.max_remote_values { + if aws_mappings > policies.aws.max_remote_values + || celld_mappings > policies.celld.max_remote_values + { return Err(NativeBindingsError::SizeLimit); } - Ok(policy) + Ok(policies) } /// Explicit configuration mappings. @@ -346,11 +451,26 @@ impl NativeStoreBindings { #[inline] #[must_use] pub fn with_aws_limits(mut self, limits: AwsPreparationLimits) -> Self { - self.limits = Some(LimitsTable { aws: limits }); + self.limits.get_or_insert_with(LimitsTable::default).aws = Some(limits); + self + } + + /// Selects a celld document policy without discarding an AWS policy. + #[inline] + #[must_use] + pub fn with_celld_limits(mut self, limits: CelldPreparationLimits) -> Self { + self.limits.get_or_insert_with(LimitsTable::default).celld = Some(limits); self } } +fn charge_mappings(counter: &mut u64, count: usize) -> Result<(), NativeBindingsError> { + *counter = counter + .checked_add(u64::try_from(count).map_err(|_error| NativeBindingsError::SizeLimit)?) + .ok_or(NativeBindingsError::SizeLimit)?; + Ok(()) +} + /// Cloneable bootstrap selection. File contents are captured once at selection. #[derive(Clone, Default)] pub enum NativeStoreBindingsInput { @@ -504,7 +624,32 @@ impl<'declarations> NativeSourceSelection<'declarations> { limits: NativeBindingsLimits, aws: Option, ) -> Result { - bindings.validate(limits, aws)?; + Self::validate_with_policies( + metadata, + bindings, + supplied, + env, + capabilities, + limits, + NativePreparationPolicySources { aws, celld: None }, + ) + } + + /// Validates all sources with independent provider policies and actual-variant capabilities. + /// + /// # Errors + /// Rejects invalid declarations, settings, conflicts and unavailable selected providers. + #[inline] + pub fn validate_with_policies>>( + metadata: Declarations, + bindings: &NativeStoreBindings, + supplied: &NativeSuppliedIds, + env: &EnvConfig, + capabilities: NativeProviderCapabilities, + limits: NativeBindingsLimits, + policies: NativePreparationPolicySources, + ) -> Result { + bindings.validate_with_policies(limits, policies)?; let declarations = metadata.into(); let selection = Self { config: resolve( @@ -523,9 +668,14 @@ impl<'declarations> NativeSourceSelection<'declarations> { "secrets", )?, }; - if (!bindings.config.is_empty() && !capabilities.appconfig_agent) - || (!bindings.secrets.is_empty() && !capabilities.secrets_manager) - || (!bindings.kv.is_empty() && !capabilities.dynamodb) + if bindings.config.values().any(|binding| match binding { + NativeConfigBinding::AppConfigAgent(_) => !capabilities.appconfig_agent, + NativeConfigBinding::Celld(_) => !capabilities.celld_config, + }) || (!bindings.secrets.is_empty() && !capabilities.secrets_manager) + || bindings.kv.values().any(|binding| match binding { + NativeKvBinding::DynamoDb(_) => !capabilities.dynamodb, + NativeKvBinding::Celld(_) => !capabilities.celld_kv, + }) { return Err(NativeBindingsError::UnsupportedProvider); } @@ -633,14 +783,234 @@ mod tests { use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::ManifestStores; use edgezero_store_aws::settings::AwsPreparationLimits; + use edgezero_store_celld::settings::CelldPreparationLimits; use super::{ - NativeBindingSource, NativeBindingsError, NativeBindingsLimits, NativeProviderCapabilities, - NativeSourceSelection, NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, + NativeBindingSource, NativeBindingsError, NativeBindingsLimits, + NativePreparationPolicySources, NativeProviderCapabilities, NativeSourceSelection, + NativeStoreBindings, NativeStoreDeclarations, NativeSuppliedIds, }; const CONFIG: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\n[config.app.documents.settings]\napplication = 'example-app'\nenvironment = 'production'\nprofile = 'envelope'\n"; + const CELLD: &str = "version=1\n[config.app]\nprovider='celld-config'\nendpoint='http://127.0.0.1:8788'\ndefault_key='settings'\n[config.app.access]\nmode='trusted-network'\n[config.app.documents.settings]\nstore='config-fixture'\nkey='settings'\n[kv.state]\nprovider='celld-kv'\nendpoint='http://127.0.0.1:8788'\nstore='state-fixture'\n[kv.state.access]\nmode='trusted-network'\n"; + + #[test] + fn celld_schema_is_structural_strict_and_settings_only() -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + for input in [ + CELLD.to_owned(), + CELLD + .replace("http://127.0.0.1:8788", "https://unreachable.invalid") + .replace( + "mode='trusted-network'", + "mode='authenticated'\ntoken_env='ABSENT_TOKEN'", + ), + ] { + let document = NativeStoreBindings::parse(&input, limits)?; + assert_eq!(document.config().len(), 1); + assert_eq!(document.kv().len(), 1); + assert!(!format!("{document:?}").contains("fixture")); + } + for bad in [ + CELLD.replace("celld-config", "celld-unknown"), + CELLD.replace("[config.app]", "[secrets.app]"), + CELLD.replace("default_key='settings'", "default_key='missing'"), + CELLD.replace("store='state-fixture'", "store='bad/store'"), + CELLD.replace("mode='trusted-network'", "mode='authenticated'"), + CELLD.replace( + "mode='trusted-network'", + "mode='trusted-network'\ntoken_env='SENTINEL'", + ), + CELLD.replace("http://127.0.0.1:8788", "http://private.invalid"), + format!("{CELLD}\n[limits.celld]\noperation_timeout_ms=0"), + format!("{CELLD}\n[limits.celld]\nunknown=1"), + ] { + let error = NativeStoreBindings::parse(&bad, limits) + .err() + .ok_or(NativeBindingsError::Invalid)?; + assert!(error.source().is_none()); + assert!(!format!("{error:?}").contains("SENTINEL")); + } + Ok(()) + } + + #[test] + fn capabilities_follow_actual_variants() -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let aws_kv = "\n[kv.state]\nprovider='aws-dynamodb'\nregion='us-east-1'\ntable='fixture-table'\nnamespace='fixture-space'\n"; + let celld_kv = CELLD + .split("[kv.state]") + .nth(1) + .ok_or(NativeBindingsError::Invalid)?; + let celld_config = CELLD + .split("[kv.state]") + .next() + .ok_or(NativeBindingsError::Invalid)?; + for (input, aws_config, aws_state, celld_app, celld_state) in [ + (CELLD.to_owned(), false, false, true, true), + (format!("{CONFIG}{aws_kv}"), true, true, false, false), + ( + format!("{CONFIG}[kv.state]{celld_kv}"), + true, + false, + false, + true, + ), + (format!("{celld_config}{aws_kv}"), false, true, true, false), + ] { + let document = NativeStoreBindings::parse(&input, limits)?; + for mask in 0_u8..16 { + let caps = NativeProviderCapabilities { + appconfig_agent: mask & 1 != 0, + dynamodb: mask & 2 != 0, + celld_config: mask & 4 != 0, + celld_kv: mask & 8 != 0, + secrets_manager: false, + }; + let accepted = (!aws_config || caps.appconfig_agent) + && (!aws_state || caps.dynamodb) + && (!celld_app || caps.celld_config) + && (!celld_state || caps.celld_kv); + assert_eq!( + NativeSourceSelection::validate( + metadata(), + &document, + &NativeSuppliedIds::default(), + &EnvConfig::default(), + caps, + limits, + None + ) + .is_ok(), + accepted + ); + } + } + Ok(()) + } + + #[test] + fn provider_policy_sources_and_mapping_counters_are_independent() + -> Result<(), NativeBindingsError> { + let limits = NativeBindingsLimits::default(); + let aws = AwsPreparationLimits { + max_remote_values: 1, + ..Default::default() + }; + let celld = CelldPreparationLimits { + max_remote_values: 2, + ..Default::default() + }; + let policies = NativePreparationPolicySources { + aws: Some(aws), + celld: Some(celld), + }; + let mixed = format!( + "{CONFIG}{}", + CELLD + .replace("version=1\n", "") + .replace("config.app", "config.remote") + ); + NativeStoreBindings::parse_with_policies(&mixed, limits, policies)?; + let too_many = format!( + "{mixed}\n[config.remote.documents.alias]\nstore='config-fixture'\nkey='settings'\n" + ); + assert_eq!( + NativeStoreBindings::parse_with_policies(&too_many, limits, policies).err(), + Some(NativeBindingsError::SizeLimit) + ); + let reverse = NativePreparationPolicySources { + aws: Some(AwsPreparationLimits { + max_remote_values: 2, + ..Default::default() + }), + celld: Some(CelldPreparationLimits { + max_remote_values: 1, + ..Default::default() + }), + }; + assert_eq!( + NativeStoreBindings::parse_with_policies(&mixed, limits, reverse).err(), + Some(NativeBindingsError::SizeLimit) + ); + for (table, explicit) in [ + ( + "[limits.aws]\nmax_remote_values=1", + NativePreparationPolicySources { + aws: None, + celld: Some(celld), + }, + ), + ( + "[limits.celld]\nmax_remote_values=2", + NativePreparationPolicySources { + aws: Some(aws), + celld: None, + }, + ), + ( + "[limits.aws]\nmax_remote_values=1\n[limits.celld]\nmax_remote_values=2", + NativePreparationPolicySources::default(), + ), + ] { + let document = NativeStoreBindings::parse_with_policies( + &format!("{mixed}\n{table}"), + limits, + explicit, + )?; + let resolved = document.validate_with_policies(limits, explicit)?; + assert_eq!(resolved.aws.max_remote_values, 1); + assert_eq!(resolved.celld.max_remote_values, 2); + assert!(matches!( + document.validate_with_policies(limits, policies), + Err(NativeBindingsError::Duplicate) + )); + } + let document = NativeStoreBindings::parse(&mixed, limits)?; + for typed in [ + document + .clone() + .with_aws_limits(aws) + .with_celld_limits(celld), + document.with_celld_limits(celld).with_aws_limits(aws), + ] { + let resolved = + typed.validate_with_policies(limits, NativePreparationPolicySources::default())?; + assert_eq!(resolved.aws.max_remote_values, 1); + assert_eq!(resolved.celld.max_remote_values, 2); + } + Ok(()) + } + + #[test] + fn explicit_celld_policy_raises_caps_during_file_parse() -> Result<(), Box> { + let mut input = CELLD.to_owned(); + for index in 0..1_024_u32 { + writeln!( + input, + "\n[config.app.documents.alias{index}]\nstore='config-fixture'\nkey='settings'" + )?; + } + let limits = NativeBindingsLimits::default(); + assert_eq!( + NativeStoreBindings::parse(&input, limits).err(), + Some(NativeBindingsError::SizeLimit) + ); + let explicit = NativePreparationPolicySources { + celld: Some(CelldPreparationLimits { + max_remote_values: 2_048, + ..Default::default() + }), + aws: None, + }; + let root = tempfile::tempdir()?; + let path = root.path().join("bindings.toml"); + fs::write(&path, input)?; + NativeStoreBindings::read_with_policies(&path, limits, explicit)?; + Ok(()) + } + fn metadata() -> StoresMetadata { StoresMetadata { config: Some(StoreMetadata { @@ -725,6 +1095,7 @@ mod tests { appconfig_agent: true, secrets_manager: false, dynamodb: false, + ..Default::default() }, NativeBindingsLimits::default(), None, @@ -866,6 +1237,7 @@ mod tests { appconfig_agent: true, secrets_manager: false, dynamodb: false, + ..Default::default() }; let mut supplied = NativeSuppliedIds::default(); let env = EnvConfig::default(); diff --git a/crates/edgezero-adapter-axum/src/native_stores.rs b/crates/edgezero-adapter-axum/src/native_stores.rs index 5c51937f..f06f5150 100644 --- a/crates/edgezero-adapter-axum/src/native_stores.rs +++ b/crates/edgezero-adapter-axum/src/native_stores.rs @@ -17,7 +17,9 @@ use edgezero_core::store_registry::{BoundSecretStore, ConfigStoreBinding}; #[cfg(any( feature = "aws-appconfig-agent", feature = "aws-secrets-manager", - feature = "aws-dynamodb" + feature = "aws-dynamodb", + feature = "celld-config", + feature = "celld-kv" ))] use edgezero_core::time::MonotonicClock; #[cfg(any( @@ -26,12 +28,24 @@ use edgezero_core::time::MonotonicClock; feature = "aws-dynamodb" ))] use edgezero_store_aws::AwsPreparation; -use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; +#[cfg(not(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" +)))] +use edgezero_store_aws::settings::AwsPreparationLimits; +use edgezero_store_aws::settings::{AgentAccessToken, AppConfigAgentSettings, DynamoDbKvSettings}; +#[cfg(any(feature = "celld-config", feature = "celld-kv"))] +use edgezero_store_celld::CelldPreparation; +#[cfg(not(any(feature = "celld-config", feature = "celld-kv")))] +use edgezero_store_celld::settings::CelldPreparationLimits; +use edgezero_store_celld::settings::{CelldAccessToken, CelldConfigSettings, CelldKvSettings}; use crate::config_store_limits::ConfigStoreLimits; use crate::native_bindings::{ - NativeBindingSource, NativeConfigBinding, NativeKvBinding, NativeProviderCapabilities, - NativeSecretBinding, NativeSourceSelection, NativeStoreBindings, NativeSuppliedIds, + NativeBindingSource, NativeConfigBinding, NativeKvBinding, NativePreparationPolicies, + NativeProviderCapabilities, NativeSecretBinding, NativeSourceSelection, NativeStoreBindings, + NativeSuppliedIds, }; use crate::run_options::{AxumRunOptions, failure}; @@ -64,7 +78,34 @@ pub(crate) enum NativeStoreSource { BuiltIn(B), } -pub(crate) type NativeConfigSource = (NativeConfigBinding, Option); +pub(crate) enum NativeConfigSource { + AppConfigAgent { + settings: AppConfigAgentSettings, + token: Option, + }, + Celld { + settings: CelldConfigSettings, + token: Option, + }, +} + +pub(crate) enum NativeKvSource { + DynamoDb(DynamoDbKvSettings), + Celld { + settings: CelldKvSettings, + token: Option, + }, +} + +#[cfg(any(feature = "celld-config", feature = "celld-kv"))] +pub(crate) type NativeCelldSession = Option; +#[cfg(not(any(feature = "celld-config", feature = "celld-kv")))] +pub(crate) struct NativeCelldSession; + +pub(crate) struct NativePreparationSessions { + aws: NativeAwsSession, + celld: NativeCelldSession, +} #[cfg(any( feature = "aws-appconfig-agent", @@ -226,9 +267,9 @@ fn insert( pub(crate) struct NativeSourcePlan { pub config_limits: ConfigStoreLimits, - pub aws_limits: AwsPreparationLimits, + pub policies: NativePreparationPolicies, pub config: BTreeMap<&'static str, NativeStoreSource>, - pub kv: BTreeMap<&'static str, NativeStoreSource>, + pub kv: BTreeMap<&'static str, NativeStoreSource>, pub secrets: BTreeMap<&'static str, NativeStoreSource>, } @@ -246,7 +287,7 @@ impl NativeSourcePlan { kv: overrides.kv.keys().cloned().collect(), secrets: overrides.secrets.keys().cloned().collect(), }; - let selection = NativeSourceSelection::validate( + let selection = NativeSourceSelection::validate_with_policies( metadata, bindings, &supplied, @@ -255,40 +296,65 @@ impl NativeSourcePlan { appconfig_agent: cfg!(feature = "aws-appconfig-agent"), secrets_manager: cfg!(feature = "aws-secrets-manager"), dynamodb: cfg!(feature = "aws-dynamodb"), + celld_config: cfg!(feature = "celld-config"), + celld_kv: cfg!(feature = "celld-kv"), }, options.bindings_limits(), - options.aws_limits(), + options.preparation_policies(), ) .map_err(|category| failure("sources", "bindings", &category.to_string()))?; - for binding in bindings.config().values() { - let NativeConfigBinding::AppConfigAgent(settings) = binding; - if let Some(name) = &settings.access_token_env { - options.agent_token(name)?; - } - } let plan = Self { config_limits: ConfigStoreLimits::from_env(options.env_config())?, - aws_limits: bindings - .validate(options.bindings_limits(), options.aws_limits()) + policies: bindings + .validate_with_policies(options.bindings_limits(), options.preparation_policies()) .map_err(|category| failure("sources", "bindings", &category.to_string()))?, - config: resolve(selection.config, overrides.config, "config", |id| { - let NativeConfigBinding::AppConfigAgent(settings) = bindings + config: resolve( + selection.config, + overrides.config, + "config", + |id| match bindings .config() .get(id) - .ok_or_else(|| failure("config", id, "invalid binding"))?; - let token = settings - .access_token_env - .as_ref() - .map(|name| options.agent_token(name)) - .transpose()?; - Ok((NativeConfigBinding::AppConfigAgent(settings.clone()), token)) - })?, + .ok_or_else(|| failure("config", id, "invalid binding"))? + { + NativeConfigBinding::AppConfigAgent(settings) => { + Ok(NativeConfigSource::AppConfigAgent { + token: settings + .access_token_env + .as_deref() + .map(|name| options.agent_token(name)) + .transpose()?, + settings: settings.clone(), + }) + } + NativeConfigBinding::Celld(settings) => Ok(NativeConfigSource::Celld { + token: settings + .access + .token_env() + .map(|name| options.celld_token(name)) + .transpose()?, + settings: settings.clone(), + }), + }, + )?, kv: resolve(selection.kv, overrides.kv, "kv", |id| { - bindings + match bindings .kv() .get(id) - .cloned() - .ok_or_else(|| failure("kv", id, "invalid binding")) + .ok_or_else(|| failure("kv", id, "invalid binding"))? + { + NativeKvBinding::DynamoDb(settings) => { + Ok(NativeKvSource::DynamoDb(settings.clone())) + } + NativeKvBinding::Celld(settings) => Ok(NativeKvSource::Celld { + token: settings + .access + .token_env() + .map(|name| options.celld_token(name)) + .transpose()?, + settings: settings.clone(), + }), + } })?, secrets: resolve(selection.secrets, overrides.secrets, "secrets", |id| { bindings @@ -313,48 +379,78 @@ impl NativeSourcePlan { not(any( feature = "aws-appconfig-agent", feature = "aws-secrets-manager", - feature = "aws-dynamodb" + feature = "aws-dynamodb", + feature = "celld-config", + feature = "celld-kv" )), expect( clippy::unnecessary_wraps, - reason = "the same fallible session boundary is used when native services are compiled" + reason = "compiled-out sessions retain the fallible preparation boundary" ) )] - pub(crate) fn session(&self) -> anyhow::Result { + pub(crate) fn sessions(&self) -> anyhow::Result { + // Budgets start together and include time spent preparing the other provider. #[cfg(any( feature = "aws-appconfig-agent", feature = "aws-secrets-manager", feature = "aws-dynamodb" ))] + let aws = if self.config.values().any(|source| { + matches!( + source, + NativeStoreSource::BuiltIn(NativeConfigSource::AppConfigAgent { .. }) + ) + }) || self.kv.values().any(|source| { + matches!( + source, + NativeStoreSource::BuiltIn(NativeKvSource::DynamoDb(_)) + ) + }) || self + .secrets + .values() + .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) { - let selected = self - .kv - .values() - .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) - || self - .config - .values() - .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))) - || self - .secrets - .values() - .any(|source| matches!(source, NativeStoreSource::BuiltIn(_))); - if selected { - return AwsPreparation::new(MonotonicClock::default(), self.aws_limits) - .map(Some) - .map_err(|category| failure("sources", "bindings", &category.to_string())); - } - Ok(None) - } + Some( + AwsPreparation::new(MonotonicClock::default(), self.policies.aws) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?, + ) + } else { + None + }; #[cfg(not(any( feature = "aws-appconfig-agent", feature = "aws-secrets-manager", feature = "aws-dynamodb" )))] - { - let _: AwsPreparationLimits = self.aws_limits; - Ok(NativeAwsSession) - } + let aws = { + let _: AwsPreparationLimits = self.policies.aws; + NativeAwsSession + }; + #[cfg(any(feature = "celld-config", feature = "celld-kv"))] + let celld = if self.config.values().any(|source| { + matches!( + source, + NativeStoreSource::BuiltIn(NativeConfigSource::Celld { .. }) + ) + }) || self.kv.values().any(|source| { + matches!( + source, + NativeStoreSource::BuiltIn(NativeKvSource::Celld { .. }) + ) + }) { + Some( + CelldPreparation::new(MonotonicClock::default(), self.policies.celld) + .map_err(|category| failure("sources", "bindings", &category.to_string()))?, + ) + } else { + None + }; + #[cfg(not(any(feature = "celld-config", feature = "celld-kv")))] + let celld = { + let _: CelldPreparationLimits = self.policies.celld; + NativeCelldSession + }; + Ok(NativePreparationSessions { aws, celld }) } } @@ -404,108 +500,422 @@ where } } -#[cfg_attr( - not(feature = "aws-appconfig-agent"), - expect( - clippy::unused_async, - reason = "compiled-out provider retains the same preparation future boundary" - ) -)] -pub(crate) async fn prepare_aws_config( - builtin: NativeConfigSource, - session: &mut NativeAwsSession, - id: &str, -) -> anyhow::Result { - #[cfg(feature = "aws-appconfig-agent")] - { - let (NativeConfigBinding::AppConfigAgent(settings), token) = builtin; - let handle = session - .as_mut() - .ok_or_else(|| failure("config", id, "invalid preparation session"))? - .prepare_appconfig(&settings, token.as_ref()) - .await - .map_err(|category| failure("config", id, &category.to_string()))?; - Ok(ConfigStoreBinding { - default_key: settings.default_key, - handle, - }) - } - #[cfg(not(feature = "aws-appconfig-agent"))] - { - let _: (NativeConfigSource, &mut NativeAwsSession) = (builtin, session); - Err(failure("config", id, "provider not compiled")) +impl NativePreparationSessions { + #[cfg_attr( + not(any(feature = "aws-appconfig-agent", feature = "celld-config")), + expect( + clippy::unused_async, + reason = "compiled-out config dispatch keeps the asynchronous boundary" + ) + )] + pub(crate) async fn prepare_config( + &mut self, + builtin: NativeConfigSource, + id: &str, + ) -> anyhow::Result { + match builtin { + NativeConfigSource::AppConfigAgent { settings, token } => { + #[cfg(feature = "aws-appconfig-agent")] + { + let handle = self + .aws + .as_mut() + .ok_or_else(|| failure("config", id, "invalid preparation session"))? + .prepare_appconfig(&settings, token.as_ref()) + .await + .map_err(|category| failure("config", id, &category.to_string()))?; + Ok(ConfigStoreBinding { + default_key: settings.default_key, + handle, + }) + } + #[cfg(not(feature = "aws-appconfig-agent"))] + { + let _: ( + AppConfigAgentSettings, + Option, + &mut NativeAwsSession, + ) = (settings, token, &mut self.aws); + Err(failure("config", id, "provider not compiled")) + } + } + NativeConfigSource::Celld { settings, token } => { + #[cfg(feature = "celld-config")] + { + let handle = self + .celld + .as_mut() + .ok_or_else(|| failure("config", id, "invalid preparation session"))? + .prepare_config(&settings, token.as_ref()) + .await + .map_err(|category| failure("config", id, &category.to_string()))?; + Ok(ConfigStoreBinding { + default_key: settings.default_key, + handle, + }) + } + #[cfg(not(feature = "celld-config"))] + { + let _: ( + CelldConfigSettings, + Option, + &mut NativeCelldSession, + ) = (settings, token, &mut self.celld); + Err(failure("config", id, "provider not compiled")) + } + } + } } -} -#[cfg_attr( - not(feature = "aws-secrets-manager"), - expect( - clippy::unused_async, - reason = "compiled-out provider retains the same preparation future boundary" - ) -)] -pub(crate) async fn prepare_aws_secrets( - builtin: NativeSecretBinding, - session: &mut NativeAwsSession, - id: &str, -) -> anyhow::Result { - #[cfg(feature = "aws-secrets-manager")] - { - let NativeSecretBinding::SecretsManager(settings) = builtin; - let handle = session - .as_mut() - .ok_or_else(|| failure("secrets", id, "invalid preparation session"))? - .prepare_secrets(&settings, id) - .await - .map_err(|category| failure("secrets", id, &category.to_string()))?; - Ok(BoundSecretStore::new(handle, id.to_owned())) - } - #[cfg(not(feature = "aws-secrets-manager"))] - { - let _: (NativeSecretBinding, &mut NativeAwsSession) = (builtin, session); - Err(failure("secrets", id, "provider not compiled")) + #[cfg_attr( + not(any(feature = "aws-dynamodb", feature = "celld-kv")), + expect( + clippy::unused_async, + reason = "compiled-out KV dispatch keeps the asynchronous boundary" + ) + )] + pub(crate) async fn prepare_kv( + &mut self, + builtin: NativeKvSource, + id: &str, + ) -> anyhow::Result { + match builtin { + NativeKvSource::DynamoDb(settings) => { + #[cfg(feature = "aws-dynamodb")] + { + self.aws + .as_mut() + .ok_or_else(|| failure("kv", id, "invalid preparation session"))? + .prepare_dynamodb(&settings) + .await + .map_err(|category| failure("kv", id, &category.to_string())) + } + #[cfg(not(feature = "aws-dynamodb"))] + { + let _: (DynamoDbKvSettings, &mut NativeAwsSession) = (settings, &mut self.aws); + Err(failure("kv", id, "provider not compiled")) + } + } + NativeKvSource::Celld { settings, token } => { + #[cfg(feature = "celld-kv")] + { + self.celld + .as_mut() + .ok_or_else(|| failure("kv", id, "invalid preparation session"))? + .prepare_kv(&settings, token.as_ref()) + .await + .map_err(|category| failure("kv", id, &category.to_string())) + } + #[cfg(not(feature = "celld-kv"))] + { + let _: ( + CelldKvSettings, + Option, + &mut NativeCelldSession, + ) = (settings, token, &mut self.celld); + Err(failure("kv", id, "provider not compiled")) + } + } + } } -} -#[cfg_attr( - not(feature = "aws-dynamodb"), - expect( - clippy::unused_async, - reason = "compiled-out KV provider retains the same preparation future boundary" - ) -)] -pub(crate) async fn prepare_aws_kv( - builtin: NativeKvBinding, - session: &mut NativeAwsSession, - id: &str, -) -> anyhow::Result { - #[cfg(feature = "aws-dynamodb")] - { - let NativeKvBinding::DynamoDb(settings) = builtin; - session - .as_mut() - .ok_or_else(|| failure("kv", id, "invalid preparation session"))? - .prepare_dynamodb(&settings) - .await - .map_err(|category| failure("kv", id, &category.to_string())) - } - #[cfg(not(feature = "aws-dynamodb"))] - { - let _: (NativeKvBinding, &mut NativeAwsSession) = (builtin, session); - Err(failure("kv", id, "provider not compiled")) + #[cfg_attr( + not(feature = "aws-secrets-manager"), + expect( + clippy::unused_async, + reason = "compiled-out secret dispatch keeps the asynchronous boundary" + ) + )] + pub(crate) async fn prepare_secrets( + &mut self, + builtin: NativeSecretBinding, + id: &str, + ) -> anyhow::Result { + #[cfg(feature = "aws-secrets-manager")] + { + let NativeSecretBinding::SecretsManager(settings) = builtin; + let handle = self + .aws + .as_mut() + .ok_or_else(|| failure("secrets", id, "invalid preparation session"))? + .prepare_secrets(&settings, id) + .await + .map_err(|category| failure("secrets", id, &category.to_string()))?; + Ok(BoundSecretStore::new(handle, id.to_owned())) + } + #[cfg(not(feature = "aws-secrets-manager"))] + { + let _: (NativeSecretBinding, &mut NativeAwsSession) = (builtin, &mut self.aws); + Err(failure("secrets", id, "provider not compiled")) + } } } #[cfg(test)] mod tests { - use super::{NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; + use super::{NativeKvSource, NativeSourcePlan, NativeStoreOverrides, NativeStoreSource}; use crate::{ - native_bindings::{NativeBindingsLimits, NativeKvBinding, NativeStoreBindings}, + native_bindings::{NativeBindingsLimits, NativeStoreBindings}, run_options::AxumRunOptions, }; use edgezero_core::app::{StoreMetadata, StoresMetadata}; use std::collections::BTreeMap; + const CELLD_KV: &str = "version=1\n[kv.state]\nprovider='celld-kv'\nendpoint='http://127.0.0.1:8788'\nstore='state-fixture'\n[kv.state.access]\nmode='trusted-network'\n"; + const CELLD_CONFIG: &str = "version=1\n[config.app]\nprovider='celld-config'\nendpoint='http://127.0.0.1:8788'\ndefault_key='settings'\n[config.app.access]\nmode='trusted-network'\n[config.app.documents.settings]\nstore='config-fixture'\nkey='settings'\n"; + + #[test] + fn celld_variants_check_independent_capabilities_without_local_roots() { + for (input, metadata, compiled) in [ + ( + CELLD_KV, + StoresMetadata { + config: None, + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + }, + cfg!(feature = "celld-kv"), + ), + ( + CELLD_CONFIG, + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: None, + secrets: None, + }, + cfg!(feature = "celld-config"), + ), + ] { + let document = NativeStoreBindings::parse(input, NativeBindingsLimits::default()) + .expect("settings only"); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("address")) + .expect("options") + .with_store_bindings(document) + .expect("selected"); + let plan = + NativeSourcePlan::validate(metadata, &options, NativeStoreOverrides::default()); + if compiled { + let selected = plan.expect("no local roots required"); + let sessions = selected.sessions().expect("provider sessions"); + #[cfg(any(feature = "celld-config", feature = "celld-kv"))] + assert!(sessions.celld.is_some()); + #[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" + ))] + assert!(sessions.aws.is_none()); + let _: super::NativePreparationSessions = sessions; + } else { + let error = plan.err().expect("selected feature absent"); + assert!(error.to_string().contains("provider not compiled")); + } + } + } + + #[test] + fn local_ready_and_deferred_select_no_provider_sessions() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::key_value_store::KvHandle; + use std::cell::Cell; + use std::rc::Rc; + use std::sync::Arc; + let root = tempfile::tempdir().expect("root"); + let handle = KvHandle::new(Arc::new( + PersistentKvStore::new(root.path().join("supplied.redb")).expect("caller-owned store"), + )); + let mut overrides = NativeStoreOverrides::default(); + overrides.insert_kv("ready", handle.clone()).expect("ready"); + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + overrides + .prepare_kv("deferred", async move { + observed.set(1); + Ok(handle) + }) + .expect("deferred"); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("address")) + .expect("options") + .with_data_dir(root.path()) + .expect("root"); + let metadata = StoresMetadata { + config: None, + kv: Some(StoreMetadata { + ids: &["local", "ready", "deferred"], + default: "local", + }), + secrets: None, + }; + let plan = + NativeSourcePlan::validate(metadata, &options, overrides).expect("valid sources"); + assert_eq!(polls.get(), 0); + let sessions = plan.sessions().expect("no sessions"); + #[cfg(any(feature = "celld-config", feature = "celld-kv"))] + assert!(sessions.celld.is_none()); + #[cfg(any( + feature = "aws-appconfig-agent", + feature = "aws-secrets-manager", + feature = "aws-dynamodb" + ))] + assert!(sessions.aws.is_none()); + let _: super::NativePreparationSessions = sessions; + } + + #[cfg(all(feature = "aws-appconfig-agent", feature = "celld-kv"))] + #[test] + fn sessions_select_actual_aws_celld_and_mixed_variants() { + let aws = "version=1\n[config.app]\nprovider='aws-appconfig-agent'\nendpoint='http://127.0.0.1:2772'\ndefault_key='settings'\n[config.app.documents.settings]\napplication='example'\nenvironment='production'\nprofile='settings'\n"; + for mixed in [false, true] { + let input = if mixed { + format!("{aws}{}", CELLD_KV.replace("version=1\n", "")) + } else { + aws.to_owned() + }; + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("address")) + .expect("options") + .with_store_bindings( + NativeStoreBindings::parse(&input, NativeBindingsLimits::default()) + .expect("bindings"), + ) + .expect("selection"); + let metadata = StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: mixed.then_some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + }; + let sessions = + NativeSourcePlan::validate(metadata, &options, NativeStoreOverrides::default()) + .expect("plan") + .sessions() + .expect("sessions"); + assert!(sessions.aws.is_some()); + assert_eq!(sessions.celld.is_some(), mixed); + } + } + + #[cfg(all(feature = "celld-config", feature = "celld-kv"))] + #[test] + fn typed_celld_sources_retain_distinct_explicit_tokens() { + use super::NativeConfigSource; + let input = format!("{CELLD_CONFIG}{}", CELLD_KV.replace("version=1\n", "")) + .replace("http://127.0.0.1:8788", "https://unreachable.invalid") + .replace( + "[config.app.access]\nmode='trusted-network'", + "[config.app.access]\nmode='authenticated'\ntoken_env='CONFIG_TOKEN'", + ) + .replace( + "[kv.state.access]\nmode='trusted-network'", + "[kv.state.access]\nmode='authenticated'\ntoken_env='KV_TOKEN'", + ); + let config_token = "config-token-012345678901234567890123456789"; + let kv_token = "kv-token-0123456789012345678901234567890123"; + let options = + AxumRunOptions::from_vars([("CONFIG_TOKEN", config_token), ("KV_TOKEN", kv_token)]) + .expect("explicit values") + .with_store_bindings( + NativeStoreBindings::parse(&input, NativeBindingsLimits::default()) + .expect("bindings"), + ) + .expect("selection"); + let metadata = StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + }; + let plan = NativeSourcePlan::validate(metadata, &options, NativeStoreOverrides::default()) + .expect("captured plan"); + let Some(NativeStoreSource::BuiltIn(NativeConfigSource::Celld { + token: Some(config), + .. + })) = plan.config.get("app") + else { + panic!("typed config token") + }; + let Some(NativeStoreSource::BuiltIn(NativeKvSource::Celld { + token: Some(kv), .. + })) = plan.kv.get("state") + else { + panic!("typed KV token") + }; + assert_eq!(config.expose(), config_token); + assert_eq!(kv.expose(), kv_token); + } + + #[test] + fn later_celld_token_or_capability_failure_never_polls_or_opens_local_stores() { + use std::cell::Cell; + use std::fs::read_dir; + use std::rc::Rc; + let root = tempfile::tempdir().expect("root"); + let input = CELLD_KV + .replace("http://127.0.0.1:8788", "https://unreachable.invalid") + .replace( + "mode='trusted-network'", + "mode='authenticated'\ntoken_env='ABSENT_TOKEN'", + ); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("address")) + .expect("options") + .with_config_dir(root.path()) + .expect("config root") + .with_data_dir(root.path()) + .expect("data root") + .with_store_bindings( + NativeStoreBindings::parse(&input, NativeBindingsLimits::default()) + .expect("bindings"), + ) + .expect("selection"); + let mut overrides = NativeStoreOverrides::default(); + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + overrides + .prepare_config("deferred", async move { + observed.set(1); + Err(super::NativeStorePreparationError::Unavailable) + }) + .expect("non-Send future"); + let metadata = StoresMetadata { + config: Some(StoreMetadata { + ids: &["local", "deferred"], + default: "local", + }), + kv: Some(StoreMetadata { + ids: &["state", "local"], + default: "state", + }), + secrets: None, + }; + let error = NativeSourcePlan::validate(metadata, &options, overrides) + .err() + .expect("invalid selected source"); + assert_eq!(polls.get(), 0); + assert_eq!(read_dir(root.path()).expect("root").count(), 0); + assert_eq!(error.chain().count(), 1); + assert!(!format!("{error:?}").contains("unreachable.invalid")); + if cfg!(feature = "celld-kv") { + assert!(error.to_string().contains("celld token")); + } else { + assert!(error.to_string().contains("provider not compiled")); + } + } + #[cfg(feature = "aws-dynamodb")] #[test] fn dynamodb_local_ready_and_non_send_deferred_sources_validate_without_polling() { @@ -549,7 +959,7 @@ mod tests { let validated = NativeSourcePlan::validate(metadata, &options, overrides).unwrap(); assert!(matches!( validated.kv.get("remote"), - Some(NativeStoreSource::BuiltIn(NativeKvBinding::DynamoDb(_))) + Some(NativeStoreSource::BuiltIn(NativeKvSource::DynamoDb(_))) )); assert!(matches!( validated.kv.get("local"), @@ -587,7 +997,7 @@ mod tests { let validated_plan = plan.unwrap(); assert!(matches!( validated_plan.kv.get("state"), - Some(NativeStoreSource::BuiltIn(NativeKvBinding::DynamoDb(_))) + Some(NativeStoreSource::BuiltIn(NativeKvSource::DynamoDb(_))) )); } else { assert!(plan.is_err()); diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index 8eff97b5..1b6c4a48 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -10,10 +10,12 @@ use edgezero_core::addr; use edgezero_core::app::StoresMetadata; use edgezero_core::env_config::EnvConfig; use edgezero_store_aws::settings::{AgentAccessToken, AwsPreparationLimits}; +use edgezero_store_celld::settings::{CelldAccessToken, CelldPreparationLimits}; use log::LevelFilter; use crate::native_bindings::{ - NativeBindingsLimits, NativeConfigBinding, NativeStoreBindings, NativeStoreBindingsInput, + NativeBindingsLimits, NativeConfigBinding, NativeKvBinding, NativePreparationPolicySources, + NativeStoreBindings, NativeStoreBindingsInput, }; /// Validated explicit production hosting options. Construction never selects a @@ -26,6 +28,7 @@ pub struct AxumRunOptions { bindings: NativeStoreBindingsInput, bindings_limits: NativeBindingsLimits, bootstrap_values: BTreeMap, + celld_limits: Option, config_dir: Option, data_dir: Option, env: EnvConfig, @@ -61,26 +64,38 @@ impl AxumRunOptions { self.aws_limits } + pub(crate) fn celld_limits(&self) -> Option { + self.celld_limits + } + + pub(crate) fn preparation_policies(&self) -> NativePreparationPolicySources { + NativePreparationPolicySources { + aws: self.aws_limits(), + celld: self.celld_limits(), + } + } + + pub(crate) fn celld_token(&self, name: &str) -> anyhow::Result { + CelldAccessToken::new(self.token_value(name, "celld token")?) + .map_err(|_category| failure("settings", "celld token", "invalid configured value")) + } + pub(crate) fn agent_token(&self, name: &str) -> anyhow::Result { + AgentAccessToken::new(self.token_value(name, "Agent token")?) + .map_err(|_category| failure("settings", "Agent token", "invalid configured value")) + } + + fn token_value(&self, name: &str, subject: &str) -> anyhow::Result { let value = match self.bootstrap_values.get(name) { Some(BootstrapValue::Value(value)) => value.clone(), Some(BootstrapValue::Ambiguous) => { - return Err(failure( - "settings", - "Agent token", - "duplicate configured value", - )); + return Err(failure("settings", subject, "duplicate configured value")); } None => { - return Err(failure( - "settings", - "Agent token", - "configured value absent", - )); + return Err(failure("settings", subject, "configured value absent")); } }; - AgentAccessToken::new(value) - .map_err(|_category| failure("settings", "Agent token", "invalid configured value")) + Ok(value) } pub(crate) fn config_dir(&self) -> Option<&Path> { @@ -136,29 +151,52 @@ impl AxumRunOptions { where Lookup: FnMut(&str) -> Option, { - let names: BTreeSet = self - .bindings - .document() - .into_iter() - .flat_map(|document| document.config().values()) - .filter_map(|binding| { - let NativeConfigBinding::AppConfigAgent(settings) = binding; - settings.access_token_env.clone() - }) - .collect(); - for name in names { + // Capture shared names once; validate them against every selected token type. + let mut names = BTreeMap::::new(); + if let Some(document) = self.bindings.document() { + for binding in document.config().values() { + match binding { + NativeConfigBinding::AppConfigAgent(settings) => { + if let Some(name) = &settings.access_token_env { + names.entry(name.clone()).or_default().0 = true; + } + } + NativeConfigBinding::Celld(settings) => { + if let Some(name) = settings.access.token_env() { + names.entry(name.to_owned()).or_default().1 = true; + } + } + } + } + for binding in document.kv().values() { + if let NativeKvBinding::Celld(settings) = binding + && let Some(name) = settings.access.token_env() + { + names.entry(name.to_owned()).or_default().1 = true; + } + } + } + for (name, (aws, celld)) in names { + let subject = if celld { "celld token" } else { "Agent token" }; let value = lookup(&name) .and_then(|value| value.into_string().ok()) .ok_or_else(|| { failure( "settings", - "Agent token", + subject, "configured value absent or non-Unicode", ) })?; - AgentAccessToken::new(value.clone()).map_err(|_category| { - failure("settings", "Agent token", "invalid configured value") - })?; + if aws { + AgentAccessToken::new(value.clone()).map_err(|_category| { + failure("settings", "Agent token", "invalid configured value") + })?; + } + if celld { + CelldAccessToken::new(value.clone()).map_err(|_category| { + failure("settings", "celld token", "invalid configured value") + })?; + } self.bootstrap_values .insert(name, BootstrapValue::Value(value)); } @@ -259,6 +297,7 @@ impl AxumRunOptions { bindings_limits: NativeBindingsLimits::default(), aws_limits: None, bootstrap_values: BTreeMap::new(), + celld_limits: None, addr, config_dir: None, data_dir: None, @@ -341,7 +380,7 @@ impl AxumRunOptions { return Err(failure("settings", "bindings", "duplicate selection")); } bindings - .validate(self.bindings_limits, self.aws_limits) + .validate_with_policies(self.bindings_limits, self.preparation_policies()) .map_err(|category| failure("settings", "bindings", &category.to_string()))?; self.bindings = NativeStoreBindingsInput::Inline(bindings); Ok(self) @@ -360,12 +399,12 @@ impl AxumRunOptions { return Err(failure("settings", "bindings", "duplicate selection")); } let path = locator.into(); - let bindings = - NativeStoreBindings::read_with_policy(&path, self.bindings_limits, self.aws_limits) - .map_err(|category| failure("settings", "bindings", &category.to_string()))?; - bindings - .validate(self.bindings_limits, self.aws_limits) - .map_err(|category| failure("settings", "bindings", &category.to_string()))?; + let bindings = NativeStoreBindings::read_with_policies( + &path, + self.bindings_limits, + self.preparation_policies(), + ) + .map_err(|category| failure("settings", "bindings", &category.to_string()))?; self.bindings = NativeStoreBindingsInput::File { path, bindings }; Ok(self) } @@ -433,13 +472,49 @@ impl AxumRunOptions { .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; if let Some(document) = self.bindings.document() { document - .validate(self.bindings_limits, Some(limits)) + .validate_with_policies( + self.bindings_limits, + NativePreparationPolicySources { + aws: Some(limits), + celld: self.celld_limits, + }, + ) .map_err(|category| failure("settings", "AWS limits", &category.to_string()))?; } self.aws_limits = Some(limits); Ok(self) } + /// Selects a celld policy independently of any AWS policy. + /// + /// # Errors + /// Rejects duplicate policies, same-provider document conflicts and invalid limits. + #[inline] + pub fn with_celld_preparation_limits( + mut self, + limits: CelldPreparationLimits, + ) -> anyhow::Result { + if self.celld_limits.is_some() { + return Err(failure("settings", "celld limits", "duplicate policy")); + } + limits + .validate() + .map_err(|category| failure("settings", "celld limits", &category.to_string()))?; + if let Some(document) = self.bindings.document() { + document + .validate_with_policies( + self.bindings_limits, + NativePreparationPolicySources { + aws: self.aws_limits, + celld: Some(limits), + }, + ) + .map_err(|category| failure("settings", "celld limits", &category.to_string()))?; + } + self.celld_limits = Some(limits); + Ok(self) + } + /// Selects an existing absolute config root; never creates it. /// /// # Errors @@ -568,10 +643,219 @@ mod tests { const BINDINGS: &str = "version = 1\n[config.app]\nprovider = 'aws-appconfig-agent'\ndefault_key = 'settings'\nendpoint = 'http://127.0.0.1:2772'\naccess_token_env = 'AGENT_TOKEN'\n[config.app.documents.settings]\napplication = 'example'\nenvironment = 'production'\nprofile = 'envelope'\n"; + const CELLD: &str = "version=1\n[config.remote]\nprovider='celld-config'\nendpoint='https://unreachable.invalid'\ndefault_key='settings'\n[config.remote.access]\nmode='authenticated'\ntoken_env='CELL_TOKEN'\n[config.remote.documents.settings]\nstore='config-fixture'\nkey='settings'\n[kv.state]\nprovider='celld-kv'\nendpoint='https://unreachable.invalid'\nstore='state-fixture'\n[kv.state.access]\nmode='authenticated'\ntoken_env='CELL_TOKEN'\n"; + const CELL_TOKEN: &str = "celld-bootstrap-value-01234567890123456789"; + fn explicit() -> AxumRunOptions { AxumRunOptions::new(SocketAddr::from(([127, 0, 0, 1], 1234))).expect("explicit options") } + #[test] + fn celld_capture_unions_config_and_kv_names_and_validates_shared_aws_use() { + for (input, expected) in [ + (CELLD.to_owned(), 1), + ( + CELLD.replace( + "[kv.state.access]\nmode='authenticated'\ntoken_env='CELL_TOKEN'", + "[kv.state.access]\nmode='authenticated'\ntoken_env='OTHER_TOKEN'", + ), + 2, + ), + ( + format!( + "{BINDINGS}{}", + CELLD + .replace("version=1\n", "") + .replace("CELL_TOKEN", "AGENT_TOKEN") + ), + 1, + ), + ] { + let document = NativeStoreBindings::parse(&input, NativeBindingsLimits::default()) + .expect("bindings"); + let mut options = explicit().with_store_bindings(document).expect("inline"); + let mut calls = 0_usize; + options + .capture_agent_tokens(|_name| { + calls += 1; + Some(OsString::from(CELL_TOKEN)) + }) + .expect("captured union"); + assert_eq!(calls, expected); + let name = if input.contains("AGENT_TOKEN") { + "AGENT_TOKEN" + } else { + "CELL_TOKEN" + }; + assert_eq!( + options.celld_token(name).expect("celld token").expose(), + CELL_TOKEN + ); + if name == "AGENT_TOKEN" { + assert_eq!( + options.agent_token(name).expect("AWS token").expose(), + CELL_TOKEN + ); + } + } + let shared = format!( + "{BINDINGS}{}", + CELLD + .replace("version=1\n", "") + .replace("CELL_TOKEN", "AGENT_TOKEN") + ); + let document = + NativeStoreBindings::parse(&shared, NativeBindingsLimits::default()).expect("bindings"); + let mut options = explicit().with_store_bindings(document).expect("inline"); + // Valid AWS token, invalid celld token: shared names must not skip the stricter type. + let error = options + .capture_agent_tokens(|_name| Some(OsString::from("short"))) + .expect_err("celld minimum"); + assert_eq!(error.chain().count(), 1); + assert!(error.to_string().contains("celld token")); + } + + #[test] + fn celld_bootstrap_is_explicit_closed_and_trusted_network_looks_up_nothing() { + let document = + NativeStoreBindings::parse(CELLD, NativeBindingsLimits::default()).expect("bindings"); + let options = explicit().with_store_bindings(document).expect("inline"); + options + .celld_token("CELL_TOKEN") + .expect_err("explicit token required"); + for raw in [ + None, + Some(OsString::new()), + Some(OsString::from("short")), + Some(OsString::from("invalid token sentinel")), + ] { + let error = options + .clone() + .capture_agent_tokens(|_name| raw.clone()) + .expect_err("invalid token"); + assert_eq!(error.chain().count(), 1); + assert!(!format!("{error:?}").contains("sentinel")); + } + let supplied = options + .with_bootstrap_values([("CELL_TOKEN", CELL_TOKEN)]) + .expect("explicit token"); + assert_eq!( + supplied.celld_token("CELL_TOKEN").expect("token").expose(), + CELL_TOKEN + ); + assert!(!format!("{:?}", supplied.celld_token("CELL_TOKEN")).contains(CELL_TOKEN)); + let duplicate = + AxumRunOptions::from_vars([("CELL_TOKEN", CELL_TOKEN), ("CELL_TOKEN", CELL_TOKEN)]) + .expect("pairs"); + duplicate + .celld_token("CELL_TOKEN") + .expect_err("duplicate token values rejected"); + let trusted = CELLD + .replace("https://unreachable.invalid", "http://127.0.0.1:8788") + .replace( + "mode='authenticated'\ntoken_env='CELL_TOKEN'", + "mode='trusted-network'", + ); + let mut trusted_options = explicit() + .with_store_bindings( + NativeStoreBindings::parse(&trusted, NativeBindingsLimits::default()) + .expect("trusted bindings"), + ) + .expect("inline"); + trusted_options + .capture_agent_tokens(|_name| panic!("trusted network must not resolve tokens")) + .expect("no lookup"); + } + + #[cfg(unix)] + #[test] + fn celld_non_unicode_capture_fails_closed() { + use std::os::unix::ffi::OsStringExt as _; + let document = + NativeStoreBindings::parse(CELLD, NativeBindingsLimits::default()).expect("bindings"); + let mut options = explicit().with_store_bindings(document).expect("inline"); + assert!( + options + .capture_agent_tokens(|_name| Some(OsString::from_vec(vec![0xff]))) + .is_err() + ); + } + + #[test] + fn options_preserve_independent_policy_sources_in_both_orders() { + let aws = AwsPreparationLimits { + max_remote_values: 7, + ..Default::default() + }; + let celld = CelldPreparationLimits { + max_remote_values: 9, + ..Default::default() + }; + for options in [ + explicit() + .with_aws_preparation_limits(aws) + .expect("AWS") + .with_celld_preparation_limits(celld) + .expect("celld"), + explicit() + .with_celld_preparation_limits(celld) + .expect("celld") + .with_aws_preparation_limits(aws) + .expect("AWS"), + ] { + assert_eq!( + options.aws_limits().expect("AWS policy").max_remote_values, + 7 + ); + assert_eq!( + options + .celld_limits() + .expect("celld policy") + .max_remote_values, + 9 + ); + assert!( + options + .clone() + .with_celld_preparation_limits(celld) + .is_err() + ); + assert!(options.with_aws_preparation_limits(aws).is_err()); + } + for (table, explicit_aws) in [ + ("[limits.celld]\nmax_remote_values=9", true), + ("[limits.aws]\nmax_remote_values=7", false), + ] { + let document = NativeStoreBindings::parse( + &format!("{CELLD}\n{table}"), + NativeBindingsLimits::default(), + ) + .expect("file policy"); + let selected = explicit() + .with_store_bindings(document.clone()) + .expect("inline"); + if explicit_aws { + selected + .with_aws_preparation_limits(aws) + .expect("cross-provider policy"); + explicit() + .with_aws_preparation_limits(aws) + .expect("AWS") + .with_store_bindings(document) + .expect("cross-provider file"); + } else { + selected + .with_celld_preparation_limits(celld) + .expect("cross-provider policy"); + explicit() + .with_celld_preparation_limits(celld) + .expect("celld") + .with_store_bindings(document) + .expect("cross-provider file"); + } + } + } + #[test] fn native_prefix_matching_agrees_with_portable_env_config() { for key in [ @@ -827,7 +1111,29 @@ mod tests { let Some(expected) = env::var_os("EDGEZERO_NATIVE_BOOTSTRAP_TEST") else { return; }; - if expected == "valid" { + if expected == "celld" { + let options = AxumRunOptions::from_env().expect("celld env token capture"); + assert_eq!( + options + .celld_token("CELL_TOKEN") + .expect("captured celld token") + .expose(), + CELL_TOKEN + ); + let path = env::var_os("EDGEZERO__STORE_BINDINGS_FILE").expect("locator"); + fs::write( + PathBuf::from(path), + CELLD.replace("CELL_TOKEN", "OTHER_TOKEN"), + ) + .expect("change file after capture"); + assert_eq!( + options + .celld_token("CELL_TOKEN") + .expect("retained token") + .expose(), + CELL_TOKEN + ); + } else if expected == "valid" { let options = AxumRunOptions::from_env().expect("env token capture"); assert_eq!( options @@ -848,7 +1154,10 @@ mod tests { .expect("captured document") .config() .get("app") - .expect("captured binding"); + .expect("captured binding") + else { + panic!("expected AppConfig binding") + }; assert_eq!(settings.access_token_env.as_deref(), Some("AGENT_TOKEN")); assert_eq!( options @@ -918,6 +1227,42 @@ mod tests { assert!(output.status.success()); } + #[cfg(unix)] + #[test] + fn real_env_celld_capture_rejects_invalid_selected_values_and_retains_snapshot() { + use std::env::current_exe; + use std::os::unix::ffi::OsStringExt as _; + use std::process::Command; + let root = tempfile::tempdir().expect("root"); + let path = root.path().join("bindings.toml"); + for (token, valid) in [ + (Some(OsString::from(CELL_TOKEN)), true), + (None, false), + (Some(OsString::new()), false), + (Some(OsString::from("short")), false), + (Some(OsString::from_vec(vec![0xff])), false), + ] { + fs::write(&path, CELLD).expect("bindings"); + let mut command = Command::new(current_exe().expect("test binary")); + command + .env_clear() + .arg("--exact") + .arg("run_options::tests::env_bootstrap_child") + .env( + "EDGEZERO_NATIVE_BOOTSTRAP_TEST", + if valid { "celld" } else { "invalid" }, + ) + .env("EDGEZERO__STORE_BINDINGS_FILE", &path); + if let Some(value) = token { + command.env("CELL_TOKEN", value); + } + let output = command.output().expect("isolated env child"); + assert!(output.status.success(), "child completed assertions"); + assert!(!String::from_utf8_lossy(&output.stdout).contains(CELL_TOKEN)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(CELL_TOKEN)); + } + } + #[test] fn defaults_and_invalid_supplied_scalars_are_distinct() { let options = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); diff --git a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs index 91ad1830..59728e4f 100644 --- a/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs @@ -31,3 +31,5 @@ aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] aws-stores = ["aws-appconfig-agent", "aws-secrets-manager"] aws-dynamodb = ["edgezero-adapter-axum/aws-dynamodb"] +celld-config = ["edgezero-adapter-axum/celld-config"] +celld-kv = ["edgezero-adapter-axum/celld-kv"] diff --git a/crates/edgezero-adapter-axum/tests/fixtures/native_celld_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/native_celld_host.rs new file mode 100644 index 00000000..e0b4f95e --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/fixtures/native_celld_host.rs @@ -0,0 +1,130 @@ +//! Public-runner child for deterministic scoped HTTP startup tests, not a celld service. + +use std::env::var_os; +use std::fs::write; +use std::future; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, LocalStartupFuture, PreparedStores, run_app_with_native_stores, +}; +use edgezero_adapter_axum::native_stores::{NativeStoreOverrides, NativeStorePreparationError}; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::context::RequestContext; +use edgezero_core::extractor::{AppConfig as TypedAppConfig, State}; +use edgezero_core::router::RouterService; +use edgezero_core::{EdgeError, action}; +use serde::Deserialize; +use tokio::time::{sleep, timeout}; +use validator::Validate; + +struct NativeCelldApp; + +#[derive(Deserialize, Validate, edgezero_core::AppConfig)] +struct FixtureConfig { + #[validate(length(min = 4_u64))] + greeting: String, +} + +#[expect( + clippy::missing_trait_methods, + reason = "fixture retains unused Hooks defaults" +)] +impl Hooks for NativeCelldApp { + fn routes() -> RouterService { + RouterService::builder() + .get("/snapshot", snapshot) + .get("/kv", kv) + .build() + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + ids: &["app"], + default: "app", + }), + kv: Some(StoreMetadata { + ids: &["state"], + default: "state", + }), + secrets: None, + } + } +} + +#[action] +async fn snapshot(State(greeting): State>) -> Result { + Ok(greeting.to_string()) +} + +#[action] +async fn kv(ctx: RequestContext) -> Result { + let store = ctx + .kv_store("state") + .ok_or_else(|| EdgeError::service_unavailable("fixture KV missing"))?; + let bytes = store + .get_bytes("value") + .await? + .ok_or_else(|| EdgeError::service_unavailable("fixture value missing"))?; + String::from_utf8(bytes.to_vec()) + .map_err(|_error| EdgeError::internal(anyhow::anyhow!("fixture value malformed"))) +} + +fn initialize<'startup>( + app: &'startup mut App, + stores: &'startup PreparedStores, +) -> LocalStartupFuture<'startup> { + Box::pin(async move { + let binding = stores + .config() + .and_then(|registry| registry.named_ref("app")) + .ok_or_else(|| EdgeError::service_unavailable("fixture config missing"))?; + let config = TypedAppConfig::::from_binding( + binding, + None, + stores.secrets(), + app.config_extraction_limits(), + app.monotonic_clock(), + ) + .await?; + if let Some(marker) = var_os("FIXTURE_INITIALIZER_ENTERED") { + write(marker, b"entered").map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("fixture marker unavailable")) + })?; + } + if let Some(path) = var_os("FIXTURE_INITIALIZER_GATE") { + let gate = PathBuf::from(path); + timeout(Duration::from_secs(8), async { + while !gate.is_file() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("fixture initializer deadline")) + })?; + } + app.insert_state(Arc::::from(config.greeting)); + Ok(()) + }) +} + +fn main() -> anyhow::Result<()> { + let mut overrides = NativeStoreOverrides::default(); + if let Some(marker) = var_os("FIXTURE_DEFERRED_MARKER") { + overrides.prepare_kv("state", async move { + write(marker, b"polled").map_err(|_error| NativeStorePreparationError::Unavailable)?; + Err(NativeStorePreparationError::Unavailable) + })?; + } + run_app_with_native_stores::( + AxumRunOptions::from_env()?, + overrides, + future::pending::<()>(), + initialize, + )?; + Ok(()) +} diff --git a/crates/edgezero-adapter-axum/tests/native_aws_startup.rs b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs index a72c00f2..784d906c 100644 --- a/crates/edgezero-adapter-axum/tests/native_aws_startup.rs +++ b/crates/edgezero-adapter-axum/tests/native_aws_startup.rs @@ -43,6 +43,7 @@ mod tests { reason: &'static str, body: String, content_type: &'static str, + headers: &'static str, } impl Reply { @@ -52,6 +53,7 @@ mod tests { reason: "OK", body, content_type, + headers: "", } } } @@ -198,11 +200,12 @@ mod tests { fn send_response(stream: &mut TcpStream, reply: &Reply) { write!( stream, - "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n", reply.status, reply.reason, reply.content_type, - reply.body.len() + reply.body.len(), + reply.headers ) .expect("write response headers"); stream @@ -391,6 +394,7 @@ mod tests { reason: "Service Unavailable", body: "AGENT_BODY_SENTINEL".to_owned(), content_type: "text/plain", + headers: "", } } } @@ -418,6 +422,7 @@ mod tests { }) .to_string(), content_type: "application/x-amz-json-1.1", + headers: "", } } @@ -485,6 +490,77 @@ secret_id = "{ARN}" assert!(body.get("VersionId").is_none()); } + #[cfg(feature = "celld-config")] + #[test] + fn production_runner_composes_celld_config_aws_secret_and_local_secret() { + let root = tempfile::tempdir().expect("isolated mixed-provider process"); + let document = envelope("token", "celld-startup-greeting"); + assert!( + document.len() > 128, + "celld document exceeds the independent AWS payload budget" + ); + let config = FixtureServer::once( + Reply { + headers: "x-celld-store-version: 1\r\n", + ..Reply::ok(document, "application/octet-stream") + }, + true, + ); + let secrets = FixtureServer::once(secrets_reply(TOKEN_SENTINEL), true); + let path = root.path().join("mixed-native-stores.toml"); + fs::write( + &path, + format!( + r#"version = 1 +[config.app] +provider = "celld-config" +endpoint = "{}" +default_key = "settings" +[config.app.access] +mode = "trusted-network" +[config.app.documents.settings] +store = "app-config" +key = "settings" +[secrets.vault] +provider = "aws-secrets-manager" +region = "us-east-1" +[secrets.vault.entries.token] +secret_id = "{ARN}" +[limits.aws] +max_snapshot_bytes = 128 +max_config_document_bytes = 128 +max_secret_value_bytes = 128 +"#, + config.endpoint() + ), + ) + .expect("mixed binding variants"); + let mut child = ChildHost::spawn(&root, &path, secrets.endpoint()); + assert_eq!( + response_body(&child.wait_http("/snapshot")), + "celld-startup-greeting|native-fixture-secret-value" + ); + assert_eq!( + response_body(&child.wait_http("/local-secret")), + LOCAL_SENTINEL + ); + let request = config.take_request(); + assert!( + request + .headers + .starts_with("get /v1/stores/app-config/value?key=settings "), + "scoped config request" + ); + assert!( + !request.headers.contains("authorization:"), + "unrelated Agent token must not enter trusted celld transport" + ); + request_body_is_serialized_get_secret(&secrets.take_request()); + child.stop(); + assert!(config.finish().is_some()); + assert!(secrets.finish().is_some()); + } + #[test] fn production_runner_loads_envelope_and_real_sdk_secret_before_serving_mixed_stores() { let root = tempfile::tempdir().expect("temporary process root"); diff --git a/crates/edgezero-adapter-axum/tests/native_celld_startup.rs b/crates/edgezero-adapter-axum/tests/native_celld_startup.rs new file mode 100644 index 00000000..2db75cd6 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/native_celld_startup.rs @@ -0,0 +1,423 @@ +//! Deterministic loopback HTTP fixtures through the public native runner. +//! This is not real celld, TLS, fleet, persistence or generated-consumer acceptance. + +#![cfg(all(feature = "celld-config", feature = "celld-kv"))] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "fixture helpers precede startup scenarios" +)] + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::{ErrorKind, Read, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + const WAIT: Duration = Duration::from_secs(12); + + struct ScopedFixture { + address: SocketAddr, + config: Arc)>>, + gate: Arc, + requests: Arc>>, + stop: Arc, + worker: Option>, + } + + impl ScopedFixture { + fn new(config_status: u16, document: Vec, gated: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("loopback fixture"); + listener.set_nonblocking(true).expect("nonblocking fixture"); + let address = listener.local_addr().expect("fixture address"); + let config = Arc::new(Mutex::new((config_status, document))); + let requests = Arc::new(Mutex::new(Vec::new())); + let gate = Arc::new(AtomicBool::new(!gated)); + let stop = Arc::new(AtomicBool::new(false)); + let worker = { + let worker_config = Arc::clone(&config); + let worker_requests = Arc::clone(&requests); + let worker_gate = Arc::clone(&gate); + let worker_stop = Arc::clone(&stop); + thread::spawn(move || { + let deadline = Instant::now().checked_add(WAIT).expect("fixture deadline"); + while !worker_stop.load(Ordering::Relaxed) && Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, peer)) => { + assert!(peer.ip().is_loopback()); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded read"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("bounded write"); + let request = read_request(&mut stream); + assert!(!request.to_ascii_lowercase().contains("authorization:")); + let line = request.lines().next().expect("request line").to_owned(); + worker_requests + .lock() + .expect("request log") + .push(line.clone()); + let (status, body) = if line.starts_with( + "GET /v1/stores/config-fixture/value?key=settings ", + ) { + while !worker_gate.load(Ordering::Relaxed) + && !worker_stop.load(Ordering::Relaxed) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(5)); + } + worker_config.lock().expect("document").clone() + } else { + assert!( + line.starts_with( + "GET /v1/stores/state-fixture/value?key=value " + ), + "unexpected scoped request" + ); + (200, b"live-value".to_vec()) + }; + let content_type = if status == 200 { + "application/octet-stream" + } else { + "application/json" + }; + // Failure tests may close early after checking Content-Length. + let _sent = write!(stream, "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\nx-celld-store-version: 1\r\nConnection: close\r\n\r\n", body.len()) + .and_then(|()| stream.write_all(&body)); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + }) + }; + Self { + address, + config, + gate, + requests, + stop, + worker: Some(worker), + } + } + + fn endpoint(&self) -> String { + format!("http://{}", self.address) + } + + fn count(&self) -> usize { + self.requests.lock().expect("request log").len() + } + + fn wait_request(&self) { + wait_until(|| self.count() != 0, "scoped request"); + } + } + + impl Drop for ScopedFixture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + self.gate.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + worker.join().expect("fixture thread"); + } + } + } + + fn read_request(stream: &mut TcpStream) -> String { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1_024]; + while !bytes.windows(4).any(|part| part == b"\r\n\r\n") { + let count = stream.read(&mut buffer).expect("request read"); + assert_ne!(count, 0, "complete request headers"); + bytes.extend_from_slice(buffer.get(..count).expect("read fits buffer")); + assert!(bytes.len() <= 0x4000, "bounded request"); + } + String::from_utf8(bytes).expect("HTTP fixture request") + } + + struct Host { + child: Child, + address: SocketAddr, + readers: Vec>>, + } + + impl Host { + fn spawn(root: &Path, bindings: &Path, initializer_gate: bool, supply_state: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve app address"); + let address = listener.local_addr().expect("app address"); + drop(listener); + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero-native-celld-host")); + command + .env_clear() + .current_dir(root) + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("EDGEZERO__LOGGING__LEVEL", "off") + .env( + "FIXTURE_INITIALIZER_ENTERED", + root.join("initializer-entered"), + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if initializer_gate { + command.env("FIXTURE_INITIALIZER_GATE", root.join("initializer-release")); + } + if supply_state { + command.env("FIXTURE_DEFERRED_MARKER", root.join("deferred-polled")); + } + let mut child = command.spawn().expect("public runner child"); + let streams: [Box; 2] = [ + Box::new(child.stdout.take().expect("stdout")), + Box::new(child.stderr.take().expect("stderr")), + ]; + let readers = streams + .into_iter() + .map(|mut stream| { + thread::spawn(move || { + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("child output"); + bytes + }) + }) + .collect(); + Self { + child, + address, + readers, + } + } + + fn no_listener(&self) { + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + .expect_err("no listener before readiness"); + } + + fn get(&mut self, path: &str) -> String { + let deadline = Instant::now() + .checked_add(WAIT) + .expect("readiness deadline"); + loop { + assert!( + self.child.try_wait().expect("child status").is_none(), + "host exited before readiness" + ); + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(40)) + { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bounded app read"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("app request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("app response"); + assert!( + response.starts_with("HTTP/1.1 200"), + "successful fixture response" + ); + return response + .split_once("\r\n\r\n") + .expect("response headers") + .1 + .to_owned(); + } + assert!(Instant::now() < deadline, "host readiness timeout"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn failure(&mut self) -> String { + let deadline = Instant::now().checked_add(WAIT).expect("failure deadline"); + loop { + if let Some(status) = self.child.try_wait().expect("child status") { + assert!(!status.success(), "expected startup failure"); + self.child.wait().expect("reap failed host"); + let mut bytes = Vec::new(); + for reader in self.readers.drain(..) { + bytes.extend(reader.join().expect("output reader")); + } + return String::from_utf8_lossy(&bytes).into_owned(); + } + assert!(Instant::now() < deadline, "startup failure timeout"); + thread::sleep(Duration::from_millis(10)); + } + } + } + + impl Drop for Host { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _killed = self.child.kill(); + } + let _reaped = self.child.wait(); + for reader in self.readers.drain(..) { + let _joined = reader.join(); + } + } + } + + fn wait_until bool>(ready: Ready, label: &str) { + let deadline = Instant::now().checked_add(WAIT).expect("fixture deadline"); + while !ready() { + assert!(Instant::now() < deadline, "timeout waiting for {label}"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn envelope(greeting: &str) -> Vec { + serde_json::to_vec(&BlobEnvelope::new( + json!({"greeting": greeting}), + "2026-10-15T12:00:00Z".to_owned(), + )) + .expect("typed config envelope") + } + + fn bindings(endpoint: &str) -> String { + format!( + "version=1 +[config.app] +provider='celld-config' +endpoint='{endpoint}' +default_key='settings' +[config.app.access] +mode='trusted-network' +[config.app.documents.settings] +store='config-fixture' +key='settings' +[kv.state] +provider='celld-kv' +endpoint='{endpoint}' +store='state-fixture' +[kv.state.access] +mode='trusted-network' +" + ) + } + + #[test] + fn public_runner_gates_preparation_and_initializer_then_keeps_snapshot_and_live_kv() { + let root = tempfile::tempdir().expect("isolated child root"); + let fixture = ScopedFixture::new(200, envelope("first-greeting"), true); + let path = root.path().join("bindings.toml"); + fs::write(&path, bindings(&fixture.endpoint())).expect("bindings"); + let mut host = Host::spawn(root.path(), &path, true, false); + fixture.wait_request(); + host.no_listener(); + assert!(!root.path().join("initializer-entered").exists()); + // File settings were captured before the gated fetch; later edits cannot change sources/defaults. + fs::write(&path, "invalid later file contents").expect("replace captured file"); + fixture.gate.store(true, Ordering::Relaxed); + wait_until( + || root.path().join("initializer-entered").is_file(), + "initializer entry", + ); + host.no_listener(); + assert_eq!(fixture.count(), 1, "KV setup performs no remote probe"); + fs::write(root.path().join("initializer-release"), b"ready").expect("release initializer"); + assert_eq!(host.get("/snapshot"), "first-greeting"); + *fixture.config.lock().expect("edit remote document") = (200, envelope("second-greeting")); + assert_eq!(host.get("/snapshot"), "first-greeting"); + assert_eq!(fixture.count(), 1, "snapshot reads never fetch again"); + assert_eq!(host.get("/kv"), "live-value"); + assert_eq!(host.get("/kv"), "live-value"); + assert_eq!(fixture.count(), 3); + drop(host); + fs::write(&path, bindings(&fixture.endpoint())).expect("fresh process bindings"); + let mut restarted = Host::spawn(root.path(), &path, false, false); + assert_eq!(restarted.get("/snapshot"), "second-greeting"); + assert_eq!(fixture.count(), 4, "fresh preparation adopts remote edits"); + } + + #[test] + fn config_failures_never_initialize_listen_or_fall_back() { + for (status, document, policy, category) in [ + ( + 404, + json!({"error": "not_found"}).to_string().into_bytes(), + "", + "missing", + ), + (200, vec![0xff], "", "malformed"), + ( + 200, + envelope("document-too-large"), + "\n[limits.celld]\nmax_config_document_bytes=4\n", + "size limit", + ), + ] { + let root = tempfile::tempdir().expect("root"); + let fixture = ScopedFixture::new(status, document, false); + let path = root.path().join("bindings.toml"); + fs::write(&path, format!("{}{policy}", bindings(&fixture.endpoint()))) + .expect("bindings"); + let mut host = Host::spawn(root.path(), &path, false, false); + let error = host.failure(); + host.no_listener(); + assert!(!root.path().join("initializer-entered").exists()); + assert_eq!(fixture.count(), 1); + assert!(error.contains(category), "closed failure category"); + assert!(!error.contains("config-fixture")); + assert!(!error.contains(&fixture.endpoint())); + } + } + + #[test] + fn supplied_remote_source_conflict_never_polls_or_fetches() { + let root = tempfile::tempdir().expect("root"); + let fixture = ScopedFixture::new(200, envelope("unused-greeting"), false); + let path = root.path().join("bindings.toml"); + fs::write(&path, bindings(&fixture.endpoint())).expect("bindings"); + let mut host = Host::spawn(root.path(), &path, false, true); + let error = host.failure(); + host.no_listener(); + assert!(error.contains("duplicate")); + assert_eq!(fixture.count(), 0); + assert!(!root.path().join("initializer-entered").exists()); + assert!(!root.path().join("deferred-polled").exists()); + } + + #[test] + fn invalid_later_kv_credentials_fail_before_any_config_request() { + let root = tempfile::tempdir().expect("root"); + let fixture = ScopedFixture::new(200, envelope("unused-greeting"), false); + let path = root.path().join("bindings.toml"); + let input = bindings(&fixture.endpoint()) + .replace( + &format!( + "[kv.state]\nprovider='celld-kv'\nendpoint='{}'", + fixture.endpoint() + ), + "[kv.state]\nprovider='celld-kv'\nendpoint='https://unreachable.invalid'", + ) + .replace( + "[kv.state.access]\nmode='trusted-network'", + "[kv.state.access]\nmode='authenticated'\ntoken_env='ABSENT_TOKEN'", + ); + fs::write(&path, input).expect("bindings"); + let mut host = Host::spawn(root.path(), &path, false, false); + let error = host.failure(); + host.no_listener(); + assert!(error.contains("celld token")); + assert!(!root.path().join("initializer-entered").exists()); + assert_eq!(fixture.count(), 0); + assert!(!error.contains("ABSENT_TOKEN")); + assert!(!error.contains("unreachable.invalid")); + } +} diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 981fb61e..75c445f4 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -48,7 +48,9 @@ toml = { workspace = true } [dev-dependencies] edgezero-core = { workspace = true, features = ["test-utils"] } +sha2 = { workspace = true } tempfile = { workspace = true } +url = { workspace = true } [features] default = [ diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 9789d92a..eaa86060 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -635,13 +635,13 @@ pub struct ConfigValidateArgs { /// for the declared adapter set and well-formed handler paths. #[arg(long)] pub strict: bool, - /// Native Cargo target triple used for provider capability resolution. + /// Native Cargo target control to normalize; provider availability remains unknown. #[arg(long, requires = "store_bindings")] pub target: Option, - /// Resolve with all target features enabled. + /// Normalize the all-features target control without resolving provider availability. #[arg(long)] pub target_all_features: bool, - /// Add native Cargo features for target capability resolution. + /// Additional native target feature controls; bindings validation remains structural. #[arg(long, value_delimiter = ',')] pub target_features: Vec, /// Suppress target default features. diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 2b133ff3..db69853a 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -458,7 +458,7 @@ pub fn run_config_push_typed(args: &ConfigPushArgs) -> Result<(), String> where C: DeserializeOwned + Serialize + Validate + AppConfigMeta, { - reject_aws_bound_config_push(args)?; + reject_remote_bound_config_push(args)?; // Pre-flight: load + validate. let ctx = load_push_context(args)?; run_shared_checks(&ctx.validation, Some(&args.adapter))?; @@ -1525,6 +1525,8 @@ fn load_validated_native_bindings( appconfig_agent: true, secrets_manager: true, dynamodb: true, + celld_config: true, + celld_kv: true, }, limits, None, @@ -1591,7 +1593,7 @@ fn run_native_bindings_validate(_args: &ConfigValidateArgs) -> Result<(), String } #[cfg(feature = "edgezero-adapter-axum")] -fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { +fn reject_remote_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { let Some(path) = args.store_bindings.as_deref() else { return Ok(()); }; @@ -1605,17 +1607,21 @@ fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { .map_err(|_error| "failed to load application manifest".to_owned())?; let logical = resolve_config_store_id(args.store.as_deref(), manifest.manifest())?; let bindings = load_validated_native_bindings(manifest.manifest(), path)?; - if matches!( - bindings.config().get(&logical), - Some(NativeConfigBinding::AppConfigAgent(_)) - ) { - return Err("config push does not publish to AWS-bound native config stores; update the selected binding through its deployment workflow".to_owned()); + let remote = match bindings.config().get(&logical) { + Some(NativeConfigBinding::AppConfigAgent(_)) => Some("AWS"), + Some(NativeConfigBinding::Celld(_)) => Some("celld"), + None => None, + }; + if let Some(provider) = remote { + return Err(format!( + "config push does not publish to {provider}-bound native config stores; update the selected binding through its deployment workflow" + )); } Ok(()) } #[cfg(not(feature = "edgezero-adapter-axum"))] -fn reject_aws_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { +fn reject_remote_bound_config_push(args: &ConfigPushArgs) -> Result<(), String> { if args.store_bindings.is_some() { return Err("`--store-bindings` requires the CLI Axum native-bindings feature".to_owned()); } @@ -2170,6 +2176,8 @@ mod tests { use std::borrow::Cow; use std::fs; + use std::io::ErrorKind; + use std::net::TcpListener; #[cfg(unix)] use std::sync::Mutex; use tempfile::TempDir; @@ -2691,7 +2699,7 @@ default = "remote" .expect("write bindings"); let mut args = push_args(&manifest, "axum"); args.store_bindings = Some(bindings.clone()); - let error = reject_aws_bound_config_push(&args).expect_err("AWS-bound push is refused"); + let error = reject_remote_bound_config_push(&args).expect_err("AWS-bound push is refused"); assert!(error.contains("does not publish to AWS-bound")); for (local, dry_run) in [(false, false), (true, false), (false, true), (true, true)] { args.local = local; @@ -2706,10 +2714,10 @@ default = "remote" } fs::write(&bindings, AWS_BINDINGS_FIXTURE).expect("write unrelated AWS binding"); - reject_aws_bound_config_push(&args).expect("unrelated declared AWS binding is allowed"); + reject_remote_bound_config_push(&args).expect("unrelated declared AWS binding is allowed"); fs::write(&bindings, AWS_BINDINGS_FIXTURE.replace("remote", "unknown")) .expect("write typo"); - reject_aws_bound_config_push(&args) + reject_remote_bound_config_push(&args) .expect_err("undeclared bindings are not a valid push selection"); } @@ -2744,6 +2752,110 @@ default = "remote" } } + #[cfg(feature = "edgezero-adapter-axum")] + const CELLD_BINDINGS_FIXTURE: &str = r#"version = 1 +[config.remote] +provider = "celld-config" +endpoint = "https://127.0.0.1:7780" +default_key = "main" +[config.remote.access] +mode = "authenticated" +token_env = "CELLD_TEST_TOKEN_MUST_NOT_BE_RESOLVED" +[config.remote.documents.main] +store = "checkout-config" +key = "application-envelope" +"#; + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn celld_bindings_only_validation_does_not_resolve_tokens_features_or_endpoints() { + let manifest_text = PUSH_MANIFEST.replace( + "ids = [\"app_config\"]", + "ids = [\"app_config\", \"remote\"]\ndefault = \"app_config\"", + ); + let (dir, manifest, app_config) = setup_project(&manifest_text, VALID_APP_CONFIG); + fs::remove_file(app_config).expect("structural validation needs no local app config"); + let listener = TcpListener::bind("127.0.0.1:0").expect("probe sentinel"); + listener + .set_nonblocking(true) + .expect("nonblocking sentinel"); + let endpoint = format!( + "https://{}", + listener.local_addr().expect("sentinel address") + ); + let bindings = dir.path().join("celld.toml"); + fs::write( + &bindings, + CELLD_BINDINGS_FIXTURE.replace("https://127.0.0.1:7780", &endpoint), + ) + .expect("write celld bindings"); + let mut args = args_for(&manifest); + args.adapter = Some("axum".to_owned()); + args.store_bindings = Some(bindings); + args.target_features = vec!["a-feature-that-does-not-exist".to_owned()]; + args.target_no_default_features = true; + run_config_validate(&args).expect("valid structure has unknown target availability"); + assert!(matches!(listener.accept(), Err(error) if error.kind() == ErrorKind::WouldBlock)); + assert!(!dir.path().join(".edgezero").exists()); + } + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn celld_push_refuses_only_selected_remote_config_without_local_side_effects() { + let manifest_text = PUSH_MANIFEST.replace( + "ids = [\"app_config\"]", + "ids = [\"app_config\", \"remote\"]\ndefault = \"app_config\"", + ); + let (dir, manifest, app_config) = setup_project(&manifest_text, VALID_APP_CONFIG); + fs::remove_file(app_config).expect("remote refusal precedes app TOML loading"); + let bindings = dir.path().join("celld.toml"); + fs::write( + &bindings, + CELLD_BINDINGS_FIXTURE.replace("config.remote", "config.app_config"), + ) + .expect("write selected celld binding"); + let mut args = push_args(&manifest, "axum"); + args.store_bindings = Some(bindings.clone()); + for (local, dry_run) in [(false, false), (true, false), (false, true), (true, true)] { + args.local = local; + args.dry_run = dry_run; + let error = run_config_push_typed::(&args) + .expect_err("remote publication unsupported"); + assert!(error.contains("does not publish to celld-bound")); + assert!(!error.contains("CELLD_TEST_TOKEN")); + assert!(!dir.path().join(".edgezero").exists()); + } + fs::write(&bindings, CELLD_BINDINGS_FIXTURE).expect("unrelated remote config"); + reject_remote_bound_config_push(&args) + .expect("local selected ID is not blocked by another remote ID"); + } + + #[cfg(feature = "edgezero-adapter-axum")] + #[test] + fn celld_validation_and_push_share_strict_structural_rejection() { + let (dir, manifest, app_config) = setup_project(PUSH_MANIFEST, FIXTURE_APP_CONFIG); + fs::remove_file(app_config).expect("structural errors need no app TOML"); + let bindings = dir.path().join("celld.toml"); + let mut validate = args_for(&manifest); + validate.adapter = Some("axum".to_owned()); + validate.store_bindings = Some(bindings.clone()); + let mut push = push_args(&manifest, "axum"); + push.store_bindings = Some(bindings.clone()); + for input in [ + CELLD_BINDINGS_FIXTURE.to_owned(), + CELLD_BINDINGS_FIXTURE.replace("celld-config", "celld-secret"), + CELLD_BINDINGS_FIXTURE.replace("default_key = \"main\"", "default_key = \"missing\""), + ] { + fs::write(&bindings, input).expect("invalid document"); + let validation = run_config_validate(&validate).expect_err("invalid bindings"); + assert_eq!( + run_config_push_typed::(&push).expect_err("invalid push bindings"), + validation + ); + assert!(!dir.path().join(".edgezero").exists()); + } + } + #[test] fn raw_validates_a_well_formed_project() { let _lock = manifest_guard().lock().expect("manifest guard"); diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 60ca8d84..d8c9195f 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1443,20 +1443,39 @@ mod tests { let axum_cargo = fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/Cargo.toml")) .expect("read generated axum Cargo.toml"); + let parsed: toml::Value = + toml::from_str(&axum_cargo).expect("parse generated Axum Cargo.toml"); + let features = parsed + .get("features") + .and_then(toml::Value::as_table) + .expect("generated native features"); for feature in [ "aws-appconfig-agent", "aws-secrets-manager", - "aws-stores", "aws-dynamodb", + "celld-config", + "celld-kv", ] { - assert!( - axum_cargo.contains(feature), - "missing generated native feature {feature}" + assert_eq!( + features.get(feature), + Some(&toml::Value::Array(vec![toml::Value::String(format!( + "edgezero-adapter-axum/{feature}" + ))])), + "incorrect generated native forwarding for {feature}" ); } - assert!( - axum_cargo.contains("default = []"), - "AWS features must be opt-in" + assert_eq!( + features.get("default"), + Some(&toml::Value::Array(Vec::new())), + "native providers must be opt-in" + ); + assert_eq!( + features.get("aws-stores"), + Some(&toml::Value::Array(vec![ + toml::Value::String("aws-appconfig-agent".to_owned()), + toml::Value::String("aws-secrets-manager".to_owned()) + ])), + "AWS alias membership must not acquire other providers" ); let axum_main = fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) diff --git a/crates/edgezero-cli/tests/celld_generated_proof.rs b/crates/edgezero-cli/tests/celld_generated_proof.rs new file mode 100644 index 00000000..a6e3f666 --- /dev/null +++ b/crates/edgezero-cli/tests/celld_generated_proof.rs @@ -0,0 +1,787 @@ +//! Explicit generated native consumer proofs for the scoped API embedded in celld. +//! Ordinary tests require no service. The ignored real-service proof requires an +//! explicit `CELLD_BIN`, curl and OpenSSL; missing tools are failures, not skips. + +#[cfg(test)] +#[cfg(unix)] +mod tests { + #![expect( + clippy::arbitrary_source_item_ordering, + reason = "keep fixture ownership, generated source and proof steps together" + )] + + use std::collections::BTreeMap; + use std::env; + use std::fs; + use std::io::{ErrorKind, Read as _, Write as _}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + use sha2::{Digest as _, Sha256}; + use tempfile::TempDir; + use url::Url; + + const APP_TOKEN: &str = "fixture-app-token-not-a-production-credential-1234567890"; + const OPERATOR_TOKEN: &str = "fixture-operator-token-not-a-production-credential-123456"; + const WAIT: Duration = Duration::from_secs(30); + + fn deadline(duration: Duration) -> Instant { + Instant::now() + .checked_add(duration) + .expect("representable proof deadline") + } + + fn envelope(greeting: &str) -> Vec { + serde_json::to_vec(&BlobEnvelope::new( + json!({"greeting":greeting}), + "2026-10-15T12:00:00Z".to_owned(), + )) + .expect("whole configuration envelope") + } + + type Rows = BTreeMap<(String, String), Vec>; + + struct Fixture { + endpoint: String, + rows: Arc>, + requests: Arc, + config_reads: Arc, + stop: Arc, + worker: Option>, + } + + impl Fixture { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("scoped fixture"); + listener.set_nonblocking(true).expect("nonblocking fixture"); + let endpoint = format!("http://{}", listener.local_addr().expect("fixture address")); + let rows = Arc::new(Mutex::new(BTreeMap::from([( + ("app-config".to_owned(), "settings".to_owned()), + envelope("first-config"), + )]))); + let requests = Arc::new(AtomicUsize::new(0)); + let config_reads = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let (worker_rows, worker_requests, worker_config, worker_stop) = ( + Arc::clone(&rows), + Arc::clone(&requests), + Arc::clone(&config_reads), + Arc::clone(&stop), + ); + let worker = thread::spawn(move || { + let end = deadline(Duration::from_mins(10)); + while !worker_stop.load(Ordering::Relaxed) && Instant::now() < end { + match listener.accept() { + Ok((mut stream, peer)) => { + assert!(peer.ip().is_loopback()); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("fixture timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .expect("fixture timeout"); + worker_requests.fetch_add(1, Ordering::Relaxed); + let (method, target, body) = read_request(&mut stream); + let (status, payload, kind) = fixture_response( + &method, + &target, + &body, + &worker_rows, + &worker_config, + ); + write!(stream,"HTTP/1.1 {status}\r\nx-celld-store-version: 1\r\ncontent-type: {kind}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",payload.len()).expect("fixture headers"); + stream.write_all(&payload).expect("fixture body"); + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("scoped fixture accept: {error}"), + } + } + }); + Self { + endpoint, + rows, + requests, + config_reads, + stop, + worker: Some(worker), + } + } + } + impl Drop for Fixture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + worker.join().expect("reap scoped fixture"); + } + } + } + + fn read_request(stream: &mut TcpStream) -> (String, String, Vec) { + let mut bytes = Vec::new(); + loop { + let mut byte = [0]; + stream.read_exact(&mut byte).expect("fixture request head"); + bytes.push(byte[0]); + assert!(bytes.len() <= 16 * 1024, "bounded fixture head"); + if bytes.ends_with(b"\r\n\r\n") { + break; + } + } + let head = String::from_utf8(bytes).expect("ASCII fixture head"); + let mut line = head + .lines() + .next() + .expect("request line") + .split_ascii_whitespace(); + let method = line.next().expect("method").to_owned(); + let target = line.next().expect("target").to_owned(); + let length = head + .lines() + .filter_map(|header| header.split_once(':')) + .find_map(|(key, value)| { + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("length")) + }) + .unwrap_or(0); + assert!(length <= 1024 * 1024, "bounded fixture value"); + let mut body = vec![0; length]; + stream.read_exact(&mut body).expect("fixture request body"); + (method, target, body) + } + + fn fixture_response( + method: &str, + target: &str, + body: &[u8], + contents: &Mutex, + reads: &AtomicUsize, + ) -> (&'static str, Vec, &'static str) { + let url = Url::parse(&format!("http://fixture{target}")).expect("fixture URL"); + let parts = url.path().split('/').collect::>(); + let parameters = url.query_pairs().into_owned().collect::>(); + let store = parts.get(3).expect("store").to_string(); + let value_key = parameters.get("key").cloned().unwrap_or_default(); + let mut rows = contents.lock().expect("fixture rows"); + if parts.last() == Some(&"keys") { + let prefix = parameters.get("prefix").map_or("", String::as_str); + let after = parameters + .get("cursor") + .and_then(|continuation| continuation.strip_prefix("after:")) + .unwrap_or(""); + let limit = parameters + .get("limit") + .map_or(100, |value| value.parse::().expect("page limit")); + let available_keys = rows + .keys() + .filter(|(alias, key)| { + alias == &store && key.starts_with(prefix) && key.as_str() > after + }) + .map(|(_, key)| key.clone()) + .collect::>(); + let more = available_keys.len() > limit; + let keys = available_keys.into_iter().take(limit).collect::>(); + let cursor = + more.then(|| format!("after:{}", keys.last().expect("nonempty continuing page"))); + return ( + "200 OK", + serde_json::to_vec(&json!({"keys":keys,"cursor":cursor})).expect("page"), + "application/json", + ); + } + let pair = (store.clone(), value_key); + match method { + "GET" | "HEAD" => { + if store == "app-config" { + reads.fetch_add(1, Ordering::Relaxed); + } + match rows.get(&pair) { + Some(value) => ( + "200 OK", + if method == "HEAD" { + Vec::new() + } else { + value.clone() + }, + "application/octet-stream", + ), + None => ( + "404 Not Found", + if method == "HEAD" { + Vec::new() + } else { + serde_json::to_vec(&json!({"error":"not_found"})).expect("miss") + }, + "application/json", + ), + } + } + "PUT" => { + rows.insert(pair, body.to_vec()); + ("204 No Content", Vec::new(), "application/octet-stream") + } + "DELETE" => { + rows.remove(&pair); + ("204 No Content", Vec::new(), "application/octet-stream") + } + _ => panic!("unexpected scoped fixture method"), + } + } + + struct App { + child: Child, + address: SocketAddr, + log: PathBuf, + } + impl Drop for App { + fn drop(&mut self) { + let _killed = self.child.kill(); + let _reaped = self.child.wait(); + } + } + impl App { + fn start( + binary: &Path, + project: &Path, + bindings: &Path, + mode: &str, + tls: Option<(&str, &Path)>, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("app address"); + let address = listener.local_addr().expect("app address"); + drop(listener); + let log = project.join(format!("app-{}-{mode}.log", address.port())); + let mut command = Command::new(binary); + command + .current_dir(project) + .env_clear() + .env("HOME", project.join("empty-home")) + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("EDGEZERO__STORE_BINDINGS_FILE", bindings) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", address.port().to_string()) + .env("CELLD_PROOF_MODE", mode) + .stdout(Stdio::null()) + .stderr(Stdio::from(fs::File::create(&log).expect("app log"))); + if let Some((token, ca)) = tls { + command + .env("CELLD_APP_TOKEN", token) + .env("SSL_CERT_FILE", ca); + } + Self { + child: command.spawn().expect("generated app"), + address, + log, + } + } + fn request(&mut self) -> String { + let end = deadline(WAIT); + loop { + if let Some(status) = self.child.try_wait().expect("app state") { + panic!( + "generated app exited {status}: {}", + fs::read_to_string(&self.log).unwrap_or_default() + ); + } + if let Ok(mut stream) = + TcpStream::connect_timeout(&self.address, Duration::from_millis(50)) + { + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("app response timeout"); + write!( + stream, + "GET /celld-proof HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .expect("app request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("app response"); + return response; + } + assert!(Instant::now() < end, "app startup deadline"); + thread::sleep(Duration::from_millis(10)); + } + } + fn rejected(&mut self) { + let end = deadline(WAIT); + loop { + if let Some(status) = self.child.try_wait().expect("rejected app state") { + assert!(!status.success(), "selected unavailable provider must fail"); + break; + } + assert!( + Instant::now() < end, + "invalid source unexpectedly stayed alive" + ); + thread::sleep(Duration::from_millis(10)); + } + TcpStream::connect_timeout(&self.address, Duration::from_millis(50)) + .expect_err("rejected startup has no listener"); + } + } + + fn generate(root: &Path) -> PathBuf { + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["new", "celld-proof", "--dir"]) + .arg(root) + .output() + .expect("real CLI scaffold"); + assert!( + output.status.success(), + "scaffold: {}", + String::from_utf8_lossy(&output.stderr) + ); + let project = root.join("celld-proof"); + let manifest = project.join("edgezero.toml"); + let mut text = fs::read_to_string(&manifest).expect("generated manifest"); + text.push_str( + r#" +[[triggers.http]] +id = "celld-proof" +path = "/celld-proof" +methods = ["GET"] +handler = "celld_proof_core::handlers::celld_probe" +adapters = ["axum"] +[stores.config] +ids = ["app_config"] +[stores.kv] +ids = ["state"] +"#, + ); + fs::write(manifest, text).expect("probe declarations"); + fs::write( + project.join("crates/celld-proof-core/src/config.rs"), + " +use serde::Deserialize; +use validator::Validate; +#[derive(Debug, Deserialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct CelldProofConfig { + #[validate(length(min = 3_u64))] + pub greeting: String, +} +pub struct StartupState { + pub greeting: String, + pub mutated: std::sync::atomic::AtomicBool, +} +", + ) + .expect("typed config"); + let handlers = project.join("crates/celld-proof-core/src/handlers.rs"); + let mut source = fs::read_to_string(&handlers).expect("generated handlers"); + source.push_str(r#" +#[action] +pub async fn celld_probe( + edgezero_core::extractor::State(state): edgezero_core::extractor::State>, + kv: edgezero_core::extractor::Kv, +) -> Result, EdgeError> { + let store = kv.default().ok_or_else(|| EdgeError::service_unavailable("KV missing"))?; + if !state.mutated.swap(true, std::sync::atomic::Ordering::SeqCst) { + match std::env::var("CELLD_PROOF_MODE").unwrap_or_default().as_str() { + "put" => { + store.put_bytes("probe/binary", vec![0,255,1,128].into()).await?; + store.put_bytes("probe/empty", Vec::new().into()).await?; + store.put_bytes_with_ttl("probe/ttl", vec![7].into(),std::time::Duration::from_secs(60)).await?; + } + "delete" => { store.delete("probe/binary").await?; } + _ => {} + } + } + let binary = store.get_bytes("probe/binary").await?.map(|v|v.to_vec()); + let empty = store.get_bytes("probe/empty").await?.is_some_and(|v|v.is_empty()); + let exists = store.exists("probe/binary").await?; + let ttl = store.get_bytes("probe/ttl").await?.is_some(); + let page = store.list_keys_page("probe/",None,1).await?; + let next = store.list_keys_page("probe/",page.cursor.as_deref(),1).await?; + Ok(Text::new(format!("greeting={};binary={binary:?};empty={empty};exists={exists};ttl={ttl};first={};next={}",state.greeting,page.keys.join(","),next.keys.join(",")))) +} +"#); + fs::write(handlers, source).expect("portable proof handler"); + fs::write(project.join("crates/celld-proof-adapter-axum/src/main.rs"),r#" +use std::sync::{Arc,atomic::AtomicBool}; +use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; +use edgezero_core::{EdgeError,extractor::AppConfig}; +use celld_proof_core::config::{CelldProofConfig,StartupState}; +fn main() -> anyhow::Result<()> { + run_production_app_with_initializer::(|app,stores| { + Box::pin(async move { + let binding=stores.config().and_then(|r|r.named_ref("app_config")) + .ok_or_else(|| EdgeError::service_unavailable("config binding missing"))?; + let config=AppConfig::::from_binding(binding,None,stores.secrets(), + app.config_extraction_limits(),app.monotonic_clock()).await?; + app.insert_state(Arc::new(StartupState{greeting:config.greeting.clone(),mutated:AtomicBool::new(false)})); + Ok(()) + }) + })?; + Ok(()) +} +"#).expect("production initializer"); + project + } + + fn bindings(project: &Path, endpoint: &str, authenticated: bool) -> PathBuf { + let path = project.join("celld-bindings.toml"); + let access = if authenticated { + "mode = \"authenticated\"\ntoken_env = \"CELLD_APP_TOKEN\"" + } else { + "mode = \"trusted-network\"" + }; + fs::write( + &path, + format!( + r#"version = 1 +[config.app_config] +provider = "celld-config" +endpoint = "{endpoint}" +default_key = "settings" +[config.app_config.access] +{access} +[config.app_config.documents.settings] +store = "app-config" +key = "settings" +[kv.state] +provider = "celld-kv" +endpoint = "{endpoint}" +store = "state" +[kv.state.access] +{access} +"# + ), + ) + .expect("native celld bindings"); + path + } + + fn build(project: &Path, features: Option<&str>) -> PathBuf { + let target = env::var_os("EDGEZERO_GENERATED_TEST_TARGET_DIR") + .map_or_else(|| project.join("target"), PathBuf::from); + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); + command + .current_dir(project) + .env("CARGO_TARGET_DIR", &target) + .args(["build", "--adapter", "axum", "--", "--offline"]); + if let Some(selected_features) = features { + command.args(["--features", selected_features]); + } + let output = command.output().expect("real CLI build"); + assert!( + output.status.success(), + "generated build: {}", + String::from_utf8_lossy(&output.stderr) + ); + target.join("debug/celld-proof-adapter-axum") + } + + #[test] + #[ignore = "builds actual generated native consumers; coordinator owns Cargo integration"] + fn generated_celld_default_off_kv_and_config_restart() { + let temp = TempDir::new().expect("project"); + let project = generate(temp.path()); + let fixture = Fixture::start(); + let binding = bindings(&project, &fixture.endpoint, false); + for features in [None, Some("celld-config"), Some("celld-kv")] { + let binary = build(&project, features); + let mut app = App::start(&binary, &project, &binding, "read", None); + app.rejected(); + assert_eq!( + fixture.requests.load(Ordering::Relaxed), + 0, + "all source validation must precede config I/O" + ); + } + let binary = build(&project, Some("celld-config,celld-kv")); + let mut first = App::start(&binary, &project, &binding, "put", None); + let response = first.request(); + assert!( + response.contains("200 OK") + && response.contains("greeting=first-config") + && response.contains("binary=Some([0, 255, 1, 128])") + && response.contains("empty=true") + && response.contains("exists=true") + && response.contains("first=probe/binary") + && response.contains("next=probe/empty"), + "generated live KV/config: {response}" + ); + assert_eq!(fixture.config_reads.load(Ordering::Relaxed), 1); + fixture.rows.lock().expect("fixture update").insert( + ("app-config".to_owned(), "settings".to_owned()), + envelope("second-config"), + ); + assert!(first.request().contains("greeting=first-config")); + assert_eq!( + fixture.config_reads.load(Ordering::Relaxed), + 1, + "requests use immutable startup snapshot" + ); + drop(first); + let mut second = App::start(&binary, &project, &binding, "read", None); + let restarted_response = second.request(); + assert!( + restarted_response.contains("greeting=second-config") + && restarted_response.contains("binary=Some([0, 255, 1, 128])"), + "restart adoption and persistence: {restarted_response}" + ); + drop(second); + let all_binary = build( + &project, + Some("celld-config,celld-kv,aws-stores,aws-dynamodb"), + ); + let mut coexistence = App::start(&all_binary, &project, &binding, "read", None); + let coexistence_response = coexistence.request(); + assert!( + coexistence_response.contains("greeting=second-config") + && coexistence_response.contains("empty=true"), + "compiled AWS providers do not require AWS inputs for celld-only bindings: {coexistence_response}" + ); + drop(coexistence); + let mut deleting = App::start(&binary, &project, &binding, "delete", None); + assert!(deleting.request().contains("binary=None")); + drop(deleting); + for value in [vec![255], Vec::new(), envelope("x")] { + fixture + .rows + .lock() + .expect("malformed config") + .insert(("app-config".to_owned(), "settings".to_owned()), value); + let mut failed = App::start(&binary, &project, &binding, "read", None); + failed.rejected(); + } + fixture + .rows + .lock() + .expect("missing config") + .remove(&("app-config".to_owned(), "settings".to_owned())); + let mut missing = App::start(&binary, &project, &binding, "read", None); + missing.rejected(); + assert!( + !project.join(".edgezero").exists(), + "remote-bound IDs create no local roots" + ); + } + + struct Celld { + child: Child, + endpoint: String, + cert: PathBuf, + } + impl Drop for Celld { + fn drop(&mut self) { + let _killed = self.child.kill(); + let _reaped = self.child.wait(); + } + } + impl Celld { + fn start(root: &Path) -> Self { + let binary = env::var_os("CELLD_BIN") + .map(PathBuf::from) + .expect("CELLD_BIN is required; this proof never silently skips"); + assert!( + binary.is_absolute() && binary.is_file(), + "explicit celld executable required" + ); + let cert = root.join("celld-cert.pem"); + let key = root.join("celld-key.pem"); + let certificate_status = Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=celld-proof", + "-addext", + "subjectAltName=IP:127.0.0.1", + "-addext", + "basicConstraints=critical,CA:FALSE", + "-addext", + "extendedKeyUsage=serverAuth", + "-keyout", + ]) + .arg(&key) + .arg("-out") + .arg(&cert) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("explicit OpenSSL prerequisite"); + assert!(certificate_status.success()); + let policy = root.join("celld-policy.json"); + fs::write(&policy,serde_json::to_vec(&json!({ + "version":1_u8,"listen":"127.0.0.1:0","tls":{"cert":cert,"key":key}, + "stores":[{"alias":"app-config","namespace":"proof-config"},{"alias":"state","namespace":"proof-state"}], + "access":{"mode":"authenticated","tokens":[ + {"id":"app","sha256":format!("{:x}",Sha256::digest(APP_TOKEN)),"grants":[{"store":"app-config","operations":["read"]},{"store":"state","operations":["read","write","delete","list"]}]}, + {"id":"operator","sha256":format!("{:x}",Sha256::digest(OPERATOR_TOKEN)),"grants":[{"store":"app-config","operations":["read","write"]}]} + ]} + })).expect("operator policy")).expect("write operator policy"); + let log = root.join("celld.log"); + let output = fs::File::create(&log).expect("celld log"); + let child = Command::new(binary) + .current_dir(root) + .env_clear() + .env("CELLD_TEST_DATA_DIR", root.join("celld-data")) + .args([ + "--listen", + "127.0.0.1:0", + "--internal-listen", + "127.0.0.1:0", + "--store-api-config", + ]) + .arg(&policy) + .stdout(Stdio::from(output.try_clone().expect("log clone"))) + .stderr(Stdio::from(output)) + .spawn() + .expect("real celld process"); + let mut service = Self { + child, + endpoint: String::new(), + cert, + }; + let end = deadline(WAIT); + loop { + let text = fs::read_to_string(&log).expect("celld log"); + if let Some(address) = text.lines().find_map(|line| { + line.split_once("celld scoped stores listening on ") + .map(|(_, listen_address)| listen_address.trim()) + }) { + service.endpoint = format!("https://{address}"); + break; + } + if let Some(status) = service.child.try_wait().expect("celld state") { + panic!("celld startup {status}: {text}"); + } + assert!(Instant::now() < end, "real celld startup deadline"); + thread::sleep(Duration::from_millis(10)); + } + service + } + fn publish(&self, root: &Path, greeting: &str) { + let document = root.join("published-config.json"); + fs::write(&document, envelope(greeting)).expect("operator-owned document"); + let mut child = Command::new("curl") + .args([ + "--disable", + "--silent", + "--show-error", + "--noproxy", + "*", + "--max-time", + "10", + "--cacert", + ]) + .arg(&self.cert) + .args(["--request", "PUT", "--data-binary"]) + .arg(format!("@{}", document.display())) + .args([ + "--url", + &format!("{}/v1/stores/app-config/value?key=settings", self.endpoint), + "--config", + "-", + "--output", + ]) + .arg(root.join("publish-response")) + .args(["--write-out", "%{http_code}"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("explicit curl operator prerequisite"); + child + .stdin + .take() + .expect("operator pipe") + .write_all( + format!("header = \"Authorization: Bearer {OPERATOR_TOKEN}\"\n").as_bytes(), + ) + .expect("operator credential on private stdin"); + let output = child.wait_with_output().expect("operator publish"); + assert!( + output.status.success(), + "operator publish: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "204"); + } + } + + #[test] + #[ignore = "requires explicit modified CELLD_BIN, curl and OpenSSL; runs actual generated consumer over verified TLS"] + fn generated_native_consumer_uses_embedded_celld_over_tls() { + let temp = TempDir::new().expect("real-service fixture"); + let service = Celld::start(temp.path()); + service.publish(temp.path(), "first-config"); + let project = generate(temp.path()); + let binding = bindings(&project, &service.endpoint, true); + let binary = build(&project, Some("celld-config,celld-kv")); + let mut first = App::start( + &binary, + &project, + &binding, + "put", + Some((APP_TOKEN, &service.cert)), + ); + let response = first.request(); + assert!( + response.contains("200 OK") + && response.contains("greeting=first-config") + && response.contains("binary=Some([0, 255, 1, 128])") + && response.contains("ttl=true"), + "real service path: {response}" + ); + service.publish(temp.path(), "second-config"); + assert!(first.request().contains("greeting=first-config")); + thread::sleep(Duration::from_secs(61)); + let expired = first.request(); + assert!( + expired.contains("ttl=false") && expired.contains("greeting=first-config"), + "live elapsed service expiry with immutable configuration: {expired}" + ); + drop(first); + drop(service); + let restarted_service = Celld::start(temp.path()); + let restarted_binding = bindings(&project, &restarted_service.endpoint, true); + let mut second = App::start( + &binary, + &project, + &restarted_binding, + "read", + Some((APP_TOKEN, &restarted_service.cert)), + ); + let restarted_response = second.request(); + assert!( + restarted_response.contains("greeting=second-config") + && restarted_response.contains("binary=Some([0, 255, 1, 128])"), + "real service restart adoption: {restarted_response}" + ); + drop(second); + let mut rejected = App::start( + &binary, + &project, + &restarted_binding, + "read", + Some(( + "wrong-fixture-token-longer-than-32-bytes", + &restarted_service.cert, + )), + ); + rejected.rejected(); + let diagnostic = fs::read_to_string(&rejected.log).expect("closed error"); + assert!( + !diagnostic.contains(APP_TOKEN) + && !diagnostic.contains(OPERATOR_TOKEN) + && !diagnostic.contains(&restarted_service.endpoint) + ); + assert!(!project.join(".edgezero").exists()); + } +} diff --git a/crates/edgezero-store-celld/Cargo.toml b/crates/edgezero-store-celld/Cargo.toml new file mode 100644 index 00000000..434249ea --- /dev/null +++ b/crates/edgezero-store-celld/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "edgezero-store-celld" +description = "Optional native scoped celld configuration snapshots and live KV for EdgeZero" +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +config = ["_transport"] +kv = ["_transport", "dep:base64", "dep:sha2"] +_transport = ["dep:edgezero-core", "dep:bytes", "dep:async-trait", "dep:reqwest", "dep:tokio", "dep:futures-util", "dep:serde_json"] + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-trait = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } +bytes = { workspace = true, optional = true } +edgezero-core = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true, features = ["stream"] } +serde_json = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio-rustls = { workspace = true } diff --git a/crates/edgezero-store-celld/src/client.rs b/crates/edgezero-store-celld/src/client.rs new file mode 100644 index 00000000..2f5563ec --- /dev/null +++ b/crates/edgezero-store-celld/src/client.rs @@ -0,0 +1,852 @@ +//! One bounded HTTP exchange. No discovery, redirects, proxies or mutation replay. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "keep transport construction, request lifecycle and response validators together" +)] + +use std::fmt; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::StreamExt as _; +#[cfg(feature = "kv")] +use reqwest::header::CONTENT_LENGTH; +use reqwest::header::{ + ACCEPT_ENCODING, AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderValue, +}; +use reqwest::redirect::Policy; +use reqwest::retry::never; +use reqwest::{Client, Method, StatusCode}; +use serde::Deserialize; +use tokio::time::timeout; +use url::Url; + +use crate::PreparationError; +use crate::settings::{CelldAccessToken, CelldPreparationLimits}; + +const ERROR_CAP: usize = 1_024; +const VERSION_HEADER: &str = "x-celld-store-version"; + +pub(crate) struct ScopedClient { + authorization: Option, + client: Client, + clock: MonotonicClock, + endpoint: Url, + limits: CelldPreparationLimits, +} + +impl fmt::Debug for ScopedClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ScopedClient").finish_non_exhaustive() + } +} + +#[derive(Clone, Copy)] +pub(crate) enum ResponseKind { + #[cfg(feature = "kv")] + Exists, + #[cfg(feature = "kv")] + Mutation, + #[cfg(feature = "kv")] + Page, + #[cfg(feature = "config")] + Document, + #[cfg(feature = "kv")] + Value, +} + +impl ScopedClient { + pub(crate) fn new( + endpoint: Url, + token: Option<&CelldAccessToken>, + clock: MonotonicClock, + limits: CelldPreparationLimits, + ) -> Result { + Self::build(endpoint, token, clock, limits, None) + } + + // A root-only seam keeps tests on the production transport policy. Production + // always passes None; no arbitrary client or insecure TLS switch is exposed. + fn build( + endpoint: Url, + token: Option<&CelldAccessToken>, + clock: MonotonicClock, + limits: CelldPreparationLimits, + root: Option, + ) -> Result { + let mut builder = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .retry(never()) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .connection_verbose(false) + .http1_only() + .connect_timeout(Duration::from_millis(limits.connect_timeout_ms)) + .timeout(Duration::from_millis(limits.operation_timeout_ms)); + if let Some(certificate) = root { + builder = builder.tls_certs_only([certificate]); + } + let authorization = token + .map(|credential| { + let mut header = HeaderValue::from_str(&format!("Bearer {}", credential.expose())) + .map_err(|_error| PreparationError::Credentials)?; + header.set_sensitive(true); + Ok(header) + }) + .transpose()?; + Ok(Self { + authorization, + client: builder + .build() + .map_err(|_error| PreparationError::Unavailable)?, + clock, + endpoint, + limits, + }) + } + + pub(crate) fn deadline(&self) -> Result { + self.clock + .now() + .checked_add(Duration::from_millis(self.limits.operation_timeout_ms)) + .map(Deadline::at_instant) + .ok_or(PreparationError::Deadline) + } + + pub(crate) fn check(&self, deadline: Deadline) -> Result { + deadline + .remaining_at(self.clock.now()) + .ok_or(PreparationError::Deadline) + } + + pub(crate) fn url(&self, store: &str, resource: &str) -> Result { + let mut url = self.endpoint.clone(); + url.path_segments_mut() + .map_err(|_error| PreparationError::InvalidEndpoint)? + .clear() + .extend(["v1", "stores", store, resource]); + Ok(url) + } + + /// Covers sending, streaming and decoding with one real timer and injected + /// monotonic checks. None means a validated miss, never a protocol failure. + pub(crate) async fn execute( + &self, + method: Method, + url: Url, + body: Option, + cap: usize, + deadline: Deadline, + #[cfg_attr( + not(feature = "kv"), + expect( + unused_variables, + reason = "config-only builds have one request kind and no optional misses" + ) + )] + kind: ResponseKind, + ) -> Result>, PreparationError> { + let budget = self + .check(deadline)? + .min(Duration::from_millis(self.limits.operation_timeout_ms)); + let mut request = self + .client + .request(method.clone(), url) + .header(ACCEPT_ENCODING, "identity") + .timeout(budget); + if let Some(authorization) = &self.authorization { + request = request.header(AUTHORIZATION, authorization.clone()); + } + if let Some(payload) = body { + request = request + .header(CONTENT_TYPE, "application/octet-stream") + .body(payload); + } + let result = timeout(budget, async { + let response = request.send().await.map_err(http_error)?; + let headers = response.headers(); + validate_headers(headers)?; + let status = response.status(); + #[cfg(feature = "kv")] + let success = status + == if matches!(kind, ResponseKind::Mutation) { + StatusCode::NO_CONTENT + } else { + StatusCode::OK + }; + #[cfg(not(feature = "kv"))] + let success = status == StatusCode::OK; + #[cfg(feature = "kv")] + if matches!(kind, ResponseKind::Mutation) + && success + && headers + .get(CONTENT_LENGTH) + .is_some_and(|length| length.as_bytes() != b"0") + { + return Err(PreparationError::Malformed); + } + let missing = status == StatusCode::NOT_FOUND; + // The wire HEAD contract has no error body, including non-404 errors. + if method == Method::HEAD { + return if success { + Ok(Some(Vec::new())) + } else if missing { + Ok(None) + } else { + Err(status_error(status) + .map_or(PreparationError::Malformed, |(_, error)| error)) + }; + } + #[cfg(feature = "kv")] + let expected_type = if !success || matches!(kind, ResponseKind::Page) { + "application/json" + } else { + "application/octet-stream" + }; + #[cfg(not(feature = "kv"))] + let expected_type = if success { + "application/octet-stream" + } else { + "application/json" + }; + validate_content_type(headers, expected_type)?; + let response_cap = if success { cap } else { ERROR_CAP }; + let bytes = collect(response, response_cap, &self.clock, deadline).await?; + if success { + return Ok(Some(bytes)); + } + let (expected, error) = status_error(status).ok_or(PreparationError::Malformed)?; + let parsed: ErrorBody = + serde_json::from_slice(&bytes).map_err(|_error| PreparationError::Malformed)?; + if parsed.error != expected { + return Err(PreparationError::Malformed); + } + #[cfg(feature = "kv")] + if missing && matches!(kind, ResponseKind::Value) { + return Ok(None); + } + Err(error) + }) + .await + .map_err(|_elapsed| PreparationError::Deadline); + self.check(deadline)?; + result? + } +} + +fn validate_headers(headers: &HeaderMap) -> Result<(), PreparationError> { + let mut versions = headers.get_all(VERSION_HEADER).iter(); + if versions.next().is_none_or(|value| value.as_bytes() != b"1") || versions.next().is_some() { + return Err(PreparationError::UnsupportedVersion); + } + let mut encodings = headers.get_all(CONTENT_ENCODING).iter(); + if encodings + .next() + .is_some_and(|value| !value.as_bytes().eq_ignore_ascii_case(b"identity")) + || encodings.next().is_some() + { + return Err(PreparationError::Malformed); + } + Ok(()) +} + +fn validate_content_type(headers: &HeaderMap, expected: &str) -> Result<(), PreparationError> { + let mut types = headers.get_all(CONTENT_TYPE).iter(); + if types + .next() + .is_none_or(|value| value.as_bytes() != expected.as_bytes()) + || types.next().is_some() + { + return Err(PreparationError::Malformed); + } + Ok(()) +} + +async fn collect( + response: reqwest::Response, + cap: usize, + clock: &MonotonicClock, + deadline: Deadline, +) -> Result, PreparationError> { + let cap_u64 = u64::try_from(cap).map_err(|_error| PreparationError::SizeLimit)?; + if response + .content_length() + .is_some_and(|length| length > cap_u64) + { + return Err(PreparationError::SizeLimit); + } + let mut bytes = Vec::with_capacity(cap.min(0x4000)); + let mut stream = response.bytes_stream(); + while let Some(next_chunk) = stream.next().await { + let chunk = next_chunk.map_err(http_error)?; + if deadline.is_expired_at(clock.now()) { + return Err(PreparationError::Deadline); + } + if chunk.len() > cap.saturating_sub(bytes.len()) { + return Err(PreparationError::SizeLimit); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ErrorBody { + error: String, +} + +#[expect( + clippy::needless_pass_by_value, + reason = "consume transport errors at the redaction boundary so no source chain escapes" +)] +fn http_error(error: reqwest::Error) -> PreparationError { + if error.is_timeout() { + PreparationError::Deadline + } else { + PreparationError::Unavailable + } +} + +fn status_error(status: StatusCode) -> Option<(&'static str, PreparationError)> { + Some(match status.as_u16() { + 400 => ("invalid_input", PreparationError::InvalidResource), + 401 => ("unauthenticated", PreparationError::Unauthenticated), + 403 => ("forbidden", PreparationError::Forbidden), + 404 => ("not_found", PreparationError::Missing), + 413 => ("limit_exceeded", PreparationError::SizeLimit), + 426 => ("unsupported_version", PreparationError::UnsupportedVersion), + 503 => ("unavailable", PreparationError::Unavailable), + 504 => ("deadline", PreparationError::Deadline), + _ => return None, + }) +} + +#[cfg(test)] +#[cfg(any(feature = "config", feature = "kv"))] +mod tests { + use std::env::{current_exe, var}; + use std::io::ErrorKind; + use std::net::TcpListener; + use std::process::Command; + + use edgezero_core::time::MonotonicClock; + use reqwest::Method; + use url::Url; + + use super::{ResponseKind, ScopedClient}; + use crate::PreparationError; + use crate::settings::{CelldAccessToken, CelldPreparationLimits}; + use crate::test_support::{Fixture, Reply}; + + #[tokio::test(flavor = "current_thread")] + async fn native_tls_requires_trust_matching_identity_and_valid_dates() { + for (certificate, trusted, accepted) in [ + ( + include_bytes!("fixtures/tls-valid.der").as_slice(), + true, + true, + ), + ( + include_bytes!("fixtures/tls-valid.der").as_slice(), + false, + false, + ), + ( + include_bytes!("fixtures/tls-wrong.der").as_slice(), + true, + false, + ), + ( + include_bytes!("fixtures/tls-expired.der").as_slice(), + true, + false, + ), + ] { + let fixture = Fixture::tls(certificate, vec![]).await; + fixture.seed("config", "key", b"TLS").await; + let token = + CelldAccessToken::new("test-tls-bearer-credential-at-least-32-bytes".to_owned()) + .unwrap(); + let root = trusted.then(|| { + reqwest::Certificate::from_der(include_bytes!("fixtures/tls-ca.der")).unwrap() + }); + let client = ScopedClient::build( + Url::parse(fixture.endpoint()).unwrap(), + Some(&token), + MonotonicClock::default(), + CelldPreparationLimits::default(), + root, + ) + .unwrap(); + let mut url = client.url("config", "value").unwrap(); + url.query_pairs_mut().append_pair("key", "key"); + let result = client + .execute( + Method::GET, + url, + None, + 16, + client.deadline().unwrap(), + kind(), + ) + .await; + if accepted { + assert_eq!(result.unwrap(), Some(b"TLS".to_vec())); + assert_eq!( + fixture.requests().await[0].authorization.as_deref(), + Some(format!("Bearer {}", token.expose()).as_str()) + ); + } else { + assert_eq!(result.unwrap_err(), PreparationError::Unavailable); + assert_eq!( + fixture.count().await, + 0, + "TLS failure must never fall back to plaintext" + ); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn native_tls_keeps_distinct_credentials_on_reused_connections() { + let fixture = Fixture::tls(include_bytes!("fixtures/tls-valid.der"), vec![]).await; + fixture.seed("config", "key", b"TLS").await; + let mut clients = Vec::new(); + for token_text in [ + "first-tls-credential-at-least-32-bytes", + "second-tls-credential-at-least-32-bytes", + ] { + let token = CelldAccessToken::new(token_text.to_owned()).unwrap(); + clients.push( + ScopedClient::build( + Url::parse(fixture.endpoint()).unwrap(), + Some(&token), + MonotonicClock::default(), + CelldPreparationLimits::default(), + Some( + reqwest::Certificate::from_der(include_bytes!("fixtures/tls-ca.der")) + .unwrap(), + ), + ) + .unwrap(), + ); + } + for index in [0, 1, 0] { + let client = &clients[index]; + let mut url = client.url("config", "value").unwrap(); + url.query_pairs_mut().append_pair("key", "key"); + assert_eq!( + client + .execute( + Method::GET, + url, + None, + 16, + client.deadline().unwrap(), + kind() + ) + .await + .unwrap(), + Some(b"TLS".to_vec()) + ); + } + let requests = fixture.requests().await; + assert_eq!(requests[0].connection, requests[2].connection); + assert_ne!(requests[0].connection, requests[1].connection); + assert_eq!(requests[0].authorization, requests[2].authorization); + assert_ne!(requests[0].authorization, requests[1].authorization); + } + + #[test] + fn ambient_proxy_is_not_used_by_the_production_client() { + let sentinel = TcpListener::bind("127.0.0.1:0").unwrap(); + sentinel.set_nonblocking(true).unwrap(); + let proxy = format!("http://{}", sentinel.local_addr().unwrap()); + let mut command = + Command::new(current_exe().expect("locate proxy fixture test executable")); + command + .env_clear() + .env("CELLD_PROXY_FIXTURE_CHILD", "selected"); + for variable in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + command.env(variable, &proxy); + } + let output = command + .env("NO_PROXY", "") + .env("no_proxy", "") + .args([ + "--exact", + "client::tests::ambient_proxy_child", + "--nocapture", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("CELLD_DIRECT_TRANSPORT_PROVEN")); + assert!(matches!(sentinel.accept(), Err(error) if error.kind() == ErrorKind::WouldBlock)); + } + + #[tokio::test(flavor = "current_thread")] + #[expect( + clippy::print_stdout, + reason = "the parent requires an explicit child execution marker" + )] + async fn ambient_proxy_child() { + if var("CELLD_PROXY_FIXTURE_CHILD").as_deref() != Ok("selected") { + return; + } + let fixture = Fixture::new(vec![]).await; + fixture.seed("config", "key", b"direct").await; + let client = ScopedClient::new( + Url::parse(fixture.endpoint()).unwrap(), + None, + MonotonicClock::default(), + CelldPreparationLimits::default(), + ) + .unwrap(); + let mut url = client.url("config", "value").unwrap(); + url.query_pairs_mut().append_pair("key", "key"); + assert_eq!( + client + .execute( + Method::GET, + url, + None, + 16, + client.deadline().unwrap(), + kind() + ) + .await + .unwrap(), + Some(b"direct".to_vec()) + ); + assert_eq!(fixture.count().await, 1); + println!("CELLD_DIRECT_TRANSPORT_PROVEN"); + } + + #[tokio::test(flavor = "current_thread")] + async fn oversized_response_headers_fail_at_the_http_parser_bound() { + let header = format!( + "x-celld-store-version: 1\r\nx-excess: {}\r\n", + "x".repeat(512 * 1024) + ); + let fixture = Fixture::new(vec![Reply::custom( + 200, + "application/octet-stream", + &header, + b"x", + )]) + .await; + let client = ScopedClient::new( + Url::parse(fixture.endpoint()).expect("fixture origin"), + None, + MonotonicClock::default(), + CelldPreparationLimits::default(), + ) + .expect("bounded client"); + let url = client.url("config", "value").expect("value URL"); + assert_eq!( + client + .execute( + Method::GET, + url, + None, + 16, + client.deadline().expect("deadline"), + kind() + ) + .await + .expect_err("oversized headers"), + PreparationError::Unavailable + ); + assert_eq!(fixture.count().await, 1); + } + + fn kind() -> ResponseKind { + #[cfg(feature = "kv")] + { + ResponseKind::Value + } + #[cfg(not(feature = "kv"))] + { + ResponseKind::Document + } + } + + async fn read(fixture: &Fixture, cap: usize) -> Result>, PreparationError> { + let client = ScopedClient::new( + Url::parse(fixture.endpoint()).expect("parse read fixture endpoint"), + None, + MonotonicClock::default(), + CelldPreparationLimits::default(), + ) + .expect("construct read fixture client"); + let mut url = client + .url("state", "value") + .expect("construct read fixture value URL"); + url.query_pairs_mut().append_pair("key", "k"); + client + .execute( + Method::GET, + url, + None, + cap, + client.deadline().expect("construct read fixture deadline"), + kind(), + ) + .await + } + + #[tokio::test(flavor = "current_thread")] + async fn raw_binary_empty_and_encoded_keys_use_real_transport() { + let fixture = Fixture::new(vec![]).await; + let key = " /+?%&#=\u{e9}\u{65e5}"; + fixture.seed("state", key, &[0, 255, 1]).await; + let client = ScopedClient::new( + Url::parse(fixture.endpoint()).unwrap(), + None, + MonotonicClock::default(), + CelldPreparationLimits::default(), + ) + .unwrap(); + let mut url = client.url("state", "value").unwrap(); + url.query_pairs_mut().append_pair("key", key); + let bytes = client + .execute( + Method::GET, + url.clone(), + None, + 3, + client.deadline().unwrap(), + kind(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(bytes, [0, 255, 1]); + fixture.seed("state", key, b"").await; + assert!( + client + .execute( + Method::GET, + url, + None, + 0, + client.deadline().unwrap(), + kind() + ) + .await + .unwrap() + .unwrap() + .is_empty() + ); + assert_eq!(fixture.value("state", key).await.unwrap(), b""); + let requests = fixture.requests().await; + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.authorization.is_none() + && request.encoding.as_deref() == Some("identity") + && request.body.is_empty() + && request.method == "GET") + ); + assert_eq!(requests[0].connection, requests[1].connection); + assert!(requests[0].target.contains("%2B")); + } + + #[tokio::test(flavor = "current_thread")] + async fn every_response_requires_exactly_one_version_and_identity_encoding() { + for headers in [ + "", + "x-celld-store-version: 2\r\n", + "x-celld-store-version: 1\r\nx-celld-store-version: 1\r\n", + "x-celld-store-version: 1, 1\r\n", + ] { + let fixture = Fixture::new(vec![Reply::custom( + 200, + "application/octet-stream", + headers, + b"v", + )]) + .await; + assert_eq!( + read(&fixture, 100).await, + Err(PreparationError::UnsupportedVersion) + ); + } + for headers in [ + "Content-Encoding: gzip\r\n", + "Content-Encoding: identity\r\nContent-Encoding: identity\r\n", + ] { + let fixture = Fixture::new(vec![Reply::custom( + 200, + "application/octet-stream", + &format!("x-celld-store-version: 1\r\n{headers}"), + b"v", + )]) + .await; + assert_eq!(read(&fixture, 100).await, Err(PreparationError::Malformed)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn exact_error_status_categories_and_bounded_strict_json() { + for (status, category, error) in [ + (400, "invalid_input", PreparationError::InvalidResource), + (401, "unauthenticated", PreparationError::Unauthenticated), + (403, "forbidden", PreparationError::Forbidden), + (413, "limit_exceeded", PreparationError::SizeLimit), + ( + 426, + "unsupported_version", + PreparationError::UnsupportedVersion, + ), + (503, "unavailable", PreparationError::Unavailable), + (504, "deadline", PreparationError::Deadline), + ] { + let bytes = serde_json::to_vec(&serde_json::json!({"error": category})).unwrap(); + let fixture = + Fixture::new(vec![Reply::response(status, "application/json", &bytes)]).await; + assert_eq!(read(&fixture, 100).await, Err(error)); + } + for bytes in [ + br#"{"error":"forbidden"}"#.as_slice(), + br#"{"error":"unavailable","extra":1}"#, + br#"{"error":"unavailable","error":"unavailable"}"#, + b"not json", + ] { + let fixture = Fixture::new(vec![Reply::response(503, "application/json", bytes)]).await; + assert_eq!(read(&fixture, 100).await, Err(PreparationError::Malformed)); + } + let fixture = Fixture::new(vec![Reply::response( + 503, + "application/json", + &vec![b'x'; 1_025], + )]) + .await; + assert_eq!(read(&fixture, 100).await, Err(PreparationError::SizeLimit)); + } + + #[tokio::test(flavor = "current_thread")] + async fn misses_are_distinct_from_empty_and_bad_statuses() { + let missing_fixture = Fixture::new(vec![]).await; + #[cfg(feature = "kv")] + assert_eq!(read(&missing_fixture, 1).await.unwrap(), None); + #[cfg(not(feature = "kv"))] + assert_eq!( + read(&missing_fixture, 1).await, + Err(PreparationError::Missing) + ); + for status in [201, 204, 206, 302, 500] { + let fixture = Fixture::new(vec![Reply::response( + status, + "application/octet-stream", + b"", + )]) + .await; + assert_eq!(read(&fixture, 10).await, Err(PreparationError::Malformed)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn declared_and_chunked_values_are_capped_before_unbounded_collection() { + let declared = Reply::Bytes(b"HTTP/1.1 200 Fixture\r\nx-celld-store-version: 1\r\nContent-Type: application/octet-stream\r\nContent-Length: 9999999\r\n\r\n".to_vec()); + let chunked = Reply::Bytes(b"HTTP/1.1 200 Fixture\r\nx-celld-store-version: 1\r\nContent-Type: application/octet-stream\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n3\r\ndef\r\n0\r\n\r\n".to_vec()); + for reply in [declared, chunked] { + let fixture = Fixture::new(vec![reply]).await; + assert_eq!(read(&fixture, 5).await, Err(PreparationError::SizeLimit)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn redirects_never_reach_another_destination() { + let destination = Fixture::new(vec![]).await; + let source = Fixture::new(vec![Reply::custom( + 302, + "application/json", + &format!( + "x-celld-store-version: 1\r\nLocation: {}/v1/stores/state/value?key=k\r\n", + destination.endpoint() + ), + b"{}", + )]) + .await; + assert_eq!(read(&source, 100).await, Err(PreparationError::Malformed)); + assert_eq!(destination.count().await, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn slow_headers_and_body_share_a_finite_operation_deadline() { + for reply in [Reply::Stall, Reply::SlowBody] { + let fixture = Fixture::new(vec![reply]).await; + let limits = CelldPreparationLimits { + operation_timeout_ms: 40, + connect_timeout_ms: 10, + ..CelldPreparationLimits::default() + }; + let client = ScopedClient::new( + Url::parse(fixture.endpoint()).unwrap(), + None, + MonotonicClock::default(), + limits, + ) + .unwrap(); + let mut url = client.url("state", "value").unwrap(); + url.query_pairs_mut().append_pair("key", "k"); + assert_eq!( + client + .execute( + Method::GET, + url, + None, + 100, + client.deadline().unwrap(), + kind() + ) + .await, + Err(PreparationError::Deadline) + ); + } + } + + #[test] + fn sensitive_header_and_owned_debug_never_expose_tokens_or_resources() { + let token = CelldAccessToken::new("secret-sentinel".repeat(3)).unwrap(); + let client = ScopedClient::new( + Url::parse("https://resource-sentinel.invalid").unwrap(), + Some(&token), + MonotonicClock::default(), + CelldPreparationLimits::default(), + ) + .unwrap(); + assert!(client.authorization.as_ref().unwrap().is_sensitive()); + let debug = format!("{client:?} {token:?}"); + assert!(!debug.contains("secret-sentinel")); + assert!(!debug.contains("resource-sentinel")); + for error in [ + PreparationError::Credentials, + PreparationError::Unauthenticated, + PreparationError::Forbidden, + PreparationError::Unavailable, + ] { + assert!(!format!("{error} {error:?}").contains("sentinel")); + } + } +} diff --git a/crates/edgezero-store-celld/src/config.rs b/crates/edgezero-store-celld/src/config.rs new file mode 100644 index 00000000..78ecdbd2 --- /dev/null +++ b/crates/edgezero-store-celld/src/config.rs @@ -0,0 +1,140 @@ +//! Immutable config: there is deliberately no client in a published snapshot. + +use std::collections::BTreeMap; + +use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigValue, finish_bounded_config_read, +}; +use edgezero_core::time::{Deadline, MonotonicClock}; + +pub(crate) struct ConfigSnapshot { + entries: BTreeMap, +} + +impl ConfigSnapshot { + pub(crate) const fn new(entries: BTreeMap) -> Self { + Self { entries } + } +} + +#[async_trait::async_trait(?Send)] +impl ConfigStore for ConfigSnapshot { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.entries.get(key).cloned()) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let result = finish_bounded_config_read( + Ok(self.entries.get(key).cloned()), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + // Check even if the helper returned a size failure: deadline wins. + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + result + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use edgezero_core::config_store::{ConfigStore as _, ConfigStoreError, ConfigValue}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + + use super::ConfigSnapshot; + + #[tokio::test(flavor = "current_thread")] + async fn bounded_reads_count_exact_utf8_and_independent_caps() { + let snapshot = ConfigSnapshot::new(BTreeMap::from([( + "k".to_owned(), + ConfigValue::from("\u{e9}"), + )])); + let clock = MonotonicClock::default(); + for (backend, value) in [(1, 2), (2, 1)] { + assert!(matches!( + snapshot + .get_bounded( + "k", + &clock, + clock.deadline_after(Duration::from_secs(1)), + backend, + value + ) + .await, + Err(ConfigStoreError::ValueTooLarge) + )); + } + let result = snapshot + .get_bounded( + "k", + &clock, + clock.deadline_after(Duration::from_secs(1)), + 2, + 2, + ) + .await + .unwrap(); + assert_eq!(result.backend_bytes, 2); + assert_eq!(result.value.unwrap().as_str(), "\u{e9}"); + assert_eq!( + snapshot + .get_bounded( + "missing", + &clock, + clock.deadline_after(Duration::from_secs(1)), + 0, + 0 + ) + .await + .unwrap() + .backend_bytes, + 0 + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn deadline_wins_even_after_size_failure() { + let snapshot = ConfigSnapshot::new(BTreeMap::from([( + "k".to_owned(), + ConfigValue::from("bytes"), + )])); + let now = MonotonicInstant::now(); + let later = now.checked_add(Duration::from_secs(2)).unwrap(); + let ticks = Arc::new(AtomicUsize::new(0)); + let clock = MonotonicClock::new(move || { + if ticks.fetch_add(1, Ordering::SeqCst) < 2 { + now + } else { + later + } + }); + let result = snapshot + .get_bounded( + "k", + &clock, + Deadline::at_instant(now.checked_add(Duration::from_secs(1)).unwrap()), + 0, + 0, + ) + .await; + assert!(matches!(result, Err(ConfigStoreError::DeadlineExceeded))); + } +} diff --git a/crates/edgezero-store-celld/src/fixtures/README.md b/crates/edgezero-store-celld/src/fixtures/README.md new file mode 100644 index 00000000..cc46fcec --- /dev/null +++ b/crates/edgezero-store-celld/src/fixtures/README.md @@ -0,0 +1,16 @@ +# Disposable TLS fixtures + +These DER certificates and the PKCS#8 key are public, test-only material. Never use +this key or trust this root outside the loopback test fixture. + +- `tls-valid.der`: SAN `127.0.0.1`, valid from 2020 through 2040. +- `tls-wrong.der`: SAN `127.0.0.2`, valid from 2020 through 2040. +- `tls-expired.der`: SAN `127.0.0.1`, expired January 2020. +- `tls-ca.der`: disposable signing root; its private key was deleted. +- `tls-key.der`: shared disposable P-256 leaf key. + +Generated locally with OpenSSL. Runtime tests use the normal rustls server and +production reqwest policy; their private seam changes only the trusted root. +No OpenSSL executable, system trust changes or external service is needed to run +these tests. Replace the valid leaves before 2040 if these tests are maintained +that long. diff --git a/crates/edgezero-store-celld/src/fixtures/tls-ca.der b/crates/edgezero-store-celld/src/fixtures/tls-ca.der new file mode 100644 index 00000000..c61b8057 Binary files /dev/null and b/crates/edgezero-store-celld/src/fixtures/tls-ca.der differ diff --git a/crates/edgezero-store-celld/src/fixtures/tls-expired.der b/crates/edgezero-store-celld/src/fixtures/tls-expired.der new file mode 100644 index 00000000..ca523182 Binary files /dev/null and b/crates/edgezero-store-celld/src/fixtures/tls-expired.der differ diff --git a/crates/edgezero-store-celld/src/fixtures/tls-key.der b/crates/edgezero-store-celld/src/fixtures/tls-key.der new file mode 100644 index 00000000..f176d61a Binary files /dev/null and b/crates/edgezero-store-celld/src/fixtures/tls-key.der differ diff --git a/crates/edgezero-store-celld/src/fixtures/tls-valid.der b/crates/edgezero-store-celld/src/fixtures/tls-valid.der new file mode 100644 index 00000000..55dd3ca1 Binary files /dev/null and b/crates/edgezero-store-celld/src/fixtures/tls-valid.der differ diff --git a/crates/edgezero-store-celld/src/fixtures/tls-wrong.der b/crates/edgezero-store-celld/src/fixtures/tls-wrong.der new file mode 100644 index 00000000..65165c41 Binary files /dev/null and b/crates/edgezero-store-celld/src/fixtures/tls-wrong.der differ diff --git a/crates/edgezero-store-celld/src/kv.rs b/crates/edgezero-store-celld/src/kv.rs new file mode 100644 index 00000000..41e84533 --- /dev/null +++ b/crates/edgezero-store-celld/src/kv.rs @@ -0,0 +1,707 @@ +//! Live byte-oriented KV. Each method performs at most one bounded exchange. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "group the live store and its operations before private wire models and cursor helpers" +)] + +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use url::Url; + +use crate::PreparationError; +use crate::client::{ResponseKind, ScopedClient}; +use crate::settings::{CelldPreparationLimits, valid_key}; + +const MAX_PROVIDER_CURSOR: usize = 8_192; +const MAX_SERVICE_CURSOR: usize = 2_048; + +pub(crate) struct CelldKvStore { + client: Arc, + endpoint: Url, + limits: CelldPreparationLimits, + store: String, +} + +impl CelldKvStore { + pub(crate) const fn new( + client: Arc, + endpoint: Url, + store: String, + limits: CelldPreparationLimits, + ) -> Self { + Self { + client, + endpoint, + limits, + store, + } + } + + fn value_url(&self, key: &str) -> Result { + if !valid_key(key, false) { + return Err(PreparationError::InvalidResource); + } + let mut url = self.client.url(&self.store, "value")?; + url.query_pairs_mut().append_pair("key", key); + Ok(url) + } + + #[expect( + clippy::big_endian_bytes, + reason = "cursor fingerprints frame each field with a canonical big-endian u64 length" + )] + fn fingerprint(&self, prefix: &str) -> Result { + let mut digest = Sha256::new(); + for part in [ + "celld-store-v1", + self.endpoint.as_str(), + &self.store, + prefix, + ] { + let size = u64::try_from(part.len()).map_err(|_error| PreparationError::SizeLimit)?; + digest.update(size.to_be_bytes()); + digest.update(part.as_bytes()); + } + Ok(URL_SAFE_NO_PAD.encode(digest.finalize())) + } + + async fn put( + &self, + key: &str, + value: Bytes, + ttl: Option, + ) -> Result<(), PreparationError> { + let deadline = self.client.deadline()?; + let mut url = self.value_url(key)?; + if u64::try_from(value.len()).map_err(|_error| PreparationError::SizeLimit)? + > self.limits.max_value_bytes + { + return Err(PreparationError::SizeLimit); + } + if let Some(duration) = ttl { + url.query_pairs_mut() + .append_pair("ttl_seconds", &ttl_seconds(duration)?.to_string()); + } + self.client + .execute( + Method::PUT, + url, + Some(value), + 0, + deadline, + ResponseKind::Mutation, + ) + .await?; + Ok(()) + } + + async fn page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let deadline = self.client.deadline()?; + if !valid_key(prefix, true) || !(1..=1_000).contains(&limit) { + return Err(PreparationError::InvalidResource); + } + let fingerprint = self.fingerprint(prefix)?; + let prior = cursor + .map(|encoded| decode_cursor(encoded, &fingerprint, prefix)) + .transpose()?; + let mut url = self.client.url(&self.store, "keys")?; + { + let mut query = url.query_pairs_mut(); + query + .append_pair("prefix", prefix) + .append_pair("limit", &limit.to_string()); + if let Some(previous) = &prior { + query.append_pair("cursor", &previous.service); + } + } + let cap = usize::try_from(self.limits.max_page_bytes) + .map_err(|_error| PreparationError::InvalidLimits)?; + let body = self + .client + .execute(Method::GET, url, None, cap, deadline, ResponseKind::Page) + .await? + .ok_or(PreparationError::Malformed)?; + let result = (|| { + let page: WirePage = + serde_json::from_slice(&body).map_err(|_error| PreparationError::Malformed)?; + if page.keys.len() > limit { + return Err(PreparationError::SizeLimit); + } + let mut last = prior.as_ref().and_then(|previous| previous.last.as_deref()); + for key in &page.keys { + if !valid_key(key, false) + || !key.starts_with(prefix) + || last.is_some_and(|previous_key| key.as_str() <= previous_key) + { + return Err(PreparationError::Malformed); + } + last = Some(key); + } + let next = if let Some(service) = page.cursor { + if service.is_empty() + || service.len() > MAX_SERVICE_CURSOR + || prior + .as_ref() + .is_some_and(|previous| service == previous.service) + { + return Err(PreparationError::Malformed); + } + let envelope = Cursor { + version: 1, + fingerprint, + last: last.map(str::to_owned), + service, + }; + let bytes = + serde_json::to_vec(&envelope).map_err(|_error| PreparationError::Malformed)?; + let encoded = URL_SAFE_NO_PAD.encode(bytes); + if encoded.len() > MAX_PROVIDER_CURSOR { + return Err(PreparationError::SizeLimit); + } + Some(encoded) + } else { + None + }; + Ok(KvPage { + keys: page.keys, + cursor: next, + }) + })(); + // Includes parsing/validation time and gives deadline precedence. + self.client.check(deadline)?; + result + } +} + +#[async_trait::async_trait(?Send)] +impl KvStore for CelldKvStore { + async fn delete(&self, key: &str) -> Result<(), KvError> { + let deadline = self.client.deadline().map_err(kv_error)?; + let url = self.value_url(key).map_err(kv_error)?; + self.client + .execute( + Method::DELETE, + url, + None, + 0, + deadline, + ResponseKind::Mutation, + ) + .await + .map_err(kv_error)?; + Ok(()) + } + + async fn exists(&self, key: &str) -> Result { + let deadline = self.client.deadline().map_err(kv_error)?; + let url = self.value_url(key).map_err(kv_error)?; + self.client + .execute(Method::HEAD, url, None, 0, deadline, ResponseKind::Exists) + .await + .map(|body| body.is_some()) + .map_err(kv_error) + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let deadline = self.client.deadline().map_err(kv_error)?; + let url = self.value_url(key).map_err(kv_error)?; + let cap = usize::try_from(self.limits.max_value_bytes) + .map_err(|_error| kv_error(PreparationError::InvalidLimits))?; + self.client + .execute(Method::GET, url, None, cap, deadline, ResponseKind::Value) + .await + .map(|body| body.map(Bytes::from)) + .map_err(kv_error) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.page(prefix, cursor, limit).await.map_err(kv_error) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.put(key, value, None).await.map_err(kv_error) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.put(key, value, Some(ttl)).await.map_err(kv_error) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WirePage { + keys: Vec, + #[serde(deserialize_with = "optional_string")] + cursor: Option, +} + +fn optional_string<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + version: u8, + fingerprint: String, + last: Option, + service: String, +} + +fn decode_cursor(text: &str, fingerprint: &str, prefix: &str) -> Result { + if text.is_empty() || text.len() > MAX_PROVIDER_CURSOR { + return Err(PreparationError::InvalidResource); + } + let bytes = URL_SAFE_NO_PAD + .decode(text) + .map_err(|_error| PreparationError::InvalidResource)?; + let cursor: Cursor = + serde_json::from_slice(&bytes).map_err(|_error| PreparationError::InvalidResource)?; + if cursor.version != 1 + || cursor.fingerprint != fingerprint + || cursor.service.is_empty() + || cursor.service.len() > MAX_SERVICE_CURSOR + || cursor + .last + .as_deref() + .is_some_and(|last| !valid_key(last, false) || !last.starts_with(prefix)) + { + return Err(PreparationError::InvalidResource); + } + Ok(cursor) +} + +fn ttl_seconds(ttl: Duration) -> Result { + // Validate the direct trait input BEFORE rounding. A sub-minimum or + // maximum-plus-one-nanosecond duration must not become a valid wire TTL. + if ttl < Duration::from_mins(1) || ttl > Duration::from_hours(8_760) { + return Err(PreparationError::InvalidResource); + } + ttl.as_secs() + .checked_add(u64::from(ttl.subsec_nanos() != 0)) + .ok_or(PreparationError::InvalidResource) +} + +fn kv_error(error: PreparationError) -> KvError { + match error { + PreparationError::InvalidBinding + | PreparationError::InvalidEndpoint + | PreparationError::InvalidLimits + | PreparationError::InvalidResource => KvError::Validation(error.to_string()), + PreparationError::SizeLimit => KvError::LimitExceeded { + message: "celld size limit".to_owned(), + }, + // Core has no typed auth/protocol/deadline variants. Do not smuggle + // remote JSON or transport source chains into its generic error variants. + PreparationError::Credentials + | PreparationError::Unauthenticated + | PreparationError::Forbidden + | PreparationError::Missing + | PreparationError::Malformed + | PreparationError::UnsupportedVersion + | PreparationError::Unavailable + | PreparationError::Deadline => KvError::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::key_value_store::KvStore as _; + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; + use url::Url; + + use super::{CelldKvStore, ttl_seconds}; + use crate::CelldPreparation; + use crate::client::ScopedClient; + use crate::settings::{CelldAccess, CelldKvSettings, CelldPreparationLimits}; + use crate::test_support::{Fixture, Reply}; + + fn store(fixture: &Fixture, alias: &str) -> CelldKvStore { + let endpoint = Url::parse(fixture.endpoint()).unwrap(); + let limits = CelldPreparationLimits::default(); + let client = Arc::new( + ScopedClient::new(endpoint.clone(), None, MonotonicClock::default(), limits).unwrap(), + ); + CelldKvStore::new(client, endpoint, alias.to_owned(), limits) + } + + #[tokio::test(flavor = "current_thread")] + async fn public_setup_is_probe_free_and_runtime_deadlines_are_fresh() { + let fixture = Fixture::new(vec![]).await; + let now = MonotonicInstant::now(); + let elapsed = Arc::new(AtomicU64::new(0)); + let ticks = Arc::clone(&elapsed); + let clock = MonotonicClock::new(move || { + now.checked_add(Duration::from_millis(ticks.load(Ordering::SeqCst))) + .unwrap() + }); + let settings = CelldKvSettings { + endpoint: fixture.endpoint().to_owned(), + access: CelldAccess::TrustedNetwork { + allow_remote_http: false, + }, + store: "state".to_owned(), + }; + let mut preparation = + CelldPreparation::new(clock, CelldPreparationLimits::default()).unwrap(); + let kv = preparation.prepare_kv(&settings, None).await.unwrap(); + assert_eq!(fixture.count().await, 0); + assert_eq!(preparation.stats().mappings, 1); + assert_eq!(preparation.stats().unique_retained_payload_bytes, 0); + elapsed.store(60_000, Ordering::SeqCst); + kv.put_bytes("k", Bytes::from_static(&[0, 255, 7])) + .await + .unwrap(); + assert_eq!( + kv.get_bytes("k").await.unwrap().unwrap(), + Bytes::from_static(&[0, 255, 7]) + ); + assert!(kv.exists("k").await.unwrap()); + kv.put_bytes("k", Bytes::new()).await.unwrap(); + assert_eq!(kv.get_bytes("k").await.unwrap(), Some(Bytes::new())); + kv.delete("k").await.unwrap(); + kv.delete("k").await.unwrap(); + assert!(!kv.exists("k").await.unwrap()); + assert_eq!(kv.get_bytes("k").await.unwrap(), None); + assert_eq!(preparation.stats().unique_retained_payload_bytes, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn aliases_isolate_and_explicit_sharing_preserves_exact_unicode_keys() { + let fixture = Fixture::new(vec![]).await; + let first = store(&fixture, "first"); + let shared = store(&fixture, "first"); + let second = store(&fixture, "second"); + for key in ["\u{e9}", "e\u{301}", &"\u{e9}".repeat(256)] { + first + .put_bytes(key, Bytes::copy_from_slice(key.as_bytes())) + .await + .unwrap(); + assert_eq!( + shared.get_bytes(key).await.unwrap().unwrap().as_ref(), + key.as_bytes() + ); + assert_eq!(second.get_bytes(key).await.unwrap(), None); + } + let count = fixture.count().await; + first + .get_bytes(&"x".repeat(513)) + .await + .expect_err("overlong key must be rejected before transport"); + first + .get_bytes("control\n") + .await + .expect_err("control character in key must be rejected before transport"); + assert_eq!(fixture.count().await, count); + } + + #[tokio::test(flavor = "current_thread")] + async fn pagination_is_one_page_and_cursors_bind_origin_store_prefix_and_progress() { + let fixture = Fixture::new(vec![]).await; + for key in ["app/a", "app/b", "app/c", "other"] { + fixture.seed("state", key, b"v").await; + } + let kv = store(&fixture, "state"); + let page = kv.list_keys_page("app/", None, 1).await.unwrap(); + assert_eq!(page.keys, ["app/a"]); + let cursor = page.cursor.unwrap(); + let next = kv.list_keys_page("app/", Some(&cursor), 1).await.unwrap(); + assert_eq!(next.keys, ["app/b"]); + assert_eq!(fixture.count().await, 2); + let other_fixture = Fixture::new(vec![]).await; + store(&other_fixture, "state") + .list_keys_page("app/", Some(&cursor), 1) + .await + .expect_err("cursor from another origin must be rejected"); + assert_eq!(other_fixture.count().await, 0); + store(&fixture, "other") + .list_keys_page("app/", Some(&cursor), 1) + .await + .expect_err("cursor from another store must be rejected"); + kv.list_keys_page("different", Some(&cursor), 1) + .await + .expect_err("cursor from another prefix must be rejected"); + kv.list_keys_page("app/", Some(&"x".repeat(8_193)), 1) + .await + .expect_err("overlong provider cursor must be rejected"); + assert_eq!(fixture.count().await, 2); + let last = kv + .list_keys_page("app/", next.cursor.as_deref(), 1) + .await + .unwrap(); + assert_eq!(last.keys, ["app/c"]); + assert_eq!(last.cursor, None); + } + + #[tokio::test(flavor = "current_thread")] + async fn empty_changed_continuation_is_returned_once_and_retains_last_key() { + let fixture = Fixture::new(vec![ + Reply::response( + 200, + "application/json", + br#"{"keys":["p/a"],"cursor":"one"}"#, + ), + Reply::response(200, "application/json", br#"{"keys":[],"cursor":"two"}"#), + Reply::response( + 200, + "application/json", + br#"{"keys":["p/a"],"cursor":null}"#, + ), + ]) + .await; + let kv = store(&fixture, "state"); + let first = kv.list_keys_page("p/", None, 10).await.unwrap(); + let empty = kv + .list_keys_page("p/", first.cursor.as_deref(), 10) + .await + .unwrap(); + assert!(empty.keys.is_empty()); + assert!(empty.cursor.is_some()); + assert_eq!(fixture.count().await, 2); + kv.list_keys_page("p/", empty.cursor.as_deref(), 10) + .await + .expect_err("continuation must not repeat the previous page's last key"); + assert_eq!(fixture.count().await, 3); + } + + #[tokio::test(flavor = "current_thread")] + async fn pages_reject_unknown_duplicate_missing_malformed_and_unordered_fields() { + for bytes in [ + br#"{"keys":["z"],"cursor":null}"#.as_slice(), + br#"{"keys":["p/b","p/a"],"cursor":null}"#, + br#"{"keys":["p/a","p/a"],"cursor":null}"#, + br#"{"keys":[],"cursor":""}"#, + br#"{"keys":[],"cursor":null,"extra":1}"#, + br#"{"keys":[],"keys":[],"cursor":null}"#, + br#"{"keys":[]}"#, + b"not json", + ] { + let fixture = Fixture::new(vec![Reply::response(200, "application/json", bytes)]).await; + store(&fixture, "state") + .list_keys_page("p/", None, 100) + .await + .expect_err("malformed or unordered page must be rejected"); + } + let too_large_cursor = + serde_json::to_vec(&serde_json::json!({"keys":[], "cursor":"x".repeat(2_049)})) + .unwrap(); + let fixture = Fixture::new(vec![Reply::response( + 200, + "application/json", + &too_large_cursor, + )]) + .await; + store(&fixture, "state") + .list_keys_page("", None, 1_000) + .await + .expect_err("overlong service cursor must be rejected"); + let repeated_cursor_fixture = Fixture::new(vec![ + Reply::response(200, "application/json", br#"{"keys":[],"cursor":"same"}"#), + Reply::response(200, "application/json", br#"{"keys":[],"cursor":"same"}"#), + ]) + .await; + let kv = store(&repeated_cursor_fixture, "state"); + let first = kv.list_keys_page("", None, 1).await.unwrap(); + kv.list_keys_page("", first.cursor.as_deref(), 1) + .await + .expect_err("unchanged service cursor must be rejected"); + } + + #[tokio::test(flavor = "current_thread")] + async fn ttl_wire_rounding_renewal_and_plain_reset_are_explicit() { + let fixture = Fixture::new(vec![]).await; + let kv = store(&fixture, "state"); + kv.put_bytes_with_ttl("k", Bytes::new(), Duration::from_millis(1)) + .await + .expect_err("sub-minimum TTL must be rejected before transport"); + assert_eq!(fixture.count().await, 0); + kv.put_bytes_with_ttl( + "k", + Bytes::new(), + Duration::from_mins(1) + Duration::from_nanos(1), + ) + .await + .unwrap(); + kv.put_bytes_with_ttl("k", Bytes::new(), Duration::from_mins(2)) + .await + .unwrap(); + kv.put_bytes("k", Bytes::new()).await.unwrap(); + let requests = fixture.requests().await; + assert!(requests[0].target.contains("ttl_seconds=61")); + assert!(requests[1].target.contains("ttl_seconds=120")); + assert!(!requests[2].target.contains("ttl_seconds")); + // These are wire assertions, not proof of real service elapsed expiry. + } + + #[tokio::test(flavor = "current_thread")] + async fn applied_then_lost_put_and_delete_are_not_replayed_fresh_or_reused() { + for reused in [false, true] { + for delete in [false, true] { + let replies = if reused { + vec![Reply::response(404, "application/json", b""), Reply::Drop] + } else { + vec![Reply::Drop] + }; + let fixture = Fixture::new(replies).await; + fixture.seed("state", "mut", b"original").await; + let kv = store(&fixture, "state"); + if reused { + assert!(!kv.exists("warm").await.unwrap()); + } + let result = if delete { + kv.delete("mut").await + } else { + kv.put_bytes("mut", Bytes::from_static(b"applied")).await + }; + result.expect_err("lost mutation reply must return an error without replay"); + assert_eq!( + fixture.value("state", "mut").await, + if delete { + None + } else { + Some(b"applied".to_vec()) + } + ); + assert_eq!(fixture.count().await, if reused { 2 } else { 1 }); + let requests = fixture.requests().await; + if reused { + assert_eq!(requests[0].connection, requests[1].connection); + } + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn timed_out_write_may_have_applied_and_is_not_replayed() { + let fixture = Fixture::new(vec![Reply::Stall]).await; + let limits = CelldPreparationLimits { + operation_timeout_ms: 40, + connect_timeout_ms: 10, + ..CelldPreparationLimits::default() + }; + let endpoint = Url::parse(fixture.endpoint()).unwrap(); + let client = Arc::new( + ScopedClient::new(endpoint.clone(), None, MonotonicClock::default(), limits).unwrap(), + ); + let kv = CelldKvStore::new(client, endpoint, "state".to_owned(), limits); + kv.put_bytes("mut", Bytes::from_static(b"applied")) + .await + .expect_err("stalled mutation reply must reach the operation deadline"); + assert_eq!(fixture.value("state", "mut").await.unwrap(), b"applied"); + assert_eq!(fixture.count().await, 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn provider_caps_and_direct_trait_input_checks_precede_requests() { + let fixture = Fixture::new(vec![]).await; + let endpoint = Url::parse(fixture.endpoint()).unwrap(); + let limits = CelldPreparationLimits { + max_value_bytes: 2, + max_config_document_bytes: 2, + max_page_bytes: 4, + ..CelldPreparationLimits::default() + }; + let client = Arc::new( + ScopedClient::new(endpoint.clone(), None, MonotonicClock::default(), limits).unwrap(), + ); + let kv = CelldKvStore::new(client, endpoint, "state".to_owned(), limits); + kv.put_bytes("k", Bytes::from_static(b"123")) + .await + .expect_err("value above the provider cap must be rejected before transport"); + kv.get_bytes("") + .await + .expect_err("empty value key must be rejected before transport"); + kv.list_keys_page("", None, 0) + .await + .expect_err("zero page size must be rejected before transport"); + kv.list_keys_page("", None, 1_001) + .await + .expect_err("page size above the service maximum must be rejected before transport"); + assert_eq!(fixture.count().await, 0); + fixture.seed("state", "k", b"123").await; + kv.get_bytes("k") + .await + .expect_err("response value above the provider cap must be rejected"); + kv.list_keys_page("", None, 1) + .await + .expect_err("response page above the provider cap must be rejected"); + assert_eq!(fixture.count().await, 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn head_and_mutations_require_version_and_their_exact_success_status() { + for reply in [ + Reply::custom(404, "application/json", "", b""), + Reply::response(204, "application/octet-stream", b""), + ] { + let fixture = Fixture::new(vec![reply]).await; + store(&fixture, "state") + .exists("k") + .await + .expect_err("HEAD with invalid version or status must be rejected"); + } + for reply in [ + Reply::custom(204, "application/octet-stream", "", b""), + Reply::response(200, "application/octet-stream", b""), + Reply::response(201, "application/octet-stream", b""), + ] { + let fixture = Fixture::new(vec![reply]).await; + store(&fixture, "state") + .delete("k") + .await + .expect_err("mutation with invalid version or status must be rejected"); + } + } + + #[test] + fn ttl_validates_then_rounds_up_without_shortening() { + assert_eq!(ttl_seconds(Duration::from_mins(1)).unwrap(), 60); + assert_eq!( + ttl_seconds(Duration::from_mins(1) + Duration::from_nanos(1)).unwrap(), + 61 + ); + assert_eq!( + ttl_seconds(Duration::from_hours(8_760)).unwrap(), + 31_536_000 + ); + for ttl in [ + Duration::ZERO, + Duration::from_millis(59_999), + Duration::from_hours(8_760) + Duration::from_nanos(1), + Duration::MAX, + ] { + ttl_seconds(ttl).expect_err("out-of-range TTL must be rejected before rounding"); + } + } +} diff --git a/crates/edgezero-store-celld/src/lib.rs b/crates/edgezero-store-celld/src/lib.rs new file mode 100644 index 00000000..5a5bbce7 --- /dev/null +++ b/crates/edgezero-store-celld/src/lib.rs @@ -0,0 +1,63 @@ +//! Scoped native celld stores. Settings are available without runtime features. +//! +//! Config is an immutable startup snapshot; KV is live. Owned errors retain no +//! endpoint, credential, resource, remote body or underlying transport error. + +#![cfg_attr( + all(not(target_arch = "wasm32"), feature = "_transport"), + expect( + clippy::pub_use, + reason = "the supported preparation API is re-exported at the crate root; this lint is module-scoped" + ) +)] + +pub mod settings; + +#[cfg(all(not(target_arch = "wasm32"), feature = "_transport"))] +mod client; +#[cfg(all(not(target_arch = "wasm32"), feature = "config"))] +mod config; +#[cfg(all(not(target_arch = "wasm32"), feature = "kv"))] +mod kv; +#[cfg(all(not(target_arch = "wasm32"), feature = "_transport"))] +mod preparation; +#[cfg(test)] +#[cfg(all(not(target_arch = "wasm32"), feature = "_transport"))] +mod test_support; +#[cfg(all(not(target_arch = "wasm32"), feature = "_transport"))] +pub use preparation::{CelldPreparation, PreparationStats}; + +/// Closed failure categories, never transport error chains or server text. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "keep binding validation, service failures and transport failures grouped in the agreed closed API" +)] +pub enum PreparationError { + #[error("invalid binding")] + InvalidBinding, + #[error("invalid endpoint")] + InvalidEndpoint, + #[error("invalid preparation limits")] + InvalidLimits, + #[error("invalid resource")] + InvalidResource, + #[error("credential failure")] + Credentials, + #[error("unauthenticated")] + Unauthenticated, + #[error("forbidden")] + Forbidden, + #[error("missing resource")] + Missing, + #[error("size limit")] + SizeLimit, + #[error("malformed response")] + Malformed, + #[error("unsupported version")] + UnsupportedVersion, + #[error("provider unavailable")] + Unavailable, + #[error("deadline exceeded")] + Deadline, +} diff --git a/crates/edgezero-store-celld/src/preparation.rs b/crates/edgezero-store-celld/src/preparation.rs new file mode 100644 index 00000000..c1ba95a8 --- /dev/null +++ b/crates/edgezero-store-celld/src/preparation.rs @@ -0,0 +1,715 @@ +//! One startup's aggregate clock, accounting and authorization-scoped caches. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "group public session operations before private accounting and feature-gated preparation" +)] + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(feature = "config")] +use edgezero_core::config_store::{ConfigStoreHandle, ConfigValue}; +#[cfg(feature = "kv")] +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::time::{Deadline, MonotonicClock}; +#[cfg(feature = "config")] +use reqwest::Method; +#[cfg(feature = "config")] +use std::collections::{BTreeMap, HashMap}; + +use crate::PreparationError; +#[cfg(feature = "config")] +use crate::client::ResponseKind; +use crate::client::ScopedClient; +#[cfg(feature = "config")] +use crate::config::ConfigSnapshot; +#[cfg(feature = "kv")] +use crate::kv::CelldKvStore; +#[cfg(feature = "config")] +use crate::settings::CelldConfigSettings; +#[cfg(feature = "kv")] +use crate::settings::CelldKvSettings; +use crate::settings::{CelldAccess, CelldAccessToken, CelldPreparationLimits, endpoint}; + +/// Aggregate counters, not process memory measurements. Metadata counts bounded +/// identifier storage plus conservative per-record overhead. Staging includes at +/// least two document-sized buffers, but excludes allocator and HTTP chunk overhead. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PreparationStats { + pub mappings: u64, + pub unique_retained_payload_bytes: u64, + pub metadata_bytes: u64, + pub peak_transient_staging_bytes: u64, +} + +/// A session is permanently poisoned after any failed or cancelled preparation. +/// Handles already returned are immutable/config or independently live/KV. +pub struct CelldPreparation { + clients: Vec<(ClientIdentity, Arc)>, + clock: MonotonicClock, + deadline: Deadline, + limits: CelldPreparationLimits, + poisoned: bool, + stats: PreparationStats, + #[cfg(feature = "config")] + values: HashMap, +} + +impl fmt::Debug for CelldPreparation { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CelldPreparation") + .field("stats", &self.stats) + .finish_non_exhaustive() + } +} + +impl CelldPreparation { + /// Starts one shared startup deadline, before any client construction. + /// + /// # Errors + /// Rejects invalid limits and unrepresentable deadlines. + #[inline] + pub fn new( + clock: MonotonicClock, + limits: CelldPreparationLimits, + ) -> Result { + limits.validate()?; + let deadline = clock + .now() + .checked_add(Duration::from_millis(limits.preparation_timeout_ms)) + .map(Deadline::at_instant) + .ok_or(PreparationError::InvalidLimits)?; + Ok(Self { + clients: Vec::new(), + clock, + deadline, + limits, + poisoned: false, + stats: PreparationStats::default(), + #[cfg(feature = "config")] + values: HashMap::new(), + }) + } + + /// Fetches all mapped complete UTF-8 documents, with no stale/local fallback. + /// + /// # Errors + /// Returns a closed category on validation, quota, deadline or protocol failure. + /// A failure poisons the session, including failed validation. + #[cfg(feature = "config")] + #[inline] + pub async fn prepare_config( + &mut self, + settings: &CelldConfigSettings, + token: Option<&CelldAccessToken>, + ) -> Result { + self.begin()?; + let result = self.config_inner(settings, token).await; + if result.is_ok() { + self.poisoned = false; + } + result + } + + /// Builds and accounts a live handle. This intentionally performs no remote + /// probe and makes no claim about connectivity or any operation permission. + /// Live calls start fresh deadlines, independent of this session's deadline. + /// + /// # Errors + /// Returns a closed category on invalid input, quota or client construction. + #[cfg(feature = "kv")] + #[inline] + #[expect( + clippy::unused_async, + reason = "the agreed preparation API is uniformly async; live KV setup deliberately performs no remote probe" + )] + pub async fn prepare_kv( + &mut self, + settings: &CelldKvSettings, + token: Option<&CelldAccessToken>, + ) -> Result { + self.begin()?; + let result = self.kv_inner(settings, token); + if result.is_ok() { + self.poisoned = false; + } + result + } + + /// Safe aggregate counters; no resource or authorization identifiers. + #[inline] + #[must_use] + pub const fn stats(&self) -> PreparationStats { + self.stats + } + + fn begin(&mut self) -> Result<(), PreparationError> { + if self.poisoned { + return Err(PreparationError::InvalidBinding); + } + self.poisoned = true; + self.check() + } + + fn check(&self) -> Result<(), PreparationError> { + if self.deadline.is_expired_at(self.clock.now()) { + return Err(PreparationError::Deadline); + } + Ok(()) + } + + fn charge_metadata(&mut self, bytes: usize) -> Result<(), PreparationError> { + self.stats.metadata_bytes = u64::try_from(bytes) + .ok() + .and_then(|count| self.stats.metadata_bytes.checked_add(count)) + .filter(|total| *total <= self.limits.max_metadata_bytes) + .ok_or(PreparationError::SizeLimit)?; + Ok(()) + } + + fn charge_mapping(&mut self, sizes: &[usize]) -> Result<(), PreparationError> { + self.stats.mappings = self + .stats + .mappings + .checked_add(1) + .filter(|count| *count <= self.limits.max_remote_values) + .ok_or(PreparationError::SizeLimit)?; + // Covers map nodes, strings in snapshot/cache targets and temporary target + // lists conservatively. This is a logical metadata quota, not RSS. + let bytes = sizes.iter().try_fold(512_usize, |total, size| { + size.checked_mul(3) + .and_then(|scaled_size| total.checked_add(scaled_size)) + .ok_or(PreparationError::SizeLimit) + })?; + self.charge_metadata(bytes) + } + + fn client( + &mut self, + endpoint_text: &str, + access: &CelldAccess, + token: Option<&CelldAccessToken>, + ) -> Result<(usize, Arc), PreparationError> { + if access.token_env().is_some() != token.is_some() { + return Err(PreparationError::Credentials); + } + let endpoint = endpoint(endpoint_text, access)?; + let identity = ClientIdentity { + endpoint: endpoint.as_str().to_owned(), + access: access.clone(), + token: token.map(|credential| credential.expose().to_owned()), + }; + if let Some((index, (_, client))) = self + .clients + .iter() + .enumerate() + .find(|(_, (key, _))| *key == identity) + { + return Ok((index, Arc::clone(client))); + } + if self.clients.len() >= 128 { + return Err(PreparationError::SizeLimit); + } + let metadata = [ + identity.endpoint.len(), + access.token_env().map_or(0, str::len), + token.map_or(0, |credential| credential.expose().len()), + ] + .into_iter() + .try_fold(1_024_usize, |total, size| { + size.checked_mul(3) + .and_then(|scaled_size| total.checked_add(scaled_size)) + .ok_or(PreparationError::SizeLimit) + })?; + self.charge_metadata(metadata)?; + let client = Arc::new(ScopedClient::new( + endpoint, + token, + self.clock.clone(), + self.limits, + )?); + self.check()?; + let index = self.clients.len(); + self.clients.push((identity, Arc::clone(&client))); + Ok((index, client)) + } + + #[cfg(feature = "config")] + async fn config_inner( + &mut self, + settings: &CelldConfigSettings, + token: Option<&CelldAccessToken>, + ) -> Result { + settings.validate()?; + // Charge every logical mapping before deduplication and before the first + // client/fetch, including mappings aliasing an already retained document. + for (key, document) in &settings.documents { + self.charge_mapping(&[ + key.len(), + settings.endpoint.len(), + document.store.len(), + document.key.len(), + ])?; + } + let (client_id, client) = self.client(&settings.endpoint, &settings.access, token)?; + let mut entries = BTreeMap::new(); + for (key, document) in &settings.documents { + self.check()?; + let target = TargetIdentity { + client_id, + store: document.store.clone(), + key: document.key.clone(), + }; + let value = if let Some(value) = self.values.get(&target) { + value.clone() + } else { + let remaining = self + .limits + .max_snapshot_bytes + .checked_sub(self.stats.unique_retained_payload_bytes) + .ok_or(PreparationError::SizeLimit)?; + let cap = usize::try_from(remaining.min(self.limits.max_config_document_bytes)) + .map_err(|_error| PreparationError::InvalidLimits)?; + let mut url = client.url(&document.store, "value")?; + url.query_pairs_mut().append_pair("key", &document.key); + let deadline = + Deadline::at_instant(self.deadline.instant().min(client.deadline()?.instant())); + let body = client + .execute( + Method::GET, + url, + None, + cap, + deadline, + ResponseKind::Document, + ) + .await? + .ok_or(PreparationError::Missing)?; + self.check()?; + let bytes = + u64::try_from(body.len()).map_err(|_error| PreparationError::SizeLimit)?; + self.stats.peak_transient_staging_bytes = self + .stats + .peak_transient_staging_bytes + .max(bytes.checked_mul(2).ok_or(PreparationError::SizeLimit)?); + let text = String::from_utf8(body).map_err(|_error| PreparationError::Malformed); + self.check()?; + let value = ConfigValue::from(text?); + self.stats.unique_retained_payload_bytes = self + .stats + .unique_retained_payload_bytes + .checked_add(bytes) + .filter(|total| *total <= self.limits.max_snapshot_bytes) + .ok_or(PreparationError::SizeLimit)?; + self.values.insert(target, value.clone()); + value + }; + entries.insert(key.clone(), value); + } + self.check()?; + Ok(ConfigStoreHandle::new(Arc::new(ConfigSnapshot::new( + entries, + )))) + } + + #[cfg(feature = "kv")] + fn kv_inner( + &mut self, + settings: &CelldKvSettings, + token: Option<&CelldAccessToken>, + ) -> Result { + settings.validate()?; + self.charge_mapping(&[settings.endpoint.len(), settings.store.len()])?; + let (_, client) = self.client(&settings.endpoint, &settings.access, token)?; + let endpoint = endpoint(&settings.endpoint, &settings.access)?; + self.check()?; + Ok(KvHandle::new(Arc::new(CelldKvStore::new( + client, + endpoint, + settings.store.clone(), + self.limits, + )))) + } +} + +// Session policy is immutable, so it is implicitly part of every cache identity. +// Access includes the variable reference and remote-HTTP policy, not just bearer +// bytes. Equal tokens selected through different references remain separate. +#[derive(Eq, PartialEq)] +struct ClientIdentity { + endpoint: String, + access: CelldAccess, + token: Option, +} + +impl fmt::Debug for ClientIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ClientIdentity").finish_non_exhaustive() + } +} + +#[cfg(feature = "config")] +#[derive(Eq, Hash, PartialEq)] +struct TargetIdentity { + client_id: usize, + store: String, + key: String, +} + +#[cfg(feature = "config")] +impl fmt::Debug for TargetIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TargetIdentity").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use edgezero_core::time::MonotonicClock; + + use super::CelldPreparation; + use crate::PreparationError; + use crate::settings::{CelldAccess, CelldAccessToken, CelldPreparationLimits}; + + #[test] + fn cache_identity_includes_credentials_reference_and_complete_access_policy() { + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + let access = CelldAccess::Authenticated { + token_env: "FIRST".to_owned(), + }; + let token = CelldAccessToken::new("a".repeat(32)).unwrap(); + let (first, _) = preparation + .client("https://resource-sentinel.invalid", &access, Some(&token)) + .unwrap(); + let (same, _) = preparation + .client("https://resource-sentinel.invalid/", &access, Some(&token)) + .unwrap(); + assert_eq!(first, same); + let other = CelldAccessToken::new("b".repeat(32)).unwrap(); + assert_ne!( + preparation + .client("https://resource-sentinel.invalid", &access, Some(&other)) + .unwrap() + .0, + first + ); + let other_reference = CelldAccess::Authenticated { + token_env: "SECOND".to_owned(), + }; + assert_ne!( + preparation + .client( + "https://resource-sentinel.invalid", + &other_reference, + Some(&token) + ) + .unwrap() + .0, + first + ); + let trusted = CelldAccess::TrustedNetwork { + allow_remote_http: false, + }; + let trusted_opt_in = CelldAccess::TrustedNetwork { + allow_remote_http: true, + }; + let (trusted_id, _) = preparation + .client("https://resource-sentinel.invalid", &trusted, None) + .unwrap(); + assert_ne!( + preparation + .client("https://resource-sentinel.invalid", &trusted_opt_in, None) + .unwrap() + .0, + trusted_id + ); + assert!(matches!( + preparation.client("https://resource-sentinel.invalid", &trusted, Some(&token)), + Err(PreparationError::Credentials) + )); + assert!(matches!( + preparation.client("https://resource-sentinel.invalid", &access, None), + Err(PreparationError::Credentials) + )); + assert!(!format!("{preparation:?} {:?}", preparation.clients[0].0).contains("sentinel")); + assert_eq!(preparation.stats().unique_retained_payload_bytes, 0); + } + + #[test] + fn client_cache_and_metadata_are_finite_for_direct_callers() { + let access = CelldAccess::TrustedNetwork { + allow_remote_http: false, + }; + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + for index in 0..128_u16 { + preparation + .client( + &format!("https://127.0.0.1:{}", 1_000 + index), + &access, + None, + ) + .unwrap(); + } + assert!(matches!( + preparation.client("https://127.0.0.1:2000", &access, None), + Err(PreparationError::SizeLimit) + )); + assert_eq!(preparation.clients.len(), 128); + let limits = CelldPreparationLimits { + max_metadata_bytes: 1, + ..CelldPreparationLimits::default() + }; + let mut metadata_limited = + CelldPreparation::new(MonotonicClock::default(), limits).unwrap(); + assert!(matches!( + metadata_limited.client("https://127.0.0.1", &access, None), + Err(PreparationError::SizeLimit) + )); + assert!(metadata_limited.clients.is_empty()); + } +} + +#[cfg(test)] +#[cfg(feature = "config")] +mod config_tests { + use std::collections::BTreeMap; + use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Duration; + + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; + use tokio::time::sleep; + + use super::CelldPreparation; + use crate::PreparationError; + use crate::settings::{ + CelldAccess, CelldConfigSettings, CelldDocument, CelldPreparationLimits, + }; + use crate::test_support::{Fixture, Reply}; + + fn settings(fixture: &Fixture, mappings: &[(&str, &str)]) -> CelldConfigSettings { + CelldConfigSettings { + endpoint: fixture.endpoint().to_owned(), + access: CelldAccess::TrustedNetwork { + allow_remote_http: false, + }, + default_key: mappings + .first() + .expect("config fixture requires a default mapping") + .0 + .to_owned(), + documents: mappings + .iter() + .map(|(local, remote)| { + ( + (*local).to_owned(), + CelldDocument { + store: "config".to_owned(), + key: (*remote).to_owned(), + }, + ) + }) + .collect::>(), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn exact_bytes_dedup_and_restart_adoption_without_request_time_io() { + let fixture = Fixture::new(vec![]).await; + let text = " {\"schema\": 1, \"payload\": {\"a\": 1}}\n"; + fixture.seed("config", "remote", text.as_bytes()).await; + fixture.seed("config", "empty", b"").await; + let settings = settings( + &fixture, + &[("first", "remote"), ("alias", "remote"), ("empty", "empty")], + ); + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + let snapshot = preparation.prepare_config(&settings, None).await.unwrap(); + assert_eq!(fixture.count().await, 2); + assert_eq!(preparation.stats().mappings, 3); + assert_eq!( + preparation.stats().unique_retained_payload_bytes, + u64::try_from(text.len()).unwrap() + ); + assert!( + preparation.stats().peak_transient_staging_bytes + >= u64::try_from(text.len() * 2).unwrap() + ); + assert_eq!(snapshot.get("first").await.unwrap().unwrap().as_str(), text); + assert_eq!(snapshot.get("alias").await.unwrap().unwrap().as_str(), text); + assert_eq!(snapshot.get("empty").await.unwrap().unwrap().as_str(), ""); + assert!(snapshot.get("unmapped").await.unwrap().is_none()); + fixture.seed("config", "remote", b"new revision").await; + let same_session = preparation.prepare_config(&settings, None).await.unwrap(); + assert_eq!( + same_session.get("first").await.unwrap().unwrap().as_str(), + text + ); + assert_eq!(preparation.stats().mappings, 6); + assert_eq!(fixture.count().await, 2); + drop(preparation); + assert_eq!(snapshot.get("first").await.unwrap().unwrap().as_str(), text); + let mut fresh = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + let adopted = fresh.prepare_config(&settings, None).await.unwrap(); + assert_eq!( + adopted.get("first").await.unwrap().unwrap().as_str(), + "new revision" + ); + assert_eq!(fixture.count().await, 4); + } + + #[tokio::test(flavor = "current_thread")] + async fn mapping_and_metadata_caps_are_precharged_before_dedup_or_first_fetch() { + for limits in [ + CelldPreparationLimits { + max_remote_values: 1, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + max_metadata_bytes: 1, + ..CelldPreparationLimits::default() + }, + ] { + let fixture = Fixture::new(vec![]).await; + fixture.seed("config", "shared", b"v").await; + let settings = settings(&fixture, &[("a", "shared"), ("b", "shared")]); + let mut preparation = CelldPreparation::new(MonotonicClock::default(), limits).unwrap(); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::SizeLimit) + )); + assert_eq!(fixture.count().await, 0); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::InvalidBinding) + )); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn retained_quota_clips_transfer_and_failure_permanently_poisons_session() { + let fixture = Fixture::new(vec![]).await; + fixture.seed("config", "one", b"123").await; + fixture.seed("config", "two", b"456").await; + let settings = settings(&fixture, &[("a", "one"), ("b", "two")]); + let limits = CelldPreparationLimits { + max_config_document_bytes: 4, + max_value_bytes: 4, + max_snapshot_bytes: 5, + ..CelldPreparationLimits::default() + }; + let mut preparation = CelldPreparation::new(MonotonicClock::default(), limits).unwrap(); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::SizeLimit) + )); + assert_eq!(preparation.stats().unique_retained_payload_bytes, 3); + assert_eq!(fixture.count().await, 2); + fixture.seed("config", "two", b"4").await; + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::InvalidBinding) + )); + assert_eq!(fixture.count().await, 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn required_misses_invalid_utf8_and_late_invalid_mapping_publish_nothing() { + for (bytes, expected) in [ + (Some(vec![255]), PreparationError::Malformed), + (None, PreparationError::Missing), + ] { + let fixture = Fixture::new(vec![]).await; + if let Some(payload) = bytes { + fixture.seed("config", "remote", &payload).await; + } + let settings = settings(&fixture, &[("a", "remote")]); + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + assert!( + matches!(preparation.prepare_config(&settings, None).await, Err(error) if error == expected) + ); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::InvalidBinding) + )); + assert_eq!(fixture.count().await, 1); + } + let fixture = Fixture::new(vec![]).await; + let settings = settings(&fixture, &[("a", "valid"), ("b", "invalid\n")]); + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::InvalidResource) + )); + assert_eq!(fixture.count().await, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn aggregate_deadline_never_restarts_between_bindings() { + let fixture = Fixture::new(vec![]).await; + fixture.seed("config", "remote", b"v").await; + let start = MonotonicInstant::now(); + let elapsed = Arc::new(AtomicU64::new(0)); + let ticks = Arc::clone(&elapsed); + let clock = MonotonicClock::new(move || { + start + .checked_add(Duration::from_millis(ticks.load(Ordering::SeqCst))) + .unwrap() + }); + let mut preparation = + CelldPreparation::new(clock, CelldPreparationLimits::default()).unwrap(); + let settings = settings(&fixture, &[("a", "remote")]); + preparation.prepare_config(&settings, None).await.unwrap(); + elapsed.store(30_000, Ordering::SeqCst); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::Deadline) + )); + assert_eq!(fixture.count().await, 1); + } + + #[tokio::test(flavor = "current_thread")] + #[expect( + clippy::integer_division_remainder_used, + reason = "Tokio select generates bounded branch-index arithmetic" + )] + async fn cancellation_also_poisons_unpublished_partial_preparation() { + let fixture = Fixture::new(vec![Reply::Stall]).await; + let settings = settings(&fixture, &[("a", "remote")]); + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .unwrap(); + let mut pending = Box::pin(preparation.prepare_config(&settings, None)); + tokio::select! { + biased; + _result = pending.as_mut() => panic!("stalled preparation completed before cancellation"), + () = async { + while fixture.count().await == 0 { + sleep(Duration::from_millis(1)).await; + } + } => {} + () = sleep(Duration::from_secs(3)) => panic!("request admission deadline"), + } + drop(pending); + assert!(matches!( + preparation.prepare_config(&settings, None).await, + Err(PreparationError::InvalidBinding) + )); + assert_eq!(fixture.count().await, 1); + } +} diff --git a/crates/edgezero-store-celld/src/settings.rs b/crates/edgezero-store-celld/src/settings.rs new file mode 100644 index 00000000..c8efe2cb --- /dev/null +++ b/crates/edgezero-store-celld/src/settings.rs @@ -0,0 +1,461 @@ +//! Pure structural validation. This module never reads the environment. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "keep each public settings shape beside its validator and redacted Debug implementation" +)] + +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fmt; +use url::{Host, Url}; + +use crate::PreparationError; + +/// Explicit listener access policy, not a credential value. +#[derive(Clone, Deserialize, Eq, Hash, PartialEq)] +#[serde(tag = "mode", rename_all = "kebab-case", deny_unknown_fields)] +pub enum CelldAccess { + TrustedNetwork { + #[serde(default)] + allow_remote_http: bool, + }, + Authenticated { + token_env: String, + }, +} + +impl CelldAccess { + /// Selected bootstrap variable, without looking it up. + #[inline] + #[must_use] + pub fn token_env(&self) -> Option<&str> { + match self { + Self::Authenticated { token_env } => Some(token_env), + Self::TrustedNetwork { .. } => None, + } + } +} + +impl fmt::Debug for CelldAccess { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CelldAccess").finish_non_exhaustive() + } +} + +/// One complete remote document selected by a service-side store alias. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CelldDocument { + pub store: String, + pub key: String, +} + +impl fmt::Debug for CelldDocument { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CelldDocument").finish_non_exhaustive() + } +} + +/// Explicit local lookup mappings to complete UTF-8 documents. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CelldConfigSettings { + pub endpoint: String, + pub access: CelldAccess, + pub default_key: String, + pub documents: BTreeMap, +} + +impl CelldConfigSettings { + /// Validates every mapping without credentials, runtime or network access. + /// + /// # Errors + /// Rejects invalid endpoint, access policy, default, lookup key or target. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + endpoint(&self.endpoint, &self.access)?; + if self.documents.is_empty() || !self.documents.contains_key(&self.default_key) { + return Err(PreparationError::InvalidBinding); + } + for (key, document) in &self.documents { + if !valid_key(key, false) || key.trim().is_empty() { + return Err(PreparationError::InvalidBinding); + } + if !valid_alias(&document.store) || !valid_key(&document.key, false) { + return Err(PreparationError::InvalidResource); + } + } + Ok(()) + } +} + +impl fmt::Debug for CelldConfigSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CelldConfigSettings") + .finish_non_exhaustive() + } +} + +/// One live scoped store alias. No physical namespace is accepted. +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CelldKvSettings { + pub endpoint: String, + pub access: CelldAccess, + pub store: String, +} + +impl CelldKvSettings { + /// Validates the binding without probing remote permissions. + /// + /// # Errors + /// Rejects invalid endpoint, access policy or store alias. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + endpoint(&self.endpoint, &self.access)?; + if !valid_alias(&self.store) { + return Err(PreparationError::InvalidResource); + } + Ok(()) + } +} + +impl fmt::Debug for CelldKvSettings { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CelldKvSettings").finish_non_exhaustive() + } +} + +/// Captured application bearer token. Debug reveals neither value nor length. +#[derive(Clone)] +pub struct CelldAccessToken(String); + +impl CelldAccessToken { + /// Validates explicit bytes against the embedded service's token contract. + /// + /// # Errors + /// Rejects tokens outside 32..=512 ASCII graphic bytes. + #[inline] + pub fn new(value: String) -> Result { + if !(32..=512).contains(&value.len()) || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(PreparationError::Credentials); + } + Ok(Self(value)) + } + + /// Exposes bytes only where explicitly needed for transport. + #[inline] + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for CelldAccessToken { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CelldAccessToken([redacted])") + } +} + +/// Aggregate preparation and independent live-operation bounds. +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CelldPreparationLimits { + pub max_remote_values: u64, + pub max_config_document_bytes: u64, + pub max_snapshot_bytes: u64, + pub max_metadata_bytes: u64, + pub max_value_bytes: u64, + pub max_page_bytes: u64, + pub preparation_timeout_ms: u64, + pub operation_timeout_ms: u64, + pub connect_timeout_ms: u64, +} + +impl Default for CelldPreparationLimits { + #[inline] + fn default() -> Self { + Self { + max_remote_values: 1_024, + max_config_document_bytes: 0x0010_0000, + max_snapshot_bytes: 0x0080_0000, + max_metadata_bytes: 0x0010_0000, + max_value_bytes: 0x0010_0000, + max_page_bytes: 0x0020_0000, + preparation_timeout_ms: 30_000, + operation_timeout_ms: 5_000, + connect_timeout_ms: 1_000, + } + } +} + +impl CelldPreparationLimits { + /// Checks positive, representable quotas and ordered finite deadlines. + /// + /// # Errors + /// Returns a fixed category for invalid bounds or service-ceiling violations. + #[inline] + pub fn validate(&self) -> Result<(), PreparationError> { + let quotas = [ + self.max_remote_values, + self.max_config_document_bytes, + self.max_snapshot_bytes, + self.max_metadata_bytes, + self.max_value_bytes, + self.max_page_bytes, + ]; + if quotas.iter().any(|value| *value == 0 || usize::try_from(*value).is_err()) + || self.max_value_bytes > 0x0010_0000 + || self.max_config_document_bytes > 0x0010_0000 + || self.max_config_document_bytes > self.max_snapshot_bytes + || self.max_page_bytes > 0x0020_0000 + || self.connect_timeout_ms == 0 + || self.connect_timeout_ms > self.operation_timeout_ms + || self.operation_timeout_ms > self.preparation_timeout_ms + // Match the portable clock's finite seven-day horizon. + || self.preparation_timeout_ms > 604_800_000 + { + return Err(PreparationError::InvalidLimits); + } + Ok(()) + } +} + +pub(crate) fn endpoint(value: &str, access: &CelldAccess) -> Result { + if value.len() > 4_096 + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + || value.contains(['\\', '?', '#', '@']) + { + return Err(PreparationError::InvalidEndpoint); + } + let origin_suffix = value + .strip_prefix("http://") + .or_else(|| value.strip_prefix("https://")) + .ok_or(PreparationError::InvalidEndpoint)?; + let (authority, path) = origin_suffix.split_once('/').unwrap_or((origin_suffix, "")); + if !path.is_empty() || authority.contains('%') || authority.ends_with(':') { + return Err(PreparationError::InvalidEndpoint); + } + let parsed = Url::parse(value).map_err(|_error| PreparationError::InvalidEndpoint)?; + if parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.host_str().is_none() + || parsed.port() == Some(0) + || parsed.path() != "/" + { + return Err(PreparationError::InvalidEndpoint); + } + let raw_host = if authority.starts_with('[') { + authority + .split_once(']') + .map(|(host, _port)| host) + .and_then(|host| host.strip_prefix('[')) + } else { + authority.split(':').next() + } + .ok_or(PreparationError::InvalidEndpoint)?; + let loopback = match parsed.host() { + Some(Host::Ipv4(ip)) => { + if raw_host != ip.to_string() { + return Err(PreparationError::InvalidEndpoint); + } + ip.is_loopback() + } + Some(Host::Ipv6(ip)) => { + if raw_host != ip.to_string() { + return Err(PreparationError::InvalidEndpoint); + } + ip.is_loopback() + } + Some(Host::Domain(host)) => { + if host.ends_with('.') || host.is_empty() { + return Err(PreparationError::InvalidEndpoint); + } + false + } + None => return Err(PreparationError::InvalidEndpoint), + }; + match access { + CelldAccess::Authenticated { token_env } => { + if !valid_variable(token_env) { + return Err(PreparationError::InvalidBinding); + } + if parsed.scheme() != "https" { + return Err(PreparationError::InvalidEndpoint); + } + } + CelldAccess::TrustedNetwork { allow_remote_http } => { + if parsed.scheme() == "http" && !loopback && !allow_remote_http { + return Err(PreparationError::InvalidEndpoint); + } + } + } + Ok(parsed) +} + +pub(crate) fn valid_alias(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +pub(crate) fn valid_key(value: &str, empty: bool) -> bool { + (empty || !value.is_empty()) && value.len() <= 512 && !value.chars().any(char::is_control) +} + +fn valid_variable(value: &str) -> bool { + let mut bytes = value.bytes(); + value.len() <= 128 + && bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +#[cfg(test)] +mod tests { + use super::{CelldAccess, CelldAccessToken, CelldKvSettings, CelldPreparationLimits, endpoint}; + use crate::PreparationError; + + #[test] + fn endpoints_are_explicit_origins_not_normalized_paths_or_obfuscated_ips() { + let trusted = CelldAccess::TrustedNetwork { + allow_remote_http: false, + }; + for good in [ + "http://127.0.0.1:7780", + "http://[::1]:7780/", + "https://stores.example", + ] { + endpoint(good, &trusted).expect("explicit loopback or TLS origin must be accepted"); + } + for bad in [ + "http://localhost", + "http://127.1", + "http://2130706433", + "http://127.0.0.1/a/..", + "http://127.0.0.1//", + "http://127.0.0.1?", + "http://127.0.0.1#", + "http://user@127.0.0.1", + "http://127.0.0.1\\x", + " http://127.0.0.1", + "http://private.example", + "http://127.0.0.1:0", + "http://127.0.0.1:", + "https://stores.example.", + ] { + endpoint(bad, &trusted).expect_err("invalid endpoint origin must be rejected"); + } + let remote = CelldAccess::TrustedNetwork { + allow_remote_http: true, + }; + endpoint("http://private.example", &remote) + .expect("remote HTTP origin with explicit opt-in must be accepted"); + let authenticated = CelldAccess::Authenticated { + token_env: "TOKEN".to_owned(), + }; + endpoint("http://127.0.0.1", &authenticated) + .expect_err("authenticated access must reject plaintext HTTP"); + endpoint("https://stores.example", &authenticated) + .expect("authenticated TLS origin must be accepted"); + } + + #[test] + fn config_and_kv_value_caps_are_independent() { + let limits = CelldPreparationLimits { + max_value_bytes: 1, + ..CelldPreparationLimits::default() + }; + limits + .validate() + .expect("lower live KV cap does not constrain config snapshots"); + assert_eq!( + CelldPreparationLimits { + max_config_document_bytes: 0x0010_0001, + ..limits + } + .validate() + .expect_err("config retains its independent service ceiling"), + PreparationError::InvalidLimits + ); + } + + #[test] + fn tokens_match_service_bounds_and_debug_is_fully_redacted() { + for size in [0, 1, 31, 513] { + CelldAccessToken::new("x".repeat(size)) + .expect_err("token length outside service bounds must be rejected"); + } + for value in [" ".repeat(32), "\n".repeat(32), "\u{e9}".repeat(32)] { + CelldAccessToken::new(value) + .expect_err("token with non-graphic ASCII bytes must be rejected"); + } + let token = CelldAccessToken::new("s".repeat(32)).unwrap(); + assert_eq!(format!("{token:?}"), "CelldAccessToken([redacted])"); + assert_eq!(token.clone().expose().len(), 32); + CelldAccessToken::new("x".repeat(512)) + .expect("maximum-length graphic ASCII token must be accepted"); + } + + #[test] + fn settings_and_limits_reject_unknown_or_mixed_fields() { + for access in [ + r#"{"mode":"trusted-network","token_env":"TOKEN"}"#, + r#"{"mode":"authenticated","token_env":"TOKEN","allow_remote_http":true}"#, + "{}", + ] { + serde_json::from_str::(access) + .expect_err("mixed or missing access mode fields must be rejected"); + } + let settings: CelldKvSettings = serde_json::from_str(r#"{"endpoint":"http://127.0.0.1","store":"Case_Sensitive-1","access":{"mode":"trusted-network"}}"#).unwrap(); + settings.validate().unwrap(); + assert_eq!(settings.access.token_env(), None); + serde_json::from_str::(r#"{"unknown":1}"#) + .expect_err("unknown preparation limit field must be rejected"); + CelldPreparationLimits::default().validate().unwrap(); + for limits in [ + CelldPreparationLimits { + max_remote_values: 0, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + max_value_bytes: 0x0010_0001, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + max_snapshot_bytes: 1, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + max_metadata_bytes: 0, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + connect_timeout_ms: 5_001, + ..CelldPreparationLimits::default() + }, + CelldPreparationLimits { + preparation_timeout_ms: u64::MAX, + ..CelldPreparationLimits::default() + }, + ] { + limits + .validate() + .expect_err("invalid preparation quotas or deadlines must be rejected"); + } + } +} diff --git a/crates/edgezero-store-celld/src/test_support.rs b/crates/edgezero-store-celld/src/test_support.rs new file mode 100644 index 00000000..14e38041 --- /dev/null +++ b/crates/edgezero-store-celld/src/test_support.rs @@ -0,0 +1,323 @@ +//! Private real TCP fixtures shared by config and KV tests. No transport mocking. + +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "keep request/reply models before fixture ownership and TCP serving" +)] +#![expect( + clippy::field_scoped_visibility_modifiers, + reason = "fixture records are inspected by sibling colocated test modules" +)] + +use std::collections::{BTreeMap, VecDeque}; +use std::mem; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{ + AsyncBufReadExt as _, AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _, BufReader, +}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; +use tokio::task::{JoinHandle, JoinSet}; +use tokio::time::{sleep, timeout}; +use tokio_rustls::TlsAcceptor; +use url::Url; + +pub(crate) struct Request { + pub(crate) body: Vec, + pub(crate) connection: usize, + pub(crate) method: String, + pub(crate) target: String, + pub(crate) authorization: Option, + pub(crate) encoding: Option, +} + +pub(crate) enum Reply { + Bytes(Vec), + #[cfg(feature = "kv")] + Drop, + Stall, + SlowBody, +} + +impl Reply { + pub(crate) fn response(status: u16, content_type: &str, body: &[u8]) -> Self { + Self::custom(status, content_type, "x-celld-store-version: 1\r\n", body) + } + + pub(crate) fn custom(status: u16, content_type: &str, headers: &str, body: &[u8]) -> Self { + let mut reply = format!("HTTP/1.1 {status} Fixture\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n{headers}\r\n", body.len()).into_bytes(); + reply.extend_from_slice(body); + Self::Bytes(reply) + } +} + +#[derive(Default)] +struct State { + requests: Vec, + replies: VecDeque, + values: BTreeMap<(String, String), Vec>, +} + +pub(crate) struct Fixture { + endpoint: String, + state: Arc>, + task: JoinHandle<()>, +} + +impl Fixture { + pub(crate) async fn new(replies: Vec) -> Self { + Self::start(replies, None).await + } + + pub(crate) async fn tls(certificate: &'static [u8], replies: Vec) -> Self { + use tokio_rustls::rustls::ServerConfig; + use tokio_rustls::rustls::crypto::aws_lc_rs::default_provider; + use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + include_bytes!("fixtures/tls-key.der").to_vec(), + )); + let config = ServerConfig::builder_with_provider(default_provider().into()) + .with_safe_default_protocol_versions() + .expect("fixture provider supports default TLS protocol versions") + .with_no_client_auth() + .with_single_cert(vec![CertificateDer::from(certificate.to_vec())], key) + .expect("fixture certificate matches the bundled private key"); + Self::start(replies, Some(TlsAcceptor::from(Arc::new(config)))).await + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "Tokio select generates bounded branch-index arithmetic" + )] + async fn start(replies: Vec, acceptor: Option) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture loopback listener"); + let scheme = if acceptor.is_some() { "https" } else { "http" }; + let endpoint = format!( + "{scheme}://{}", + listener.local_addr().expect("read bound fixture address") + ); + let state = Arc::new(Mutex::new(State { + replies: replies.into(), + ..State::default() + })); + let server_state = Arc::clone(&state); + let task = tokio::spawn(async move { + let mut connections = JoinSet::new(); + let mut index = 0_usize; + loop { + tokio::select! { + biased; + accepted = listener.accept() => { + let Ok((stream, _address)) = accepted else { break; }; + let connection_state = Arc::clone(&server_state); + let connection_acceptor = acceptor.clone(); + index = index.checked_add(1).expect("fixture connection count must fit usize"); + connections.spawn(async move { + if let Some(tls_acceptor) = connection_acceptor { + if let Ok(Ok(tls_stream)) = timeout( + Duration::from_secs(3), tls_acceptor.accept(stream), + ).await { + serve(tls_stream, connection_state, index).await; + } + } else { + serve(stream, connection_state, index).await; + } + }); + } + Some(_result) = connections.join_next(), if !connections.is_empty() => {} + } + } + }); + Self { + endpoint, + state, + task, + } + } + + pub(crate) fn endpoint(&self) -> &str { + &self.endpoint + } + + pub(crate) async fn seed(&self, store: &str, key: &str, bytes: &[u8]) { + self.state + .lock() + .await + .values + .insert((store.to_owned(), key.to_owned()), bytes.to_vec()); + } + + pub(crate) async fn value(&self, store: &str, key: &str) -> Option> { + self.state + .lock() + .await + .values + .get(&(store.to_owned(), key.to_owned())) + .cloned() + } + + pub(crate) async fn count(&self) -> usize { + self.state.lock().await.requests.len() + } + + pub(crate) async fn requests(&self) -> Vec { + mem::take(&mut self.state.lock().await.requests) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn serve( + socket: S, + state: Arc>, + connection: usize, +) { + let mut stream = BufReader::new(socket); + loop { + let mut first = String::new(); + if stream.read_line(&mut first).await.unwrap_or(0) == 0 { + return; + } + let mut parts = first.split_whitespace(); + let method = parts + .next() + .expect("fixture request line includes a method") + .to_owned(); + let target = parts + .next() + .expect("fixture request line includes a target") + .to_owned(); + let mut length = 0; + let mut authorization = None; + let mut encoding = None; + loop { + let mut line = String::new(); + if stream.read_line(&mut line).await.unwrap_or(0) == 0 { + return; + } + if line == "\r\n" { + break; + } + if let Some((name, raw_value)) = line.split_once(':') { + let value = raw_value.trim(); + if name.eq_ignore_ascii_case("content-length") { + length = value + .parse() + .expect("fixture Content-Length is a valid length"); + } + if name.eq_ignore_ascii_case("authorization") { + authorization = Some(value.to_owned()); + } + if name.eq_ignore_ascii_case("accept-encoding") { + encoding = Some(value.to_owned()); + } + } + } + let mut body = vec![0; length]; + if stream.read_exact(&mut body).await.is_err() { + return; + } + let request = Request { + body, + connection, + method, + target, + authorization, + encoding, + }; + let reply = { + let mut locked = state.lock().await; + let dynamic = apply(&request, &mut locked.values); + locked.requests.push(request); + locked.replies.pop_front().unwrap_or(dynamic) + }; + match reply { + Reply::Bytes(bytes) => { + if stream.get_mut().write_all(&bytes).await.is_err() { + return; + } + } + #[cfg(feature = "kv")] + Reply::Drop => return, + Reply::Stall => { + sleep(Duration::from_secs(5)).await; + return; + } + Reply::SlowBody => { + if stream.get_mut().write_all(b"HTTP/1.1 200 Fixture\r\nx-celld-store-version: 1\r\nContent-Type: application/octet-stream\r\nContent-Length: 2\r\n\r\nx").await.is_err() { return; } + sleep(Duration::from_secs(5)).await; + return; + } + } + } +} + +fn apply(request: &Request, values: &mut BTreeMap<(String, String), Vec>) -> Reply { + let url = Url::parse(&format!("http://fixture{}", request.target)) + .expect("fixture request target forms a valid URL"); + let segments: Vec<_> = url + .path_segments() + .expect("fixture URL has path segments") + .collect(); + let store = (*segments + .get(2) + .expect("fixture store path includes an alias")) + .to_owned(); + let params: BTreeMap<_, _> = url.query_pairs().into_owned().collect(); + let key = params.get("key").cloned().unwrap_or_default(); + let target = (store.clone(), key); + match request.method.as_str() { + "PUT" => { + values.insert(target, request.body.clone()); + Reply::response(204, "application/octet-stream", b"") + } + "DELETE" => { + values.remove(&target); + Reply::response(204, "application/octet-stream", b"") + } + "HEAD" => Reply::response( + if values.contains_key(&target) { + 200 + } else { + 404 + }, + "application/octet-stream", + b"", + ), + _ if url.path().ends_with("/keys") => { + let prefix = params.get("prefix").map_or("", String::as_str); + let after = params.get("cursor").map_or("", String::as_str); + let limit: usize = params + .get("limit") + .expect("fixture page request has a limit") + .parse() + .expect("fixture page limit is numeric"); + let mut keys: Vec<_> = values + .keys() + .filter(|(alias, stored_key)| { + *alias == store && stored_key.starts_with(prefix) && stored_key.as_str() > after + }) + .map(|(_, stored_key)| stored_key.clone()) + .collect(); + let more = keys.len() > limit; + keys.truncate(limit); + let cursor = if more { keys.last().cloned() } else { None }; + let bytes = serde_json::to_vec(&serde_json::json!({"keys": keys, "cursor": cursor})) + .expect("serialize fixture page JSON"); + Reply::response(200, "application/json", &bytes) + } + _ => values.get(&target).map_or_else( + || Reply::response(404, "application/json", br#"{"error":"not_found"}"#), + |bytes| Reply::response(200, "application/octet-stream", bytes), + ), + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5d7c6197..4d68d82b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -51,6 +51,7 @@ export default defineConfig({ { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, { text: 'Native AWS stores', link: '/guide/native-aws-stores' }, + { text: 'Native celld stores', link: '/guide/native-celld-stores' }, { text: 'DynamoDB KV', link: '/guide/dynamodb-kv' }, ], }, diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index a96242bb..ccd1da40 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -328,6 +328,8 @@ With explicit AWS bindings, the runner fetches mapped values before its initiali and before it accepts requests. It does not fall back to a local provider when an AWS lookup or preparation fails. See the [native AWS stores guide](../native-aws-stores.md) for the schema, feature selection, permissions, and lifecycle limits. +The independent [native celld providers](../native-celld-stores.md) add startup +configuration snapshots and live KV through a scoped listener inside celld. The native binding file path must be absolute. `EDGEZERO__STORE_BINDINGS_FILE` is read only by the environment-loading runner. Explicit `AxumRunOptions` selection diff --git a/docs/guide/native-aws-stores.md b/docs/guide/native-aws-stores.md index 10253c9e..115d2ecc 100644 --- a/docs/guide/native-aws-stores.md +++ b/docs/guide/native-aws-stores.md @@ -37,8 +37,8 @@ edgezero config validate \ ``` The bindings path must be absolute. Target controls include -`--target-all-features`, `--target-no-default-features`, and `--target `. If Cargo's -resolved target feature graph is unavailable or ambiguous offline, the command reports +`--target-all-features`, `--target-no-default-features`, and `--target `. The command +normalizes these controls without resolving Cargo's target feature graph. It always reports structural validation only and says provider availability is unknown. This check does not load credentials, contact AWS or the Agent, prove resource access, or validate remote contents. Runtime startup validates the actual selected inputs again. Set diff --git a/docs/guide/native-celld-stores.md b/docs/guide/native-celld-stores.md new file mode 100644 index 00000000..4f870525 --- /dev/null +++ b/docs/guide/native-celld-stores.md @@ -0,0 +1,190 @@ +# Native celld stores + +The native provider reads complete configuration documents at startup and exposes live KV operations through a scoped listener **inside celld**. There is no gateway, sidecar, Worker bridge or celld HTTP adapter. + +This is an unreleased integration. It requires the modified celld revision `f72ae21a515859570622ac9d6ca9c19cf6870b81`. Upstream celld v0.6.2 does not have this API. Local tests do not establish fleet failover, deployment security or production readiness. + +## Build only the providers you use + +Generated Axum targets forward two independent, default-off features: + +```toml +[adapters.axum.build] +features = ["celld-config", "celld-kv"] +``` + +The provider crate uses `config` and `kv`. Its settings compile without live transport dependencies. Applications do not depend on the celld runtime or V8. Existing AWS features and the `aws-stores` alias keep their current membership. + +A deployment file cannot enable a provider omitted from the application binary. Startup checks the compiled capabilities before preparing any selected store. + +## Keep declarations portable + +The application declares logical IDs in `edgezero.toml`: + +```toml +[stores.config] +ids = ["application"] +default = "application" + +[stores.kv] +ids = ["state"] +default = "state" +``` + +A separate native deployment file selects providers: + +```toml +version = 1 + +[config.application] +provider = "celld-config" +endpoint = "https://stores.example.internal:7780" +default_key = "settings" + +[config.application.access] +mode = "authenticated" +token_env = "CHECKOUT_STORE_TOKEN" + +[config.application.documents.settings] +store = "checkout-config" +key = "application-envelope" + +[kv.state] +provider = "celld-kv" +endpoint = "https://stores.example.internal:7780" +store = "checkout-state" + +[kv.state.access] +mode = "authenticated" +token_env = "CHECKOUT_STORE_TOKEN" +``` + +These are fictional resource names. `store` is a service-owned alias, never a physical namespace or runtime scope. The schema rejects unknown fields, malformed endpoints, undeclared IDs and conflicting sources. Endpoints are HTTP or HTTPS origins without userinfo, query, fragment or a non-root path. Keys and aliases retain their exact case and UTF-8 bytes. + +Select the absolute file path with `EDGEZERO__STORE_BINDINGS_FILE`. The environment-loading runner captures the selected file and the union of selected config/KV token names once. Explicit `AxumRunOptions` uses supplied bootstrap values, not ambient variables. Tokens are 32 through 512 header-safe ASCII graphic bytes. Generate random application tokens and inject them through deployment secret management. Do not put token values in TOML or URLs. + +Different IDs can select celld, AWS, local providers or supplied handles. Celld-only bindings need no AWS credentials or local roots. An unbound ID retains its existing local default and may require a local root. There is no celld secret provider. A typed celld config document may reference a separately bound AWS secret store. + +## Configure the listener in celld + +Add `--store-api-config /absolute/scoped-stores.json` to the existing celld deployment command. Without this selection, celld opens no scoped socket. + +An authenticated policy has this form: + +```json +{ + "version": 1, + "listen": "0.0.0.0:7780", + "tls": { + "cert": "/etc/celld/server-chain.pem", + "key": "/etc/celld/server-key.pem" + }, + "stores": [ + { "alias": "checkout-config", "namespace": "production-checkout-config" }, + { "alias": "checkout-state", "namespace": "production-checkout-state" } + ], + "access": { + "mode": "authenticated", + "tokens": [ + { + "id": "checkout", + "sha256": "", + "grants": [ + { "store": "checkout-config", "operations": ["read"] }, + { + "store": "checkout-state", + "operations": ["read", "write", "delete", "list"] + } + ] + } + ] + } +} +``` + +Replace the verifier placeholder through operator-owned provisioning. It is not a usable policy value. The application receives only its own bearer token. Celld owns the TLS key, policy, physical namespaces, fleet discovery and fleet credentials. + +Celld captures policy and certificates at startup. Restart celld to adopt changed grants, replace certificates or revoke a token. Overlapping credential IDs permit staged token replacement. There is no issuance service or hot reload. Loaded application config snapshots remain available after remote revocation until the application restarts. Live KV calls use current server authorization. + +### Trusted-network access + +A binding may instead select: + +```toml +[config.application.access] +mode = "trusted-network" +``` + +Literal loopback HTTP needs no further client opt-in. Remote HTTP requires `allow_remote_http = true`. This flag permits unencrypted transport; it does not establish network isolation. HTTPS still verifies the server certificate. + +The corresponding celld policy uses `mode: "trusted-network"` with a `grants` list. Every reachable caller receives those grants. Non-loopback service binding also requires `allow_remote_trusted_network: true`. Trusted-network mode rejects supplied credentials, and authentication failure never falls back to anonymous access. + +Restrict the scoped listener to intended workloads. Block application access to celld's internal operator listener and fleet credentials, including filesystem and process views. A private IP or successful TLS request does not prove these restrictions. + +```mermaid +flowchart TD + App[Native application with its application token] --> Scoped[celld scoped TLS listener] + Scoped --> Policy[Exact alias and operation authorization] + Policy --> Owner[Existing in-process owner dispatch] + Owner --> KV[Reserved celld KV storage] + Operator[Operator-owned policy and fleet credentials] --> Scoped +``` + +The diagram has no application route to the internal operator listener. Enforce that boundary in the actual deployment, not just in application code. + +## Configuration lifecycle + +Startup validates every source selection, policy, setting and selected credential before provider I/O or deferred preparation. It prepares config snapshots before running the application initializer, publishing readiness or accepting requests. Missing documents, invalid UTF-8, quota failures and remote errors fail startup without local or stale fallback. + +The provider preserves whole UTF-8 documents, including whitespace and empty documents. It does not project JSON fields or resolve secrets. Core validates `BlobEnvelope`, typed configuration and secret references when the application initializer requests them. + +Ordinary and bounded config reads use immutable shared values and perform no network I/O. Publish new complete documents through operator-owned tooling, then restart the app to adopt them. Multiple documents and providers are not an atomic release. `edgezero config push` refuses selected celld config bindings; it does not publish to celld or write a substitute local file. + +## Live KV and limits + +KV preparation builds a handle without probing the service. Startup success alone does not prove connectivity or operation grants. Each live call has a fresh deadline. + +| Limit | Default or service ceiling | +| ------------------------------------------------ | -------------------------------------------------------- | +| Raw value and config document | 1 MiB each | +| Retained config snapshot per preparation session | 8 MiB | +| Logical mappings before deduplication | 1,024, counting each config document and KV binding | +| Preparation metadata | 1 MiB | +| Key page JSON | 2 MiB | +| Page entries | At most 1,000 | +| Service cursor / provider cursor | 2 KiB / 8 KiB | +| Remote key | Nonempty UTF-8, at most 512 bytes, no controls | +| Store alias | At most 64 ASCII letters, digits, underscores or hyphens | +| Preparation / operation / connect timeout | 30 / 5 / 1 seconds | + +`[limits.celld]` or `with_celld_preparation_limits` selects one celld policy. File and explicit policies do not merge. AWS has an independent policy and accounting. Client caches hold at most 128 authorization-scoped entries. Accounting describes logical payload and metadata quotas, not process RSS. + +Binary and empty values remain distinct from missing keys. Missing delete succeeds. TTL is between 60 and 31,536,000 seconds. Fractional valid durations round upward without shortening lifetime; unsupported durations fail rather than clamp. TTL overwrite renews expiry. Plain put removes expiry. + +Listing returns one bounded page, including a short or empty continuing page. It is not a snapshot. Cursors bind endpoint, alias and prefix, but never confer authorization. Preserve a returned continuation rather than scanning repeatedly to fill a page. + +The client disables redirects, ambient proxies, retries and decompression. It validates the version header and bounds streamed values, pages and error bodies. Celld scoped dispatch also disables ambiguous replay. A failed or timed-out mutation may have applied. The provider does not promise exactly-once operations, CAS or transactions. + +Provider-owned errors and Debug output redact credentials and omit remote payloads, URLs and source chains. Dependency DEBUG/TRACE logging is outside that promise. Application-owned initializer errors and logs remain the application's responsibility. Review deployment logging before enabling it. + +## Validation and local proofs + +Offline validation remains structural-only: + +```sh +edgezero config validate --adapter axum \ + --store-bindings /etc/my-app/native-stores.toml \ + --target-features celld-config,celld-kv +``` + +Provider availability is always unknown offline. Target controls normalize and forward Cargo arguments; validation does not resolve the application's feature graph, capture tokens, connect to endpoints or verify grants/content. + +Normal CI uses deterministic loopback client and public-runner fixtures. The generated-consumer proof builds default-off, config-only, KV-only and combined targets. The separate real-service command requires an explicit modified celld executable, Linux namespace tools, curl and OpenSSL: + +```sh +CELLD_BIN=/absolute/path/to/modified/celld \ +EDGEZERO_GENERATED_TEST_TARGET_DIR=/absolute/path/to/disposable-build-cache \ +node scripts/run_native_celld_integration.mjs +``` + +Missing prerequisites fail the command. It uses disposable data and dummy credentials, checks private namespace isolation, and runs a generated native app against the embedded API with certificate verification. It does not access a real fleet or AWS account. The plan records executed results and remaining gates. Multi-node forwarding, reload, production storage/replication, upgrades and stress acceptance remain separate work. diff --git a/docs/superpowers/plans/2026-10-15-native-celld-store-providers.md b/docs/superpowers/plans/2026-10-15-native-celld-store-providers.md new file mode 100644 index 00000000..c6a608ac --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-native-celld-store-providers.md @@ -0,0 +1,500 @@ +# Native celld store providers implementation plan + +- Status: embedded celld and EdgeZero provider integration are implemented. Provider, independent-feature, startup and generated-consumer checks have executed, including verified native TLS and disposable namespace isolation. The final integration checkpoint below records current results. Fleet, deployment and stress acceptance remain open. Work checkboxes describe implementation, not production acceptance. +- Governing spec: [Native celld store providers](../specs/2026-10-15-native-celld-store-providers-design.md). +- EdgeZero baseline: `350527e4b7387a86da3f4cdaea0e8459665aa0f6`, with the spec introduced at `32737dd29b8f1fefd6e035be8036780e58b2412c` and the approved structural-only CLI clarification applied afterward. +- celld research baseline: `90b43017241f81189453d326d05948f388b34652`, version 0.6.2. Recheck the selected source and executable before running proofs. +- Approved scope clarification: offline CLI validation stays structural-only and reports target provider availability as unknown. Do not add a Cargo feature-graph resolver. +- Authority: the user requested committing the spec/plan and starting implementation, then explicitly selected implementation inside celld with no separate service. This authorizes scoped celld source changes and EdgeZero integration under this plan. No push, production deployment or real fleet/AWS access is authorized. + +## 1. Outcome and invariants + +A native application can select celld independently for each declared configuration or KV store ID. Configuration is an immutable startup snapshot. KV operations remain live. AWS, celld, local stores and supplied handles can coexist in the same native runner without changing portable application declarations or HTTP handlers. + +Required invariants: + +- Keep core `ConfigStore`, `KvStore`, handles, registries, bounded-read contracts and `#[async_trait(?Send)]` future bounds unchanged. +- Applications call a scoped service API using store aliases. They never receive fleet HMAC secrets, fleet-bucket discovery credentials, internal node addresses or arbitrary runtime scopes. +- Support explicit trusted-network and authenticated listeners. Both modes enforce store and operation grants before backend dispatch. Authentication failure never selects anonymous access. +- Config preparation finishes before the app initializer, readiness and listener acceptance. A missing mapped document or failed remote call never falls back to local data. +- Validate the complete source plan, selected credentials, settings and compiled capabilities before opening runner-owned providers or polling supplied deferred futures. +- A celld-only deployment needs no AWS credentials and no local roots for remotely bound IDs. Local defaults still require their existing roots. +- No automatic replay of mutations with an ambiguous outcome, at either client or service layer. A timeout does not mean that no write occurred. +- Errors owned by EdgeZero contain fixed categories and validated logical IDs, not credentials, physical namespaces, URLs, response bodies or arbitrary source chains. +- Default-generated applications, AWS-only builds and WASM adapters do not acquire celld runtime, V8 or Worker tooling dependencies. + +Out of scope: a separate gateway, sidecar or proxy service; a celld HTTP adapter, Worker bridge or application runtime; celld secrets; fleet provisioning; config publication; secret management; live config refresh; CAS, transactions or counters; dynamic provider registration; extraction of existing local stores; AWS redesign; process-wide memory guarantees; automatic fleet failover acceptance. + +## 2. Embedded v1 decisions and release gates + +The scoped API does not exist in the researched upstream celld version. The initial implementation now lives inside celld, not another service. Its listener schema, protocol, limits and restart-based policy adoption are recorded in the governing spec. The table below defines what must stay explicit and what needs release/acceptance evidence; provider settings must not bypass those remaining gates. + +| Decision | Required output | Constraint | +| --- | --- | --- | +| API placement | Resolved by the user: inside the existing celld process | No separately operated gateway, sidecar or proxy. Celld implementation worktree: `/home/pav/worktrees/feat-scoped-store-api`, branch `feat/scoped-store-api`, based on the researched pin. Initial implementation revision: `f72ae21a515859570622ac9d6ca9c19cf6870b81`. Release/upgrade acceptance remains open. | +| Listener policy | Exact configuration schema for mode, bind address, alias mapping and operation grants | Disabled by default; tokenless loopback default; explicit non-loopback opt-in and separately enforced network restrictions. No client-supplied caller identity. | +| Authenticated access | Token verifier format, secret injection, reload, overlap, revocation and restart policy | Random application bearer tokens over TLS. No issuance service or application fleet credentials. A documented bounded restart-based service policy is acceptable if hot reload is unnecessary. | +| HTTP protocol | Version, methods, status/error schema, TTL units/rounding, cursor shape, malformed-input and body rules | Missing differs from empty. No mutation replay. An incompatible version fails explicitly. The implemented v1 protocol is pinned to the celld revision above; release acceptance remains open. | +| Transport policy | Native TLS in the celld scoped listener, certificate validation, trusted-network HTTP opt-in and internal network protection | Bearer tokens require TLS. A private address is not proof of isolation or encryption. Reuse existing in-process owner dispatch rather than a local HTTP proxy. The application client and any retained celld peer transport must not redirect or discover ambient proxies. | +| Limits and compatibility | Concrete service/client limits, deadlines, preparation quotas and supported celld revision policy | Account separately for raw bytes, backend JSON byte-array/base64 encoding, decoded payloads, key pages, cursors, metadata and retained snapshots. No claim that the portable 25 MiB ceiling is always reachable. | +| Public settings | Provider tags, feature names, alias/endpoint/access settings and policy ownership | The implemented bindings and defaults are documented in the native celld guide; they remain unreleased. Settings contain references, not credentials. | + +Keep the remaining release and operational acceptance gates explicit. The v1 protocol and binding examples below are implemented preview contracts, not a released compatibility promise. API placement is settled. The scoped listener remains inside celld. + +## 3. Delivery order + +```mermaid +flowchart TD + Baseline[Stage 0: baseline and protocol decisions] --> Service[Stage 1: one scoped service implementation] + Service --> Proof[Stage 2: native transport and authorization proof] + Proof --> Settings[Stage 3: provider crate and typed settings] + Settings --> HTTP[Stage 4: bounded scoped HTTP client] + HTTP --> KV[Stage 5: live KV] + HTTP --> Config[Stage 6: config snapshots] + KV --> Compose[Stage 7: native source composition] + Config --> Compose + Compose --> CLI[Stage 8: generated features and structural CLI] + CLI --> Consumer[Stage 9: generated application acceptance] + Consumer --> Gates[Stage 10: docs and final checks] +``` + +The settings and client stages may use fake scoped-service fixtures after the contract is frozen, but only after Stage 2 proves the chosen real celld path is feasible. KV and config work can be researched independently. Keep shared manifests, lockfiles, settings, session state and native composition under one edit owner. Serialize Cargo commands that share a target directory. + +## 4. File map and proposed API shape + +Existing production files expected to change narrowly: + +| Area | Files | Change | +| --- | --- | --- | +| Workspace | `Cargo.toml`, `Cargo.lock` | Native provider member, workspace dependency and reviewed HTTP feature selection | +| Native features | `crates/edgezero-adapter-axum/Cargo.toml`, `src/lib.rs` | Settings-only dependency and independent `celld-config` / `celld-kv` forwarding | +| Bindings | `crates/edgezero-adapter-axum/src/native_bindings.rs` | Strict config/KV variants, capabilities and independent provider policy validation | +| Credential capture | `crates/edgezero-adapter-axum/src/run_options.rs` | Capture selected celld token names once without coupling them to `AgentAccessToken` | +| Preparation | `crates/edgezero-adapter-axum/src/native_stores.rs`, `src/dev_server.rs` | Typed provider dispatch, separate preparation sessions and existing staging/readiness path | +| Generated features | `crates/edgezero-adapter-axum/src/templates/Cargo.toml.hbs`, CLI generator tests | Default-off independent celld aliases, preserving AWS alias membership | +| CLI | `crates/edgezero-cli/src/config.rs` | Structural celld validation, availability unknown and remote config-push refusal | +| Dependency checks and CI | `scripts/check_native_store_dependency_contract.sh`, `.github/workflows/test.yml`, applicable format workflow | Isolated feature graphs, focused provider/startup/generated checks and explicit service-proof job | +| Documentation | New `docs/guide/native-celld-stores.md`; existing Axum, configuration, CLI and navigation pages where applicable | Deployment boundaries, bindings, limits, operator publication and proof limitations | + +New EdgeZero files: + +```text +crates/edgezero-store-celld/ + Cargo.toml + src/lib.rs + src/settings.rs + src/client.rs + src/preparation.rs + src/config.rs + src/kv.rs + +crates/edgezero-adapter-axum/tests/ + native_celld_startup.rs + fixtures/native_celld_host.rs + +crates/edgezero-cli/tests/ + celld_generated_proof.rs + +scripts/ + run_native_celld_integration.mjs + run_native_celld_integration.test.mjs +``` + +Keep ordinary unit tests colocated. Separate test modules only when fixture size warrants them. Add one shared test-support module if actual repeated fixture code needs it, not a public transport/provider framework. + +The scoped listener lives inside the celld repository, not in a new EdgeZero service package. Work in `/home/pav/worktrees/feat-scoped-store-api` on branch `feat/scoped-store-api`, based on celld `90b43017241f81189453d326d05948f388b34652`; keep the research checkout unchanged. Inspect celld's `main.rs`, `startup.rs`, `operator_cell.rs`, `kv_cli.rs`, `peer_auth.rs`, `crates/logic/kv.rs` and `js/services/kv.js`. Reuse existing owner dispatch inside the process after authorization. Keep pure policy decisions in `crates/logic` and effectful listener/credential/TLS handling in `crates/celld`. Do not depend on the full celld crate or copy its runtime into the EdgeZero application provider. + +Implemented provider interface: + +- `edgezero-store-celld` has no default features. Strict settings and redacted credential types remain available without the HTTP client or Tokio. Native-only `config` and `kv` features share one internal scoped HTTP implementation. +- Typed config settings map each local lookup key to a scoped store alias and remote key. The default key must name a mapping. Typed KV settings select one scoped store alias. Physical namespace mapping stays in service policy. +- A `CelldPreparation` session owns the aggregate config deadline, mapping/metadata and snapshot accounting, and compatible client reuse. Programmatic callers create and reuse an explicit session. Live KV contents are outside snapshot quotas. +- Factories return existing `ConfigStoreHandle` and `KvHandle` outputs. No new portable store trait, global provider registry or executor. +- Preserve `NativeStoreBindings::new(config, secrets)`, `with_kv`, `with_aws_limits`, existing AWS settings and ready/deferred override behavior. Add celld policy inputs without forcing existing callers through a new constructor. + +Implemented deployment binding schema: + +```toml +version = 1 + +[config.app] +provider = "celld-config" +endpoint = "http://127.0.0.1:7780" +default_key = "settings" + +[config.app.access] +mode = "trusted-network" + +[config.app.documents.settings] +store = "checkout-config" +key = "application-envelope" + +[kv.state] +provider = "celld-kv" +endpoint = "https://stores.example.internal" +store = "checkout-state" + +[kv.state.access] +mode = "authenticated" +token_env = "CHECKOUT_STORE_TOKEN" +``` + +The first alias has read-only grants; the second has the required KV operations. The example hostname is fictional. The service owns physical namespace selection, TLS and grants. An authenticated binding with a selected missing/empty token fails startup; it never becomes anonymous. Neither binding contains a fleet credential. Represent `access` as a validated tagged type, not an untyped field bag. + +## 5. Stage 0: record baseline and close the contract gate + +### Work + +- [x] Record HEAD, dirty files and governing spec hash. Preserve the current approved spec edits and unrelated work. Recheck local repository instructions. +- [x] Inventory the actual core store contracts, native source plan, startup staging, bootstrap token capture and generated feature tests. Confirm that offline validation normalizes controls but does not resolve target capabilities. +- [x] Verify the selected celld source, executable version, backend protocol and test tooling. Record whether the scoped API will need an external change or release. At the researched pin, operator values use byte arrays inline and `put-base64`/`valueEncoding` above 1 MiB; listing uses a raw last-key `after`, not a scoped opaque cursor; there is no native `exists` operation. Specify the service's bounded existence implementation and cursor wrapper rather than assuming those HTTP operations map one-to-one. +- [ ] Finalize the remaining listener/protocol decisions in section 2 in the governing spec. Placement is resolved: the scoped API runs in celld, with native TLS when authenticated. Include an operation/error table, limit table and deployment trust-boundary diagram. Record implementation files and resulting celld revision. +- [x] Define a fixture policy using isolated local storage, dummy application tokens, explicit ports and resource ownership. No ambient cloud credentials, production fleet discovery, internet-exposed listeners or real AWS acceptance. +- [x] Record a focused baseline for existing source validation, startup, CLI structure and dependency graphs before production edits. Classify pre-existing failures once; do not silently exclude them. + +### Exit gate + +An embedded celld implementation, a frozen first-version contract and a safe runnable local setup. The user has selected placement and authorized implementation. Contract research alone is not runtime proof. + +## 6. Stage 1: implement the scoped service boundary + +### Work + +- [x] Implement a disabled-by-default scoped listener inside the existing celld process. Reuse existing owner-routing effects, not a loopback HTTP bridge or separate binary. Keep alias/grant policy separate from dispatch, with no administrative proxy or arbitrary scope/address input. +- [x] Validate all listener policy at startup. Disabled means no listener. Enabled tokenless access defaults to loopback. Missing/invalid mode cannot select trusted-network access. +- [x] Resolve aliases and authorize the exact operation before constructing a backend request. Config aliases are read-only. Whole-namespace grants and dedicated config namespaces are sufficient for v1. +- [x] Implement authenticated credential verification, native TLS on the scoped listener and the defined replacement/revocation policy. Never log supplied tokens or verifier material. Trusted-network listeners grant the same configured permissions to every reachable caller. +- [x] Implement the approved versioned raw-byte value operations, existence check and bounded JSON key pages. Reject duplicate/malformed parameters and unsupported operations. Return a distinct missing response for reads and body-free existence responses. +- [ ] Keep discovery, fleet authentication, namespace-to-scope derivation and wire compatibility inside this boundary. Bound privileged encoded requests/responses independently of the public raw-byte budget, including inline byte-array expansion and the pinned base64 form. Reject over-cap writes before backend dispatch and over-cap responses before unbounded decoding, including encoding paths excluded by approved value caps. +- [x] Dispatch authorized operations through celld's existing in-process owner path. Do not add external operator discovery, a local HTTP client or a new privileged service where existing dispatch suffices. Audit any reused peer transport for redirect/proxy behavior and bounded parsing. The pinned external `operator_cell::Reachable` helper is research material, not the implementation to copy. +- [x] Define one retry owner. Permit only explicitly proven pre-dispatch refusals if the approved contract needs them; never replay an ambiguous mutation or add nested client/service retry loops. +- [ ] Apply finite request deadlines and body, page, cursor and concurrency limits. Bound work and memory under slow callers and disconnects. Do not claim dropping a request undoes an applied backend write. + +### Tests and exit gate + +Drive public handlers with an instrumented backend. Prove zero backend calls for out-of-grant aliases, config writes, malformed input and denied operations in both modes. Prove credential isolation, missing/revoked-token rejection, no auth fallback, disabled/loopback defaults, remote-tokenless opt-in rejection, body/page limits, deadline termination and redacted error responses. Test both backend encodings and their encoded/decoded budgets, or explicit over-cap rejection where a path is unsupported. Verify authorized calls reach only the existing owner-dispatch path, and audit/test redirect/proxy restrictions on any network hop that path uses. Test the actual chosen service implementation, not a second policy implementation in EdgeZero fixtures. + +Before moving on, document how application workloads are prevented from reaching celld's internal listener or reading fleet credentials. An HTTP authorization test does not establish deployment network isolation. + +## 7. Stage 2: prove scoped native access against real celld + +### Work + +- [ ] Start one disposable local fleet and the chosen service using the pinned executable/revision. Record commands, environment, acquisition and storage of fixture-only fleet credentials, listener addresses and cleanup. +- [x] Use a small native client without an application Worker. Through the service, exercise binary and empty put/get, missing reads, exists, overwrite, idempotent delete, prefix pages and two isolated stores. +- [x] Exercise trusted-network loopback and authenticated TLS with valid certificate verification. Use a test CA through test setup, never a production `accept_invalid_certs` switch. +- [ ] Drive incompatible protocol versions, invalid tokens, out-of-grant stores and config write denial. Observe backend dispatch counts for authorization denials. +- [x] Verify real operator-wire TTL set, renewal and plain-put reset, and document how fractional `Duration` values will map to the approved wire units without shortening lifetime. Record the value encodings and size range exercised; a small-value result does not verify the bucket-backed/base64 path. +- [x] Exercise a listing larger than one page and record ordering and continuation behavior. Preserve empty/partial pages where the backend can return them; deterministic malformed-page cases belong in later fixtures. +- [ ] Demonstrate network/credential isolation using the actual deployment arrangement selected for the proof. Host loopback success alone does not establish container or multi-host isolation. + +### Exit gate + +Retain a rerunnable command or script and explicit observed results for transport and authorization. The client has no fleet credentials and sends no internal runtime requests. A missing executable, failed fleet startup, absent scoped API or skipped authenticated case leaves this gate unverified. Do not proceed on the strength of source inspection alone. + +## 8. Stage 3: add the native provider crate and strict settings + +### Work + +- [x] Add the workspace member/dependency with independent native-only `config` and `kv` features and no defaults. Keep settings usable in CLI-only builds without service clients, Tokio or celld runtime code. +- [x] Use existing workspace dependency versions where compatible. Review `reqwest` feature unification, since workspace defaults already include blocking/JSON support. Enable only necessary provider features and record the resolved graph rather than assuming manifest defaults prove isolation. +- [x] Implement strict endpoint, access, alias, document and limit settings. Reject unknown fields, invalid combinations, empty mappings, a missing default mapping, malformed URLs, userinfo, queries/fragments and unsupported endpoint paths. +- [x] Normalize caller input once into typed internal settings. Preserve exact remote keys and alias case; do not Unicode-normalize or reinterpret them as runtime scopes. +- [x] Validate finite positive limits, checked arithmetic and relationships among encoded/decoded/body/page and session budgets. Keep service limits and application/provider limits distinct. +- [x] Add a redacted celld token wrapper separate from AWS `AgentAccessToken`. A selected token source must resolve to a valid nonempty header-safe value. No ambient lookup inside the provider. +- [x] Define closed preparation/client error categories and fixed mappings into current portable errors. Do not add provider codes or source chains to core. + +### Tests and exit gate + +Settings-only tests cover every rejected shape, URL encoding boundary, token Debug redaction, case sensitivity, limit overflow and access-mode combination without network calls or ambient variables. Independently check settings-only, config-only and KV-only dependency graphs. No Axum, CLI, AWS, full celld, V8, `workers-rs` or `worker-build` dependency enters the provider graph. + +## 9. Stage 4: implement the bounded scoped HTTP client + +### Work + +- [x] Construct requests using an encoding library and the frozen contract. Test empty prefixes, Unicode, reserved delimiters and percent encoding without allowing path/query injection or double decoding. +- [x] Disable redirects, ambient proxies and implicit mutation replay. Set finite connect and whole-operation deadlines. Preserve normal application logging; do not install a logger or subscriber. +- [x] Require TLS for bearer credentials and validate certificates. Enforce the explicit trusted-network HTTP policy selected at Stage 0. Do not infer permission from a private hostname/IP. +- [x] Stream and cap response bodies before accumulation. Request identity encoding and either reject non-identity encoding initially or implement separately budgeted wire/decoded limits under an approved contract. Bound error bodies too. +- [x] Apply endpoint, header, request-value, decoded-value, page-entry, key-byte and cursor bounds. A bounded value count alone does not bound page JSON bytes or nesting. +- [x] Distinguish valid empty values from misses and protocol failures. Validate success/error status combinations and response shapes. Unsupported versions, malformed pages and auth failures must not become misses. +- [x] Reuse compatible clients by normalized endpoint and complete transport/authorization policy. Never reuse one credential's authorized state for another. Keep secrets out of cache-key Debug and diagnostics. +- [x] Close errors at the transport boundary. Document dependency DEBUG/TRACE limitations separately if testing finds them; do not promise universal redaction from sanitizing owned errors alone. + +### Tests and exit gate + +Exercise the actual HTTP client against loopback HTTP/TLS fixtures. Cover exact key bytes, binary/empty bodies, all operation/error categories, bad versions, slow headers/body, chunked bodies, oversized declared/streamed bodies, compression refusal, malformed JSON, page/cursor limits, disconnects and certificate errors. Have a fixture apply a PUT/DELETE and then lose the reply; assert one request, a closed error and the retained effect. Add redirect and proxy sentinels proving no alternate destination receives credentials or mutations. + +## 10. Stage 5: implement live KV + +### Work + +- [x] Implement existing `KvStore` get, put, TTL put, delete, exists and paginated prefix listing over the scoped client. Return existing `Bytes`/`KvPage` types; do not expose transport models. +- [x] Enforce actual provider/service limits in addition to `KvHandle` validation. Smaller configured/provider limits fail explicitly before requests, without truncation or chunking. +- [x] Preserve binary bytes, misses and empty values. Missing delete succeeds. Plain overwrite clears prior expiry; TTL overwrite renews it under verified celld semantics. +- [x] Validate and encode TTL with checked arithmetic and the approved rounding rule. Accept the public handle's 60-second minimum and reject unsupported direct-trait durations rather than silently clamping them. +- [x] Preserve bytewise lexicographic prefix order. Bind client continuation state to the configured endpoint/store/prefix and wire version as required; reauthorize every service page. A cursor never authorizes a different store or prefix. +- [x] Return one bounded page per operation, including short or empty continuation pages. Validate malformed, oversized, out-of-prefix, out-of-order or non-progressing continuation responses. Do not scan indefinitely to fill a page. +- [x] Create fresh runtime operation deadlines per call. Do not reuse an expired startup-preparation deadline for a live handle. + +### Tests and exit gate + +Run non-TTL portable contracts against the real provider using its scoped HTTP fixture and a native Tokio runtime. A current-thread runtime/`LocalSet` can drive the existing non-`Send` futures; `futures::executor::block_on` alone cannot drive Tokio HTTP/timers. The shared full contract macro contains direct sub-second TTL cases that celld rejects, so do not invoke it unchanged or change core semantics to fit celld. Add provider-specific TTL tests for valid durations, minimum rejection, overflow, rounding, renewal, expiry and reset. Cover namespace isolation, explicit shared aliases, NFC/NFD distinction, 512-byte keys, binary/empty values, page scope/progress and one-attempt ambiguous writes. Distinguish fixture expiry proof from the real 60-second expiry check in Stage 9. + +## 11. Stage 6: implement immutable config preparation + +### Work + +- [x] Fetch every explicitly mapped document during one bounded `CelldPreparation` session, sequentially initially. Require all mapped documents; an unmapped local lookup key remains a successful miss. +- [x] Validate UTF-8 without parsing, reserializing, projecting JSON fields or resolving secrets. Preserve whitespace, empty documents and exact complete `BlobEnvelope` text. +- [x] Account for each mapping and its metadata before deduplication. Deduplicate successful fetches only for identical endpoint, alias, remote key and full access/transport policy. Count shared immutable payload allocations once. +- [x] Enforce per-document transfer/value limits, total retained snapshot bytes and an aggregate preparation deadline across all runner-owned celld config IDs. Bound transient buffers too, without describing these counters as an RSS guarantee. +- [x] On any failure drop staged state and publish no partial config registry. Never reuse a prior process snapshot or add a stale/local fallback. +- [x] Implement both ordinary and bounded core reads over immutable `ConfigValue`. Use the existing bounded-read helper where appropriate: deadline precedence, independent backend/value allowances, correct payload-byte accounting and zero bytes for a miss. +- [x] Keep typed envelope validation and nested secret resolution in core/app startup. A celld document may reference an independently bound AWS secret. Multiple documents/providers are not an atomic release. + +### Tests and exit gate + +Cover complete document preservation, empty/raw/typed documents, invalid UTF-8, missing mapped documents, unmapped keys, duplicate-target sharing versus credential isolation, mapping/metadata/per-value/session cap boundaries, zero/expired deadlines, failure cleanup and bounded snapshot reads. Prove preparation reads once and later `get` calls perform no network I/O. A remote edit remains invisible to a running snapshot and is adopted by a fresh preparation. + +## 12. Stage 7: integrate existing native composition + +### Work + +- [x] Add typed celld config/KV variants and independent `NativeProviderCapabilities` fields. Replace current kind-map nonemptiness checks with actual-variant capability checks, so celld config/KV do not require AppConfig/DynamoDB features. Reject celld under secrets, unknown fields/tags and undeclared or conflicting sources. +- [x] Separate AWS and celld policy tables and mapping counters. Existing `[limits.aws]` documents and explicit `with_aws_limits` behavior remain compatible; celld-only policy must not require an AWS table, inherit AWS quotas or create an AWS session. A file celld policy must not conflict with an explicit AWS policy, or vice versa. Preserve existing public parse/read/validate signatures where possible through additive methods. Add only the celld policy inputs required by the approved budgets. +- [x] Replace the AWS-only config-source tuple with provider-specific typed source payloads carrying captured credentials. KV sources also need captured celld credentials. Do not reuse AWS token types or put opaque futures around session-borrowing built-in preparation. +- [x] Extend the existing bootstrap capture boundary to selected token names in celld config and KV bindings. `from_env` captures once; explicit options use only `with_bootstrap_values` or other supplied values. Missing, empty, duplicate and non-Unicode selected values fail before preparation. Unrelated ambient variables stay irrelevant. +- [x] Select preparation sessions by actual provider variants, not `BuiltIn(_)`. Create one celld session for selected celld bindings and the existing AWS session only for selected AWS bindings. Keep their state/budgets separate. +- [x] Extend the existing per-kind staging loops in `build_production_stores`. Preserve original defaults, shared handles, app initializer timing, readiness, failure cleanup and local root selection. +- [x] Register `fixtures/native_celld_host.rs` as a feature-gated `[[bin]]` and `native_celld_startup.rs` as the corresponding `[[test]]` in the Axum manifest, following the existing AWS fixture pattern. Verify all-target builds still work with celld features off. +- [x] Keep duplicate-source rejection and all-source validation before local opens, remote setup or supplied future polling. Existing ready handles remain caller-owned; the runner cannot undo their prior side effects. + +### Tests and exit gate + +Drive the public native runner in subprocess fixtures where environment capture matters. Cover local-only, AWS-only, celld-only and mixed providers across multiple IDs, plus ready and non-`Send` deferred inputs. Table-test actual-variant capabilities with celld-only/AWS-disabled, AWS-only/celld-disabled and each missing provider in mixed bindings. Separately prove mapping counters and policy-source conflicts are scoped to their provider. Count local opens, remote calls and deferred polls on invalid settings/capabilities/sources. Prove zero preparation on validation failure, no local fallback, no initializer/listener on failed setup, unchanged defaults and local roots only where needed. Include different config/KV tokens, one token used by both, explicit missing tokens and celld-only startup with ambient AWS discovery disabled. Reuse existing AWS regressions unchanged unless the changed dispatch requires an additive case. + +## 13. Stage 8: generated features and structural-only CLI + +### Work + +- [x] Forward independent default-off `celld-config` and `celld-kv` features from Axum and generated native target manifests. Do not expand `aws-stores` or add an all-provider alias without a concrete need. +- [x] Add only settings support to CLI/native-bindings dependencies. The CLI need not compile live celld clients merely to parse deployment files. +- [x] Extend existing structural bindings validation to celld syntax, policy, declared IDs and conflicts. Continue reporting target provider availability as unknown regardless of requested feature names. Do not resolve Cargo metadata, probe endpoints, fetch tokens or validate remote authorization/content. +- [x] Reuse current Cargo argument normalization and forwarding unchanged where possible. Do not add a parallel resolver or rewrite arbitrary custom shell commands. +- [x] Generalize the config-push guard by selected remote config variant so both AWS-bound and celld-bound config IDs reject unsupported publication before writing local files. Keep local-bound IDs' current behavior. +- [x] Update generator/parser assertions and CLI messages, preserving existing command arguments and AWS behavior. Check `crates/edgezero-cli/src/args.rs` help text for inherited claims of target-feature resolution and describe normalization/forwarding without an availability verdict. + +### Tests and exit gate + +Test valid/invalid celld deployment documents through public `config validate`, including absent tokens, an unreachable endpoint and disabled target provider features. A structurally valid document still reports availability unknown and performs zero remote/token lookup. Verify feature controls are normalized/forwarded, not interpreted as a capability verdict. Prove selected remote config push fails without local side effects and local config push still works. Compile generated targets independently with neither feature, each feature alone, both and combined AWS features; startup, not offline CLI validation, rejects an uncompiled selected provider. + +## 14. Stage 9: generated consumer and real-service acceptance + +### Work + +- [x] Generate an actual application using the existing CLI scaffold. Use portable logical declarations and handlers/extractors, the production native runner and a normal typed app initializer. Do not substitute a hand-written adapter host for this proof. +- [ ] First run against the deterministic scoped fixture to prove default-off rejection before I/O, feature forwarding, source conflicts, root handling, closed failures and CLI behavior. +- [ ] Then run the same generated native target against the chosen scoped service and real disposable celld. Test trusted-network loopback and authenticated TLS. Keep all fleet credentials in service/fleet setup, not the app's environment or filesystem view. +- [ ] Through normal application endpoints, observe binary/empty KV, put/get/delete/exists/list, multi-page order, two-store isolation and intentional sharing. Restart the app against the same fleet to prove persistence is not process-local state. +- [ ] Exercise a valid real TTL for at least the service's 60-second minimum, renewal and no-TTL reset. Use a bounded opt-in slow test and finite polling, not sub-second durations or a fixture clock presented as celld evidence. +- [ ] Publish a new complete config document using operator-owned test setup. The running app keeps its initialized snapshot; a fresh process adopts the new version. Missing, oversized, invalid UTF-8 or invalid typed config fails before readiness as applicable. +- [ ] Where installed fixtures permit it, compose celld config/KV with AWS secret fixtures and local bindings through the same generated app. Mark missing fixture coverage as unverified; do not obtain real AWS credentials to complete it. +- [ ] Verify network isolation and credential-file denial in the actual tested deployment arrangement. Retain authorization dispatch observations and fixture resource identities. + +### Harness and exit gate + +Add one explicit local-service harness using the repository's existing Node test-harness style if it reduces repeated setup. It must check tool/version availability, use isolated state and dummy/test credentials, clear ambient deployment credentials, set startup/operation/shutdown deadlines, reap owned process trees and clean up in a `finally` path. It must never kill an arbitrary listener or remove non-fixture storage. Unit-test orchestration and failure cleanup without launching real services. + +The real-service command must fail or report an explicit unverified gate when prerequisites are missing, not exit successfully after skipping all assertions. Default unit tests stay deterministic and require no external services or credentials. Run real-service acceptance explicitly in a prepared job or with operator permission. Record exact command, app/service/celld revisions, tested features, outcome, elapsed time and redacted log path. Single-node proof does not establish fleet failover, crash recovery, encrypted overlays, large-value bucket operation, upgrades or performance. + +## 15. Stage 10: documentation and stable integration checks + +### Work + +- [ ] Document the required celld build/version, embedded listener configuration, application bindings, operator-owned aliases/grants and both access modes. Include TLS and network restrictions, token injection, replacement/revocation, fleet credential separation and safe examples. +- [ ] Explain startup-only config adoption, non-atomic multi-document releases, optional independent secret providers, service/provider limits, TTL constraints, empty continuation pages, non-snapshot listing and ambiguous mutation outcomes. +- [ ] Explain that offline CLI validation is structural-only, startup checks compiled capabilities, and config push does not publish remote config. Keep resource provisioning/publication instructions operator-owned. +- [ ] Extend isolated dependency checks and focused CI commands. Use the nonzero-test wrapper for new ignored/generated suites so a mistaken test filter cannot produce a passing zero-test job. +- [ ] Add the real-service job only after the chosen service artifact and safe local setup are pinned and available. Keep it separate from default no-service unit suites. +- [ ] Inspect the combined diff, rerun affected gates at a stable checkpoint and record remaining blockers without changing unrelated work. Do not commit, push or deploy without a separate request. + +### Planned commands + +These are future checks, not results. New feature/test names below are proposed by this plan. Finalize them with the files they exercise. Use `--offline --locked` once the reviewed dependency graph is fetched and locked; dependency acquisition itself needs an explicit prepared environment. + +```sh +# Focused provider units after their corresponding stages. +cargo test --offline --locked -p edgezero-store-celld --no-default-features --lib +cargo test --offline --locked -p edgezero-store-celld --no-default-features --features config --lib +cargo test --offline --locked -p edgezero-store-celld --no-default-features --features kv --lib +cargo test --offline --locked -p edgezero-store-celld --all-features --lib + +# Native and CLI integration, serialized when using the same Cargo target directory. +cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,celld-config,celld-kv --lib +cargo test --offline --locked -p edgezero-adapter-axum --features celld-config,celld-kv,test-utils --test native_celld_startup +cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,aws-dynamodb,celld-config,celld-kv --lib +cargo test --offline --locked -p edgezero-cli --lib +scripts/run_test_nonzero.sh --ignored generated_celld_default_off_kv_and_config_restart cargo test --offline --locked -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum --test celld_generated_proof generated_celld_default_off_kv_and_config_restart + +# Existing AWS regressions and isolated dependency contracts. +cargo test --offline --locked -p edgezero-store-aws --all-features --lib +cargo test --offline --locked -p edgezero-adapter-axum --features aws-stores,test-utils --test native_aws_startup +bash scripts/check_native_store_dependency_contract.sh + +# Stable combined checkpoint and repository-required gates. +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin + +# Explicit local-service harness once implemented and its prerequisites are prepared. +node --test scripts/run_native_celld_integration.test.mjs +CELLD_BIN=/absolute/path/to/modified/celld node scripts/run_native_celld_integration.mjs +``` + +Also run independent no-default/config-only/KV-only Axum/provider lint and compile scopes, existing generated AWS/DynamoDB proofs affected by shared dispatch/template changes, and selected Fastly/Cloudflare/Spin WASM graph/build checks. Extend `scripts/check_adapter_feature_matrix.sh` or add explicit provider combinations; its existing adapter/CLI/test-utils matrix does not cover celld feature isolation. For published docs changes run `npm run lint`, `npm run format` and `npm run build` from `docs/` with dependencies already prepared. Do not infer formatting coverage for process documents excluded by `docs/.prettierignore`. + +Developer/CI dependency-graph audits do not add a capability resolver to the CLI. The dependency script must assert separate normal dependency graphs for settings-only CLI, new provider settings/config/KV modes, default native targets, each WASM adapter and AWS-only builds. Also inspect selected provider-root feature edges: default Axum already uses HTTP/Tokio, and CLI/default native builds may include celld settings types, so package presence alone cannot prove live-provider activation. Check both directions: celld must not add AWS service SDKs, and default/AWS-only graphs must not activate celld live transport features. Assert config-only versus KV-only feature separation and no full celld runtime, V8 or Worker tooling. Workspace `--all-features` alone cannot prove isolation. + +## 16. Evidence, stopping rules and handoff + +For each behavioral unit record `check | tested revision or input fingerprint | result | elapsed time | log`. Reuse passing evidence while its relevant source, fixture, dependency and environment inputs remain unchanged. Run narrow checks during editing, neighboring checks at each completed unit, and broader suites at the stable combined checkpoint. + +The two highest-risk facts need direct executed evidence: + +1. Out-of-grant access and config writes never reach the privileged backend in either mode. Handler source and policy design alone are insufficient. Instrument service dispatch, then exercise the real scoped service under the tested network boundary. +2. The native transport preserves values/expiry and never replays an ambiguous mutation. Inspect pinned wire handling, execute client fault fixtures and drive a generated app through the actual service/celld path. State exactly which claims each level proves. + +Stop and revise the plan if safe scoped access cannot be delivered without app fleet authority, a full celld runtime dependency, a Worker bridge, a portable core contract change or an additional unapproved service. Do not conceal that change behind custom overrides or a test-only transport. + +Handoff must include changed files, concrete protocol decisions, service/provider versions, completed stages, exact checks and results, known baseline failures, intentionally unexecuted acceptance cases and remaining operational risks. No stage is complete merely because a package compiles or a service starts. + +## Stage 0 execution record + +The user requested committing the spec/plan and starting implementation. Commit `a12265b52770c50ca7c5384bd1037037b892b0ea` contains both documentation changes. The checkout was clean immediately afterward; no production source changed. Governing spec blob: `6c84a41957fcb56047b2259eb6dd79fc3fd6beb6`. + +Executed against the unchanged production source at that revision: + +| Command | Outcome | +| --- | --- | +| `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib native_bindings::tests` | 11 passed; 35.9 seconds including initial compilation | +| `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --lib` | 238 passed; native startup, source validation, local stores and bootstrap included | +| `cargo test --offline --locked -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum --lib bindings_only` | 2 passed; structural validation without app TOML and shared validation/push errors | +| `bash scripts/check_native_store_dependency_contract.sh` | Passed existing isolated AWS/native/WASM dependency assertions; no celld implementation exists yet | + +The last three commands ran sequentially in 20.3 seconds total. Full command output is retained in the implementation conversation. No baseline failures occurred in these scopes. Broader all-provider/generated/runtime suites have not run for this task. + +The local celld research checkout is clean at the required pin. `celld` is not on PATH and no executable was found in that checkout's usual debug/release locations or the user Cargo/local bin locations. Docker is available, but no daemon/image/service check, image pull, fleet setup or credential access was performed. Source availability does not satisfy the real-service gate. + +The user subsequently resolved placement: implement inside celld with no separate gateway/service. An isolated celld worktree now exists at `/home/pav/worktrees/feat-scoped-store-api`, branch `feat/scoped-store-api`, based on the researched pin. The first embedded implementation and local proof are recorded below. No production deployment, real fleet/AWS access or additional service was used. + +## Embedded celld implementation checkpoint + +The initial celld implementation is committed as `f72ae21a515859570622ac9d6ca9c19cf6870b81` on `feat/scoped-store-api`; the celld worktree was clean. At this checkpoint, EdgeZero changes after `a12265b5` were documentation-only; the later provider checkpoint follows below. Do not label the initial listener as a released celld capability. The clean celld repository was moved to `/home/pav/Projects/celld` and its worktree metadata repaired so the implementation branch does not depend on temporary Git storage; `/tmp/edgezero-celld-research` remains a compatibility symlink with unchanged source contents. + +Implemented files: + +- `crates/logic/scoped_store.rs`, its explicit integration test and module/manifest registration: std-only exact alias/principal/operation policy, validated once, actually called by the HTTP handler. +- `crates/celld/scoped_store.rs` and library export: strict capped JSON policy capture, token verifier checks, native TLS configuration, closed errors, HTTP operations, binary values and scope-bound key-page cursors. +- `crates/celld/main/store_api.rs`, `main.rs` and `main/cli.rs`: disabled-by-default third listener, startup file selection, admission accounting, existing owner dispatch/output gates, body/operation/connection budgets and shutdown socket closure. +- `crates/celld/generation.rs`: required reserved KV registration during enabled generation construction/reload, plus local built-in KV-only bootstrap without application source. The existing generation host requires a default fetch; the built-in 404 is not a KV bridge and scoped traffic never passes through it. +- `crates/celld/main/peer_tunnel.rs`: explicit no-replay policy, preserving legacy behavior for existing callers. The regression applies a pooled request then loses its reply and proves no fresh attempt in the scoped mode. Outer dispatch also stops after a failed scoped attempt. +- `crates/celld/tests/scoped_store_startup.rs`: real local binary, cleared environment, isolated storage and owned child processes. Explicit TLS proof uses disposable certificate material; slow expiry proof uses actual elapsed time. +- `docs/services/scoped-stores.md` and `docs/security.md`: embedded deployment/configuration and protocol, with remaining acceptance limits disclosed. + +Two read-only investigations identified the peer-pool replay hazard and missing reserved-runtime registration before edits. The coordinator inspected their evidence. Zellij follow-up delivery failed, so worker assignments were not treated as acknowledged transfers; their processes were released and the coordinator completed the coupled implementation locally, preserving and inspecting the partial HTTP/config scaffold. + +Build preparation fetched the exact locked dependencies without changing the lockfile. The initial offline check had failed because the checkout lacked cached packages. All celld checks use installed Rust 1.98.1, not the older global default. + +Executed checks: + +```sh +cargo test --offline --locked -p celld-logic --test scoped_store +cargo test --offline --locked -p celld --all-targets +cargo test --offline --locked -p celld --test scoped_store_startup -- --ignored --nocapture +cargo clippy --offline --locked -p celld -p celld-logic --all-targets -- -D warnings +cargo fmt --all -- --check +``` + +The proof inventory is six pure-policy tests, seven actual HTTP-handler/config tests, one pooled replay regression, one ordinary real-binary test, one explicit native TLS test and one explicit real TTL test. All 17 passed. The two ignored acceptance tests were explicitly selected and both passed; they were not counted as passing skips. Strict all-target Clippy and workspace formatting checks passed. The final native/test/lint/format batch took 84.7 seconds, including the 61.4-second real expiry proof. Tested code fingerprint: `e909661efdf160a14ec8f62ffc224ff5fb590d921fc1dd635140dd08585ed55b`, covering 182 tracked/untracked workspace source/manifests. It is SHA-256 of compact JSON containing sorted pairs of repository-relative path and file SHA-256 from `git ls-files --cached --others --exclude-standard crates Cargo.toml Cargo.lock`; documentation-only changes do not alter it. The TLS certificate fixture initially failed certificate validation because it was marked as a CA; the corrected fixture is an end-entity certificate and normal certificate checking remains enabled. An initial KV-only generation without a default fetch also failed; the engine's default-fetch requirement is now explicit in the built-in bootstrap rather than hidden in an application script. The 61-second expiry test observes expiry, renewal beyond the original expiry and plain overwrite removing expiry. + +Remaining gates: deployment-level network/credential isolation, multi-node owner forwarding and reload acceptance, production persistence/replication and fault/shutdown/body-budget stress. The local standalone path emits `local reload requires replication`; it has not been claimed as a fleet recovery proof or silently suppressed. Celld's private conformance/simulation harness is not available in this checkout and has not run. No larger-value bucket, failover, upgrade or performance acceptance is claimed. Complete remaining scoped-access gates before treating Stage 2 as fully passed or adding stable EdgeZero provider tags. + +## EdgeZero provider integration checkpoint + +Implemented, still unreleased: + +- `edgezero-store-celld` with settings-only availability, independent `config`/`kv` features, captured redacted tokens, bounded reqwest transport, immutable config snapshots and live KV. Public core traits and non-`Send` futures are unchanged. +- Per-variant native capabilities, typed config/KV credentials, separate AWS/celld preparation sessions and policies, provider-specific accounting and complete pre-I/O validation. Existing constructors, AWS policy methods, local roots, supplied handles and startup staging remain. +- Default-off Axum/generated feature forwarding, structural-only CLI validation and selected remote config-push refusal. Provider availability remains unknown offline. +- Public-runner and generated-consumer fixtures, TLS certificate and proxy tests, no-replay fault cases, dependency isolation checks and CI wiring. The new [native celld guide](../../guide/native-celld-stores.md) documents deployment constraints. +- The mixed public-runner regression loads celld config, resolves an AWS Secrets Manager reference through the real pinned SDK and serves a separate local environment secret. Its celld document exceeds the independent 128-byte AWS snapshot quota. An unrelated Agent token must not enter the trusted celld request. + +The coordinator reviewed the workers' source-only handoffs, registered the test binaries and owned all Cargo commands. Worker formatting was never counted as compilation or behavior proof. Initial type/lint failures were fixed before the results below. One cancellation test initially cancelled before HTTP admission; it now observes an admitted stalled request before dropping the future. Config-document and live-KV value caps are independent and each retains the service ceiling. + +### Executed checks + +EdgeZero uses Rust/Cargo 1.95.0. All Cargo checks below used `--offline --locked` after the coordinator updated the lockfile offline. The lockfile adds only the new workspace package and dependency edges; existing registry versions did not move. Fixture TLS server dependencies are native dev dependencies. + +| Check | Result | Runtime / log | +| --- | --- | --- | +| Provider settings-only / config-only / KV-only / combined units | 4 / 27 / 30 / 38 passed | Runtime suites about 2.9 seconds each; `/tmp/edgezero-celld-provider*.log` | +| Independent provider feature Clippy, all targets | Passed in each mode | `/tmp/edgezero-celld-clippy-{settings,config,kv,provider}.log` | +| Axum native-only / config-only / KV-only units and lib Clippy | 250 passed in each mode; Clippy passed | `/tmp/edgezero-celld-native-{axum,celld-config,celld-kv}.log` | +| Combined AWS/celld native units | 253 passed | `/tmp/edgezero-celld-combined.log` | +| Public celld startup subprocesses | 4 passed | `/tmp/edgezero-celld-native.log`, repeated in combined run | +| AWS startup and mixed celld/AWS/local subprocesses | 5 AWS-only and 6 combined passed | 0.30 seconds runtime each; `/tmp/edgezero-celld-{aws-only,mixed-startup}-final.log` | +| Default CLI library | 233 passed | 0.12 seconds runtime; `/tmp/edgezero-celld-cli-default.log` | +| Native dependency and provider-root feature isolation | Passed | `/tmp/edgezero-celld-dependencies.log` | +| Generated celld consumer, five feature combinations | Passed; one explicitly selected ignored test | 16.40 seconds; `/tmp/edgezero-celld-generated-combined-final.log` | +| Real embedded celld, generated native TLS consumer and namespace isolation | Passed; one explicitly selected ignored test, including 61-second expiry | 64.48 seconds consumer runtime; `/tmp/edgezero-celld-real-final.log` | +| Existing generated AWS / DynamoDB fixtures | Both passed, one selected test each | 36.83 / 14.63 seconds runtime; `/tmp/edgezero-celld-{aws,dynamodb}-generated-regression.log` | +| Provider integration runner Node tests | 24 passed, including 9 celld tests | 1.19 seconds; `/tmp/edgezero-celld-node-all.log` | +| Workspace formatting and all-target/all-feature Clippy | Passed | Clippy 47.41 seconds; `/tmp/edgezero-celld-{fmt,clippy-workspace}.log` | +| Workspace all-target tests | 2,028 passed, 6 ignored across 34 targets | `/tmp/edgezero-celld-workspace-tests-final.log` | +| Workspace all-target check with Fastly/Cloudflare/Spin, then Spin WASI P2 check | Both passed | 16.55 / 19.53 seconds; `/tmp/edgezero-celld-{workspace-check,wasm-check}.log` | +| Docs offline dependency preparation, lint, format and site build | Passed | `/tmp/edgezero-celld-docs-*.log`; process specs/plans remain outside the site formatter's match set | +| Shell syntax and `git diff --check` | Passed | Both new/extended shell scripts checked | + +The first whole-workspace test attempt ran in a Zellij TTY and blocked in the existing non-TTY consent test. Its stdin was confirmed as `/dev/pts/2`, and the test source explicitly assumes noninteractive CI. Only that owned test process and its waiting scheduler were stopped. The rerun used `exec `. A receiving node forwards to the namespace owner. +3. That client is crate-private, not a standalone supported Rust client. Depending on the full `celld` crate would also bring its runtime dependencies, including V8. +4. The internal listener requires a trusted private network or encrypted overlay. celld terminates no TLS there. The operator API is alpha and may change between releases. +5. The fleet HMAC secret is fleet-wide authority, not a namespace-scoped application credential. Normal CLI discovery also requires access to fleet bucket metadata. Do not silently give an HTTP application these privileges. +6. A namespace has one owner/writer. Reads reach that owner and are documented not to return stale values. These facts do not provide application-key CAS, conditional create or an atomic multi-call read-modify-write. +7. Documented limits include 512-byte keys, 25 MiB values, pages of at most 1000 keys and a 60-second minimum expiry. Values above 1 MiB require a fleet bucket. Expired keys become invisible at expiry. + +These findings describe the researched upstream revision. Executed proof for the new embedded listener is recorded in the implementation plan; it does not certify deployment isolation or fleet behavior. + +## Agreed decisions + +- Applications use a scoped API implemented inside the existing celld process. Do not add a gateway, sidecar, Worker bridge or other separately operated service. Do not put the fleet HMAC secret or celld fleet-bucket discovery credentials in the EdgeZero process. +- Support explicit trusted-network and authenticated access modes. Application bearer tokens are optional, not a prerequisite for private deployments. Both modes restrict exposed stores and operations. +- Config uses startup snapshots, matching the native AWS config provider. Restart to adopt published changes. +- No celld Worker runtime or celld secret provider is added. + +A Worker HTTP bridge is excluded from this proposal. Spawning `celld kv` per application operation and reading or modifying celld's backing SQLite/bucket files are also excluded. + +## Scoped API inside celld + +Implement the scoped API inside celld itself. It is not an existing feature of the researched release, so this work includes celld source changes and a build containing them. The user explicitly rejected a separate gateway or service. Use a disabled-by-default, separately permissioned listener in the existing celld process; do not expose administrative routes by reusing the internal listener. Reuse celld's existing namespace-owner routing after grant checks, rather than making the application discover nodes or sign internal requests. Authenticated TLS terminates in celld so this feature does not require another proxy process. + +```mermaid +flowchart TD + App[Native application] --> API + subgraph Celld[Existing celld process] + API[Scoped store listener] --> Mode{Configured access mode} + Mode --> Network[Trusted network: listener grants] + Mode --> Auth[Authenticated TLS: verify credential and select grants] + Network --> Policy[Check store alias and allowed operation] + Auth --> Policy + Policy --> Dispatch[Existing authorized KV and owner routing] + end + Dispatch --> Fleet[celld namespace owner over protected internal network] + Operator[Operator-owned listener policy and optional credentials] --> API +``` + +### Access modes and grants + +Configure one explicit access mode per listener. Missing or invalid configuration must not silently select tokenless access. + +| Mode | Caller access control | Store permissions | +| --- | --- | --- | +| Trusted network | No application credentials. Firewall rules, security groups or network policies control who can connect. | Every caller that can reach the listener receives its configured store and operation grants. | +| Authenticated | Verify a caller credential. Operator-provisioned random bearer tokens over TLS are the proposed first method. | The verified credential selects explicit store and operation grants. | + +In trusted-network mode, reachability grants access. A private IP alone does not identify an application or isolate it from other workloads. Applications needing different permissions require separate listeners with enforced network isolation, or authenticated access. An application-supplied name or header is not caller identity. + +For authenticated mode, celld keeps SHA-256 token verifiers and grants, compares candidate digests in constant time and terminates TLS on the scoped listener. The application receives only its own random token through deployment secret injection, not through remote config or a celld secret store. Policy is captured once at celld startup. Replacement/revocation requires updating that policy and restarting the node; old grants remain active until the restart. Distinct credential IDs with overlapping grants support a staged token replacement without changing the application API. No credential-issuance service or policy hot reload is included. + +For example, a trusted-network listener or an authenticated credential may grant: + +| Store alias | Physical namespace, service-side only | Allowed operations | +| --- | --- | --- | +| `checkout-config` | `production-checkout-config` | Read | +| `checkout-state` | `production-checkout-state` | Read, write, delete, list | + +The client requests a store alias, never an arbitrary celld runtime scope, node address or bucket path. Resolve the alias and check its grant before any backend call in both modes. Start with whole-namespace grants and dedicated config namespaces. Prefix-based authorization is unnecessary for the initial requirement and would add key-normalization and listing risks. + +Do not expose fleet discovery, deployments, eviction, shutdown, arbitrary cell calls or an administrative proxy endpoint. Neither trusted-network reachability nor a compromised application token authorizes these actions. A compromised celld node already has fleet authority, so its host and internal network remain security boundaries. The new listener does not grant its callers that authority. + +### Defaults and transport protection + +- The API is disabled unless configured. When enabled, tokenless access binds to loopback by default. +- Non-loopback tokenless access requires explicit trusted-network configuration and deployment-level network restrictions. Binding to a private address does not prove those restrictions exist. +- Authentication failures never fall back to trusted-network access. Do not mix authenticated and anonymous grants on one listener in the initial design. +- The provider supports tokenless connections without application credential provisioning. If a binding explicitly selects a token source, a missing or empty token is an error, not a request to connect anonymously. +- Authentication and encryption are separate decisions. Bearer credentials require TLS. Trusted-network mode may use HTTP on loopback or an explicitly accepted protected network. A private network does not inherently provide confidentiality; use TLS or an encrypted overlay where needed. +- Fleet credentials remain inside celld in both modes. Optional application authentication does not disable authentication on celld's internal reserved-cell protocol. + +### Operations + +Use a small versioned HTTP contract with raw bytes for values and bounded JSON for key pages. Exact route spelling is a proposal, not a shipped API: + +- `GET /v1/stores/{alias}/value?key=...` reads bytes. Missing is distinct from an empty successful body. +- `HEAD` on the same route checks existence without returning the value body. +- `PUT` on the same route stores raw bytes. Optional `ttl_seconds` is an integer from 60 through 31,536,000. The provider must round supported fractional `Duration` inputs upward to seconds, never earlier than requested. Omitting TTL clears a previous expiry. +- `DELETE` on the same route deletes a key, including a missing key. +- `GET /v1/stores/{alias}/keys?prefix=...&cursor=...&limit=...` returns a bounded page and opaque continuation. + +Construct URLs using an encoding library and define one decoding rule. Reject malformed or duplicate parameters rather than accepting ambiguous input. Bind continuation to the authorized store and prefix, and reauthorize every page. Never treat a cursor as authority. + +The service and client both enforce finite deadlines and body/page limits. The service owns celld wire-version compatibility, namespace scope derivation, fleet authentication and node discovery/refresh. Raw-byte HTTP avoids exposing celld's JSON byte-array expansion to application clients, but the privileged backend transport still needs its own encoded-body budget. + +No automatic replay of ambiguous mutations in the first version. A timeout does not prove that a write failed. Error categories must distinguish authentication, authorization, missing values, invalid input, limits and backend unavailability without returning internal error chains. + +Authorization tests must prove out-of-grant store denial and config write denial before backend dispatch in both access modes. Authenticated mode must also prove isolation between credentials. Trusted-network tests must make shared listener permissions explicit. Test disabled and loopback defaults, rejection of implicit remote tokenless exposure, missing selected tokens, and no fallback after authentication failure. Deployment tests must separately prove network restrictions on the scoped listener and that application workloads cannot reach the internal listener or read fleet credentials. + +### Embedded listener v1 contract + +Enable the listener only with an absolute `--store-api-config` JSON policy path or `CELLD_STORE_API_CONFIG`. Without a selection there is no scoped socket. The policy requires `version = 1`, explicit access mode, store aliases and operation grants. Its default address is `127.0.0.1:7780`; explicit port zero is permitted for disposable tests. Non-loopback tokenless binding requires `allow_remote_trusted_network = true` as well as operator-enforced network restrictions. Authenticated mode requires native TLS certificate/key files and cannot enable the trusted-network opt-in. + +Policy accepts at most 128 store aliases, 128 credential IDs and 1,024 grant entries in a file capped at 256 KiB. Aliases/credential IDs are case-sensitive ASCII alphanumerics, underscore or hyphen, at most 64 bytes. Service-side namespace IDs are nonempty, at most 512 UTF-8 bytes and contain no control characters. Application callers never select those namespace IDs directly. + +Initial wire limits are 1 MiB raw values, 4 MiB plus 16 KiB encoded backend value responses, 2 MiB encoded key pages, 2 KiB cursors and at most 1,000 keys per page, default 100. TLS/header completion has a five-second budget, each operation a ten-second budget and the scoped listener admits at most 32 simultaneous connections. These are finite feature limits, not an RSS certification. Larger bucket-backed values remain outside the tested scope. + +Every scoped response includes `x-celld-store-version: 1`. Value GET returns 200 with raw bytes or 404 for a miss. HEAD returns 200/404 without a body. PUT and DELETE return 204 on success. Key pages return `{ "keys": ["name"], "cursor": null }` or a bounded opaque cursor. The service checks its version, alias/physical-namespace fingerprint, prefix and continuation progress on every page, and reauthorizes the operation. Cursors are not secret, signatures or authority. Invalid/non-progressing backend pages fail rather than triggering an unbounded scan. + +Errors use fixed categories `invalid_input`, `unauthenticated`, `forbidden`, `not_found`, `limit_exceeded`, `unsupported_version`, `unavailable` and `deadline`, with statuses 400, 401, 403, 404, 413, 426, 503 and 504 respectively. HEAD errors have no body. Requests reject malformed/duplicate/unknown query parameters, malformed percent escapes, invalid UTF-8 and unsupported content encoding. Invalid authentication never falls back to anonymous access, including a supplied bearer header on a trusted-network listener. + +The celld implementation reuses existing reserved KV classes, routing and output gates. A local store-only bootstrap uses celld's built-in KV runtime and a built-in 404 default required by its generation host, not application Worker source or a KV-over-Worker bridge. Enabled KV registration survives deployment generation construction/reload. Calls use an explicit no-replay path through both owner dispatch and the pooled peer tunnel; buffering request bytes alone is not proof that retrying a mutation is safe. + +## Proposed provider design + +Add a native-only `edgezero-store-celld` crate, independent of Axum, the CLI and `edgezero-store-aws`. Use the existing runtime rather than creating another executor. Do not pull the full celld runtime, V8, workers-rs or worker-build into native application dependencies. + +Suggested opt-in Axum features are `celld-config` and `celld-kv`. Keep them independent, disabled by default and compatible with AWS features. Final provider tags and settings depend on the transport decision. + +```mermaid +flowchart TD + App[Portable application and logical store IDs] --> Registries[Core config, KV and secret registries] + Bindings[Explicit native deployment bindings] --> Startup[Validate sources and prepare before readiness] + Startup --> Celld[edgezero-store-celld] + Startup --> AWS[edgezero-store-aws] + Startup --> Local[Existing local providers] + Celld --> Config[Immutable config snapshots] + Celld --> KV[Live KV handles] + Config --> Registries + KV --> Registries + AWS --> Registries + Local --> Registries + Celld --> Transport[Agreed native transport] + Transport --> Owner[celld namespace owner] +``` + +### Config + +Use startup snapshots to match native AWS config adoption: + +- Map each logical config lookup key explicitly to a remote namespace/key. Require the default key to name a mapping. +- Fetch each mapped complete document during bounded preparation. Missing required mappings, invalid UTF-8, limits and transport errors fail before readiness. +- Preserve exact document bytes as UTF-8. Do not parse JSON fields, reserialize envelopes or resolve secrets in the provider. +- Publish immutable config bindings implementing ordinary and bounded core reads. Unmapped keys are misses, not remote discovery. +- Restart to adopt remote changes. Multiple documents do not imply one atomic release. Applications needing coordinated versions validate them in the existing initializer. +- Typed `BlobEnvelope` validation and nested secret resolution remain in core and application startup. celld config may reference a separately bound AWS secret store. + +Live config refresh is a separate decision, not implied by using KV as storage. + +### KV + +Implement the existing byte-oriented operations: get, put, TTL put, delete, exists and paginated prefix listing. + +- Preserve binary bytes and distinguish absent values from empty values. +- Keep namespace identity explicit and test isolation between logical IDs. Reusing one physical namespace is explicit sharing, not automatic isolation. +- Enforce transport, encoded-body, decoded-value and page limits before unbounded allocation. JSON byte-array expansion on the internal protocol needs its own budget. +- Preserve native expiry semantics. A plain overwrite removes an old TTL; TTL overwrite renews it. Verify the actual wire behavior rather than assuming it matches the Worker API. +- Bind opaque continuation state to the relevant namespace and prefix. Handle partial and empty continuation pages without unbounded scans. +- Apply finite connection and whole-operation deadlines. Do not replay ambiguous writes after a timeout or disconnect. A relative-TTL retry can change expiry even when overwriting identical bytes. +- Return closed, redacted errors. Do not retain URLs, remote bodies, namespace names, credentials or raw error chains in EdgeZero-owned diagnostics. + +Do not claim conditional writes, atomic counters, snapshot listing or a stronger identity backend. + +### Native composition and CLI + +Extend existing built-in dispatch rather than creating parallel startup: + +- Add typed config/KV binding variants and compiled-provider capabilities. Reject celld under the secret kind. +- Preserve duplicate-source rejection, undeclared-ID checks and validation-before-I/O. +- Select provider-specific preparation sessions by their actual variants. Current AWS session selection treats any built-in binding as AWS, which must not remain true once celld exists. +- Separate celld connection credentials from the AWS-specific `AgentAccessToken` config-source tuple. Keep AWS preparation limits AWS-owned. Add only the celld budget mechanism needed by the selected transport. +- Preserve AWS-only and local-only startup behavior. A celld-only deployment must not require AWS credentials or local roots for remote-bound IDs. +- Extend generated target feature forwarding and structural binding validation for celld. Keep offline validation structural-only and report target provider availability as unknown. Do not add Cargo feature-graph resolution, remote connectivity or authorization checks. Startup rejects providers not compiled into the running binary. +- Generalize the AWS-specific config-push guard so selected celld config cannot accidentally report a local file write as remote publication. + +Initial publication remains operator-owned. Do not add fleet provisioning, deployment, config publication or secret management to this provider task. + +## Delivery sequence and proof + +1. **Specify and implement the scoped API in celld.** Placement is settled: the listener and grant enforcement run inside celld, with no extra service. Finalize listener grants, optional credential provisioning/revocation, versioning, network boundaries and operation/error contracts. Scoped access, the two access modes and startup config snapshots are already agreed. +2. **Prove scoped native access.** Use a disposable local celld setup and a small native client with no application Worker. Exercise tokenless loopback and authenticated access. Observe binary put/get/delete, missing versus empty values, namespace isolation, pagination and explicit version/authentication failures. Prove config-write and out-of-grant store denial before dispatch, plus no fallback between access modes. Keep all fleet credentials inside the service boundary and record their acquisition. This is a transport and authorization gate, not production acceptance. +3. **Implement live KV.** Add typed settings, strict offline validation, bounded transport and operation tests. Prove TTL renewal/reset, malformed responses, oversized encoded/decoded payloads, continuation and ambiguous-write behavior. Use provider-specific TTL tests because the shared native contract macro currently expects direct sub-second TTLs that celld rejects. +4. **Implement config snapshots.** Prove whole-document preservation, missing and invalid UTF-8 failures, per-value/session budgets, bounded snapshot reads and restart-based adoption. +5. **Integrate Axum and CLI.** Prove mixed AWS/celld/local source selection, structural-only offline validation with target provider availability reported as unknown, startup missing-feature rejection, source conflicts, root requirements, readiness ordering and config-push refusal through public entrypoints. +6. **Exercise a generated consumer.** Run a generated native Axum app against real celld. Check mutable KV, typed config initialization and restart adoption. Where available, compose celld config/KV with existing AWS fixtures for secrets. Run feature/dependency checks proving celld does not add Worker/V8 dependencies or change AWS-only builds. + +Each behavioral unit ends with its focused regression and neighboring tests. At the combined checkpoint run affected formatting, lint, builds and integration checks. Missing external tooling or a failed service startup is an unverified gate, not a passing skip. + +Fleet failover, encrypted-overlay operation, crash recovery, large-value bucket behavior, upgrades and performance need separate runtime acceptance. A single-node local result does not establish them. + +## Open decisions + +- Freeze the embedded v1 contract above against remaining fault, shutdown and deployment-isolation tests before exposing stable EdgeZero provider settings. +- Record the supported celld implementation revision and upgrade compatibility policy before release. Fleet operation, deployment reload and production persistence need acceptance beyond the executed local proofs. + +## References + +- [Native AWS provider PR #414](https://github.com/stackpop/edgezero/pull/414) +- [Experimental celld runtime PR #420](https://github.com/stackpop/edgezero/pull/420), reference only +- [celld KV](https://celld.dev/docs/services/kv/) +- [celld security](https://celld.dev/docs/security/) +- [celld guarantees](https://celld.dev/docs/guarantees/) +- [Pinned operator client](https://github.com/denoland/celld/blob/90b43017241f81189453d326d05948f388b34652/crates/celld/operator_cell.rs) +- [Pinned KV CLI](https://github.com/denoland/celld/blob/90b43017241f81189453d326d05948f388b34652/crates/celld/kv_cli.rs) +- [Pinned peer authentication](https://github.com/denoland/celld/blob/90b43017241f81189453d326d05948f388b34652/crates/celld/peer_auth.rs) +- Existing branch design: [native store providers](2026-10-07-native-store-providers-design.md) diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index eabfb430..2f563e14 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -781,6 +781,7 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "edgezero-store-aws", + "edgezero-store-celld", "futures", "futures-util", "http", @@ -972,6 +973,15 @@ dependencies = [ "url", ] +[[package]] +name = "edgezero-store-celld" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.21", + "url", +] + [[package]] name = "either" version = "1.18.0" diff --git a/scripts/check_celld_scoped_isolation.sh b/scripts/check_celld_scoped_isolation.sh new file mode 100755 index 00000000..cd98a87f --- /dev/null +++ b/scripts/check_celld_scoped_isolation.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Explicit disposable namespace proof. No host network or production credentials. +set -euo pipefail +if [[ $# != 1 || ! -x $1 || $1 != /* ]]; then + printf 'usage: %s /absolute/path/to/modified/celld\n' "$0" >&2 + exit 2 +fi +for tool in unshare ip nsenter setpriv mount curl python3; do + command -v "$tool" >/dev/null || { printf 'missing isolation prerequisite: %s\n' "$tool" >&2; exit 1; } +done +# Always create a private user/network/mount/PID world before any ip/mount work. +exec unshare --user --map-root-user --net --mount --pid --fork --mount-proc \ + env -i PATH="$PATH" bash -s -- "$1" <<'ISOLATED' +set -euo pipefail +binary=$1 +root=$(mktemp -d /tmp/edgezero-celld-isolation.XXXXXX) +server_pid= +app_wrapper= +app_pid= +cleanup() { + for pid in "$server_pid" "$app_wrapper" "$app_pid"; do + if [[ -n $pid ]]; then kill -KILL "$pid" 2>/dev/null || true; fi + done + wait 2>/dev/null || true + rm -rf -- "$root" +} +trap cleanup EXIT +mkdir -p "$root/service" "$root/control" +printf 'fixture-fleet-credential-not-a-real-secret\n' > "$root/service/fleet-credential" +ip link set lo up +ip link add scoped-server type veth peer name scoped-app +ip addr add 10.203.0.1/24 dev scoped-server +ip link set scoped-server up +cat > "$root/service/policy.json" <<'POLICY' +{"version":1,"listen":"10.203.0.1:7780","allow_remote_trusted_network":true,"stores":[{"alias":"state","namespace":"isolated-state"},{"alias":"config","namespace":"isolated-config"}],"access":{"mode":"trusted-network","grants":[{"store":"state","operations":["read","write","delete","list"]},{"store":"config","operations":["read"]}]}} +POLICY +env -i CELLD_TEST_DATA_DIR="$root/service/data" "$binary" \ + --listen 127.0.0.1:0 --internal-listen 127.0.0.1:0 \ + --store-api-config "$root/service/policy.json" > "$root/service/server.log" 2>&1 & +server_pid=$! +ready=false +for attempt in $(seq 1 100); do + status=$(curl --noproxy '*' --max-time 1 -s -o /dev/null -w '%{http_code}' \ + 'http://10.203.0.1:7780/v1/stores/state/value?key=probe' || true) + if [[ $status == 404 ]]; then ready=true; break; fi + kill -0 "$server_pid" 2>/dev/null || { printf 'celld startup failed\n' >&2; tail -40 "$root/service/server.log" >&2; exit 1; } + sleep 0.1 +done +[[ $ready == true ]] || { printf 'scoped listener startup deadline\n' >&2; exit 1; } +internal_port=$(python3 - "$root/service/server.log" <<'PY' +import re,sys +text=open(sys.argv[1]).read() +m=re.search(r'celld internal listening on 127\.0\.0\.1:(\d+)',text) +assert m, 'internal listener address absent' +print(m.group(1)) +PY +) +# Separate app network, mounts and process visibility. Hide the entire service +# tree before dropping capabilities in the actual app probe, not just chmod it. +unshare --net --mount --pid --fork --mount-proc bash -c ' + set -e + mount --make-rprivate / + mount -t tmpfs -o size=1m,mode=000 none "$1/service" + printf ready > "$1/control/app.ready" + exec sleep 120 +' bash "$root" & +app_wrapper=$! +for attempt in $(seq 1 100); do + if [[ -f $root/control/app.ready ]]; then + read -r app_pid < "/proc/$app_wrapper/task/$app_wrapper/children" || true + if [[ -n $app_pid ]]; then break; fi + fi + sleep 0.05 +done +[[ -n $app_pid ]] || { printf 'application namespace startup failed\n' >&2; exit 1; } +ip link set scoped-app netns "$app_pid" +nsenter --target "$app_pid" --net ip addr add 10.203.0.2/24 dev scoped-app +nsenter --target "$app_pid" --net ip link set scoped-app up +nsenter --target "$app_pid" --net ip link set lo up +nsenter --target "$app_pid" --net --mount --pid \ + setpriv --bounding-set=-all --inh-caps=-all --ambient-caps=-all \ + env -i PATH="$PATH" bash -s -- "$root" "$internal_port" <<'APP' +set -euo pipefail +root=$1 +internal=$2 +base=http://10.203.0.1:7780 +response="$root/control/app-response" +request() { curl --disable --noproxy '*' --max-time 3 -s -o "$response" -w '%{http_code}' "$@"; } +[[ $(request -X PUT --data-binary 'isolated-app-value' "$base/v1/stores/state/value?key=probe") == 204 ]] +[[ $(request "$base/v1/stores/state/value?key=probe") == 200 ]] +[[ $(< "$response") == isolated-app-value ]] +[[ $(request -X PUT --data-binary forbidden "$base/v1/stores/config/value?key=probe") == 403 ]] +[[ $(request "$base/v1/stores/undeclared/value?key=probe") == 403 ]] +[[ $(request "$base/shutdown") == 404 ]] +if curl --noproxy '*' --max-time 1 -sf "http://10.203.0.1:$internal/state" >/dev/null; then + printf 'application reached the internal listener by server address\n' >&2; exit 1 +fi +if curl --noproxy '*' --max-time 1 -sf "http://127.0.0.1:$internal/state" >/dev/null; then + printf 'application reached the server loopback listener\n' >&2; exit 1 +fi +[[ ! -r $root/service/fleet-credential ]] +[[ ! -r $root/service/policy.json ]] +for comm in /proc/[0-9]*/comm; do + if [[ $(< "$comm") == celld ]]; then + printf 'application can inspect the server process through procfs\n' >&2; exit 1 + fi +done +rm -f -- "$response" +printf 'Scoped API reachable; admin listener, service files and server procfs isolated.\n' +APP +ISOLATED diff --git a/scripts/check_native_store_dependency_contract.sh b/scripts/check_native_store_dependency_contract.sh index a5b10578..7eb365d0 100755 --- a/scripts/check_native_store_dependency_contract.sh +++ b/scripts/check_native_store_dependency_contract.sh @@ -29,4 +29,43 @@ if grep -Eq '^aws-sdk-secretsmanager v' <<< "$dynamodb_graph" || grep -Eq '^aws- printf 'AWS service SDK leaked into an independently selected provider.\n' >&2 exit 1 fi -printf 'Native store SDK isolation graphs passed.\n' +# Celld settings are present in bindings-only builds, but live transport is not. +celld_features() { + local graph + graph="$(cargo tree --offline --locked --edges normal --prefix none --format '{p}|{f}' "$@")" + awk -F'|' '/^edgezero-store-celld v/ { sub(/ [(][*][)]$/, "", $2); print $2; exit }' <<< "$graph" +} + +assert_celld_off() { + local selected + selected="$(celld_features "$@")" + if [[ -n "$selected" ]]; then + printf 'Live celld features leaked into isolated graph: %s: %s\n' "$*" "$selected" >&2 + exit 1 + fi +} +assert_celld_off -p edgezero-cli --no-default-features --features cli,edgezero-adapter-axum +assert_celld_off -p edgezero-adapter-axum --no-default-features --features axum +assert_celld_off -p edgezero-adapter-axum --no-default-features --features aws-stores,aws-dynamodb + +celld_settings="$(cargo tree --offline --locked --edges normal --prefix none -p edgezero-store-celld --no-default-features)" +if grep -Eq '^(reqwest|tokio|edgezero-core|aws-config|aws-sdk-[^ ]+) v' <<< "$celld_settings"; then + printf 'Runtime/client dependency leaked into settings-only celld.\n' >&2 + exit 1 +fi +for feature in config kv; do + assert_no_sdk -p edgezero-store-celld --no-default-features --features "$feature" + graph="$(cargo tree --offline --locked --edges normal --prefix none -p edgezero-store-celld --no-default-features --features "$feature")" + if grep -Eq '^(celld(-[^ ]+)?|v8|worker|worker-build|edgezero-adapter-[^ ]+) v' <<< "$graph"; then + printf 'Server/Worker/adapter dependency leaked into celld provider: %s\n' "$feature" >&2 + exit 1 + fi + selected="$(celld_features -p edgezero-store-celld --no-default-features --features "$feature")" + other=config + if [[ "$feature" == config ]]; then other=kv; fi + if ! grep -Eq "(^|,)$feature(,|$)" <<< "$selected" || grep -Eq "(^|,)$other(,|$)" <<< "$selected"; then + printf 'Celld service feature isolation failed: %s: %s\n' "$feature" "$selected" >&2 + exit 1 + fi +done +printf 'Native store SDK and celld feature isolation graphs passed.\n' diff --git a/scripts/run_native_celld_integration.mjs b/scripts/run_native_celld_integration.mjs new file mode 100644 index 00000000..a722633c --- /dev/null +++ b/scripts/run_native_celld_integration.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { constants, createReadStream } from "node:fs"; +import { access } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const sentinel = "generated_native_consumer_uses_embedded_celld_over_tls"; +const inherited = [ + "PATH", "HOME", "CARGO_HOME", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN", "RUSTC", "RUSTDOC", + "RUSTFLAGS", "CARGO_BUILD_JOBS", "CARGO_TARGET_DIR", "EDGEZERO_GENERATED_TEST_TARGET_DIR", +]; + +export function fixtureEnvironment(source, binary) { + const env = {}; + for (const name of inherited) { + if (source[name] !== undefined) env[name] = source[name]; + } + env.CELLD_BIN = binary; + return env; +} + +async function binaryDigest(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} + +// One process group per owned command. A failed or interrupted proof must not +// leave a generated app or celld descendant running. +export function runCommand(command, args, { cwd, env, timeoutMs = 600_000 }) { + return new Promise((resolveCommand, reject) => { + const child = spawn(command, args, { cwd, env, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let failure; + let force; + const kill = (signal) => { + try { + if (process.platform === "win32") child.kill(signal); + else process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== "ESRCH") failure ??= error; + } + }; + const stop = (reason) => { + failure ??= new Error(reason); + kill("SIGTERM"); + force ??= setTimeout(() => kill("SIGKILL"), 2_000); + force.unref(); + }; + const onInterrupt = () => stop("native celld proof interrupted"); + process.once("SIGINT", onInterrupt); + process.once("SIGTERM", onInterrupt); + const timer = setTimeout(() => stop("native celld proof deadline exceeded"), timeoutMs); + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + stdout = (stdout + chunk.toString()).slice(-65_536); + }); + child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + stderr = (stderr + chunk.toString()).slice(-65_536); + }); + const clean = () => { + clearTimeout(timer); + if (force) clearTimeout(force); + process.removeListener("SIGINT", onInterrupt); + process.removeListener("SIGTERM", onInterrupt); + }; + child.once("error", (error) => { clean(); reject(error); }); + child.once("close", (code, signal) => { + clean(); + // Reap any descendant which outlived a failed parent before returning. + if (failure || code !== 0) kill("SIGKILL"); + if (failure) reject(failure); + else if (code !== 0) reject(new Error(`${command} failed: code=${code} signal=${signal}\n${stderr}`)); + else resolveCommand({ stdout, stderr }); + }); + }); +} + +export async function runIntegration({ + env = process.env, + cwd = root, + command = runCommand, + executable = (path) => access(path, constants.X_OK), + digest = binaryDigest, + log = console.log, +} = {}) { + const selected = env.CELLD_BIN; + if (!selected || !isAbsolute(selected)) { + throw new Error("CELLD_BIN must explicitly select an absolute modified celld executable; no passing skip"); + } + const binary = resolve(selected); + await executable(binary); + const childEnv = fixtureEnvironment(env, binary); + const help = await command(binary, ["--help"], { cwd, env: childEnv, timeoutMs: 10_000 }); + if (!help.stdout.includes("--store-api-config")) throw new Error("selected celld has no embedded scoped API"); + const fingerprint = await digest(binary); + if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new Error("invalid celld executable fingerprint"); + log(`celld executable SHA-256 ${fingerprint}`); + await command("bash", ["scripts/check_celld_scoped_isolation.sh", binary], { cwd, env: childEnv, timeoutMs: 60_000 }); + await command("scripts/run_test_nonzero.sh", [ + "--ignored", sentinel, + "cargo", "test", "--offline", "--locked", "-p", "edgezero-cli", "--no-default-features", + "--features", "cli,edgezero-adapter-axum", "--test", "celld_generated_proof", sentinel, + ], { cwd, env: childEnv, timeoutMs: 600_000 }); + if (await digest(binary) !== fingerprint) throw new Error("celld executable changed during proof"); + log("Native generated celld TLS and disposable isolation proofs passed. Fleet acceptance remains separate."); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runIntegration().catch((error) => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/run_native_celld_integration.test.mjs b/scripts/run_native_celld_integration.test.mjs new file mode 100644 index 00000000..81aa5d23 --- /dev/null +++ b/scripts/run_native_celld_integration.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { fixtureEnvironment, runCommand, runIntegration } from "./run_native_celld_integration.mjs"; + +const binary = resolve("fixture-celld"); + +function fixture(extra = {}) { + const calls = []; + const options = { + env: { CELLD_BIN: binary, PATH: "/fixture/path", HOME: "/fixture/home", AWS_SECRET_ACCESS_KEY: "must-not-propagate", HTTP_PROXY: "must-not-propagate", SSL_CERT_FILE: "must-not-propagate" }, + executable: async () => {}, + digest: async () => "f".repeat(64), + command: async (command, args, options) => { + calls.push({ command, args, options }); + return { stdout: "--store-api-config PATH\n", stderr: "" }; + }, + log: () => {}, + ...extra, + }; + return { calls, options }; +} + +test("fixture environment excludes deployment credentials, proxies and inherited trust inputs", () => { + assert.deepEqual(fixtureEnvironment({ + PATH: "path", HOME: "home", CARGO_HOME: "cargo", RUSTUP_TOOLCHAIN: "1.95.0", CELLD_BIN: "ignored", + AWS_ACCESS_KEY_ID: "secret", CELLD_APP_TOKEN: "secret", ALL_PROXY: "proxy", SSL_CERT_FILE: "ca", + }, binary), { PATH: "path", HOME: "home", CARGO_HOME: "cargo", RUSTUP_TOOLCHAIN: "1.95.0", CELLD_BIN: binary }); +}); + +test("missing binary is a failed prerequisite, never a passing skip", async () => { + const { calls, options } = fixture({ env: {} }); + await assert.rejects(runIntegration(options), /CELLD_BIN must explicitly/); + assert.equal(calls.length, 0); +}); + +test("unavailable executable stops before any command", async () => { + const { calls, options } = fixture({ executable: async () => { throw new Error("not executable"); } }); + await assert.rejects(runIntegration(options), /not executable/); + assert.equal(calls.length, 0); +}); + +test("vanilla celld cannot masquerade as embedded API acceptance", async () => { + const { options } = fixture({ command: async () => ({ stdout: "unmodified celld help", stderr: "" }) }); + await assert.rejects(runIntegration(options), /no embedded scoped API/); +}); + +test("isolation precedes the exact nonzero generated TLS proof", async () => { + const { calls, options } = fixture(); + await runIntegration(options); + assert.equal(calls.length, 3); + assert.equal(calls[0].command, binary); + assert.deepEqual(calls[1].args, ["scripts/check_celld_scoped_isolation.sh", binary]); + assert.equal(calls[2].command, "scripts/run_test_nonzero.sh"); + assert.equal(calls[2].args[0], "--ignored"); + const sentinel = "generated_native_consumer_uses_embedded_celld_over_tls"; + assert.equal(calls[2].args[1], sentinel); + assert.equal(calls[2].args.at(-1), sentinel, "test filter must exclude the independent deterministic suite"); + assert.ok(calls[2].args.includes("--offline")); + for (const call of calls) { + assert.equal(call.options.env.AWS_SECRET_ACCESS_KEY, undefined); + assert.equal(call.options.env.HTTP_PROXY, undefined); + assert.equal(call.options.env.SSL_CERT_FILE, undefined); + } +}); + +test("an isolation failure prevents generated acceptance", async () => { + let count = 0; + const { options } = fixture({ command: async () => { + count += 1; + if (count === 2) throw new Error("isolation unavailable"); + return { stdout: "--store-api-config", stderr: "" }; + } }); + await assert.rejects(runIntegration(options), /isolation unavailable/); + assert.equal(count, 2); +}); + +test("changing the tested executable invalidates acceptance", async () => { + let count = 0; + const { options } = fixture({ digest: async () => (++count === 1 ? "a" : "b").repeat(64) }); + await assert.rejects(runIntegration(options), /executable changed/); +}); + +test("command failure is observable", async () => { + await assert.rejects(runCommand(process.execPath, ["-e", "process.exit(7)"], { + cwd: process.cwd(), env: {}, timeoutMs: 5_000, + }), /code=7/); +}); + +test("command timeout terminates and reaps its owned descendant", { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(join(tmpdir(), "celld-runner-test-")); + const pidFile = join(root, "child.pid"); + try { + const source = [ + "const {spawn}=require('node:child_process');", + "const fs=require('node:fs');", + "const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});", + "fs.writeFileSync(process.argv[1],String(child.pid));", + "process.on('SIGTERM',()=>{child.once('exit',()=>process.exit(0));child.kill('SIGTERM');});", + "setInterval(()=>{},1000);", + ].join(""); + await assert.rejects(runCommand(process.execPath, ["-e", source, pidFile], { + cwd: root, env: {}, timeoutMs: 500, + }), /deadline exceeded/); + const pid = Number(await readFile(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock index 1058143d..a86cc4f9 100644 --- a/tests/fixtures/reusable-app/Cargo.lock +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -524,6 +524,7 @@ dependencies = [ "bytes", "edgezero-core", "edgezero-store-aws", + "edgezero-store-celld", "futures", "futures-util", "http", @@ -675,6 +676,15 @@ dependencies = [ "url", ] +[[package]] +name = "edgezero-store-celld" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.20", + "url", +] + [[package]] name = "either" version = "1.16.0"