diff --git a/Cargo.lock b/Cargo.lock index b49c91a..0c973fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -783,6 +783,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit", "validator", "walkdir", ] diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 75caf58..8d61f72 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -162,8 +162,14 @@ impl Adapter for AxumCliAdapter { _adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, _dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: axum has no remote resources. Print one note per // declared store id so the operator sees the CLI heard // them — same shape `dry_run` would have, since there is @@ -200,7 +206,10 @@ impl Adapter for AxumCliAdapter { if out.is_empty() { out.push("axum has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f858021..f2acd53 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::io::ErrorKind; @@ -10,8 +10,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -136,6 +136,10 @@ struct CloudflareCliAdapter; reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for CloudflareCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["kv_namespaces", "preview_kv_namespaces"] + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `wrangler` is the native sign-in surface for Cloudflare @@ -190,8 +194,14 @@ impl Adapter for CloudflareCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: KV ids and config ids both back to Cloudflare KV // namespaces. Secrets are runtime-managed via // `wrangler secret put` — provision is a no-op for them. @@ -204,6 +214,16 @@ impl Adapter for CloudflareCliAdapter { let wrangler_path = manifest_root.join(rel); let mut out = Vec::new(); + // Track logical -> namespace_id for freshly-created namespaces + // so the CLI's writeback can persist them under + // `[adapters.cloudflare.deployed].kv_namespaces.`. + // Keyed by LOGICAL id so teammates' env overlays (which + // change the platform binding name) still resolve the same + // mapping on their side. Only populated in the non-dry-run + // create branch below -- dry-runs and idempotency skips + // contribute nothing (no real wrangler invocation, no id to + // record). + let mut created_kv_ns: BTreeMap = BTreeMap::new(); for store in stores.kv.iter().chain(stores.config.iter()) { let logical = &store.logical; // The Cloudflare KV binding name is what the runtime @@ -273,6 +293,13 @@ impl Adapter for CloudflareCliAdapter { "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", wrangler_path.display() )); + // Record under the LOGICAL id, not the platform binding. + // Teammates' `provision --local` re-resolves logical -> + // platform via THEIR env overlay and reads the namespace + // id back via the same logical key -- keying by + // `binding` (platform) would break that lookup when + // the overlays diverge. + created_kv_ns.insert(logical.clone(), namespace_id); } for store in stores.secrets { let logical = &store.logical; @@ -284,7 +311,27 @@ impl Adapter for CloudflareCliAdapter { if out.is_empty() { out.push("cloudflare has no declared stores to provision".to_owned()); } - Ok(out) + // dry_run branch above `continue`s BEFORE reaching + // `create_kv_namespace`, so `created_kv_ns` stays empty for + // dry-runs -- `deployed` collapses to `None` and the CLI + // writeback is a no-op. An idempotent skip (binding already + // present with a real id) similarly doesn't repopulate the + // map, since the existing id is already recorded in the + // operator's `[adapters.cloudflare.deployed]` block from a + // prior run. + let deployed = if created_kv_ns.is_empty() { + None + } else { + let mut state = AdapterDeployedState::default(); + state + .sub_tables + .insert("kv_namespaces".to_owned(), created_kv_ns); + Some(state) + }; + Ok(ProvisionOutcome { + status_lines: out, + deployed, + }) } fn push_config_entries( @@ -1572,14 +1619,22 @@ id = "00112233445566778899aabbccddeeff" secrets: &secret_ids, }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); // 2 KV + 1 config + 1 secret = 4 status lines. - assert_eq!(out.len(), 4); - assert!(out[0].contains("would run `wrangler kv namespace create sessions`")); - assert!(out[1].contains("would run `wrangler kv namespace create cache`")); - assert!(out[2].contains("would run `wrangler kv namespace create app_config`")); - assert!(out[3].contains("runtime-managed via `wrangler secret put`")); + assert_eq!(out.status_lines.len(), 4); + assert!(out.status_lines[0].contains("would run `wrangler kv namespace create sessions`")); + assert!(out.status_lines[1].contains("would run `wrangler kv namespace create cache`")); + assert!(out.status_lines[2].contains("would run `wrangler kv namespace create app_config`")); + assert!(out.status_lines[3].contains("runtime-managed via `wrangler secret put`")); // Manifest untouched. let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); assert_eq!(after, "name = \"demo\"\n", "dry-run mutated wrangler.toml"); @@ -1603,19 +1658,27 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("wrangler kv namespace create prod_config"), + out.status_lines[0].contains("wrangler kv namespace create prod_config"), "dry-run uses platform name in the `wrangler` invocation: {out:?}" ); assert!( - out[0].contains("binding = \"prod_config\""), + out.status_lines[0].contains("binding = \"prod_config\""), "dry-run writes platform name as the binding: {out:?}" ); assert!( - out[0].contains("logical id `app_config`"), + out.status_lines[0].contains("logical id `app_config`"), "logical id is preserved for operator wording: {out:?}" ); } @@ -1630,7 +1693,15 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let err = CloudflareCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("wrangler.toml"), @@ -1653,12 +1724,20 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("already provisioned") - && out[0].contains("00112233445566778899aabbccddeeff"), + out.status_lines[0].contains("already provisioned") + && out.status_lines[0].contains("00112233445566778899aabbccddeeff"), "skip line names the existing id: {out:?}" ); let after = fs::read_to_string(&path).expect("read"); @@ -1686,11 +1765,19 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("would run `wrangler kv namespace create sessions`"), + out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"), "placeholder id is treated as unprovisioned: {out:?}" ); } @@ -1705,9 +1792,111 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["cloudflare has no declared stores to provision"] + ); + // No wrangler was invoked (no stores) => no id to record. + assert!( + out.deployed.is_none(), + "no-store provision has nothing to write back: {:?}", + out.deployed + ); + } + + #[cfg(unix)] + #[test] + fn cloudflare_cloud_provision_returns_created_namespace_ids() { + // Non-dry-run Cloud provision must populate + // `deployed.sub_tables["kv_namespaces"]` keyed by LOGICAL id + // (not the platform binding name). Task 16's CLI writeback + // then lands them under `[adapters.cloudflare.deployed]`. + // + // Uses the same wrangler-fake shim pattern as the + // read_config_entry tests: a shell script on PATH prints the + // Wrangler-3 `[[kv_namespaces]] / id = "..."` block that + // `extract_namespace_id` parses. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let stdout = "[[kv_namespaces]]\nbinding = \"ignored-by-parser\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let fake = fake_wrangler_returning(stdout, "", 0); + let _path = PathPrepend::new(fake.path()); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + project_dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("cloud provision succeeds against fake wrangler"); + let deployed = out + .deployed + .expect("cloud provision with creates populates deployed"); + let kv = deployed + .sub_tables + .get("kv_namespaces") + .expect("deployed carries kv_namespaces sub-table"); + // Key MUST be the LOGICAL id -- teammates' env overlays + // change the platform binding, but the logical id is + // env-overlay-independent. + assert_eq!( + kv.get(TEST_KV_ID).map(String::as_str), + Some("00112233445566778899aabbccddeeff"), + "kv_namespaces keyed by logical id `{TEST_KV_ID}`: {kv:?}" + ); + } + + #[test] + fn cloudflare_cloud_provision_dry_run_returns_none_deployed() { + // Cloud dry-run means no real `wrangler kv namespace create` + // invocation happened -- no real id to record. `deployed` + // must be `None` so the CLI writeback is a no-op. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert!( + out.deployed.is_none(), + "dry-run must not populate deployed (no wrangler ran): {:?}", + out.deployed + ); } // ---------- find_namespace_id ---------- diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index a3de1cb..33c2fd9 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -169,6 +169,10 @@ enum ConfigStoreLookup { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["service_id"] + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `fastly profile {create|delete|list}` is the native @@ -204,8 +208,14 @@ impl Adapter for FastlyCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } // Fastly is Multi for every store kind. Each id maps 1:1 // to a Fastly resource (kv-store / config-store / // secret-store) created via the Fastly CLI; the manifest @@ -343,7 +353,10 @@ impl Adapter for FastlyCliAdapter { if out.is_empty() { out.push("fastly has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1957,15 +1970,27 @@ build = \"cargo build --release\" secrets: &secret_ids, }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); // 1 KV + 1 config + 1 secret + 1 runtime-env = 4 status lines. - assert_eq!(out.len(), 4); - assert!(out[0].contains("would run `fastly kv-store create --name=sessions`")); - assert!(out[1].contains("would run `fastly config-store create --name=app_config`")); - assert!(out[2].contains("would run `fastly secret-store create --name=default`")); + assert_eq!(out.status_lines.len(), 4); + assert!(out.status_lines[0].contains("would run `fastly kv-store create --name=sessions`")); + assert!(out.status_lines[1] + .contains("would run `fastly config-store create --name=app_config`")); assert!( - out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), + out.status_lines[2].contains("would run `fastly secret-store create --name=default`") + ); + assert!( + out.status_lines[3] + .contains("would run `fastly config-store create --name=edgezero_runtime_env`"), "runtime-env store row: {out:?}", ); // Manifest untouched. @@ -1983,7 +2008,15 @@ build = \"cargo build --release\" secrets: &[], }; let err = FastlyCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("fastly.toml"), @@ -2009,9 +2042,20 @@ build = \"cargo build --release\" secrets: &[], }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["fastly has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["fastly has no declared stores to provision"] + ); } #[test] @@ -2036,10 +2080,21 @@ build = \"cargo build --release\" secrets: &[], }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("skip path succeeds without invoking fastly"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("already declared"), "got: {out:?}"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("already declared"), + "got: {out:?}" + ); } /// When `fastly.toml` declares `service_id`, the next diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a..e3f0857 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -17,8 +17,9 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, TypedSecretEntry, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -181,8 +182,14 @@ impl Adapter for SpinCliAdapter { adapter_manifest_path: Option<&str>, component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: spin provision is pure spin.toml editing — no // shell-out (Spin KV stores are provisioned by the Spin // runtime / Fermyon at deploy). For each declared KV id @@ -251,7 +258,10 @@ impl Adapter for SpinCliAdapter { if out.is_empty() { out.push("spin has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1384,6 +1394,18 @@ mod tests { fn name(&self) -> &'static str { "stub" } + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } } let entries = [ TypedSecretEntry::new("default", "one", "Demo_Token"), @@ -1649,11 +1671,19 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, true) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 2); - assert!(out[0].contains("would ensure KV label `sessions`")); - assert!(out[1].contains("would ensure KV label `cache`")); + assert_eq!(out.status_lines.len(), 2); + assert!(out.status_lines[0].contains("would ensure KV label `sessions`")); + assert!(out.status_lines[1].contains("would ensure KV label `cache`")); let after = fs::read_to_string(&path).expect("read back"); assert_eq!(after, original, "dry-run mutated spin.toml"); } @@ -1680,10 +1710,19 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("real-run succeeds"); assert!( - out[0].contains("`prod_sessions`") && out[0].contains("`sessions`"), + out.status_lines[0].contains("`prod_sessions`") + && out.status_lines[0].contains("`sessions`"), "status line names BOTH the platform label and the logical id: {out:?}" ); @@ -1712,10 +1751,21 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("real run succeeds"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("added KV label `sessions`"), + "got: {out:?}" + ); let after = fs::read_to_string(dir.path().join("spin.toml")).expect("read back"); assert!( after.contains("\"sessions\""), @@ -1733,7 +1783,15 @@ mod tests { secrets: &[], }; let err = SpinCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("spin.toml"), @@ -1760,15 +1818,24 @@ mod tests { secrets: &secret_ids, }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("config + secrets provision succeeds"); - assert_eq!(out.len(), 2); + assert_eq!(out.status_lines.len(), 2); assert!( - out[0].contains("config label") && out[0].contains("key_value_stores"), + out.status_lines[0].contains("config label") + && out.status_lines[0].contains("key_value_stores"), "config row reports KV-array write: {out:?}" ); assert!( - out[1].contains("manual"), + out.status_lines[1].contains("manual"), "secret row still flags manual declaration: {out:?}" ); @@ -1792,9 +1859,20 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["spin has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["spin has no declared stores to provision"] + ); } // ---------- dispatch_push matrix ---------- diff --git a/crates/edgezero-adapter/src/env_file.rs b/crates/edgezero-adapter/src/env_file.rs new file mode 100644 index 0000000..0acde2d --- /dev/null +++ b/crates/edgezero-adapter/src/env_file.rs @@ -0,0 +1,170 @@ +//! Line-oriented env-file dedup shared by all adapters that +//! write provision-owned `.env` / `.dev.vars` files. Key- +//! normalised: a line whose key matches an existing commented +//! OR uncommented entry is skipped. See spec §"Merge mechanics" +//! → "Line-oriented". + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// Append each `=` line iff its normalised key does +/// NOT already appear in the file (commented OR uncommented). +/// Existing lines are preserved byte-for-byte. Creates the file +/// (and parent dirs) when absent. +/// +/// # Errors +/// Returns an error string when the file cannot be read, when +/// the parent directory cannot be created, or when the write +/// fails. +#[inline] +pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> Result<(), String> { + let mut existing = String::new(); + if path.exists() { + existing = + fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; + } + let existing_keys: BTreeSet = existing.lines().filter_map(normalised_key).collect(); + + let mut to_append = String::new(); + for line in new_lines { + let Some(key) = normalised_key(line) else { + continue; + }; + if existing_keys.contains(&key) { + continue; + } + to_append.push_str(line); + if !line.ends_with('\n') { + to_append.push('\n'); + } + } + if to_append.is_empty() || dry_run { + return Ok(()); + } + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .map_err(|err| format!("create {}: {err}", parent.display()))?; + } + } + let mut combined = existing; + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&to_append); + fs::write(path, combined).map_err(|err| format!("write {}: {err}", path.display()))?; + Ok(()) +} + +/// Strip at most ONE leading `#` + adjacent whitespace, then +/// parse `=` and return the trimmed key. Returns +/// `None` for blank lines and comment-only lines. +/// +/// Single-`#` semantics matter: `## KEY=value` (double hash — +/// the markdown-style heading shape some operators use as +/// section separators inside `.env` files) is NOT treated as +/// a commented `KEY=value` line; it returns `Some("# KEY")` +/// (with the second `#` embedded in the key) so dedup does NOT +/// collapse `## KEY=v` and `KEY=v` into each other. +pub(crate) fn normalised_key(line: &str) -> Option { + let trimmed = line.trim_start(); + // Strip exactly ONE leading `#`, then any whitespace that + // follows it — `# KEY=value`, `#KEY=value`, and `KEY=value` + // all normalise to the same key; `## KEY` does NOT. + let after_hash = trimmed.strip_prefix('#').unwrap_or(trimmed); + let stripped = after_hash.trim_start(); + let (raw_key, _) = stripped.split_once('=')?; + let key = raw_key.trim(); + if key.is_empty() { + None + } else { + Some(key.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn appends_new_lines_and_skips_existing_keys() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "AAA=existing\n").unwrap(); + append_lines_dedup(&path, &["AAA=NEW".to_owned(), "BBB=NEW".to_owned()], false).unwrap(); + let after = fs::read_to_string(&path).unwrap(); + // AAA stays at the operator value; BBB appended. + assert!(after.contains("AAA=existing")); + assert!(after.contains("BBB=NEW")); + assert!(!after.contains("AAA=NEW")); + } + + #[test] + fn dedup_treats_commented_and_uncommented_form_as_same_key() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + // Operator already uncommented + edited the override line. + fs::write(&path, "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=staging\n").unwrap(); + // Re-provision would otherwise re-add the commented form. + append_lines_dedup( + &path, + &["# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + let occurrences = after + .lines() + .filter(|line| { + normalised_key(line).as_deref() == Some("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY") + }) + .count(); + assert_eq!( + occurrences, 1, + "commented override must NOT reappear: {after}" + ); + } + + #[test] + fn dry_run_makes_no_write() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "KEEP=me\n").unwrap(); + let before = fs::metadata(&path).unwrap().modified().unwrap(); + append_lines_dedup(&path, &["NEW=x".to_owned()], true).unwrap(); + let after = fs::metadata(&path).unwrap().modified().unwrap(); + assert_eq!(before, after); + } + + #[test] + fn normalised_key_strips_at_most_one_leading_hash() { + // Uncommented and single-hash forms dedup against each other: + assert_eq!(normalised_key("KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key("#KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key("# KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key(" # KEY=v"), Some("KEY".into())); + + // Double-hash leaves the second `#` in the key → DIFFERENT + // normalised key. Operator section separators using `## …` + // stay intact. + assert_eq!(normalised_key("## KEY=v"), Some("# KEY".into())); + + // Comment-only lines return None. + assert_eq!(normalised_key("# comment"), None); + assert_eq!(normalised_key("### header"), None); + assert_eq!(normalised_key(""), None); + } + + #[test] + fn creates_file_when_absent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested/subdir/.env"); + assert!(!path.exists()); + append_lines_dedup(&path, &["NEW=x".to_owned()], false).unwrap(); + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), "NEW=x\n"); + } +} diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 607548d..28f53e0 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -1,6 +1,23 @@ +#![expect( + clippy::pub_use, + reason = "crate-root re-exports for external callers; adapters + CLI read \ + `edgezero_adapter::TypeName` instead of `edgezero_adapter::registry::TypeName`" +)] + +pub mod env_file; + pub mod registry; pub mod scaffold; #[cfg(feature = "cli")] pub mod cli_support; + +// Re-exports so adapters + the CLI can write +// `edgezero_adapter::TypeName` instead of +// `edgezero_adapter::registry::TypeName`. Mirrors the surface +// adapters already touch via `registry::*` imports today. +pub use registry::{ + get_adapter, Adapter, AdapterDeployedState, ProvisionMode, ProvisionOutcome, ProvisionStores, + ResolvedStoreId, TypedSecretEntry, +}; diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce8..61f2ddb 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; -use std::path::Path; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -22,6 +22,39 @@ pub enum AdapterAction { Serve, } +/// Provision dispatch mode. `Cloud` keeps today's cloud-CLI shell-out +/// behaviour; `Local` writes adapter-local emulator state (no cloud +/// calls). Threaded through `Adapter::provision` so each adapter +/// branches once at the top of its impl. See spec §"CLI / trait +/// surface". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProvisionMode { + Cloud, + Local, +} + +/// Adapter-emitted deployed identifiers. Kept neutral (string-keyed +/// maps only) so `edgezero-adapter` stays dep-free of +/// `edgezero-core` -- the CLI maps this into the strongly typed +/// `ManifestAdapterDeployed` shape when writing `edgezero.toml`. +/// See spec §"Writeback ownership". +#[derive(Debug, Default, Clone)] +pub struct AdapterDeployedState { + pub fields: BTreeMap, + pub sub_tables: BTreeMap>, +} + +/// Return value of `Adapter::provision` (and `provision_typed`). +/// `status_lines` are operator-facing; `deployed`, when `Some`, +/// records the cloud-returned identifiers the CLI persists into +/// `edgezero.toml`'s `[adapters..deployed]` block. Local +/// provision returns `deployed: None`. +#[derive(Debug, Default, Clone)] +pub struct ProvisionOutcome { + pub deployed: Option, + pub status_lines: Vec, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -223,6 +256,20 @@ pub enum ReadConfigEntry { /// of `edgezero-core`. Defaults are no-ops; adapters override what /// they actually need. pub trait Adapter: Sync + Send { + /// Names of the `ManifestAdapterDeployed` fields this adapter + /// reads at provision time. Manifest-level cross-check + /// (`validate_deployed_field_ownership` in the CLI) rejects + /// `[adapters..deployed]` blocks whose populated fields + /// aren't in this list — catching operator typos and writeback + /// bugs before they corrupt the deployed state at next provision. + /// + /// Default is `&[]` — adapters that don't persist deployed state + /// (spin, axum today) inherit it. + #[inline] + fn deployed_fields(&self) -> &'static [&'static str] { + &[] + } + /// Execute the requested action with optional adapter-specific args. /// /// `args` is a stringly-typed pass-through for arguments meant @@ -271,23 +318,65 @@ pub trait Adapter: Sync + Send { /// (`wrangler.toml`, `fastly.toml`, `spin.toml`) relative to /// the root. `stores` carries the declared ids per kind. /// - /// Default: no-op (returns an empty `Vec`) so adapters that - /// don't own any platform resources don't need to override. + /// `deployed` carries the adapter's previously-persisted + /// deployed identifiers (e.g. Cloudflare KV namespace ids, + /// Fastly service id). Local-arm impls consult it for + /// precedence rules (spec §"CLI / trait surface"); cloud-arm + /// impls pass `None` — they produce, not consume, the deployed + /// state. `mode` selects cloud vs. local emulator paths + /// (spec §"CLI / trait surface", §"Writeback ownership"). + /// + /// No default impl is provided — every adapter must update + /// explicitly so the compiler flags any missed call sites. /// /// # Errors /// Returns a human-readable error string if any platform /// invocation or manifest edit fails. `dry_run` impls should /// describe what they *would* do without performing it. - #[inline] + #[expect( + clippy::too_many_arguments, + reason = "provision needs the manifest root, adapter manifest path, component selector, resolved stores, previously-deployed state (for local-arm precedence), dispatch mode (cloud vs local), and dry-run flag — 8 args. Each is distinct; an aggregate struct would be a larger ergonomic regression for adapter implementers." + )] fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, + dry_run: bool, + ) -> Result; + + /// Typed-secret companion to `provision`. Runs ONLY in local mode + /// (`mode == Local`); cloud mode is a no-op by spec §"CLI / trait + /// surface". The CLI dispatches this AFTER `provision` on the same + /// `manifest_root`, so per-store bindings are already in place; this + /// method only adds adapter-specific per-secret placeholders sourced + /// from `C::SECRET_FIELDS` (the generic CLI walks them; bundled + /// `edgezero` cannot). + /// + /// The default impl is a no-op so existing adapters compile + /// untouched while the per-adapter overrides land in Section 5. + /// + /// # Errors + /// The default impl never errors. Adapter overrides may return + /// human-readable error strings if local placeholder setup fails. + #[inline] + #[expect( + clippy::elidable_lifetime_names, + reason = "lifetime name 'entry explicitly documents the secret entry lifetime for clarity" + )] + fn provision_typed<'entry>( &self, _manifest_root: &Path, _adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, - _stores: &ProvisionStores<'_>, + _typed_secrets: &[TypedSecretEntry<'entry>], + _mode: ProvisionMode, _dry_run: bool, - ) -> Result, String> { - Ok(Vec::new()) + ) -> Result { + Ok(ProvisionOutcome::default()) } /// Push config entries into the platform's config store backing @@ -435,6 +524,37 @@ pub trait Adapter: Sync + Send { &[] } + /// First-run bootstrap synthesiser, called by the CLI ONLY when + /// `mode == Local` AND the adapter manifest (or related local + /// files like `runtime-config.toml`) is absent. Returns + /// `Ok(Vec::new())` for adapters that own no synthesised local + /// state (e.g. Axum — `axum.toml` stays tracked). + /// + /// Each `(relative_path, contents)` tuple is written by the CLI + /// under `manifest_root` BEFORE `validate_adapter_manifest` + /// runs, so a clean clone can pass validation. + /// + /// **Boundary contract (MUST):** signature uses only `std` + + /// types defined IN this crate. Adapters that need values from + /// the parent manifest receive them through the neutral + /// `Option<&AdapterDeployedState>` argument — the CLI translates + /// from `&Manifest` to `AdapterDeployedState` at the call site. + /// + /// # Errors + /// The default impl never errors. Adapter overrides may return + /// human-readable error strings if baseline synthesis fails. + #[inline] + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _app_name: &str, + _deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + Ok(Vec::new()) + } + /// Adapter-specific manifest check — e.g. Spin's /// `[component.*]` discovery in `spin.toml`. The adapter /// resolves its own per-adapter manifest path relative to @@ -563,6 +683,19 @@ mod tests { fn name(&self) -> &'static str { self.name } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } } fn reset() { @@ -626,4 +759,39 @@ mod tests { "expected Unsupported variant from default local impl" ); } + + #[test] + fn provision_outcome_default_is_empty() { + let outcome = ProvisionOutcome::default(); + assert!(outcome.status_lines.is_empty()); + assert!(outcome.deployed.is_none()); + } + + #[test] + fn adapter_deployed_state_round_trips_via_btreemap() { + use std::collections::BTreeMap; + let mut state = AdapterDeployedState::default(); + state.fields.insert("service_id".into(), "SVC1".into()); + let mut kv = BTreeMap::new(); + kv.insert("sessions".into(), "abc123".into()); + state.sub_tables.insert("kv_namespaces".into(), kv); + assert_eq!(state.fields["service_id"], "SVC1"); + assert_eq!(state.sub_tables["kv_namespaces"]["sessions"], "abc123"); + } + + #[test] + fn provision_typed_default_impl_returns_empty_outcome() { + let outcome = FIRST + .provision_typed( + Path::new("/tmp"), + None, + None, + &[], + ProvisionMode::Local, + true, + ) + .unwrap(); + assert!(outcome.status_lines.is_empty()); + assert!(outcome.deployed.is_none()); + } } diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 9796705..9e96a0c 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -34,8 +34,10 @@ serde = { workspace = true } similar = { workspace = true } simple_logger = { workspace = true } serde_json = { workspace = true} +tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } validator = { workspace = true } proc-macro2 = { workspace = true, features = ["span-locations"], optional = true } syn = { workspace = true, optional = true } @@ -44,9 +46,6 @@ walkdir = { workspace = true, optional = true } [build-dependencies] toml = { workspace = true } -[dev-dependencies] -tempfile = { workspace = true } - [features] default = [ "cli", diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d..4a2ad85 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -172,6 +172,12 @@ pub struct ProvisionArgs { /// without performing them. #[arg(long)] pub dry_run: bool, + /// Switch the flow from cloud-SDK shell-outs to local-file writes. + /// Adapter-local manifests, env files, and runtime-config TOML are + /// synthesised or merged in place; no cloud CLIs are invoked. See + /// spec §"CLI" for the full mode contract. + #[arg(long)] + pub local: bool, /// Path to the manifest (default: `edgezero.toml`). #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, @@ -191,6 +197,7 @@ impl Default for ProvisionArgs { Self { adapter: String::new(), dry_run: false, + local: false, manifest: default_manifest_path(), } } @@ -665,6 +672,30 @@ mod tests { .expect_err("`provision` without --adapter must error"); } + #[test] + fn provision_args_local_flag_defaults_false() { + use clap::Parser; + #[derive(Parser)] + struct Cli { + #[command(flatten)] + args: ProvisionArgs, + } + let cli = Cli::try_parse_from(["bin", "--adapter", "spin"]).unwrap(); + assert!(!cli.args.local); + } + + #[test] + fn provision_args_local_flag_parses() { + use clap::Parser; + #[derive(Parser)] + struct Cli { + #[command(flatten)] + args: ProvisionArgs, + } + let cli = Cli::try_parse_from(["bin", "--adapter", "spin", "--local"]).unwrap(); + assert!(cli.args.local); + } + // ── config push / diff stub tests (12.8 + 12.11) ────────────────── /// Bundled binary: bare `config push` parses to the stub variant. diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508b..049369e 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -21,6 +21,7 @@ use crate::args::{ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, DiffFormat}; use crate::diff::{collect_changes, render_json, render_structured}; use crate::ensure_adapter_defined; +use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; @@ -74,7 +75,7 @@ struct ResolvedAdapterPushContext { } /// Pre-loaded state shared by the raw and typed flows. -struct ValidationContext { +pub(crate) struct ValidationContext { /// Resolved app-config TOML path. Either the explicit /// `--app-config`, or `.toml` next to the manifest. app_config_path: PathBuf, @@ -96,9 +97,49 @@ struct ValidationContext { } impl ValidationContext { - fn manifest(&self) -> &Manifest { + // Accessors below are pub(crate) API for the provision flow. They + // are not called in production code in this crate yet so the + // dead_code lint fires for the lib target; we gate the suppression + // on `not(test)` so the expect is only active where the lint fires + // (production lib), and is absent when tests are compiled (where + // the methods are used and the expect would be unfulfilled). + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called in production code in this crate" + ) + )] + pub(crate) fn app_config_path(&self) -> &Path { + &self.app_config_path + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called in production code in this crate" + ) + )] + pub(crate) fn app_name(&self) -> &str { + &self.app_name + } + + pub(crate) fn manifest(&self) -> &Manifest { self.manifest_loader.manifest() } + + pub(crate) fn manifest_path(&self) -> &Path { + &self.manifest_path + } + + #[expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" + )] + pub(crate) fn raw_config(&self) -> &toml::Value { + &self.raw_config + } } // ------------------------------------------------------------------- @@ -213,8 +254,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_typed_preflight(&typed, &ctx)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -270,6 +310,33 @@ where { // Pre-flight: load + validate. let ctx = load_push_context(args)?; + + // Path containment: reject `..` traversal and absolute paths in + // every declared adapter's manifest / crate strings BEFORE + // `run_shared_checks` dispatches per-adapter validation. Spin's + // `validate_adapter_manifest` does `fs::read_to_string(manifest_root + // .join(adapter_manifest_path))`, so a malicious path resolves + // outside the project unless we reject it here first. The spec + // ("Path containment (MUST)") requires the helper run BEFORE any + // manifest-path use — that means before shared checks, not just + // before adapter dispatch. Loop over every adapter because shared + // checks iterate every adapter, not just `ctx.adapter`. + if args.local { + let manifest_root_for_check = ctx + .validation + .manifest_path() + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + for adapter_cfg in ctx.validation.manifest().adapters.values() { + assert_provision_paths_contained( + manifest_root_for_check, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.crate_path.as_deref(), + )?; + } + } + run_shared_checks(&ctx.validation)?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; @@ -281,8 +348,7 @@ where .map_err(|err| format_app_config_error(&err))?; app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation)?; + run_typed_preflight(&typed, &ctx.validation)?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -412,8 +478,7 @@ where .map_err(|err| format_app_config_error(&err))?; app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("local validation failed: {err}"))?; - typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_typed_preflight(&typed, &ctx)?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -1125,9 +1190,14 @@ fn generated_at_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -fn load_validation_context(args: &ConfigValidateArgs) -> Result { - let manifest_loader = ManifestLoader::from_path(&args.manifest) - .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; +pub(crate) fn load_validation_context_with_options( + manifest_path: &Path, + app_config_override: Option<&Path>, + strict: bool, + env_overlay: bool, +) -> Result { + let manifest_loader = ManifestLoader::from_path(manifest_path) + .map_err(|err| format!("failed to load {}: {err}", manifest_path.display()))?; // Spec: every project carries a `[app].name`. Without it we // can't compute the env-overlay prefix or resolve the default @@ -1135,18 +1205,19 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result` to drive deserialise + // validator; we keep this copy for shared checks (e.g. Spin // `[component.*]` discovery) that don't need `C`. let mut opts = AppConfigLoadOptions::default(); - opts.env_overlay = !args.no_env; + opts.env_overlay = env_overlay; let raw_config = app_config::load_app_config_raw_with_options(&app_config_path, &app_name, &opts) .map_err(|err| format_app_config_error(&err))?; @@ -1154,20 +1225,29 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result Result { + load_validation_context_with_options( + &args.manifest, + args.app_config.as_deref(), + args.strict, + !args.no_env, + ) +} + +pub(crate) fn resolve_app_config_path_primitive( + explicit: Option<&Path>, manifest_path: &Path, app_name: &str, ) -> PathBuf { - if let Some(explicit) = &args.app_config { - return explicit.clone(); + if let Some(path) = explicit { + return path.to_path_buf(); } let manifest_dir = manifest_path .parent() @@ -1179,8 +1259,21 @@ fn resolve_app_config_path( ) } +#[expect( + dead_code, + reason = "thin wrapper kept for call-site clarity; production callers use the primitive directly" +)] +fn resolve_app_config_path( + args: &ConfigValidateArgs, + manifest_path: &Path, + app_name: &str, +) -> PathBuf { + resolve_app_config_path_primitive(args.app_config.as_deref(), manifest_path, app_name) +} + fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { run_adapter_shared_checks(ctx)?; + validate_deployed_field_ownership(ctx.manifest())?; if ctx.args_strict { strict_capability_completeness(ctx.manifest())?; strict_handler_paths(ctx.manifest())?; @@ -1188,6 +1281,49 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { Ok(()) } +/// Cross-check: every populated field in a `[adapters..deployed]` +/// block must be owned by the registered adapter for that name. If +/// the adapter isn't registered in this build (feature disabled or +/// typo in the section name), skip the check — `ensure_adapter_defined` +/// surfaces the missing adapter separately. +/// +/// **Case handling:** `adapter_registry::get_adapter` normalises the +/// lookup key to `to_ascii_lowercase()` at registration and lookup +/// time, so operator spellings like `[adapters.Fastly.deployed]`, +/// `[adapters.FASTLY.deployed]`, and `[adapters.fastly.deployed]` +/// all resolve to the same registered adapter and cross-check +/// against the same `deployed_fields()` list. +/// +/// Runs at manifest-shape validation time via `run_shared_checks` +/// so `config validate --strict`, `provision`, `config push --local`, +/// and `config diff` all see the same rejection. +pub(crate) fn validate_deployed_field_ownership(manifest: &Manifest) -> Result<(), String> { + for (name, adapter_cfg) in &manifest.adapters { + let Some(deployed) = adapter_cfg.deployed.as_ref() else { + continue; + }; + let populated = deployed.populated_fields(); + if populated.is_empty() { + continue; + } + let Some(adapter) = adapter_registry::get_adapter(name) else { + // Not registered in this build; skip. Typo-detection + // is `ensure_adapter_defined`'s job. + continue; + }; + let owned = adapter.deployed_fields(); + for field in &populated { + if !owned.contains(field) { + return Err(format!( + "[adapters.{name}.deployed].{field}: field is not owned by the `{name}` adapter (owned fields: [{}])", + owned.join(", ") + )); + } + } + } + Ok(()) +} + // ------------------------------------------------------------------- // Adapter dispatch — defer per-adapter rules to each adapter crate's // `Adapter` trait impl. No `if adapter == "spin"` branches here. @@ -1287,12 +1423,9 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } -/// Typed-only adapter dispatch: feed each adapter the `#[secret]` -/// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are -/// runtime store ids, not flat-namespace candidates) so adapters -/// whose secret store has a flat-namespace constraint (Spin) can -/// detect within-secrets collisions. -fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { +pub(crate) fn build_typed_secret_entries<'ctx, C: AppConfigMeta>( + ctx: &'ctx ValidationContext, +) -> Result>, String> { let raw_table = ctx .raw_config .as_table() @@ -1304,7 +1437,7 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .secrets .as_ref() .map(StoreDeclaration::default_id); - let mut entries: Vec> = Vec::new(); + let mut entries: Vec> = Vec::new(); for field in C::SECRET_FIELDS { match field.kind { SecretKind::KeyInDefault => { @@ -1323,6 +1456,25 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result SecretKind::StoreRef => {} } } + Ok(entries) +} + +pub(crate) fn run_typed_preflight( + typed: &C, + ctx: &ValidationContext, +) -> Result<(), String> { + typed_secret_checks(typed, ctx)?; + run_adapter_typed_checks::(ctx)?; + Ok(()) +} + +/// Typed-only adapter dispatch: feed each adapter the `#[secret]` +/// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are +/// runtime store ids, not flat-namespace candidates) so adapters +/// whose secret store has a flat-namespace constraint (Spin) can +/// detect within-secrets collisions. +fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { + let entries = build_typed_secret_entries::(ctx)?; for name in ctx.manifest().adapters.keys() { if let Some(adapter) = adapter_registry::get_adapter(name) { @@ -2693,6 +2845,126 @@ default = "one" ); } + // ---------- push --local path containment ---------- + + #[test] + fn config_push_local_rejects_parent_traversal_in_adapter_manifest() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +manifest = "../outside/axum.toml" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("parent traversal in adapter manifest must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + } + + #[test] + fn config_push_local_rejects_absolute_adapter_manifest() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +manifest = "/tmp/some.toml" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("absolute adapter manifest path must be rejected"); + assert!( + err.contains("must be a project-relative path"), + "error must name the absolute-path violation: {err}" + ); + } + + /// Regression: the containment guard MUST fire before + /// `run_shared_checks`, which iterates every declared adapter and + /// calls `validate_adapter_manifest`. Spin's implementation does + /// `fs::read_to_string(manifest_root.join(rel))` — an ordering bug + /// would surface as a Spin "failed to read spin manifest" error + /// rather than the containment error. This test declares the + /// pushed adapter as axum but adds a poisoned `[adapters.spin]` + /// entry with a traversal path, and asserts the error names the + /// containment violation (proving the guard fired first). + #[test] + fn config_push_local_rejects_parent_traversal_in_sibling_spin_adapter() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "../outside/spin.toml" +component = "demo" + +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("sibling adapter with traversal path must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "guard must fire before Spin's validate_adapter_manifest reads the poisoned path: {err}" + ); + assert!( + !err.contains("failed to read spin manifest"), + "if this substring appears, the Spin fs::read escaped before the containment guard: {err}" + ); + } + // ------------------------------------------------------------------- // run_config_push_typed — 8.2 consent rules + diff // ------------------------------------------------------------------- @@ -3362,4 +3634,23 @@ ids = ["default"] "error names the empty secret field: {err}" ); } + + #[test] + fn run_typed_preflight_smoke_passes_for_valid_typed_config() { + let _lock = manifest_guard().lock().unwrap(); + let (_dir, manifest, _) = setup_project(VALID_MANIFEST, FIXTURE_APP_CONFIG); + let ctx = load_validation_context_with_options(&manifest, None, false, false).unwrap(); + let load_opts = { + let mut load_opts = AppConfigLoadOptions::default(); + load_opts.env_overlay = false; + load_opts + }; + let typed: FixtureConfig = app_config::deserialize_app_config_with_options( + ctx.app_config_path(), + ctx.app_name(), + &load_opts, + ) + .unwrap(); + run_typed_preflight(&typed, &ctx).unwrap(); + } } diff --git a/crates/edgezero-cli/src/copy_tree.rs b/crates/edgezero-cli/src/copy_tree.rs new file mode 100644 index 0000000..e8b4a01 --- /dev/null +++ b/crates/edgezero-cli/src/copy_tree.rs @@ -0,0 +1,97 @@ +//! Small internal recursive directory copy used by `provision +//! --local --dry-run` to stage mutable adapter paths. No new +//! workspace dep — built on `std::fs` only. Preserves regular +//! files and re-creates directories; symlinks and special files +//! are out of scope per spec §"Dry-run". + +use std::fs; +use std::fs::FileType; +use std::io; +use std::path::Path; + +/// True only for regular files (not directories, symlinks, fifos, +/// sockets, block/character devices). Regular-files-only IS the +/// spec §"Dry-run" semantic — clippy's warning that callers "often +/// forget `is_file()` excludes symlinks" is inverted for us: we +/// WANT that exclusion. Wrapping at one call site keeps +/// `copy_dir_recursive` free of the suppression. +#[expect( + clippy::filetype_is_file, + reason = "spec §\"Dry-run\": regular-files-only copy semantics — symlink/special-file exclusion is the intent, not a bug" +)] +fn is_regular_file(file_type: FileType) -> bool { + file_type.is_file() +} + +pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for read_result in fs::read_dir(src)? { + let entry = read_result?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else if is_regular_file(file_type) { + fs::copy(&src_path, &dst_path)?; + } else { + // Symlinks and special files (fifos, sockets, block/char + // devices) are intentionally skipped per spec §"Dry-run" + // — dry-run must not follow symlinks off the staged tree, + // and adapter source trees shouldn't contain special files. + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn copies_nested_files_and_dirs() { + let src = TempDir::new().unwrap(); + fs::create_dir_all(src.path().join("a/b")).unwrap(); + fs::write(src.path().join("a/top.toml"), "x = 1").unwrap(); + fs::write(src.path().join("a/b/nested.toml"), "y = 2").unwrap(); + + let dst = TempDir::new().unwrap(); + copy_dir_recursive(src.path(), dst.path()).unwrap(); + + assert_eq!( + fs::read_to_string(dst.path().join("a/top.toml")).unwrap(), + "x = 1" + ); + assert_eq!( + fs::read_to_string(dst.path().join("a/b/nested.toml")).unwrap(), + "y = 2" + ); + } + + #[test] + fn missing_src_returns_error() { + let dst = TempDir::new().unwrap(); + assert!(copy_dir_recursive(Path::new("/nonexistent"), dst.path()).is_err()); + } + + #[test] + #[cfg(unix)] + fn skips_symlinks_and_only_copies_regular_files() { + use std::os::unix::fs::symlink; + + let src = TempDir::new().unwrap(); + fs::write(src.path().join("real.toml"), "keep").unwrap(); + symlink(src.path().join("real.toml"), src.path().join("link.toml")).unwrap(); + + let dst = TempDir::new().unwrap(); + copy_dir_recursive(src.path(), dst.path()).unwrap(); + + assert!(dst.path().join("real.toml").exists()); + assert!( + !dst.path().join("link.toml").exists(), + "symlink must not be reproduced under the staged tree" + ); + } +} diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 925722d..6608eab 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -25,6 +25,8 @@ mod adapter; mod auth; #[cfg(feature = "cli")] mod config; +#[cfg(feature = "cli")] +mod copy_tree; #[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; #[cfg(feature = "cli")] @@ -32,6 +34,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod path_safety; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; diff --git a/crates/edgezero-cli/src/path_safety.rs b/crates/edgezero-cli/src/path_safety.rs new file mode 100644 index 0000000..21a5c48 --- /dev/null +++ b/crates/edgezero-cli/src/path_safety.rs @@ -0,0 +1,211 @@ +//! Path containment for CLI entry points that resolve +//! manifest-declared paths and let adapters write files through +//! them. See spec §"Path containment (MUST)". + +use std::path::{Component, Path, PathBuf}; + +/// Reject absolute paths and `..` traversal for the +/// `[adapters..adapter].manifest` and `.crate` strings, then +/// assert: +/// 1. each path resolves under the project root (defence in depth); +/// 2. when both `.crate` and `.manifest` are set, the manifest +/// path resolves under the crate path -- the spec's +/// stronger promise that local provision never creates +/// files outside the adapter crate or its gitignored +/// local-state dirs. +/// +/// Callers SHOULD pass the absolute manifest-loader root when +/// they have it, but the helper defensively normalises so a +/// relative `args.manifest.parent()` ("" or "examples/app-demo") +/// compares correctly. +pub(crate) fn assert_provision_paths_contained( + project_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, +) -> Result<(), String> { + // Treat "" as ".": Path::parent() returns "" for a bare + // `--manifest edgezero.toml`, and Path::new("").join(...) does + // NOT prepend anything, so starts_with would fail silently. + let root_raw = if project_root.as_os_str().is_empty() { + Path::new(".") + } else { + project_root + }; + let root = lexical_normalize(root_raw); + // When `root` normalises to "." (caller passed "" or "." -- + // a bare `--manifest edgezero.toml` or an explicit + // cwd-relative path), the joined-vs-root `starts_with` + // check is structurally broken: `lexical_normalize` strips + // the leading `./` from the join, leaving e.g. + // `crates/cf/wrangler.toml` -- which does NOT start with + // ".". Skip Step 1's containment check in that case; the + // absolute + `..` rejection below already guarantees the + // candidate sits under cwd, and Step 2 (manifest-inside- + // crate) compares two paths that BOTH go through the same + // normalisation so the leading-dot strip cancels out + // there. The relative-root test fixtures + // (`accepts_relative_root_default`, + // `accepts_empty_root_string_as_dot`) only pass with this + // short-circuit in place. + let do_step1_starts_with = root != Path::new("."); + + // Step 1: each path is project-relative + no `..` + (when + // root is concretely-rooted) resolves under the project root. + for (label, maybe_raw) in [ + ("[adapters..adapter].manifest", adapter_manifest_path), + ("[adapters..adapter].crate", adapter_crate_path), + ] { + let Some(raw) = maybe_raw else { continue }; + let candidate = Path::new(raw); + if candidate.is_absolute() { + return Err(format!( + "{label} must be a project-relative path; got absolute `{raw}`" + )); + } + if candidate + .components() + .any(|comp| matches!(comp, Component::ParentDir)) + { + return Err(format!( + "{label} must not contain `..` traversal; got `{raw}`" + )); + } + if do_step1_starts_with { + let normalized = lexical_normalize(&root.join(candidate)); + if !normalized.starts_with(&root) { + return Err(format!( + "{label} resolves outside project root `{}`: `{}`", + root.display(), + normalized.display() + )); + } + } + } + + // Step 2: when both are set, manifest MUST sit inside the + // adapter crate dir. Closes the spec's stronger promise -- + // without this, crate = "crates/cf" + manifest = + // "tmp/wrangler.toml" would pass step 1 but write to a path + // outside the adapter crate. + if let (Some(crate_raw), Some(manifest_raw)) = (adapter_crate_path, adapter_manifest_path) { + let crate_resolved = lexical_normalize(&root.join(Path::new(crate_raw))); + let manifest_resolved = lexical_normalize(&root.join(Path::new(manifest_raw))); + if !manifest_resolved.starts_with(&crate_resolved) { + return Err(format!( + "[adapters..adapter].manifest `{manifest_raw}` must \ + resolve inside [adapters..adapter].crate `{crate_raw}`; \ + resolved manifest path `{}` is not under crate path `{}`", + manifest_resolved.display(), + crate_resolved.display() + )); + } + } + Ok(()) +} + +/// Lexically normalise: collapse `.` components and pass `..` +/// through unchanged (caller already rejected `..`). No +/// `fs::canonicalize` -- paths may not exist on first-run +/// bootstrap, and canonicalising would resolve operator-set +/// symlinks on the project root. +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::Prefix(_) + | Component::RootDir + | Component::ParentDir + | Component::Normal(_) => out.push(comp.as_os_str()), + } + } + if out.as_os_str().is_empty() { + out.push("."); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn rejects_absolute_manifest_path() { + let err = + assert_provision_paths_contained(Path::new("."), Some("/etc/wrangler.toml"), None) + .unwrap_err(); + assert!(err.contains("must be a project-relative path"), "{err}"); + } + + #[test] + fn rejects_parent_traversal_in_manifest_path() { + let err = + assert_provision_paths_contained(Path::new("."), Some("../outside/spin.toml"), None) + .unwrap_err(); + assert!(err.contains("must not contain `..` traversal"), "{err}"); + } + + #[test] + fn rejects_parent_traversal_in_crate_path() { + let err = + assert_provision_paths_contained(Path::new("."), None, Some("../escape")).unwrap_err(); + assert!(err.contains("must not contain `..` traversal"), "{err}"); + } + + #[test] + fn accepts_relative_root_default() { + assert_provision_paths_contained( + Path::new("."), + Some("crates/edgezero-adapter-spin/spin.toml"), + Some("crates/edgezero-adapter-spin"), + ) + .unwrap(); + } + + #[test] + fn accepts_nested_relative_root() { + assert_provision_paths_contained( + Path::new("examples/app-demo"), + Some("crates/app-demo-adapter-spin/spin.toml"), + Some("crates/app-demo-adapter-spin"), + ) + .unwrap(); + } + + #[test] + fn accepts_empty_root_string_as_dot() { + // args.manifest.parent() returns "" for a bare `--manifest edgezero.toml`. + assert_provision_paths_contained( + Path::new(""), + Some("crates/edgezero-adapter-spin/spin.toml"), + None, + ) + .unwrap(); + } + + #[test] + fn rejects_manifest_outside_adapter_crate() { + // Crate = "crates/cf", but manifest = "tmp/wrangler.toml" + // (sibling of the crate, NOT inside it). Step 1 passes + // (both under project root); step 2 must catch the + // crate-vs-manifest mismatch. + let err = assert_provision_paths_contained( + Path::new("."), + Some("tmp/wrangler.toml"), + Some("crates/cf"), + ) + .unwrap_err(); + assert!(err.contains("must resolve inside"), "{err}"); + } + + #[test] + fn accepts_manifest_under_adapter_crate() { + assert_provision_paths_contained( + Path::new("."), + Some("crates/cf/wrangler.toml"), + Some("crates/cf"), + ) + .unwrap(); + } +} diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 13faf70..4db4b0e 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -8,16 +8,83 @@ //! each `edgezero-adapter-*` crate's `Adapter::provision` impl, not //! here. -use std::path::Path; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use similar::{ChangeTag, TextDiff}; +use toml_edit::{table, value, DocumentMut}; use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, + validate_deployed_field_ownership, }; +use crate::copy_tree::copy_dir_recursive; use crate::ensure_adapter_defined; +use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; +use edgezero_adapter::AdapterDeployedState; use edgezero_core::env_config::EnvConfig; -use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; +use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDeclaration}; + +/// Owned counterpart to the borrowed `ProvisionStores<'_>`. Used by +/// dispatch arms that need to build resolved store ids per-root +/// (e.g. inside the `run_with_staging` closure where a borrowed +/// return would dangle when the `Vec` locals dropped). Task 29 +/// (typed provision) consumes this too. +pub(crate) struct OwnedProvisionStores { + pub config: Vec, + pub kv: Vec, + pub secrets: Vec, +} + +impl OwnedProvisionStores { + pub(crate) fn as_refs(&self) -> ProvisionStores<'_> { + ProvisionStores { + config: &self.config, + kv: &self.kv, + secrets: &self.secrets, + } + } +} + +/// Resolved per-adapter allow-list inputs the dry-run driver diffs. +/// Built by the CLI from the resolved adapter manifest path (NOT a +/// static filename) so nested paths like +/// `crates/cf/config/wrangler.toml` resolve correctly. Spec §"Per- +/// adapter local state" defines membership per adapter: +/// - Axum: project-root `.edgezero/.env` only. +/// - Cloudflare: resolved `wrangler.toml` + sibling `.dev.vars`. +/// - Fastly: resolved `fastly.toml`. +/// - Spin: resolved `spin.toml` + sibling `runtime-config.toml` + +/// sibling `.env`. +/// +/// `axum.toml` is NOT in this list (it stays tracked). +pub(crate) struct DryRunAllowList { + /// (`project_path`, `staged_path`) pairs the driver diffs. + pub pairs: Vec<(PathBuf, PathBuf)>, +} + +/// # Errors +/// +/// Manifest-shape gates run before the dispatch matrix: capability +/// gate, handler-path shape, and deployed-field ownership. The +/// ownership check exists here for parity with `run_shared_checks` in +/// the config path, so `config validate` / `push` / `diff` and +/// `provision` all reject the same manifests. Extracted from +/// `run_provision` to keep that fn under the workspace `too_many_lines` +/// lint; no behaviour change. +/// +/// # Errors +/// +/// Returns the first check's error string when any of the three gates +/// rejects the manifest. +fn run_manifest_shape_gates(manifest: &Manifest, adapter_name: &str) -> Result<(), String> { + enforce_single_store_capability(manifest, adapter_name)?; + strict_handler_paths(manifest)?; + validate_deployed_field_ownership(manifest)?; + Ok(()) +} /// # Errors /// @@ -42,6 +109,27 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { ) })?; + // Path containment: reject `..` traversal and absolute paths in + // the manifest-declared adapter paths before any adapter dispatch + // or file resolution. Mirrors the `config push --local` guard + // (Task 7); the same helper closes the spec's "local provision + // never writes outside the adapter crate" promise. Cloud mode + // still targets remote SDKs so containment isn't load-bearing; + // gating on `args.local` also preserves the existing cloud + // fixtures where `manifest` lives at project root outside `crate`. + if args.local { + let manifest_root_for_check = args + .manifest + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + assert_provision_paths_contained( + manifest_root_for_check, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.crate_path.as_deref(), + )?; + } + // Linked in this build? Adapters are feature-gated; a release // built without `--features cloudflare` won't have it // registered even if the manifest declares it. @@ -53,81 +141,93 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { ) })?; - // Capability gate: mirror the strict `config validate` check for - // THIS adapter only. Without it, `provision --adapter spin` - // happily accepts a manifest with two config ids and dispatches - // to a backend that has no way to model multiple stores -- the - // failure only surfaces at runtime as a confusing "wrong store" - // miss. The check is unconditional (no --strict gate) because - // it's not stylistic: the platform genuinely cannot honour the - // declaration. - enforce_single_store_capability(manifest, &args.adapter)?; - - // Manifest-shape gate: provision is the most expensive - // operation in the CLI (it can create real Cloudflare / Fastly - // resources), so a malformed handler path or a broken - // adapter manifest should fail HERE rather than after the - // remote create succeeded. `strict_handler_paths` is cheap - // and unconditional in `config validate --strict`; we run it - // unconditionally here for the same reason as the capability - // check above. The per-adapter `validate_adapter_manifest` - // hook (Spin's `[component.*]` discovery, etc.) is the other - // half of the strict-validate preflight; it's adapter-specific - // so we call it only for the targeted adapter. - strict_handler_paths(manifest)?; + run_manifest_shape_gates(manifest, &args.adapter)?; + let manifest_root = args .manifest .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - adapter.validate_adapter_manifest( - manifest_root, - adapter_cfg.adapter.manifest.as_deref(), - adapter_cfg.adapter.component.as_deref(), - )?; - // Resolve each logical store id to its platform name via the - // same `EDGEZERO__STORES______NAME` env overlay the - // runtime reads. Provision writes the PLATFORM name into the - // per-platform manifest (wrangler.toml, spin.toml, - // fastly.toml); the logical id stays available for status-line - // wording so operators see what they declared even when the - // env override redirected the create. - let env_config = EnvConfig::from_env(); + // Fallback to "" when [app].name is unset: today's synthesiser + // default is a no-op so the value isn't consulted; per-adapter + // overrides (Tasks 17/21/24) that DO use it treat empty as the + // "operator hasn't set app.name yet" case. + let app_name = manifest.app.name.clone().unwrap_or_default(); - // Same env-resolved merged-id collision check `config validate` - // runs. Without it, `provision --adapter spin --dry-run` would - // happily ack a manifest where (e.g.) [stores.kv].sessions and - // [stores.config].app_config both resolve to platform label - // `shared` via the env overlay -- both writes would silently - // land on the same Spin KV store at runtime. Catches BOTH - // logical-id collisions and env-resolved platform-label - // collisions across merged kinds. - reject_merged_id_collisions(&args.adapter, adapter, manifest, &env_config)?; + // Translate the manifest's deployed block into the neutral + // `AdapterDeployedState` for the synthesiser call site. Task 14 + // adds the typed struct that makes this a real translation; + // today it's always `None`. + let deployed = deployed_state_for(manifest, &args.adapter); - let config_ids = resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"); - let kv_ids = resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"); - let secret_ids = resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, + let outcome = match (args.local, args.dry_run) { + (false, dry_run) => { + // Cloud: no synthesis. Validate + build stores against the + // real worktree, dispatch with mode=Cloud, deployed=None. + validate_and_dispatch( + adapter, + manifest, + adapter_cfg, + manifest_root, + &args.adapter, + None, + adapter_registry::ProvisionMode::Cloud, + dry_run, + )? + } + (true, false) => { + // Local real-write: synthesise baseline INSIDE this arm + // so cloud never touches it. Write baseline to the + // worktree, then validate + build stores + dispatch. + let baseline_pairs = adapter.synthesise_baseline_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &app_name, + deployed.as_ref(), + )?; + write_baseline_to_disk(manifest_root, &baseline_pairs)?; + validate_and_dispatch( + adapter, + manifest, + adapter_cfg, + manifest_root, + &args.adapter, + deployed.as_ref(), + adapter_registry::ProvisionMode::Local, + false, + )? + } + (true, true) => run_local_dry_run( + adapter, + manifest, + adapter_cfg, + manifest_root, + args, + &app_name, + deployed.as_ref(), + )?, }; - let lines = adapter.provision( - manifest_root, - adapter_cfg.adapter.manifest.as_deref(), - adapter_cfg.adapter.component.as_deref(), - &stores, - args.dry_run, - )?; - if args.dry_run { log::info!("[edgezero] provision --dry-run for `{}`:", args.adapter); } - for line in lines { + for line in outcome.status_lines { log::info!("{line}"); } + if let Some(deployed_writeback) = outcome.deployed.as_ref() { + let (canonical_adapter_key, _) = manifest + .adapter_entry(&args.adapter) + .ok_or_else(|| format!("adapter `{}` vanished from manifest", args.adapter))?; + merge_deployed_into_manifest( + &args.manifest, + canonical_adapter_key, + deployed_writeback, + adapter.deployed_fields(), + args.dry_run, + )?; + } Ok(()) } @@ -147,33 +247,832 @@ fn resolve_kind( }) } +/// Write each `(rel, contents)` baseline pair under `root`, skipping +/// files that already exist. Preserves operator content and earlier +/// synthesis output. Used for worktree writes (real-write local) and +/// tempdir writes (dry-run staging) — the only difference is which +/// root is passed in. +/// +/// **Path containment**: each `rel_path` MUST be relative and MUST NOT +/// contain a `..` component. Adapter-returned baseline paths are trusted +/// less than manifest-declared paths (which `assert_provision_paths +/// _contained` gates upstream); a buggy or hostile synthesiser +/// returning an absolute path or `../../etc/passwd` would otherwise +/// escape the project tree. Reject before `root.join()`. +fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<(), String> { + for (rel_path, contents) in pairs { + if rel_path.is_absolute() { + return Err(format!( + "baseline path must be project-relative, got `{}`", + rel_path.display() + )); + } + if rel_path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!( + "baseline path must not contain `..` traversal: `{}`", + rel_path.display() + )); + } + let abs = root.join(rel_path); + if abs.exists() { + continue; + } + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("create {}: {err}", parent.display()))?; + } + fs::write(&abs, contents).map_err(|err| format!("write {}: {err}", abs.display()))?; + } + Ok(()) +} + +/// Translate the parent manifest's `[adapters..deployed]` block +/// into the neutral `AdapterDeployedState` shape adapters consume via +/// `synthesise_baseline_manifest` and `provision`. Field mapping: +/// - `service_id` (scalar) → `state.fields["service_id"]`. +/// - `kv_namespaces` (map) → `state.sub_tables["kv_namespaces"]`. +/// - `preview_kv_namespaces` (map) → `state.sub_tables["preview_kv_namespaces"]`. +/// +/// Returns `None` when the adapter has no `deployed` block OR when every +/// field is empty — synthesise / provision impls treat `None` the same as +/// an empty state, so building an empty `AdapterDeployedState` would be +/// wasteful. The lookup is case-insensitive via `manifest.adapter_entry`, +/// matching how `[adapters.Fastly]` and `[adapters.fastly]` resolve to +/// the same declaration. +fn deployed_state_for( + manifest: &Manifest, + canonical_adapter_name: &str, +) -> Option { + let (_, adapter_cfg) = manifest.adapter_entry(canonical_adapter_name)?; + let deployed = adapter_cfg.deployed.as_ref()?; + let mut state = AdapterDeployedState::default(); + if let Some(service_id) = deployed.service_id.as_ref() { + state + .fields + .insert("service_id".to_owned(), service_id.clone()); + } + if !deployed.kv_namespaces.is_empty() { + state + .sub_tables + .insert("kv_namespaces".to_owned(), deployed.kv_namespaces.clone()); + } + if !deployed.preview_kv_namespaces.is_empty() { + state.sub_tables.insert( + "preview_kv_namespaces".to_owned(), + deployed.preview_kv_namespaces.clone(), + ); + } + if state.fields.is_empty() && state.sub_tables.is_empty() { + None + } else { + Some(state) + } +} + +/// Merge `state` into `[adapters..deployed]` inside +/// `manifest_path`, preserving all sibling content and adjacent +/// operator comments via `toml_edit`. `adapter_name` MUST be the +/// canonical operator-spelled key (result of +/// `manifest.adapter_entry(...)`); passing the raw `args.adapter` +/// risks creating a parallel lowercased `[adapters.cloudflare.deployed]` +/// beside an operator-spelled `[adapters.Cloudflare]` table. +/// +/// `state.fields` become scalar leaves; `state.sub_tables` become +/// nested `[]` sub-tables under `.deployed`. When +/// `dry_run` is true the helper builds the doc in memory then +/// returns without writing — used by callers who want the write +/// gated on the same `--dry-run` semantic as the surrounding +/// dispatch. +/// +/// **Adapter-emitted schema check**: every key in `state.fields` and +/// `state.sub_tables` MUST be in the known `ManifestAdapterDeployed` +/// schema AND in `owned_fields`. `validate_deployed_field_ownership` +/// gates operator-written manifests before dispatch; this gate does +/// the same for adapter-emitted output before writing back. Without +/// it, a buggy adapter's `AdapterDeployedState` could persist +/// unknown or non-owned keys into `edgezero.toml`, breaking future +/// manifest loads. +pub(crate) fn merge_deployed_into_manifest( + manifest_path: &Path, + adapter_name: &str, + state: &adapter_registry::AdapterDeployedState, + owned_fields: &[&str], + dry_run: bool, +) -> Result<(), String> { + // The canonical `ManifestAdapterDeployed` schema. If the struct + // gains a field, add it here too — the check is the ONLY defense + // against adapter-emitted unknown fields corrupting the writeback. + const KNOWN_SCALAR_FIELDS: &[&str] = &["service_id"]; + const KNOWN_SUB_TABLE_FIELDS: &[&str] = &["kv_namespaces", "preview_kv_namespaces"]; + + for key in state.fields.keys() { + if !KNOWN_SCALAR_FIELDS.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned unknown deployed field `{key}` (known scalar fields: [{}])", + KNOWN_SCALAR_FIELDS.join(", ") + )); + } + if !owned_fields.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned deployed field `{key}` it does not own (owned: [{}])", + owned_fields.join(", ") + )); + } + } + for key in state.sub_tables.keys() { + if !KNOWN_SUB_TABLE_FIELDS.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned unknown deployed sub-table `{key}` (known sub-tables: [{}])", + KNOWN_SUB_TABLE_FIELDS.join(", ") + )); + } + if !owned_fields.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned deployed sub-table `{key}` it does not own (owned: [{}])", + owned_fields.join(", ") + )); + } + } + + let raw = fs::read_to_string(manifest_path) + .map_err(|err| format!("read {}: {err}", manifest_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("parse {}: {err}", manifest_path.display()))?; + + // `entry(...).or_insert_with(table)` avoids the `IndexMut` lint + // (`clippy::indexing_slicing`) that fires on `doc["adapters"]`. + // If a sibling exists but isn't a table, we bail cleanly instead + // of clobbering it — mirrors the fastly adapter's editor pattern. + let adapters_item = doc.entry("adapters").or_insert_with(table); + let adapters_tbl = adapters_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + let named_item = adapters_tbl.entry(adapter_name).or_insert_with(table); + let named_tbl = named_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + let deployed_item = named_tbl.entry("deployed").or_insert_with(table); + let deployed_tbl = deployed_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}.deployed` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + + for (key, val) in &state.fields { + deployed_tbl.insert(key, value(val.clone())); + } + for (sub_name, sub_map) in &state.sub_tables { + let sub_item = deployed_tbl.entry(sub_name).or_insert_with(table); + let sub_tbl = sub_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}.deployed.{sub_name}` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + for (key, val) in sub_map { + sub_tbl.insert(key, value(val.clone())); + } + } + + if dry_run { + return Ok(()); + } + fs::write(manifest_path, doc.to_string()) + .map_err(|err| format!("write {}: {err}", manifest_path.display()))?; + Ok(()) +} + +/// Shared validate + env-overlay + collision-check + resolve-stores + +/// dispatch tail for both cloud and local live-mode arms. Baseline +/// synthesis (local only) fires BEFORE this helper — the tail after +/// synthesis is identical between the two arms, so factoring it here +/// keeps `run_provision` under the module's function-length lint AND +/// removes the copy-paste risk of two arms drifting out of sync. +#[expect( + clippy::too_many_arguments, + reason = "the shared tail needs adapter, manifest, adapter cfg, manifest root, adapter name, deployed state, mode, and dry-run — same 8 argument shape as `Adapter::provision` itself, whose lint annotation applies for the same reason" +)] +fn validate_and_dispatch( + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, + adapter_cfg: &ManifestAdapter, + manifest_root: &Path, + adapter_name: &str, + deployed: Option<&AdapterDeployedState>, + mode: adapter_registry::ProvisionMode, + dry_run: bool, +) -> Result { + adapter.validate_adapter_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + )?; + let env_config = EnvConfig::from_env(); + reject_merged_id_collisions(adapter_name, adapter, manifest, &env_config)?; + let config_ids = resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"); + let kv_ids = resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"); + let secret_ids = resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + adapter.provision( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &stores, + deployed, + mode, + dry_run, + ) +} + +/// Same store-construction pattern `validate_and_dispatch` runs +/// inline, but returns the owned form so the caller can hold it +/// across a closure and `.as_refs()` immediately before dispatch. +/// Used by the `(true, true)` arm today; Task 29 (typed provision) +/// consumes it too. +/// +/// The `_root` parameter is unused today — reserved for future +/// per-root state (e.g. reading a synthesised sidecar file). +/// +/// `adapter` is `&'static dyn` to match `validate_and_dispatch` and +/// `reject_merged_id_collisions`, both of which take `'static` +/// trait objects because the registry only hands out `&'static` +/// references. +fn build_stores_against( + _root: &Path, + args: &ProvisionArgs, + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, +) -> Result { + let env_config = EnvConfig::from_env(); + reject_merged_id_collisions(&args.adapter, adapter, manifest, &env_config)?; + Ok(OwnedProvisionStores { + config: resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"), + kv: resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"), + secrets: resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"), + }) +} + +/// Build the allow-list from the resolved adapter manifest path. +/// `adapter_manifest_abs` is the absolute path the adapter would +/// write to (`staged_root.join(adapter_manifest_rel)`); the helper +/// computes its sibling paths and the corresponding project-tree +/// twins by prefix-swapping `staged_root` → `project_root`. +/// +/// **Case contract:** callers MUST lowercase the adapter name +/// before passing it in. The manifest's canonical spelling (e.g. +/// `Fastly`) does NOT match the match arms. +pub(crate) fn build_dry_run_allow_list( + project_root: &Path, + staged_root: &Path, + adapter_lower: &str, + adapter_manifest_abs: &Path, +) -> DryRunAllowList { + let project_manifest = adapter_manifest_abs.strip_prefix(staged_root).map_or_else( + |_| adapter_manifest_abs.to_path_buf(), + |rel| project_root.join(rel), + ); + let manifest_parent_staged = adapter_manifest_abs + .parent() + .unwrap_or(staged_root) + .to_path_buf(); + let manifest_parent_project = project_manifest + .parent() + .unwrap_or(project_root) + .to_path_buf(); + let mut pairs: Vec<(PathBuf, PathBuf)> = Vec::new(); + match adapter_lower { + "axum" => { + pairs.push(( + project_root.join(".edgezero/.env"), + staged_root.join(".edgezero/.env"), + )); + } + "cloudflare" => { + pairs.push((project_manifest.clone(), adapter_manifest_abs.to_path_buf())); + pairs.push(( + manifest_parent_project.join(".dev.vars"), + manifest_parent_staged.join(".dev.vars"), + )); + } + "fastly" => { + pairs.push((project_manifest, adapter_manifest_abs.to_path_buf())); + } + "spin" => { + pairs.push((project_manifest.clone(), adapter_manifest_abs.to_path_buf())); + pairs.push(( + manifest_parent_project.join("runtime-config.toml"), + manifest_parent_staged.join("runtime-config.toml"), + )); + pairs.push(( + manifest_parent_project.join(".env"), + manifest_parent_staged.join(".env"), + )); + } + _ => {} + } + DryRunAllowList { pairs } +} + +/// Per-adapter default manifest filename. Fallback for when +/// `[adapters..adapter].manifest` is unset. Mirrors each +/// adapter crate's default. +pub(crate) fn default_adapter_manifest_for(adapter_lower: &str) -> &'static str { + match adapter_lower { + "cloudflare" => "wrangler.toml", + "fastly" => "fastly.toml", + "spin" => "spin.toml", + _ => "", // axum has no per-adapter manifest in the allow-list + } +} + +/// Render the dry-run report: rewritten status lines + per-file +/// unified diff. Status-line rewriting (`wrote X` → `would write X`) +/// uses only the (`project_root`, `staged_root`) prefix swap plus a +/// verb-prefix table. +pub(crate) fn render_dry_run_report( + project_root: &Path, + staged_root: &Path, + allow_list: &DryRunAllowList, + outcome: &adapter_registry::ProvisionOutcome, +) -> String { + let mut out = String::new(); + + // Status lines: rewrite staged-tempdir paths back to project- + // relative AND prefix each verb with "would ". + for line in &outcome.status_lines { + let rewritten = line.replace( + staged_root.to_string_lossy().as_ref(), + project_root.to_string_lossy().as_ref(), + ); + let with_verb = rewritten + .replacen("wrote ", "would write ", 1) + .replacen("created ", "would create ", 1) + .replacen("appended ", "would append ", 1); + out.push_str(&with_verb); + out.push('\n'); + } + + // Per-file diff section: caller-provided pairs already resolved + // (project_path, staged_path). + for (proj_path, staged_path) in &allow_list.pairs { + if !staged_path.exists() { + continue; + } + let new = fs::read_to_string(staged_path).unwrap_or_default(); + let old = fs::read_to_string(proj_path).unwrap_or_default(); + if old == new { + continue; + } + let diff = TextDiff::from_lines(&old, &new); + out.push('\n'); + out.push_str("--- "); + out.push_str(&proj_path.display().to_string()); + out.push('\n'); + out.push_str("+++ "); + out.push_str(&proj_path.display().to_string()); + out.push('\n'); + for change in diff.iter_all_changes() { + let sign = match change.tag() { + ChangeTag::Delete => "-", + ChangeTag::Insert => "+", + ChangeTag::Equal => " ", + }; + out.push_str(sign); + out.push_str(&change.to_string()); + } + } + out +} + +/// The `(true, true)` dispatch arm: synthesise the baseline manifest +/// (bytes only — no I/O against the real worktree), then stage the +/// adapter crate + `.edgezero/` + `edgezero.toml` into a tempdir and +/// run validate + build stores + dispatch entirely against the staged +/// root. The real worktree is never mutated. +/// +/// The synthesiser runs against the REAL `manifest_root` because it +/// produces bytes only; every subsequent filesystem-touching call +/// (`write_baseline_to_disk`, `validate_adapter_manifest`, +/// `build_stores_against`, `adapter.provision`) receives +/// `staged_root` from inside the `run_with_staging` closure. +fn run_local_dry_run( + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, + adapter_cfg: &ManifestAdapter, + manifest_root: &Path, + args: &ProvisionArgs, + app_name: &str, + deployed: Option<&AdapterDeployedState>, +) -> Result { + let baseline_pairs = adapter.synthesise_baseline_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + app_name, + deployed, + )?; + let adapter_crate_rel = adapter_cfg + .adapter + .crate_path + .as_deref() + .map_or_else(|| Path::new("."), Path::new); + let (outcome, tempdir) = run_with_staging( + manifest_root, + adapter_crate_rel, + |staged_root, _staged_crate| { + write_baseline_to_disk(staged_root, &baseline_pairs)?; + adapter.validate_adapter_manifest( + staged_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + )?; + let owned_stores = build_stores_against(staged_root, args, adapter, manifest)?; + // Spec §"Dry-run" step 3: pass `dry_run = false` to the + // adapter even though `args.dry_run == true`. The tempdir + // IS the dry-run mechanism — the adapter takes its real + // write branch against the staged tree so operators can + // preview the actual files that would land. If we passed + // `true`, adapters would early-return from their dry-run + // branches (cloudflare cli.rs:263, spin cli.rs:223) and + // leave the staged tree empty of the content the diff + // report is supposed to show. + adapter.provision( + staged_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &owned_stores.as_refs(), + deployed, + adapter_registry::ProvisionMode::Local, + false, + ) + }, + )?; + + let staged_root = tempdir.path(); + let adapter_lower = args.adapter.to_lowercase(); + let adapter_manifest_rel = adapter_cfg + .adapter + .manifest + .as_deref() + .unwrap_or_else(|| default_adapter_manifest_for(&adapter_lower)); + let adapter_manifest_staged = staged_root.join(adapter_manifest_rel); + let allow_list = build_dry_run_allow_list( + manifest_root, + staged_root, + &adapter_lower, + &adapter_manifest_staged, + ); + let report = render_dry_run_report(manifest_root, staged_root, &allow_list, &outcome); + // Only emit the report if it's non-empty (avoids extraneous blank + // log lines when the adapter status_lines are empty and no + // allow-list file differs). + if !report.is_empty() { + log::info!("{report}"); + } + // Clear status_lines: the sanitized report already includes the + // rewritten "would write ..." lines with staged-tempdir paths + // swapped back to project-relative form. If we returned them + // untouched, `run_provision`'s trailing `for line in + // outcome.status_lines` loop would re-log the raw versions — + // leaking `/var/folders/…` tempdir paths to operators. Spec §"Dry- + // run": stdout must NEVER contain raw tempdir paths. The + // `deployed` payload is intentionally kept: cloud writeback under + // (false, _) uses it, and local dry-run today always sees `None` + // there (adapters populate `deployed` only when writing real + // cloud state). + Ok(adapter_registry::ProvisionOutcome { + status_lines: Vec::new(), + deployed: outcome.deployed, + }) +} + +/// Stage a real recursive copy of the adapter crate dir AND the +/// `.edgezero/` dir (if present) under a fresh `TempDir`, then invoke +/// `body` with the staged paths. The original project worktree is +/// never mutated. Caller is responsible for diffing the staged tree +/// against the project tree before the returned `TempDir` drops. See +/// spec §"Dry-run". +/// +/// Called by the `(true, true)` arm of the `run_provision` dispatch +/// matrix — local dry-run stages everything into a tempdir and +/// discards it after `body` completes. +pub(crate) fn run_with_staging( + project_root: &Path, + adapter_crate_rel: &Path, + body: F, +) -> Result<(R, tempfile::TempDir), String> +where + F: FnOnce(&Path, &Path) -> Result, +{ + let tempdir = tempfile::TempDir::new() + .map_err(|err| format!("failed to create staging tempdir: {err}"))?; + let staged_root = tempdir.path(); + + // Copy `edgezero.toml` (read-only input). Symlinking would be + // tempting as an optimisation, but for the default + // `--manifest edgezero.toml` shape `project_root` is "." and + // `project_root.join("edgezero.toml")` is `./edgezero.toml`. + // Unix `symlink(src, dst)` interprets a relative `src` as + // relative to `dst`'s parent — so + // `staged_root/edgezero.toml -> ./edgezero.toml` resolves back + // to `staged_root/edgezero.toml` itself, a broken self-loop. + // Copying is small and correct. + let edgezero_toml = project_root.join("edgezero.toml"); + if edgezero_toml.exists() { + let staged_edgezero = staged_root.join("edgezero.toml"); + if let Some(parent) = staged_edgezero.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create staged parent dir: {err}"))?; + } + fs::copy(&edgezero_toml, &staged_edgezero) + .map_err(|err| format!("failed to stage edgezero.toml: {err}"))?; + } + + // Real-copy the adapter crate dir (mutable). `adapter_crate_rel` + // is project-relative (e.g. "crates/cf" or "."), so no + // `strip_prefix` is needed — the earlier draft that computed + // `crate_rel` via `strip_prefix(project_root)` silently failed for + // the default `project_root == "."` shape. + let src_crate = project_root.join(adapter_crate_rel); + let staged_crate = staged_root.join(adapter_crate_rel); + copy_dir_recursive(&src_crate, &staged_crate) + .map_err(|err| format!("failed to stage adapter crate dir: {err}"))?; + + // Real-copy `.edgezero/` if present; otherwise create empty. Some + // adapters own `.edgezero/local-config-*.json` state files (axum); + // staging must preserve them, and their absence in a green-clone + // case must still yield a mountable dir. + let dot_edgezero = project_root.join(".edgezero"); + let staged_dot = staged_root.join(".edgezero"); + if dot_edgezero.exists() { + copy_dir_recursive(&dot_edgezero, &staged_dot) + .map_err(|err| format!("failed to stage .edgezero/: {err}"))?; + } else { + fs::create_dir_all(&staged_dot) + .map_err(|err| format!("failed to create staged .edgezero/: {err}"))?; + } + + let result = body(staged_root, &staged_crate)?; + Ok((result, tempdir)) +} + #[cfg(test)] mod tests { use super::*; use crate::args::ProvisionArgs; use crate::test_support::{manifest_guard, EnvOverride, PROVISION_MANIFEST}; + use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, + }; + use std::collections::BTreeMap; + use std::env; use std::fs; + use std::io; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; use tempfile::TempDir; + use validator::Validate as _; - #[test] - fn run_provision_axum_prints_local_only_notes_for_each_store() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + // ----- fixtures for CLI-owned first-run bootstrap synthesis ----- + // + // A distinct fake adapter (`__test_bootstrap_fake__`) is + // registered per-test into the global adapter registry via the + // public `register_adapter` helper. The `manifest_guard()` mutex + // already serialises tests that touch the registry, so a + // second registration under the same name from a concurrent + // test cannot race. Observability is via two module-scope + // `AtomicBool` flags — `SYNTH_CALLED` for the synthesiser call + // and `VALIDATE_SAW_FILE` for the downstream + // `validate_adapter_manifest` invariant. + // + // The fake echoes `adapter_manifest_path` back as the + // synthesised file's relative path, mirroring the Spin override + // that lands at Task 24 — the file must land at + // `/`, NOT at a hard-coded + // path. - run_provision(&ProvisionArgs { - adapter: "axum".to_owned(), - dry_run: false, - manifest: manifest_path.clone(), - }) - .expect("axum provision exits 0 (no remote resources)"); + const FAKE_MANIFEST_BODY: &str = r#" +[app] +name = "demo-app" + +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" +"#; + + static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static NO_FIELDS_FAKE_ADAPTER: NoFieldsFakeAdapter = NoFieldsFakeAdapter; + static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); + // Captures the `deployed` argument the CLI passes into + // `FakeBootstrapAdapter::synthesise_baseline_manifest`. Used by + // Section-4 tests that assert `deployed_state_for` translated a + // real `[adapters..deployed]` block and threaded it + // through — not left it silently `None`. + static RECORDED_SYNTH_DEPLOYED: Mutex> = Mutex::new(None); + static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); + static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); + + /// RAII guard: on `set`, chdir into `new_cwd`; on drop, restore + /// the previous cwd. Callers MUST hold `manifest_guard()` while + /// this is live — process cwd is global state and can only be + /// mutated safely under that serialisation lock. + struct CwdGuard(PathBuf); + + struct FakeBootstrapAdapter; + + struct NoFieldsFakeAdapter; + + impl CwdGuard { + fn set(new_cwd: &Path) -> io::Result { + let prev = env::current_dir()?; + env::set_current_dir(new_cwd)?; + Ok(Self(prev)) + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + // Best-effort cwd restore during unwind or normal drop. + // A failure here is unrecoverable at the drop site; the + // manifest_guard() lock the caller holds is released + // regardless, so the next test acquiring it will + // set_current_dir explicitly if it needs to. + drop(env::set_current_dir(&self.0)); + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "the fake overrides name/deployed_fields/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + )] + impl Adapter for FakeBootstrapAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["service_id"] + } + + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "__test_bootstrap_fake__" + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + dry_run: bool, + ) -> Result { + RECORDED_DRY_RUN.store(dry_run, Ordering::SeqCst); + Ok(ProvisionOutcome::default()) + } + + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _app_name: &str, + deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + SYNTH_CALLED.store(true, Ordering::SeqCst); + if let Ok(mut slot) = RECORDED_SYNTH_DEPLOYED.lock() { + *slot = deployed.cloned(); + } + let rel = adapter_manifest_path.unwrap_or("spin.toml").to_owned(); + Ok(vec![(PathBuf::from(rel), "# stub\n".to_owned())]) + } + + fn validate_adapter_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + ) -> Result<(), String> { + // The synthesised file MUST exist by the time validate + // runs — that's the invariant this whole task guards. + let rel = adapter_manifest_path.unwrap_or("spin.toml"); + let abs = manifest_root.join(rel); + if abs.exists() { + VALIDATE_SAW_FILE.store(true, Ordering::SeqCst); + Ok(()) + } else { + Err(format!( + "fake validate: {} missing at validate time", + abs.display() + )) + } + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "the no-fields fake overrides execute/name/provision (required by the trait) and inherits every defaulted method — including deployed_fields, whose default `&[]` is the intent this fake exercises" + )] + impl Adapter for NoFieldsFakeAdapter { + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "__test_no_fields_fake__" + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } + } + + fn reset_fake_state() { + RECORDED_DRY_RUN.store(false, Ordering::SeqCst); + if let Ok(mut slot) = RECORDED_SYNTH_DEPLOYED.lock() { + *slot = None; + } + SYNTH_CALLED.store(false, Ordering::SeqCst); + VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); + } + + /// Walks the tree at `root` and returns a sorted `Vec<(relative + /// path, content bytes)>`. Two calls yield equal `Vec`s iff the + /// tree is byte-identical. Used by the dry-run cleanliness + /// assertion — any staging leak that writes into the worktree + /// flips one of the pairs. + fn snapshot_dir(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + snapshot_walk(root, root, &mut out).expect("snapshot walk"); + out.sort_by(|left, right| left.0.cmp(&right.0)); + out + } + + fn snapshot_walk(base: &Path, dir: &Path, out: &mut Vec<(PathBuf, Vec)>) -> io::Result<()> { + for read_result in fs::read_dir(dir)? { + let entry = read_result?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + snapshot_walk(base, &path, out)?; + } else if !file_type.is_symlink() { + // Regular files only — symlinks are intentionally + // skipped so the snapshot mirrors `copy_dir_recursive`'s + // regular-files-only semantics. + let rel = path.strip_prefix(base).unwrap_or(&path).to_path_buf(); + let content = fs::read(&path)?; + out.push((rel, content)); + } else { + // Symlink — skip. + } + } + Ok(()) } #[test] - fn run_provision_axum_dry_run_is_also_a_no_op() { + fn run_provision_cloud_non_dry_run_succeeds_when_adapter_is_side_effect_free() { + // Cloud non-dry-run smoke: the CLI dispatch matrix reaches + // the adapter and exits 0 when the adapter's Cloud arm has no + // side effects to perform. Uses axum because its Cloud + // provision is a no-op; any adapter with an empty Cloud arm + // would fit — the assertion is about the CLI's dispatch, + // not axum-specific behavior. Stronger dispatch-shape + // coverage (which dry_run value the adapter observes, which + // arm of the (local, dry_run) matrix runs) is in the + // fake-based tests: provision_cloud_dry_run_passes_dry_run + // _true_to_adapter, provision_local_no_dry_run_writes_to + // _worktree, provision_local_dry_run_leaves_worktree_clean. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -183,10 +1082,11 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), - dry_run: true, + dry_run: false, + local: false, manifest: manifest_path.clone(), }) - .expect("axum dry-run also exits 0"); + .expect("side-effect-free adapter cloud provision exits 0"); } #[test] @@ -201,6 +1101,7 @@ mod tests { let err = run_provision(&ProvisionArgs { adapter: "wat".to_owned(), dry_run: false, + local: false, manifest: manifest_path.clone(), }) .expect_err("unknown adapter must error"); @@ -210,31 +1111,6 @@ mod tests { ); } - #[test] - fn run_provision_spin_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't edit spin.toml, so CI can exercise - // dispatch by writing a single-component spin.toml the - // resolver can locate. - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write( - temp.path().join("spin.toml"), - "spin_manifest_version = 2\n[application]\nname = \"x\"\nversion = \"0\"\n[component.demo]\nsource = \"demo.wasm\"\n", - ) - .expect("write spin.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "spin".to_owned(), - dry_run: true, - manifest: manifest_path.clone(), - }) - .expect("spin dry-run dispatches cleanly"); - } - #[test] fn run_provision_rejects_malformed_handler_path_before_dispatching() { // Provision is the most expensive operation in the CLI -- @@ -268,6 +1144,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("malformed handler must error before dispatch"); @@ -279,9 +1156,13 @@ adapters = ["axum"] #[test] fn run_provision_spin_rejects_malformed_adapter_manifest_before_dispatching() { - // The adapter-specific `validate_adapter_manifest` hook - // also gates provision now -- a spin.toml with zero - // components must error before we touch any remote. + // CLI-logic test: `run_provision` MUST call + // `adapter.validate_adapter_manifest` before dispatch, and + // MUST surface its error. Spin is the illustrative example + // because its `validate_adapter_manifest` actually validates + // (a spin.toml with zero components errors); axum's is a + // no-op so it can't drive this assertion. The check itself + // is CLI-side, not Spin-specific. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -298,6 +1179,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("zero-component spin.toml must error pre-dispatch"); @@ -309,11 +1191,14 @@ adapters = ["axum"] #[test] fn run_provision_spin_rejects_multi_secret_ids_via_capability_gate() { - // Stage 5: Spin moved `config` to KV (multi-capable). Secrets - // remain Single-capable until we ship native secret support, - // so a manifest declaring two secret ids must still trip the - // gate before dispatching to the spin adapter dry-run. This - // test pins parity with `config validate --strict`. + // CLI-logic test: the capability gate + // (`enforce_single_store_capability`) reads the adapter's + // `merged_id_kinds()` and rejects manifests declaring more + // than one id in a single-capable kind. Spin is the + // illustrative example because secrets remain Single-capable + // there while `config` moved to KV; any adapter with a + // Single-capable kind + a manifest exceeding that limit would + // fit. Pins parity with `config validate --strict`. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -349,6 +1234,7 @@ default = "default" let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("Single-capability violation must error"); @@ -360,10 +1246,11 @@ default = "default" #[test] fn run_provision_spin_accepts_multi_config_ids_since_kv_migration() { - // Stage 5: config is KV-backed for Spin, so multiple config - // ids no longer trip enforce_single_store_capability. The - // dispatch reaches the adapter dry-run and reports one - // `key_value_stores` write per id. + // CLI-logic test: the capability gate accepts multiple ids + // when the adapter's `merged_id_kinds()` includes the kind. + // Spin is the illustrative example because its `config` kind + // is KV-backed (multi-capable) post-migration; any adapter + // with a multi-capable kind would fit. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -398,6 +1285,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("multi-config dispatch must succeed under KV-backed config"); @@ -405,13 +1293,13 @@ ids = ["default"] #[test] fn run_provision_spin_rejects_env_overlay_platform_label_collision_across_kv_and_config() { - // M1: provision must run the same merged-id collision check - // `config validate` runs. Without it, `provision --adapter - // spin --dry-run` happily acks a manifest where distinct - // logical ids `[stores.kv].sessions` and - // `[stores.config].app_config` BOTH resolve to platform - // label `shared` via the env overlay -- both writes would - // silently land on the same Spin KV store at runtime. + // CLI-logic test: `reject_merged_id_collisions` catches + // env-overlay collisions where distinct logical ids across + // merged kinds resolve to the same platform label. Spin is + // the illustrative example because it merges kv + config + // into a single KV backend (any adapter whose + // `merged_id_kinds()` covers 2+ kinds would fit). Pins parity + // with the same check `config validate` runs. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -451,6 +1339,7 @@ ids = ["default"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("env-overlay platform-label collision must fail provision"); @@ -480,49 +1369,1300 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("single-id case dispatches cleanly"); } + // ---------- provision --local path containment ---------- + #[test] - fn run_provision_cloudflare_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't shell out to wrangler, so CI can - // exercise dispatch without wrangler installed. + fn provision_local_rejects_parent_traversal_in_adapter_manifest() { let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write(temp.path().join("wrangler.toml"), "name = \"demo\"\n") - .expect("write wrangler.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "../outside/spin.toml" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); let manifest_str = manifest_path.to_string_lossy().into_owned(); let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + // Canary: the traversal target resolves to `/outside/spin.toml`. + // If path-safety fires before dispatch, this path must not exist after. + let outside_dir = temp + .path() + .parent() + .expect("tempdir has parent") + .join("outside"); + assert!(!outside_dir.exists(), "sentinel: outside/ absent pre-call"); - run_provision(&ProvisionArgs { - adapter: "cloudflare".to_owned(), + let err = run_provision(&ProvisionArgs { + adapter: "spin".to_owned(), dry_run: true, + local: true, manifest: manifest_path.clone(), }) - .expect("cloudflare dry-run dispatches cleanly"); + .expect_err("parent traversal in adapter manifest must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + assert!( + !outside_dir.exists(), + "sentinel: outside/ still absent after call (dispatch did not fire)" + ); } #[test] - fn run_provision_fastly_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't shell out to fastly, so CI can - // exercise dispatch without fastly installed. + fn provision_local_rejects_absolute_adapter_manifest() { let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write(temp.path().join("fastly.toml"), "name = \"demo\"\n").expect("write fastly.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + // Use a path inside a fresh tempdir subtree so we can prove + // it stays absent even though nothing outside the test would + // reasonably poke it: /tmp/some.toml would be a shared name. + let outside_root = TempDir::new().expect("outside temp dir"); + let outside_abs = outside_root.path().join("some.toml"); + let manifest_body = format!( + r#" +[app] +name = "demo-app" - run_provision(&ProvisionArgs { - adapter: "fastly".to_owned(), - dry_run: true, - manifest: manifest_path.clone(), - }) - .expect("fastly dry-run dispatches cleanly"); +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "{}" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#, + outside_abs.display() + ); + fs::write(&manifest_path, &manifest_body).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + assert!( + !outside_abs.exists(), + "sentinel: absolute path absent pre-call" + ); + + let err = run_provision(&ProvisionArgs { + adapter: "spin".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("absolute adapter manifest path must be rejected"); + assert!( + err.contains("must be a project-relative path"), + "error must name the absolute-path violation: {err}" + ); + assert!( + !outside_abs.exists(), + "sentinel: absolute path still absent after call" + ); + } + + #[test] + fn provision_local_rejects_parent_traversal_in_adapter_crate() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "../escape" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let escape_dir = temp + .path() + .parent() + .expect("tempdir has parent") + .join("escape"); + assert!(!escape_dir.exists(), "sentinel: escape/ absent pre-call"); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("parent traversal in adapter crate must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + assert!( + !escape_dir.exists(), + "sentinel: escape/ still absent after call" + ); + } + + #[test] + fn provision_local_accepts_relative_manifest_root_default() { + // Bare `--manifest edgezero.toml` — `args.manifest.parent()` + // returns "", triggering the `.unwrap_or_else(|| Path::new("."))` + // fallback. To reach that fallback we must actually load + // edgezero.toml relative to cwd, so chdir into a tempdir + // that holds a valid fixture. The `_cwd` RAII guard restores + // the previous cwd on drop; `_lock` serialises all cwd + env + // manipulation. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("edgezero.toml"), PROVISION_MANIFEST).expect("write manifest"); + // Task 11 wires the (true, true) arm through `run_with_staging`, + // which recursively copies the adapter crate dir into the + // staging tempdir. The fixture must pre-create the crate dir + // referenced by PROVISION_MANIFEST or staging errors before + // dispatch reaches the axum Section-5 stub. + fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); + let _cwd = CwdGuard::set(temp.path()).expect("chdir into tempdir"); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: PathBuf::from("edgezero.toml"), + }) + .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); + // Positive assertion: reaching axum's Section-5 stub proves + // the manifest loaded, path-safety passed, AND `run_with_staging` + // routed the closure through validate + build_stores + provision. + // Without this, an earlier failure would silently satisfy the + // negative assertions below and give false-positive coverage. + assert!( + err.contains("local mode lands in Section 5"), + "must reach axum's Section-5 stub through staging: {err}" + ); + assert!( + !err.contains("must not contain `..` traversal") + && !err.contains("must be a project-relative path") + && !err.contains("resolves outside project root"), + "path-safety must not fire for a valid fixture: {err}" + ); + } + + #[test] + fn provision_local_accepts_relative_manifest_root_nested() { + // Nested `--manifest /edgezero.toml` — parent is the + // tempdir path (non-empty), exercising the standard + // `args.manifest.parent()` code path. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); + fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); + assert!( + err.contains("local mode lands in Section 5"), + "must reach axum's Section-5 stub through staging: {err}" + ); + assert!( + !err.contains("must not contain `..` traversal") + && !err.contains("must be a project-relative path") + && !err.contains("resolves outside project root"), + "path-safety must not fire for a valid fixture: {err}" + ); + } + + // ---------- CLI-owned first-run bootstrap synthesis ---------- + + #[test] + fn provision_local_synthesises_missing_adapter_manifest_before_validation() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + // Fixture: crates/spin/ exists but spin.toml does NOT. + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path.clone(), + }) + .expect("local provision synthesises baseline then validates"); + + assert!( + SYNTH_CALLED.load(Ordering::SeqCst), + "synthesiser must fire in local mode" + ); + assert!( + VALIDATE_SAW_FILE.load(Ordering::SeqCst), + "validate must see synthesised file" + ); + let synth_path = temp.path().join("crates/spin/spin.toml"); + let synth = fs::read_to_string(&synth_path) + .expect("synthesised file lands under the configured adapter manifest path"); + assert!( + synth.contains("# stub"), + "synthesised file contains the fake payload: {synth}" + ); + // Regression guard: the synthesised file must NOT land at + // /spin.toml — that path is what the earlier "hard-code + // spin.toml" shape produced. + assert!( + !temp.path().join("spin.toml").exists(), + "sentinel: synthesis must not write to a hard-coded root-level path" + ); + } + + #[test] + fn provision_local_bootstrap_is_a_no_op_when_manifest_already_present() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + // Operator-authored content that must survive. + let operator_body = "# operator-authored\n"; + fs::write(temp.path().join("crates/spin/spin.toml"), operator_body) + .expect("write operator manifest"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path.clone(), + }) + .expect("provision passes when manifest already exists"); + + // The synthesiser fires, but write_baseline_to_disk skips + // existing files, so operator content survives byte-for-byte. + let after = fs::read_to_string(temp.path().join("crates/spin/spin.toml")) + .expect("existing spin.toml still readable"); + assert_eq!( + after, operator_body, + "existing operator-authored file must remain byte-for-byte unchanged" + ); + } + + #[test] + fn provision_cloud_never_runs_bootstrap_synthesis() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + // crates/spin/ exists but spin.toml does NOT — validation must + // therefore fail in cloud mode because bootstrap synthesis is + // NOT invoked to fill the gap. + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + let err = run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: false, + manifest: manifest_path.clone(), + }) + .expect_err("cloud mode with missing adapter manifest must error at validate"); + assert!( + err.contains("missing at validate time"), + "error surfaces the missing-manifest failure: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "synthesiser must NOT fire in cloud mode" + ); + } + + // ---------- run_with_staging dry-run helper ---------- + + #[test] + fn run_with_staging_drops_tempdir_after_body() { + let project = tempfile::TempDir::new().unwrap(); + fs::write(project.path().join("edgezero.toml"), "x").unwrap(); + let adapter_crate_rel = Path::new("crates/sample"); + let adapter_crate_abs = project.path().join(adapter_crate_rel); + fs::create_dir_all(&adapter_crate_abs).unwrap(); + fs::write(adapter_crate_abs.join("manifest.toml"), "y").unwrap(); + + let staged_paths = run_with_staging( + project.path(), + adapter_crate_rel, + |staged_root, staged_crate| Ok((staged_root.to_path_buf(), staged_crate.to_path_buf())), + ) + .unwrap(); + let (staged_root, staged_crate) = staged_paths.0; + // After staging the original project tree is byte-identical: + assert_eq!( + fs::read_to_string(project.path().join("edgezero.toml")).unwrap(), + "x" + ); + // Staged copies existed during body execution: + assert!(staged_root.is_absolute()); + assert!(staged_crate.starts_with(&staged_root)); + } + + #[test] + fn run_with_staging_copies_edgezero_toml_into_staged_root() { + // Regression for the relative-source-symlink bug AND the + // strip_prefix bug (fixed by switching to project-RELATIVE + // crate paths). Reads staged_root/edgezero.toml INSIDE the + // closure and asserts the bytes match. Uses an ABSOLUTE + // project_root to avoid mutating process cwd — the + // strip_prefix bug is not about relative project_root + // resolution itself, it's about the staging helper computing + // crate_rel incorrectly. + let project = tempfile::TempDir::new().unwrap(); + fs::write(project.path().join("edgezero.toml"), "real-project-bytes\n").unwrap(); + let adapter_crate_rel = Path::new("crates/sample"); + fs::create_dir_all(project.path().join(adapter_crate_rel)).unwrap(); + fs::write( + project.path().join(adapter_crate_rel).join("manifest.toml"), + "x", + ) + .unwrap(); + + let observed = run_with_staging( + project.path(), + adapter_crate_rel, + |staged_root, _staged_crate| { + fs::read_to_string(staged_root.join("edgezero.toml")) + .map_err(|err| format!("read staged edgezero.toml: {err}")) + }, + ) + .unwrap(); + assert_eq!(observed.0, "real-project-bytes\n"); + } + + // ---------- (local, dry-run) dispatch matrix ---------- + + #[test] + fn provision_local_dry_run_leaves_worktree_clean() { + // Snapshot the tempdir contents (relative path → content + // bytes) before the call. Run run_provision with local=true, + // dry_run=true. The axum adapter's Section-5 stub will Err + // — that's fine. The core claim is: after the call, EVERY + // file in the tempdir is byte-identical to its pre-call + // snapshot. Any staging leak (a file written into the + // worktree instead of the tempdir) would flip the assertion. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).unwrap(); + // Pre-create the axum adapter crate dir + a canary file so + // the staging copy has real content to work with. This also + // proves the crate dir itself is not clobbered. + let axum_crate = temp.path().join("crates/demo-axum"); + fs::create_dir_all(&axum_crate).unwrap(); + fs::write(axum_crate.join("Cargo.toml"), "# stub").unwrap(); + + let before = snapshot_dir(temp.path()); + + // Ignore the Result — axum's Section-5 stub Errs today; the + // core assertion is that the worktree is unchanged either way. + // Explicit type annotation quiets `let_underscore_untyped` + // and `let_underscore_must_use` — the Result is genuinely + // irrelevant to the assertion below. + let _result: Result<(), String> = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path, + }); + + let after = snapshot_dir(temp.path()); + assert_eq!( + before, after, + "dry-run must leave the worktree byte-identical" + ); + } + + #[test] + fn provision_local_no_dry_run_writes_to_worktree() { + // Non-dry-run local mode DOES write. axum can't demonstrate + // that until Section 5 lands, so use the fake adapter — its + // synthesise_baseline_manifest returns a stub file at the + // configured manifest path. In (true, false) mode, the CLI + // calls write_baseline_to_disk which materialises that file + // into the worktree before validate_adapter_manifest runs. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Pre-create the crate dir the fake references so the + // manifest validates. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + let synthesised = temp.path().join("crates/spin/spin.toml"); + assert!( + !synthesised.exists(), + "pre-condition: synthesised file absent" + ); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path, + }) + .expect("(true, false) arm should succeed with the fake adapter"); + + assert!( + synthesised.exists(), + "(true, false) arm must write synthesised baseline into the worktree" + ); + let bytes = fs::read_to_string(&synthesised).expect("read synthesised file"); + assert!( + bytes.contains("# stub"), + "content should match fake's synthesiser output" + ); + } + + // ---------- dry-run allow-list + report rendering ---------- + + #[test] + fn dry_run_status_lines_use_would_write_verb() { + // Direct fn-under-test: call render_dry_run_report against a + // synthetic ProvisionOutcome whose status_lines exercise all + // three verbs the rewriter handles. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create crate dir"); + + let outcome = ProvisionOutcome { + status_lines: vec![ + "wrote crates/spin/spin.toml".to_owned(), + "created .edgezero/.env".to_owned(), + "appended crates/spin/.env".to_owned(), + ], + ..ProvisionOutcome::default() + }; + let allow_list = DryRunAllowList { pairs: vec![] }; + let report = render_dry_run_report(temp.path(), temp.path(), &allow_list, &outcome); + assert!( + report.contains("would write crates/spin/spin.toml"), + "verb-rewriting must turn 'wrote' into 'would write': {report}" + ); + assert!( + report.contains("would create .edgezero/.env"), + "verb-rewriting must turn 'created' into 'would create': {report}" + ); + assert!( + report.contains("would append crates/spin/.env"), + "verb-rewriting must turn 'appended' into 'would append': {report}" + ); + // Negative: raw verbs must not survive + assert!(!report.contains("\nwrote "), "raw 'wrote' must be gone"); + assert!(!report.contains("\ncreated "), "raw 'created' must be gone"); + assert!( + !report.contains("\nappended "), + "raw 'appended' must be gone" + ); + } + + #[test] + fn dry_run_diff_covers_all_allowlist_paths() { + // Table-driven: for each adapter, build a fixture where the + // allow-listed files exist in the staged tree, then call + // render_dry_run_report and assert the printed diff section + // mentions each expected path and excludes non-listed paths. + let project = TempDir::new().expect("project"); + let staged = TempDir::new().expect("staged"); + + // Cloudflare fixture: wrangler.toml + sibling .dev.vars + fs::write(staged.path().join("wrangler.toml"), "name = \"cf\"\n").unwrap(); + fs::write(staged.path().join(".dev.vars"), "SECRET=abc\n").unwrap(); + let cf_allow = build_dry_run_allow_list( + project.path(), + staged.path(), + "cloudflare", + &staged.path().join("wrangler.toml"), + ); + let cf_report = render_dry_run_report( + project.path(), + staged.path(), + &cf_allow, + &ProvisionOutcome::default(), + ); + assert!( + cf_report.contains("wrangler.toml"), + "cloudflare must diff wrangler.toml: {cf_report}" + ); + assert!( + cf_report.contains(".dev.vars"), + "cloudflare must diff .dev.vars: {cf_report}" + ); + // Negative: axum-only paths must not appear + assert!( + !cf_report.contains("axum.toml"), + "axum.toml must not appear in cloudflare diff" + ); + + // Axum fixture: .edgezero/.env only + let axum_project = TempDir::new().expect("axum project"); + let axum_staged = TempDir::new().expect("axum staged"); + fs::create_dir_all(axum_staged.path().join(".edgezero")).unwrap(); + fs::write(axum_staged.path().join(".edgezero/.env"), "K=V\n").unwrap(); + let axum_allow = build_dry_run_allow_list( + axum_project.path(), + axum_staged.path(), + "axum", + &axum_staged.path().join("axum.toml"), // adapter manifest not used for axum + ); + let axum_report = render_dry_run_report( + axum_project.path(), + axum_staged.path(), + &axum_allow, + &ProvisionOutcome::default(), + ); + assert!( + axum_report.contains(".edgezero/.env"), + "axum must diff .edgezero/.env: {axum_report}" + ); + // Negative: cloudflare-only paths + assert!( + !axum_report.contains("wrangler.toml"), + "wrangler.toml must not appear in axum diff" + ); + } + + #[test] + fn dry_run_diff_handles_manifest_in_subdir_of_adapter_crate() { + // Fixture: manifest in a SUB-directory of the adapter crate. + // [adapters.cloudflare.adapter] + // crate = "crates/cf" + // manifest = "crates/cf/config/wrangler.toml" + // The static-name allow-list would compute pair location as + // `crates/cf/wrangler.toml` — WRONG. Both sides absent → + // silent no-diff. + let project = TempDir::new().expect("project"); + let staged = TempDir::new().expect("staged"); + fs::create_dir_all(staged.path().join("crates/cf/config")).unwrap(); + fs::write( + staged.path().join("crates/cf/config/wrangler.toml"), + "name = \"cf-nested\"\n", + ) + .unwrap(); + fs::write( + staged.path().join("crates/cf/config/.dev.vars"), + "SECRET=abc\n", + ) + .unwrap(); + + let allow = build_dry_run_allow_list( + project.path(), + staged.path(), + "cloudflare", + &staged.path().join("crates/cf/config/wrangler.toml"), + ); + let report = render_dry_run_report( + project.path(), + staged.path(), + &allow, + &ProvisionOutcome::default(), + ); + + // Positive: nested paths present + assert!( + report.contains("crates/cf/config/wrangler.toml"), + "nested wrangler.toml must appear in the diff: {report}" + ); + assert!( + report.contains("crates/cf/config/.dev.vars"), + "nested .dev.vars (sibling of the resolved manifest) must appear: {report}" + ); + // Negative: the WRONG (static-name) location must NOT appear. + // A regression to the old shape would silently write + // `--- crates/cf/wrangler.toml` here. + assert!( + !report.contains("--- crates/cf/wrangler.toml"), + "diff must not reference the wrong (adapter-crate-relative) location: {report}" + ); + } + + #[test] + fn provision_cloud_dry_run_passes_dry_run_true_to_adapter() { + // Cloud dry-run must not synthesise (Task 8b covers that) and + // must pass dry_run=true down to the adapter. Use the fake and + // read back RECORDED_DRY_RUN to confirm the boolean rode + // through the dispatch matrix untouched. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Cloud validates the real worktree, so the fake's synthesised + // file must already be present or validate_adapter_manifest + // will Err before dispatch. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + fs::write(temp.path().join("crates/spin/spin.toml"), "# stub\n").unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: false, + manifest: manifest_path, + }) + .expect("cloud dry-run should succeed with fake adapter"); + + assert!( + RECORDED_DRY_RUN.load(Ordering::SeqCst), + "adapter.provision must have been called with dry_run = true" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "cloud must never invoke synthesise_baseline_manifest" + ); + } + + #[test] + fn provision_local_dry_run_passes_dry_run_false_to_adapter() { + // Spec §"Dry-run" step 3: local dry-run stages a tempdir and + // dispatches with `dry_run = false` so adapters take their + // real-write branches against the staged tree. If the CLI + // hardcoded `true` here, adapters would early-return from + // their dry-run branches (cloudflare cli.rs:263, spin + // cli.rs:223) and leave the staged tree empty — the diff + // report would then miss the very files operators want to + // preview. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Local dry-run stages into a tempdir, so crates/spin/ must + // exist under the source tree for run_with_staging to copy + // it. The fake's synthesiser writes spin.toml INSIDE the + // staged tree, so we do NOT need to pre-create spin.toml + // here. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path, + }) + .expect("local dry-run should succeed with fake adapter"); + + assert!( + !RECORDED_DRY_RUN.load(Ordering::SeqCst), + "adapter.provision must be called with dry_run = false: \ + the tempdir IS the dry-run mechanism, not the boolean" + ); + assert!( + SYNTH_CALLED.load(Ordering::SeqCst), + "local dry-run must invoke synthesise_baseline_manifest" + ); + } + + // ---------- Section-5 lock-in: dry-run cleanliness against the real + // in-tree fixture, across every adapter. Ignored until Section 5's + // per-adapter local writers land (Tasks 17-28); today the adapters' + // Local-mode `provision` impls return + // `Err("local mode lands in Section 5")` before touching disk, so + // the assertions can't yet drive real behavior. This test defines + // the contract now so the eventual implementation doesn't drift. + // + // Contract A (worktree byte-identical after dry-run) is asserted + // via the existing `snapshot_dir` helper. + // + // Contract B (no tempdir path leakage into stdout) is left as a + // `TODO(section-5)` comment: the CLI uses `log::info!` for status + // lines, but `log::set_logger` is a process-wide one-shot and + // installing a capturing logger here would race the other tests + // that share the crate's default logger initialization. Adding a + // per-thread capture shim would require workspace-scope churn + // that this task explicitly declines. When Section 5 lands, a + // follow-up task can retrofit either a subprocess-based capture + // or a `tracing`-subscriber swap. + #[test] + #[ignore = "re-enable after Section 5 lands per-adapter local provision"] + fn provision_local_dry_run_worktree_clean_and_no_tempdir_paths_in_stdout() { + let _lock = manifest_guard().lock().expect("manifest guard"); + + // Resolve the repo root from the crate's manifest dir: + // `/crates/edgezero-cli` → ``. `CARGO_MANIFEST_DIR` + // is always set for `cargo test`. + let manifest_dir = env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .expect("CARGO_MANIFEST_DIR must be set under cargo test"); + let repo_root = manifest_dir + .parent() + .and_then(Path::parent) + .expect("resolve repo root from CARGO_MANIFEST_DIR") + .to_path_buf(); + let manifest_path = repo_root.join("examples/app-demo/edgezero.toml"); + let app_demo_root = manifest_path.parent().expect("app-demo dir").to_path_buf(); + assert!( + manifest_path.exists(), + "fixture missing: {}", + manifest_path.display() + ); + + for adapter in ["cloudflare", "fastly", "spin", "axum"] { + let before = snapshot_dir(&app_demo_root); + + // Ignore the Result — today's stub adapters return + // `Err("local mode lands in Section 5")`. Contract A is + // the "was the worktree modified?" claim, and it holds + // regardless of whether the adapter succeeded. Explicit + // type annotation quiets `let_underscore_untyped` / + // `let_underscore_must_use`. + let _result: Result<(), String> = run_provision(&ProvisionArgs { + adapter: (*adapter).to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }); + + let after = snapshot_dir(&app_demo_root); + assert_eq!( + before, after, + "adapter {adapter}: dry-run must leave the worktree byte-identical" + ); + + // TODO(section-5): assert no tempdir path leakage in + // stdout via captured log. The `log::info!` output from + // `render_dry_run_report` should never contain + // `/var/folders/`, `/private/var/folders/`, or `/tmp/` + // — only project-relative paths under the manifest + // root. Capture strategy TBD (see the module comment + // above this test). + } + } + + #[test] + fn validate_deployed_field_ownership_accepts_declared_field() { + // Fake registers itself as owning `service_id`. A manifest + // with [adapters.__test_bootstrap_fake__.deployed] service_id + // = "..." must validate cleanly. + use crate::config::validate_deployed_field_ownership; + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + let toml_body = r#" + [app] + name = "demo" + [adapters.__test_bootstrap_fake__] + [adapters.__test_bootstrap_fake__.adapter] + crate = "crates/spin" + manifest = "crates/spin/spin.toml" + [adapters.__test_bootstrap_fake__.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + manifest.validate().unwrap(); + validate_deployed_field_ownership(&manifest) + .expect("fake owns service_id -- must validate cleanly"); + } + + #[test] + fn validate_deployed_field_ownership_rejects_undeclared_field() { + // A different fake that owns NO deployed fields. A manifest + // with service_id under its section must be rejected. + use crate::config::validate_deployed_field_ownership; + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&NO_FIELDS_FAKE_ADAPTER); + + let toml_body = r#" + [app] + name = "demo" + [adapters.__test_no_fields_fake__] + [adapters.__test_no_fields_fake__.adapter] + crate = "crates/spin" + manifest = "crates/spin/spin.toml" + [adapters.__test_no_fields_fake__.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + manifest.validate().unwrap(); + let err = validate_deployed_field_ownership(&manifest) + .expect_err("adapter declares no fields -- must reject"); + assert!( + err.contains("service_id") && err.contains("__test_no_fields_fake__"), + "error must name the offending field and adapter: {err}" + ); + } + + // ---------- deployed_state_for + run_provision wiring ---------- + + #[test] + fn deployed_state_for_translates_all_field_kinds() { + // Manifest with a `demo` adapter carrying every deployed-field + // kind: scalar service_id, kv_namespaces map, and + // preview_kv_namespaces map. The translator must land them at + // the neutral positions each adapter reads from — scalar + // fields under `state.fields`, maps under `state.sub_tables`. + let toml_body = r#" +[app] +name = "demo" + +[adapters.demo] +[adapters.demo.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" + +[adapters.demo.deployed] +service_id = "SVC1" +kv_namespaces.sessions = "abc123" +preview_kv_namespaces.sessions = "abc123_preview" +"#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + let state = + deployed_state_for(&manifest, "demo").expect("populated deployed must be Some(state)"); + assert_eq!( + state.fields.get("service_id").map(String::as_str), + Some("SVC1") + ); + assert_eq!( + state + .sub_tables + .get("kv_namespaces") + .and_then(|map| map.get("sessions")) + .map(String::as_str), + Some("abc123") + ); + assert_eq!( + state + .sub_tables + .get("preview_kv_namespaces") + .and_then(|map| map.get("sessions")) + .map(String::as_str), + Some("abc123_preview") + ); + } + + #[test] + fn deployed_state_for_returns_none_when_all_fields_empty() { + // Adapter has NO deployed block: translator returns None so + // synthesise / provision impls see the same signal they did + // in the pre-Task-14 world (empty state = None). + let toml_body = r#" +[app] +name = "demo" + +[adapters.demo] +[adapters.demo.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" +"#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + assert!(deployed_state_for(&manifest, "demo").is_none()); + } + + #[test] + fn provision_local_threads_deployed_state_into_synthesiser() { + // Regression: `deployed_state_for` was left returning None + // through the whole of Section 4. Result: real deployed IDs + // in edgezero.toml never reached the adapter's + // synthesise_baseline_manifest call, defeating the "teammates + // regenerate local manifests from tracked durable IDs" spec + // promise. This test asserts the CLI reads + // `[adapters..deployed]` and passes it through. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo" + +[adapters.__test_bootstrap_fake__] +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.__test_bootstrap_fake__.deployed] +service_id = "SVC1" +"#; + fs::write(&manifest_path, manifest_body).unwrap(); + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path, + }) + .expect("local real-write should reach the fake's synthesiser"); + + assert!(SYNTH_CALLED.load(Ordering::SeqCst)); + let observed = RECORDED_SYNTH_DEPLOYED + .lock() + .expect("recorded deployed slot poisoned") + .clone() + .expect("synthesiser must have received Some(state)"); + assert_eq!( + observed.fields.get("service_id").map(String::as_str), + Some("SVC1"), + "manifest's `[adapters.*.deployed] service_id` must reach the adapter: {observed:?}" + ); + } + + #[test] + fn provision_rejects_deployed_block_with_field_adapter_does_not_own() { + // The ownership check exists in run_shared_checks (config + // validate + push + diff pick it up), but until this patch + // run_provision did NOT call it. That gap let + // `edgezero provision` accept manifests that `edgezero config + // validate` correctly rejected. Regression test: register + // NoFieldsFakeAdapter (owns nothing per deployed_fields()), + // put a service_id under its deployed block, and assert + // run_provision Errs with the ownership violation before + // reaching the dispatch matrix. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&NO_FIELDS_FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo" + +[adapters.__test_no_fields_fake__] +[adapters.__test_no_fields_fake__.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" + +[adapters.__test_no_fields_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.__test_no_fields_fake__.deployed] +service_id = "SVC1" +"#; + fs::write(&manifest_path, manifest_body).unwrap(); + fs::create_dir_all(temp.path().join("crates/x")).unwrap(); + + let err = run_provision(&ProvisionArgs { + adapter: "__test_no_fields_fake__".to_owned(), + dry_run: true, + local: false, + manifest: manifest_path, + }) + .expect_err("adapter owns no deployed fields: service_id must be rejected"); + assert!( + err.contains("service_id") && err.contains("__test_no_fields_fake__"), + "error must name offending field + adapter: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "ownership check must fire before synthesise_baseline_manifest" + ); + } + + // ---------- merge_deployed_into_manifest ---------- + + #[test] + fn merge_deployed_round_trips_cloudflare_namespaces_with_canonical_key() { + // Fixture declares mixed-case [adapters.Cloudflare]. Merger + // MUST use the canonical operator-spelled key — not a + // lowercased sibling — otherwise a parallel + // [adapters.cloudflare.deployed] table would appear beside + // the operator's [adapters.Cloudflare] one. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + r#" +[app] +name = "demo" + +[adapters.Cloudflare] +[adapters.Cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#, + ) + .unwrap(); + + let mut state = AdapterDeployedState::default(); + let mut kv = BTreeMap::new(); + kv.insert("sessions".to_owned(), "abc123".to_owned()); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + + // Canonical key is "Cloudflare" (as written in the manifest). + // owned_fields = &["kv_namespaces", "preview_kv_namespaces"] matches + // Cloudflare's real deployed_fields() surface. + merge_deployed_into_manifest( + &manifest_path, + "Cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + false, + ) + .unwrap(); + + let raw = fs::read_to_string(&manifest_path).unwrap(); + // Must land under the operator's spelling; NO lowercased sibling. + assert!( + raw.contains("[adapters.Cloudflare.deployed"), + "must land under operator spelling: {raw}" + ); + assert!( + !raw.contains("[adapters.cloudflare.deployed"), + "must NOT create a lowercased parallel: {raw}" + ); + // Value present. + assert!( + raw.contains("sessions = \"abc123\""), + "kv id must round-trip: {raw}" + ); + } + + #[test] + fn merge_deployed_preserves_adjacent_operator_comments() { + // Non-touched adapter sections must survive byte-for-byte, + // including their comments. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let source = r#" +[app] +name = "demo" + +# operator note about spin ordering +[adapters.spin] +[adapters.spin.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.cloudflare] +[adapters.cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#; + fs::write(&manifest_path, source).unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + // owned_fields for the cloudflare-shaped comment test needs + // to include service_id (the Fastly-only field the test uses + // here) — this is a unit test of the toml_edit writeback, + // not the ownership gate. Passing a superset keeps the test's + // focus on comment preservation. + merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["service_id", "kv_namespaces", "preview_kv_namespaces"], + false, + ) + .unwrap(); + + let raw = fs::read_to_string(&manifest_path).unwrap(); + assert!( + raw.contains("# operator note about spin ordering"), + "operator comment must survive writeback: {raw}" + ); + } + + #[test] + fn merge_deployed_dry_run_does_not_mutate_file() { + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let source = r#" +[app] +name = "demo" + +[adapters.cloudflare] +[adapters.cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#; + fs::write(&manifest_path, source).unwrap(); + let before = fs::read_to_string(&manifest_path).unwrap(); + + let mut state = AdapterDeployedState::default(); + let mut kv = BTreeMap::new(); + kv.insert("sessions".to_owned(), "abc".to_owned()); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + + merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + true, + ) + .unwrap(); + + let after = fs::read_to_string(&manifest_path).unwrap(); + assert_eq!(before, after, "dry-run must leave file byte-identical"); + } + + #[test] + fn merge_deployed_rejects_adapter_emitted_unknown_field() { + // A buggy adapter returning a deployed key that isn't in the + // `ManifestAdapterDeployed` schema must be rejected BEFORE we + // write anything to edgezero.toml. Otherwise the manifest + // would fail future loads via `deny_unknown_fields`. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, "[app]\nname = \"demo\"\n").unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("nonsense_key".to_owned(), "x".to_owned()); + + let err = merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["service_id", "kv_namespaces", "preview_kv_namespaces"], + false, + ) + .expect_err("unknown deployed field must be rejected before writeback"); + assert!( + err.contains("nonsense_key") && err.contains("unknown"), + "error must name the offending field: {err}" + ); + // File must be untouched — write never happened. + assert_eq!( + fs::read_to_string(&manifest_path).unwrap(), + "[app]\nname = \"demo\"\n" + ); + } + + #[test] + fn merge_deployed_rejects_adapter_emitted_non_owned_field() { + // A buggy adapter that emits a known deployed field it does + // NOT own must be rejected. Symmetric to + // `validate_deployed_field_ownership`, which gates operator- + // written manifests before dispatch. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, "[app]\nname = \"demo\"\n").unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + + // Cloudflare owns kv_namespaces + preview_kv_namespaces, NOT + // service_id. A Cloudflare adapter emitting service_id is a + // bug the writeback must catch. + let err = merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + false, + ) + .expect_err("known-but-non-owned deployed field must be rejected"); + assert!( + err.contains("service_id") && err.contains("does not own"), + "error must name the offending field: {err}" + ); + } + + // ---------- write_baseline_to_disk containment ---------- + + #[test] + fn write_baseline_rejects_absolute_path() { + // An adapter's `synthesise_baseline_manifest` returning an + // absolute path would escape the project tree via + // `root.join(abs)` (Rust replaces `root` when the joined + // path is absolute). + let temp = TempDir::new().unwrap(); + let pairs = vec![(PathBuf::from("/tmp/x.toml"), "content".to_owned())]; + let err = write_baseline_to_disk(temp.path(), &pairs) + .expect_err("absolute baseline path must be rejected"); + assert!( + err.contains("project-relative") && err.contains("/tmp/x.toml"), + "error must name the violation + offending path: {err}" + ); + assert!( + !temp.path().join("tmp/x.toml").exists(), + "no file must have been written" + ); + } + + #[test] + fn write_baseline_rejects_parent_traversal() { + // `../` in the adapter-returned rel path would let a buggy + // synthesiser write outside the staged root or the project + // crate. Reject before touching disk. + let temp = TempDir::new().unwrap(); + let pairs = vec![(PathBuf::from("../outside.toml"), "content".to_owned())]; + let err = write_baseline_to_disk(temp.path(), &pairs) + .expect_err("`..` traversal in baseline path must be rejected"); + assert!( + err.contains("`..` traversal") && err.contains("../outside.toml"), + "error must name the violation + offending path: {err}" + ); + assert!( + !temp.path().parent().unwrap().join("outside.toml").exists(), + "no file must have been written outside the root" + ); } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c774653..6235d76 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -354,6 +354,13 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, + /// Deploy-time identifiers returned by cloud CLIs and persisted in + /// `edgezero.toml` so teammates' `provision --local` can regenerate + /// adapter manifests with real ids. See spec §"Where durable + /// identifiers live". + #[serde(default)] + #[validate(nested)] + pub deployed: Option, /// Catch-all for any sub-table other than the four canonical ones /// (`adapter`, `build`, `commands`, `logging`). The pre-rewrite /// `[adapters..stores.*]` tables land here and are rejected by @@ -365,6 +372,51 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } +/// Deploy-time identifiers returned by cloud CLIs and persisted +/// in `edgezero.toml` so teammates' `provision --local` can +/// regenerate adapter manifests with real ids. See spec +/// §"Where durable identifiers live". +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ManifestAdapterDeployed { + /// Primary namespace ids, keyed by logical + /// `[stores.kv]` / `[stores.config]` id (Cloudflare only). + #[serde(default)] + pub kv_namespaces: BTreeMap, + /// Preview-namespace ids, keyed by the SAME logical id. + /// Separate map so a legal store id like `sessions_preview` + /// cannot collide with a sibling-suffix convention. + #[serde(default)] + pub preview_kv_namespaces: BTreeMap, + /// Fastly compute service id returned by `fastly compute deploy`. + #[serde(default)] + pub service_id: Option, +} + +impl ManifestAdapterDeployed { + /// Return the names of fields that are populated (non-empty + /// map, or `Some` value). Used by the CLI to cross-check + /// against `Adapter::deployed_fields` — but the mapping of + /// field-to-adapter lives in the adapter crates, NOT here. + /// Adding a new field to this struct requires adding a matching + /// arm below. + #[inline] + #[must_use] + pub fn populated_fields(&self) -> Vec<&'static str> { + let mut out = Vec::new(); + if !self.kv_namespaces.is_empty() { + out.push("kv_namespaces"); + } + if !self.preview_kv_namespaces.is_empty() { + out.push("preview_kv_namespaces"); + } + if self.service_id.is_some() { + out.push("service_id"); + } + out + } +} + #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] @@ -1548,6 +1600,78 @@ manifest = "fastly.toml" assert_eq!(adapter.adapter.manifest.as_deref(), Some("fastly.toml")); } + // The manifest parser treats `[adapters..deployed]` as a + // shared schema — it doesn't branch on the adapter name. These + // tests name the fixture adapter `demo` so their coverage is + // read as "the shared struct captures each field kind" rather + // than "the parser knows about a specific adapter." Section 6 + // is where individual adapters read the field subset they use. + + #[test] + fn adapter_deployed_block_captures_kv_namespace_maps() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "crates/x" + manifest = "crates/x/manifest.toml" + [adapters.demo.deployed] + kv_namespaces.sessions = "abc123" + preview_kv_namespaces.sessions = "abc123_preview" + "#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + manifest.validate().unwrap(); + let deployed = manifest.adapters["demo"].deployed.as_ref().unwrap(); + assert_eq!(deployed.kv_namespaces["sessions"], "abc123"); + assert_eq!(deployed.preview_kv_namespaces["sessions"], "abc123_preview"); + assert!(deployed.service_id.is_none()); + } + + #[test] + fn adapter_deployed_block_captures_service_id() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "crates/x" + manifest = "crates/x/manifest.toml" + [adapters.demo.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + manifest.validate().unwrap(); + assert_eq!( + manifest.adapters["demo"] + .deployed + .as_ref() + .unwrap() + .service_id + .as_deref(), + Some("SVC1") + ); + } + + #[test] + fn adapter_deployed_block_rejects_unknown_field() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "x" + manifest = "x/manifest.toml" + [adapters.demo.deployed] + typo_field = "x" + "#; + let err = toml::from_str::(toml).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{err}"); + } + // Empty/minimal manifest tests #[test] fn empty_manifest_has_defaults() { @@ -2043,4 +2167,46 @@ default = "feature__flags" .err() .expect("double-underscore store id must fail validation"); } + + #[test] + fn deployed_populated_fields_reports_service_id_when_set() { + let deployed = ManifestAdapterDeployed { + service_id: Some("SVC1".to_owned()), + ..ManifestAdapterDeployed::default() + }; + assert_eq!(deployed.populated_fields(), vec!["service_id"]); + } + + #[test] + fn deployed_populated_fields_reports_kv_maps_when_non_empty() { + let mut deployed = ManifestAdapterDeployed::default(); + deployed + .kv_namespaces + .insert("sessions".to_owned(), "abc".to_owned()); + assert_eq!(deployed.populated_fields(), vec!["kv_namespaces"]); + } + + #[test] + fn deployed_populated_fields_empty_when_all_defaults() { + let deployed = ManifestAdapterDeployed::default(); + assert!(deployed.populated_fields().is_empty()); + } + + #[test] + fn deployed_populated_fields_reports_all_when_all_set() { + let mut deployed = ManifestAdapterDeployed { + service_id: Some("SVC1".to_owned()), + ..ManifestAdapterDeployed::default() + }; + deployed + .kv_namespaces + .insert("sessions".to_owned(), "abc".to_owned()); + deployed + .preview_kv_namespaces + .insert("sessions".to_owned(), "abc_preview".to_owned()); + assert_eq!( + deployed.populated_fields(), + vec!["kv_namespaces", "preview_kv_namespaces", "service_id"] + ); + } }