From 0f924d7ce6cf244bb281047756552f4643887826 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:26:39 -0700 Subject: [PATCH 01/28] provision --local implementation: tracking branch Empty tracking commit for the implementation work tracked in: docs/superpowers/plans/2026-06-27-provision-local.md Issues: - Epic: - Per-section sub-issues linked from the epic. This PR opens as a DRAFT and stays draft until Section 1 lands its first real commit. Each section opens as its own follow-up PR that lands here before the umbrella merges to main. From 1ffe5a92d1cf100f5c8b9eaf561e611f4feb54da Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:09:09 -0700 Subject: [PATCH 02/28] Add ProvisionMode + ProvisionArgs.local for provision --local --- crates/edgezero-adapter/src/registry.rs | 11 +++++++++ crates/edgezero-cli/src/args.rs | 31 +++++++++++++++++++++++++ crates/edgezero-cli/src/provision.rs | 12 ++++++++++ 3 files changed, 54 insertions(+) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce87..d52ece17 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -22,6 +22,17 @@ 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, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d6..4a2ad85e 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/provision.rs b/crates/edgezero-cli/src/provision.rs index 13faf70e..9011dbad 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -167,6 +167,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: false, + local: false, manifest: manifest_path.clone(), }) .expect("axum provision exits 0 (no remote resources)"); @@ -184,6 +185,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("axum dry-run also exits 0"); @@ -201,6 +203,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"); @@ -230,6 +233,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("spin dry-run dispatches cleanly"); @@ -268,6 +272,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"); @@ -298,6 +303,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"); @@ -349,6 +355,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"); @@ -398,6 +405,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"); @@ -451,6 +459,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,6 +489,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("single-id case dispatches cleanly"); @@ -501,6 +511,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "cloudflare".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("cloudflare dry-run dispatches cleanly"); @@ -521,6 +532,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "fastly".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("fastly dry-run dispatches cleanly"); From feb607e17e53262cd76298a7f15403c58990a027 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:26:19 -0700 Subject: [PATCH 03/28] Add neutral ProvisionOutcome + AdapterDeployedState types + crate-root re-exports --- crates/edgezero-adapter/src/lib.rs | 15 +++++++++ crates/edgezero-adapter/src/registry.rs | 43 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 607548d2..9404fc28 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -1,6 +1,21 @@ +#![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 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 d52ece17..059e6ef4 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use std::sync::{LazyLock, PoisonError, RwLock}; @@ -33,6 +33,28 @@ pub enum ProvisionMode { 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`. /// @@ -637,4 +659,23 @@ 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"); + } } From 5896a8e5a5a44058d526a74090f2eb0433166409 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:30:03 -0700 Subject: [PATCH 04/28] Thread ProvisionMode + ProvisionOutcome through Adapter::provision --- crates/edgezero-adapter-axum/src/cli.rs | 17 ++- crates/edgezero-adapter-cloudflare/src/cli.rs | 110 ++++++++++++---- crates/edgezero-adapter-fastly/src/cli.rs | 83 +++++++++--- crates/edgezero-adapter-spin/src/cli.rs | 118 +++++++++++++++--- crates/edgezero-adapter/src/registry.rs | 46 +++++-- crates/edgezero-cli/src/provision.rs | 7 +- 6 files changed, 303 insertions(+), 78 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 75caf585..8d61f724 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 f858021c..b15bb62c 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -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, @@ -190,8 +190,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. @@ -284,7 +290,10 @@ impl Adapter for CloudflareCliAdapter { if out.is_empty() { out.push("cloudflare has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1572,14 +1581,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 +1620,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 +1655,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 +1686,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 +1727,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 +1754,20 @@ 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"] + ); } // ---------- find_namespace_id ---------- diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index a3de1cba..aef252fd 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, @@ -204,8 +204,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 +349,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 +1966,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 +2004,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 +2038,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 +2076,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 11c1d6a2..e3f08573 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/registry.rs b/crates/edgezero-adapter/src/registry.rs index 059e6ef4..5ff00960 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -304,24 +304,35 @@ 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<'_>, - _dry_run: bool, - ) -> Result, String> { - Ok(Vec::new()) - } + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, + dry_run: bool, + ) -> Result; /// Push config entries into the platform's config store backing /// `store_id`. Returns a list of human-readable status lines the @@ -596,6 +607,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() { diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 9011dbad..4484b8dc 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -114,20 +114,23 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { secrets: &secret_ids, }; - let lines = adapter.provision( + let outcome = adapter.provision( manifest_root, adapter_cfg.adapter.manifest.as_deref(), adapter_cfg.adapter.component.as_deref(), &stores, + None, // cloud arm doesn't consume deployed state; it produces it + adapter_registry::ProvisionMode::Cloud, 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}"); } + // outcome.deployed wiring lands in Task 16. Ok(()) } From 63f9a4d965228e57bbbedadd5a997764124e7465 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:13:01 -0700 Subject: [PATCH 05/28] Add Adapter::provision_typed trait method with default no-op --- crates/edgezero-adapter/src/registry.rs | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 5ff00960..84418466 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -334,6 +334,37 @@ pub trait Adapter: Sync + Send { 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>, + _typed_secrets: &[TypedSecretEntry<'entry>], + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } + /// Push config entries into the platform's config store backing /// `store_id`. Returns a list of human-readable status lines the /// CLI logs verbatim. @@ -702,4 +733,20 @@ mod tests { 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()); + } } From 51216035d149148843bfe5200e36a87e32979b8d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:46:13 -0700 Subject: [PATCH 06/28] Extract run_typed_preflight; route validate/push/diff through it --- crates/edgezero-cli/src/config.rs | 157 +++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 27 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508bd..1660615b 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -74,7 +74,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 +96,53 @@ 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() } + + #[expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" + )] + 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 +257,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{}", @@ -281,8 +324,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 +454,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 +1166,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 +1181,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 +1201,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, + ) +} + +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,6 +1235,18 @@ 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)?; if ctx.args_strict { @@ -1287,12 +1355,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 +1369,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 +1388,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) { @@ -3362,4 +3446,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(); + } } From a8e54e1e6b9fbc266c7ab9f670f514939958c3d5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:08:33 -0700 Subject: [PATCH 07/28] Task 5 fix: promote resolve_app_config_path_primitive to pub(crate) per brief --- crates/edgezero-cli/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 1660615b..f9616505 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1217,7 +1217,7 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result, manifest_path: &Path, app_name: &str, From d184c8fe63f7ab0bef3821c70ead865054ff72ad Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:10:58 -0700 Subject: [PATCH 08/28] Add path_safety module with containment helper --- crates/edgezero-cli/src/lib.rs | 2 + crates/edgezero-cli/src/path_safety.rs | 218 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 crates/edgezero-cli/src/path_safety.rs diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 925722d1..c1ae0e7d 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -32,6 +32,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 00000000..96a48599 --- /dev/null +++ b/crates/edgezero-cli/src/path_safety.rs @@ -0,0 +1,218 @@ +//! 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. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "consumed by provision.rs (Task 8) and config push arm (Task 7)" + ) +)] +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(); + } +} From a347422faca84e8986581cc68e16eda450f8a504 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:55:36 -0700 Subject: [PATCH 09/28] Wire path_safety into config push --local --- crates/edgezero-cli/src/config.rs | 82 ++++++++++++++++++++++++++ crates/edgezero-cli/src/path_safety.rs | 7 --- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index f9616505..0b3a7839 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, }; @@ -336,6 +337,21 @@ where push_ctx: &push_ctx, }; + // Path containment check: reject `..` traversal and absolute paths + // in the manifest-declared adapter paths before any adapter dispatch. + if args.local { + let adapter_crate_path = ctx + .validation + .manifest() + .adapter_entry(ctx.adapter.name()) + .and_then(|(_, cfg)| cfg.adapter.crate_path.clone()); + assert_provision_paths_contained( + manifest_root, + adapter_manifest_path.as_deref(), + adapter_crate_path.as_deref(), + )?; + } + // Build envelope. // Honour --key override (5.4): if the caller supplied an explicit key, // use it; otherwise fall back to the manifest's resolved logical store id. @@ -2777,6 +2793,72 @@ 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}" + ); + } + // ------------------------------------------------------------------- // run_config_push_typed — 8.2 consent rules + diff // ------------------------------------------------------------------- diff --git a/crates/edgezero-cli/src/path_safety.rs b/crates/edgezero-cli/src/path_safety.rs index 96a48599..21a5c487 100644 --- a/crates/edgezero-cli/src/path_safety.rs +++ b/crates/edgezero-cli/src/path_safety.rs @@ -18,13 +18,6 @@ use std::path::{Component, Path, PathBuf}; /// they have it, but the helper defensively normalises so a /// relative `args.manifest.parent()` ("" or "examples/app-demo") /// compares correctly. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "consumed by provision.rs (Task 8) and config push arm (Task 7)" - ) -)] pub(crate) fn assert_provision_paths_contained( project_root: &Path, adapter_manifest_path: Option<&str>, From d8d6622d676b3243b9a0651d8f0404971a968a08 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:19 -0700 Subject: [PATCH 10/28] Wire path_safety + ProvisionMode into run_provision --- crates/edgezero-cli/src/provision.rs | 246 ++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 4484b8dc..337dc50f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -15,6 +15,7 @@ use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, }; 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_core::env_config::EnvConfig; use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; @@ -42,6 +43,33 @@ 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(), + )?; + } + + let mode = if args.local { + adapter_registry::ProvisionMode::Local + } else { + adapter_registry::ProvisionMode::Cloud + }; + // 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. @@ -120,7 +148,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { adapter_cfg.adapter.component.as_deref(), &stores, None, // cloud arm doesn't consume deployed state; it produces it - adapter_registry::ProvisionMode::Cloud, + mode, args.dry_run, )?; @@ -156,6 +184,7 @@ mod tests { use crate::args::ProvisionArgs; use crate::test_support::{manifest_guard, EnvOverride, PROVISION_MANIFEST}; use std::fs; + use std::path::PathBuf; use tempfile::TempDir; #[test] @@ -540,4 +569,219 @@ ids = ["default"] }) .expect("fastly dry-run dispatches cleanly"); } + + // ---------- provision --local path containment ---------- + + #[test] + 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"); + 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"); + + let err = run_provision(&ProvisionArgs { + adapter: "spin".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .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 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"); + // 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" + +[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 "". Path-safety must not reject the well-formed + // adapter paths in this fixture; the axum adapter itself + // then errors from Section 5 because local mode isn't wired + // yet, but that error is NOT a path-safety error. + 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); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: PathBuf::from("edgezero.toml"), + }) + .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + 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. Same shape as above: path-safety passes, + // the axum adapter errors from its Section 5 stub. + 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); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + 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}" + ); + } } From 7074ac795cf0fdf0301643933366c1e1ec1123fa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:19:53 -0700 Subject: [PATCH 11/28] Add Adapter::synthesise_baseline_manifest + CLI bootstrap before validate --- crates/edgezero-adapter/src/registry.rs | 33 +- crates/edgezero-cli/src/provision.rs | 410 +++++++++++++++++++++--- 2 files changed, 393 insertions(+), 50 deletions(-) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 84418466..ae23187c 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -510,6 +510,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 diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 337dc50f..3169f84b 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -8,7 +8,8 @@ //! each `edgezero-adapter-*` crate's `Adapter::provision` impl, not //! here. -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use crate::args::ProvisionArgs; use crate::config::{ @@ -17,8 +18,9 @@ use crate::config::{ 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}; /// # Errors /// @@ -64,12 +66,6 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { )?; } - let mode = if args.local { - adapter_registry::ProvisionMode::Local - } else { - adapter_registry::ProvisionMode::Cloud - }; - // 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. @@ -108,50 +104,63 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { .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(); - - // 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)?; - 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, + // 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(); + + // 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 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) => { + // Local dry-run: staging harness lands in Task 10/11. + return Err("local dry-run staging lands in Task 10/11".to_owned()); + } }; - let outcome = adapter.provision( - manifest_root, - adapter_cfg.adapter.manifest.as_deref(), - adapter_cfg.adapter.component.as_deref(), - &stores, - None, // cloud arm doesn't consume deployed state; it produces it - mode, - args.dry_run, - )?; - if args.dry_run { log::info!("[edgezero] provision --dry-run for `{}`:", args.adapter); } @@ -178,15 +187,202 @@ 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, Task 10+) — the only difference +/// is which root is passed in. +fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<(), String> { + for (rel_path, contents) in pairs { + 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 deployed block for `canonical_adapter_name` +/// into the neutral `AdapterDeployedState` shape. Task 14 introduces the typed +/// `ManifestAdapterDeployed` struct; until that lands this returns `None` +/// unconditionally. The synthesiser call path already receives +/// `Option<&AdapterDeployedState>` — just always `None` today. Section 4 +/// fills in the real translation. +fn deployed_state_for( + _manifest: &Manifest, + _canonical_adapter_name: &str, +) -> Option { + None +} + +/// 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, + ) +} + #[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::fs; use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; + // ----- 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. + + 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 SYNTH_CALLED: AtomicBool = AtomicBool::new(false); + static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); + + struct FakeBootstrapAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + )] + impl Adapter for FakeBootstrapAdapter { + 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 { + 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); + 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() + )) + } + } + } + + fn reset_fake_state() { + SYNTH_CALLED.store(false, Ordering::SeqCst); + VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); + } + #[test] fn run_provision_axum_prints_local_only_notes_for_each_store() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -784,4 +980,120 @@ ids = ["default"] "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" + ); + } } From a0bec7ab69ff3374e1aafa76a1da1a391cd92e59 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:55:51 -0700 Subject: [PATCH 12/28] Add copy_tree helper; promote tempfile + toml_edit to CLI runtime deps --- Cargo.lock | 1 + crates/edgezero-cli/Cargo.toml | 5 +-- crates/edgezero-cli/src/copy_tree.rs | 64 ++++++++++++++++++++++++++++ crates/edgezero-cli/src/lib.rs | 5 +++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 crates/edgezero-cli/src/copy_tree.rs diff --git a/Cargo.lock b/Cargo.lock index dcbb7529..82194bd3 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-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 97967055..9e96a0c4 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/copy_tree.rs b/crates/edgezero-cli/src/copy_tree.rs new file mode 100644 index 00000000..29d9fc4b --- /dev/null +++ b/crates/edgezero-cli/src/copy_tree.rs @@ -0,0 +1,64 @@ +//! 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::io; +use std::path::Path; + +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 file_type.is_symlink() { + // Symlinks intentionally skipped per spec §"Dry-run". + } else { + // Regular files (and any other non-dir, non-symlink entry) + // get copied. Special files won't appear in normal adapter + // source trees; if one does, `fs::copy` will surface its + // own error rather than silently drop it. + fs::copy(&src_path, &dst_path)?; + } + } + 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()); + } +} diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index c1ae0e7d..c13be57f 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -25,6 +25,11 @@ mod adapter; mod auth; #[cfg(feature = "cli")] mod config; +// Gated on `test` for now: the only callers today live in the module's +// own test suite. Task 10's `run_with_staging` will drop the gate when +// it adds the first production caller. +#[cfg(all(test, feature = "cli"))] +mod copy_tree; #[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; #[cfg(feature = "cli")] From ba3f891202decacd19a1b8c4b13904d5f0cb64c7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:51:41 -0700 Subject: [PATCH 13/28] Move config push --local containment guard above run_shared_checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_shared_checks iterates every declared adapter and dispatches validate_adapter_manifest, which for Spin does fs::read_to_string(manifest_root.join(rel)). With the containment guard sitting after run_shared_checks, a manifest declaring [adapters.spin.adapter].manifest = "../outside/spin.toml" could trigger a filesystem read outside the project root before the guard rejected it — a spec violation of §"Path containment (MUST)" which requires the helper run BEFORE any manifest-path use. Fix by relocating the check to fire immediately after load_push_context, and looping over every declared adapter (not just ctx.adapter) since run_shared_checks reads all of them. Also close Task 7's Minor about the duplicate adapter_entry call by removing the now-redundant per-adapter guard block. Regression test: config_push_local_rejects_parent_traversal_in_ sibling_spin_adapter declares a poisoned Spin adapter alongside the pushed axum adapter, and asserts the error names the containment violation (not Spin's "failed to read spin manifest" message that would surface under the old ordering). Also tighten copy_tree's else-branch to explicitly gate on is_regular_file() rather than "everything non-dir non-symlink", add a Unix symlink-skip test, and drop a stale #[expect(dead_code)] on ValidationContext::manifest_path that now has real callers. --- crates/edgezero-cli/src/config.rs | 100 ++++++++++++++++++++++----- crates/edgezero-cli/src/copy_tree.rs | 47 +++++++++++-- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 0b3a7839..461d8c10 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -129,10 +129,6 @@ impl ValidationContext { self.manifest_loader.manifest() } - #[expect( - dead_code, - reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" - )] pub(crate) fn manifest_path(&self) -> &Path { &self.manifest_path } @@ -314,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; @@ -337,21 +360,6 @@ where push_ctx: &push_ctx, }; - // Path containment check: reject `..` traversal and absolute paths - // in the manifest-declared adapter paths before any adapter dispatch. - if args.local { - let adapter_crate_path = ctx - .validation - .manifest() - .adapter_entry(ctx.adapter.name()) - .and_then(|(_, cfg)| cfg.adapter.crate_path.clone()); - assert_provision_paths_contained( - manifest_root, - adapter_manifest_path.as_deref(), - adapter_crate_path.as_deref(), - )?; - } - // Build envelope. // Honour --key override (5.4): if the caller supplied an explicit key, // use it; otherwise fall back to the manifest's resolved logical store id. @@ -2859,6 +2867,60 @@ ids = ["default"] ); } + /// 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 // ------------------------------------------------------------------- diff --git a/crates/edgezero-cli/src/copy_tree.rs b/crates/edgezero-cli/src/copy_tree.rs index 29d9fc4b..e8b4a018 100644 --- a/crates/edgezero-cli/src/copy_tree.rs +++ b/crates/edgezero-cli/src/copy_tree.rs @@ -5,9 +5,24 @@ //! 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)? { @@ -17,14 +32,13 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { let dst_path = dst.join(entry.file_name()); if file_type.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; - } else if file_type.is_symlink() { - // Symlinks intentionally skipped per spec §"Dry-run". - } else { - // Regular files (and any other non-dir, non-symlink entry) - // get copied. Special files won't appear in normal adapter - // source trees; if one does, `fs::copy` will surface its - // own error rather than silently drop it. + } 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(()) @@ -61,4 +75,23 @@ mod tests { 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" + ); + } } From 1617bb86bfd51462147aab27fee26d52c2573428 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:05:53 -0700 Subject: [PATCH 14/28] Make provision_local accept-tests actually exercise their code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-cwd variant of the accept-test (--manifest edgezero.toml) previously wrote the manifest to a tempdir and set EDGEZERO_MANIFEST, but run_provision reads args.manifest directly (no env fallback). The test therefore failed on manifest load ("failed to load edgezero.toml") and its negative !contains(path-safety-markers) assertion vacuously passed — no actual coverage of the `args.manifest.parent() == ""` fallback. Fix by adding a CwdGuard RAII helper that chdirs into the tempdir under the manifest_guard() serialisation lock and restores the previous cwd on drop. Both accept-tests now also assert positively that the error is the (true, true) dispatch stub ("local dry-run staging lands in Task 10/11"), proving the manifest loaded AND path-safety passed AND we reached the dispatch matrix. Drop the now-unnecessary EnvOverride from both tests. Reviewer: reviewer of Task 9 pushed this as a Low ahead of Task 10 because run_with_staging depends on manifest-root/cwd correctness. --- crates/edgezero-cli/src/provision.rs | 69 +++++++++++++++++++++------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 3169f84b..5375dac2 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -274,8 +274,10 @@ mod tests { use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, }; + use std::env; use std::fs; - use std::path::PathBuf; + use std::io; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; @@ -315,8 +317,33 @@ serve = "echo" 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; + 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 only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" @@ -928,16 +955,16 @@ ids = ["default"] #[test] fn provision_local_accepts_relative_manifest_root_default() { // Bare `--manifest edgezero.toml` — `args.manifest.parent()` - // returns "". Path-safety must not reject the well-formed - // adapter paths in this fixture; the axum adapter itself - // then errors from Section 5 because local mode isn't wired - // yet, but that error is NOT a path-safety error. + // 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"); - 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); + fs::write(temp.path().join("edgezero.toml"), PROVISION_MANIFEST).expect("write manifest"); + let _cwd = CwdGuard::set(temp.path()).expect("chdir into tempdir"); let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), @@ -945,7 +972,15 @@ ids = ["default"] local: true, manifest: PathBuf::from("edgezero.toml"), }) - .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + .expect_err("must reach the (true, true) dispatch stub"); + // Positive assertion: the (true, true) arm's stub error + // proves the manifest loaded AND path-safety passed. Without + // this, a manifest-load failure would silently satisfy the + // negative assertions below and give false-positive coverage. + assert!( + err.contains("local dry-run staging lands in Task 10/11"), + "must reach dispatch matrix, not fail on manifest load: {err}" + ); assert!( !err.contains("must not contain `..` traversal") && !err.contains("must be a project-relative path") @@ -956,15 +991,13 @@ ids = ["default"] #[test] fn provision_local_accepts_relative_manifest_root_nested() { - // Nested `--manifest /edgezero.toml` — parent is - // the tempdir path. Same shape as above: path-safety passes, - // the axum adapter errors from its Section 5 stub. + // 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"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), @@ -972,7 +1005,11 @@ ids = ["default"] local: true, manifest: manifest_path.clone(), }) - .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + .expect_err("must reach the (true, true) dispatch stub"); + assert!( + err.contains("local dry-run staging lands in Task 10/11"), + "must reach dispatch matrix, not fail on manifest load: {err}" + ); assert!( !err.contains("must not contain `..` traversal") && !err.contains("must be a project-relative path") From 12d7129c7c288052a05ce414f816334efccd8f40 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:44:46 -0700 Subject: [PATCH 15/28] Add run_with_staging tempdir helper for dry-run staging --- crates/edgezero-cli/src/provision.rs | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 5375dac2..09f76340 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -266,6 +266,80 @@ fn validate_and_dispatch( ) } +/// 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". +/// +/// Gated on `#[cfg(test)]` for now: the only callers are the +/// same-file tests. Task 11 lifts this gate (and `lib.rs`'s +/// `mod copy_tree;` gate) together when the `(true, true)` dispatch +/// arm gains a real caller. +#[cfg(test)] +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, +{ + use crate::copy_tree::copy_dir_recursive; + + 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::*; @@ -1133,4 +1207,64 @@ ids = ["default"] "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"); + } } From 42c198e2506ca55d61989d72ad595654f63f2166 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:02:26 -0700 Subject: [PATCH 16/28] Wire mode x dry-run dispatch matrix into run_provision --- crates/edgezero-cli/src/lib.rs | 5 +- crates/edgezero-cli/src/provision.rs | 315 +++++++++++++++++++++++++-- 2 files changed, 295 insertions(+), 25 deletions(-) diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index c13be57f..6608eabe 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -25,10 +25,7 @@ mod adapter; mod auth; #[cfg(feature = "cli")] mod config; -// Gated on `test` for now: the only callers today live in the module's -// own test suite. Task 10's `run_with_staging` will drop the gate when -// it adds the first production caller. -#[cfg(all(test, feature = "cli"))] +#[cfg(feature = "cli")] mod copy_tree; #[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 09f76340..6d083a1f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -15,6 +15,7 @@ use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, }; +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}; @@ -22,6 +23,27 @@ use edgezero_adapter::AdapterDeployedState; use edgezero_core::env_config::EnvConfig; 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, + } + } +} + /// # Errors /// /// Returns an error string if the manifest can't be loaded, the @@ -155,10 +177,15 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { false, )? } - (true, true) => { - // Local dry-run: staging harness lands in Task 10/11. - return Err("local dry-run staging lands in Task 10/11".to_owned()); - } + (true, true) => run_local_dry_run( + adapter, + manifest, + adapter_cfg, + manifest_root, + args, + &app_name, + deployed.as_ref(), + )?, }; if args.dry_run { @@ -266,6 +293,91 @@ fn validate_and_dispatch( ) } +/// 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"), + }) +} + +/// 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)?; + 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, + true, + ) + }, + )?; + Ok(outcome) +} + /// 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 @@ -273,11 +385,9 @@ fn validate_and_dispatch( /// against the project tree before the returned `TempDir` drops. See /// spec §"Dry-run". /// -/// Gated on `#[cfg(test)]` for now: the only callers are the -/// same-file tests. Task 11 lifts this gate (and `lib.rs`'s -/// `mod copy_tree;` gate) together when the `(true, true)` dispatch -/// arm gains a real caller. -#[cfg(test)] +/// 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, @@ -286,8 +396,6 @@ pub(crate) fn run_with_staging( where F: FnOnce(&Path, &Path) -> Result, { - use crate::copy_tree::copy_dir_recursive; - let tempdir = tempfile::TempDir::new() .map_err(|err| format!("failed to create staging tempdir: {err}"))?; let staged_root = tempdir.path(); @@ -388,6 +496,7 @@ serve = "echo" "#; static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -439,8 +548,9 @@ serve = "echo" _stores: &ProvisionStores<'_>, _deployed: Option<&AdapterDeployedState>, _mode: ProvisionMode, - _dry_run: bool, + dry_run: bool, ) -> Result { + RECORDED_DRY_RUN.store(dry_run, Ordering::SeqCst); Ok(ProvisionOutcome::default()) } @@ -480,10 +590,44 @@ serve = "echo" } fn reset_fake_state() { + RECORDED_DRY_RUN.store(false, Ordering::SeqCst); 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_prints_local_only_notes_for_each_store() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -1038,6 +1182,12 @@ ids = ["default"] 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 { @@ -1046,14 +1196,15 @@ ids = ["default"] local: true, manifest: PathBuf::from("edgezero.toml"), }) - .expect_err("must reach the (true, true) dispatch stub"); - // Positive assertion: the (true, true) arm's stub error - // proves the manifest loaded AND path-safety passed. Without - // this, a manifest-load failure would silently satisfy the + .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 dry-run staging lands in Task 10/11"), - "must reach dispatch matrix, not fail on manifest load: {err}" + 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") @@ -1072,6 +1223,7 @@ ids = ["default"] 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(), @@ -1079,10 +1231,10 @@ ids = ["default"] local: true, manifest: manifest_path.clone(), }) - .expect_err("must reach the (true, true) dispatch stub"); + .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); assert!( - err.contains("local dry-run staging lands in Task 10/11"), - "must reach dispatch matrix, not fail on manifest load: {err}" + 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") @@ -1267,4 +1419,125 @@ ids = ["default"] .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" + ); + } + + #[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" + ); + } } From cd87c24d5eddbecb2a5e84906342867590d427f7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:38:05 -0700 Subject: [PATCH 17/28] Add dry-run allow-list diff + would-write status rewriting --- crates/edgezero-cli/src/provision.rs | 341 ++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 6d083a1f..fdc3a7dd 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -11,6 +11,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use similar::{ChangeTag, TextDiff}; + use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, @@ -44,6 +46,23 @@ impl OwnedProvisionStores { } } +/// 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 /// /// Returns an error string if the manifest can't be loaded, the @@ -321,6 +340,138 @@ fn build_stores_against( }) } +/// 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 @@ -353,7 +504,7 @@ fn run_local_dry_run( .crate_path .as_deref() .map_or_else(|| Path::new("."), Path::new); - let (outcome, _tempdir) = run_with_staging( + let (outcome, tempdir) = run_with_staging( manifest_root, adapter_crate_rel, |staged_root, _staged_crate| { @@ -375,6 +526,28 @@ fn run_local_dry_run( ) }, )?; + + 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}"); + } Ok(outcome) } @@ -1505,6 +1678,172 @@ ids = ["default"] ); } + // ---------- 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 From 66ab4585fd5acf3abbee81dc066ca93e264391fb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:53:05 -0700 Subject: [PATCH 18/28] Add provision_local_dry_run worktree-clean + no-tempdir-leak test (ignored until Section 5) --- crates/edgezero-cli/src/provision.rs | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index fdc3a7dd..72ded685 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -1879,4 +1879,80 @@ ids = ["default"] "cloud must never 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). + } + } } From 2b3e70028b6a16218d588db7804e07ce0daeb72c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:11:03 -0700 Subject: [PATCH 19/28] Add ManifestAdapterDeployed struct + deployed field on ManifestAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed block is parsed as a shared schema — the manifest parser never branches on the adapter name inside `[adapters..deployed]`. Field membership (kv_namespaces / preview_kv_namespaces / service_id) reflects which adapters happen to use which subset, but that mapping lives in edgezero-adapter (Section 6's deployed_state_for), not core. Tests name the fixture adapter `demo` and describe the field kind they capture ("captures kv_namespace_maps", "captures service_id") rather than the adapter that consumes it, so a future reader doesn't mistake the parser for adapter-aware. --- crates/edgezero-core/src/manifest.rs | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c7746530..8089d7a9 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,27 @@ 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, +} + #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] @@ -1548,6 +1576,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() { From 5531f72be35e9cbc83ef98397ad123e778a870b0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:23:35 -0700 Subject: [PATCH 20/28] Fix local dry-run to dispatch adapters with dry_run = false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §"Dry-run" step 3: the tempdir IS the dry-run mechanism. Adapters take their real-write branches against the staged tree so operators can preview the actual files that would land. My Task 11 brief incorrectly said hardcode `true` in the (true, true) arm; a review caught it before Section 5 landed real writers that would have hit the wrong branches. Adds provision_local_dry_run_passes_dry_run_false_to_adapter using the existing FakeBootstrapAdapter + RECORDED_DRY_RUN observer. --- crates/edgezero-cli/src/provision.rs | 53 +++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 72ded685..a639e6b0 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -515,6 +515,15 @@ fn run_local_dry_run( 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(), @@ -522,7 +531,7 @@ fn run_local_dry_run( &owned_stores.as_refs(), deployed, adapter_registry::ProvisionMode::Local, - true, + false, ) }, )?; @@ -1880,6 +1889,48 @@ ids = ["default"] ); } + #[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' From 6033e728d71b45e68bae8f14aa9806bb647ef113 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:53:43 -0700 Subject: [PATCH 21/28] Add adapter-owned deployed_fields + Manifest-level cross-check --- crates/edgezero-adapter-cloudflare/src/cli.rs | 4 + crates/edgezero-adapter-fastly/src/cli.rs | 4 + crates/edgezero-adapter/src/registry.rs | 14 +++ crates/edgezero-cli/src/config.rs | 37 ++++++++ crates/edgezero-cli/src/provision.rs | 90 +++++++++++++++++++ crates/edgezero-core/src/manifest.rs | 66 ++++++++++++++ 6 files changed, 215 insertions(+) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index b15bb62c..b3477e2d 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -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 diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index aef252fd..33c2fd99 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -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 diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index ae23187c..61f2ddb8 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -256,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 diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 461d8c10..d0275dfe 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1273,6 +1273,7 @@ fn resolve_app_config_path( 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())?; @@ -1280,6 +1281,42 @@ 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. +/// +/// 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. diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index a639e6b0..6bc25d8f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -644,6 +644,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; + use validator::Validate as _; // ----- fixtures for CLI-owned first-run bootstrap synthesis ----- // @@ -678,6 +679,7 @@ serve = "echo" "#; static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static NO_FIELDS_FAKE_ADAPTER: NoFieldsFakeAdapter = NoFieldsFakeAdapter; static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -690,6 +692,8 @@ serve = "echo" struct FakeBootstrapAdapter; + struct NoFieldsFakeAdapter; + impl CwdGuard { fn set(new_cwd: &Path) -> io::Result { let prev = env::current_dir()?; @@ -714,6 +718,10 @@ serve = "echo" reason = "the fake only exercises name/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(()) } @@ -771,6 +779,33 @@ serve = "echo" } } + #[expect( + clippy::missing_trait_methods, + reason = "the no-fields fake exercises deployed_fields default; every other trait method inherits its default (no-op or Unsupported)" + )] + 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); SYNTH_CALLED.store(false, Ordering::SeqCst); @@ -2006,4 +2041,59 @@ ids = ["default"] // 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}" + ); + } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 8089d7a9..6235d764 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -393,6 +393,30 @@ pub struct ManifestAdapterDeployed { 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"))] @@ -2143,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"] + ); + } } From af4baa967ceb7e41835fe894b4f64882ff291e3b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:00:02 -0700 Subject: [PATCH 22/28] Task 15 followup: refresh #[expect] reasons + document case-handling Docstring-only cleanups flagged by Task 15 review: - FakeBootstrapAdapter's #[expect(missing_trait_methods)] reason listed the four originally-overridden methods (name / provision / synthesise_baseline_manifest / validate_adapter_manifest) but not the newly-added deployed_fields override. Refreshed the list. - NoFieldsFakeAdapter's reason previously said "every other trait method inherits its default (no-op or Unsupported)" without distinguishing the three required-but-implemented methods from the actual inherited defaults. Rewrote to name the required overrides explicitly and spell out that deployed_fields's `&[]` default is the intent this fake exercises. - validate_deployed_field_ownership doc comment now records the case-handling invariant (adapter_registry::get_adapter normalises via to_ascii_lowercase, so [adapters.Fastly.deployed] resolves to the same registered adapter as [adapters.fastly.deployed]) so a future reader doesn't have to re-derive it from the registry. --- crates/edgezero-cli/src/config.rs | 7 +++++++ crates/edgezero-cli/src/provision.rs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index d0275dfe..049369e3 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1287,6 +1287,13 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { /// 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. diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 6bc25d8f..715e61d1 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -715,7 +715,7 @@ serve = "echo" #[expect( clippy::missing_trait_methods, - reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + 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] { @@ -781,7 +781,7 @@ serve = "echo" #[expect( clippy::missing_trait_methods, - reason = "the no-fields fake exercises deployed_fields default; every other trait method inherits its default (no-op or Unsupported)" + 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> { From b3466c1e36457203681aef2168db876f0ff561e6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:07:48 -0700 Subject: [PATCH 23/28] Add toml_edit-based [adapters..deployed] writeback --- crates/edgezero-cli/src/provision.rs | 203 ++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 715e61d1..e943d41e 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -12,6 +12,7 @@ use std::fs; use std::path::{Path, PathBuf}; use similar::{ChangeTag, TextDiff}; +use toml_edit::{table, value, DocumentMut}; use crate::args::ProvisionArgs; use crate::config::{ @@ -213,7 +214,17 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { for line in outcome.status_lines { log::info!("{line}"); } - // outcome.deployed wiring lands in Task 16. + 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, + args.dry_run, + )?; + } Ok(()) } @@ -266,6 +277,82 @@ fn deployed_state_for( None } +/// 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. +pub(crate) fn merge_deployed_into_manifest( + manifest_path: &Path, + adapter_name: &str, + state: &adapter_registry::AdapterDeployedState, + dry_run: bool, +) -> Result<(), String> { + 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 @@ -638,6 +725,7 @@ mod tests { use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, }; + use std::collections::BTreeMap; use std::env; use std::fs; use std::io; @@ -2096,4 +2184,117 @@ ids = ["default"] "error must name the offending field and adapter: {err}" ); } + + // ---------- 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). + merge_deployed_into_manifest(&manifest_path, "Cloudflare", &state, 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()); + merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, 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, true).unwrap(); + + let after = fs::read_to_string(&manifest_path).unwrap(); + assert_eq!(before, after, "dry-run must leave file byte-identical"); + } } From 277f614960075ca84ec5a933e126abc1ad52ae81 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:14:19 -0700 Subject: [PATCH 24/28] Cloudflare: cloud provision returns created namespace ids via deployed --- crates/edgezero-adapter-cloudflare/src/cli.rs | 129 +++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index b3477e2d..f2acd53a 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; @@ -214,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 @@ -283,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; @@ -294,9 +311,26 @@ impl Adapter for CloudflareCliAdapter { if out.is_empty() { out.push("cloudflare has no declared stores to provision".to_owned()); } + // 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: None, + deployed, }) } @@ -1772,6 +1806,97 @@ id = "00112233445566778899aabbccddeeff" 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 ---------- From d7d6b1a80230f94a7805fb821d0d5e9528437d2c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:21:37 -0700 Subject: [PATCH 25/28] Add edgezero_adapter::env_file::append_lines_dedup (used by Section 5) --- crates/edgezero-adapter/src/env_file.rs | 170 ++++++++++++++++++++++++ crates/edgezero-adapter/src/lib.rs | 2 + 2 files changed, 172 insertions(+) create mode 100644 crates/edgezero-adapter/src/env_file.rs diff --git a/crates/edgezero-adapter/src/env_file.rs b/crates/edgezero-adapter/src/env_file.rs new file mode 100644 index 00000000..0acde2d7 --- /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 9404fc28..28f53e0c 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -4,6 +4,8 @@ `edgezero_adapter::TypeName` instead of `edgezero_adapter::registry::TypeName`" )] +pub mod env_file; + pub mod registry; pub mod scaffold; From 9c21d15271fbcc6263827c8d6c2830a552c3ae60 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:43:42 -0700 Subject: [PATCH 26/28] Wire deployed_state_for translator + validate_deployed_field_ownership into run_provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking review findings: - deployed_state_for was left as a `None`-returning stub through the whole of Section 4. Real deployed IDs in [adapters..deployed] never reached the adapter's synthesise_baseline_manifest call — the main Section 4 promise (teammates' `provision --local` regenerates local manifests from tracked durable IDs) was broken. Translator now maps service_id → state.fields, kv_namespaces + preview_kv_namespaces → state.sub_tables. Returns None only when every field is empty (matches pre-Task-14 signal for empty state). - validate_deployed_field_ownership was wired into run_shared_checks (config validate / push / diff) but NOT into run_provision. Gap let `edgezero provision` accept deployed blocks that `config validate` correctly rejected. Now called from a new run_manifest_shape_gates helper that also holds the existing capability + handler-path checks; run_provision drops from 101 to 90 lines and stays under the workspace too_many_lines lint. Four new tests: deployed_state_for_translates_all_field_kinds, deployed_state_for_returns_none_when_all_fields_empty, provision_local_threads_deployed_state_into_synthesiser (extends the existing fake with a RECORDED_SYNTH_DEPLOYED observer), provision_rejects_deployed_block_with_field_adapter_does_not_own. --- crates/edgezero-cli/src/provision.rs | 283 ++++++++++++++++++++++++--- 1 file changed, 251 insertions(+), 32 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index e943d41e..9e2b45d8 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -17,6 +17,7 @@ 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; @@ -64,6 +65,27 @@ pub(crate) struct DryRunAllowList { 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 /// /// Returns an error string if the manifest can't be loaded, the @@ -119,28 +141,8 @@ 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() @@ -264,17 +266,47 @@ fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<() Ok(()) } -/// Translate the parent manifest's deployed block for `canonical_adapter_name` -/// into the neutral `AdapterDeployedState` shape. Task 14 introduces the typed -/// `ManifestAdapterDeployed` struct; until that lands this returns `None` -/// unconditionally. The synthesiser call path already receives -/// `Option<&AdapterDeployedState>` — just always `None` today. Section 4 -/// fills in the real translation. +/// 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, + manifest: &Manifest, + canonical_adapter_name: &str, ) -> Option { - None + 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 @@ -731,6 +763,7 @@ mod tests { 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 _; @@ -769,6 +802,12 @@ 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); @@ -838,9 +877,12 @@ serve = "echo" adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, _app_name: &str, - _deployed: Option<&AdapterDeployedState>, + 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())]) } @@ -896,6 +938,9 @@ serve = "echo" 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); } @@ -2185,6 +2230,180 @@ ids = ["default"] ); } + // ---------- 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] From fac82ab1b8f69d5da78050fca21b432744321490 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:50:55 -0700 Subject: [PATCH 27/28] Clean up misleading adapter-named tests in provision.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Category B (deleted, redundant with fake-based Task 11 coverage): - run_provision_axum_dry_run_is_also_a_no_op - run_provision_spin_dry_run_dispatches_to_adapter - run_provision_cloudflare_dry_run_dispatches_to_adapter - run_provision_fastly_dry_run_dispatches_to_adapter All four asserted only "run_provision reaches the adapter with the requested dry_run value." That's strictly weaker than provision_cloud_dry_run_passes_dry_run_true_to_adapter (which observes RECORDED_DRY_RUN on the fake) and the corresponding local matrix tests. Delegating "does this adapter's dry-run branch run" to the adapter's own crate tests where relevant. Category A (renamed + docstring): - run_provision_axum_prints_local_only_notes_for_each_store → run_provision_cloud_non_dry_run_succeeds_when_adapter_is_side _effect_free. The old name promised assertions the test never made; the new name says what actually runs. - Four `run_provision_spin_*` tests kept — they exercise CLI logic that requires an adapter with a specific `merged_id_kinds()` shape (capability gate, collision detection, validate_adapter_manifest actually validating). Spin is the illustrative example, and each test's docstring now says so. Test count: 191 → 187 (-4). No coverage loss — every deleted test was subsumed by a fake-based counterpart with stronger observers. --- crates/edgezero-cli/src/provision.rs | 148 ++++++++------------------- 1 file changed, 40 insertions(+), 108 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 9e2b45d8..00de4797 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -979,7 +979,18 @@ serve = "echo" } #[test] - fn run_provision_axum_prints_local_only_notes_for_each_store() { + 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"); @@ -993,25 +1004,7 @@ serve = "echo" local: false, manifest: manifest_path.clone(), }) - .expect("axum provision exits 0 (no remote resources)"); - } - - #[test] - fn run_provision_axum_dry_run_is_also_a_no_op() { - 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); - - run_provision(&ProvisionArgs { - adapter: "axum".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("axum dry-run also exits 0"); + .expect("side-effect-free adapter cloud provision exits 0"); } #[test] @@ -1036,32 +1029,6 @@ serve = "echo" ); } - #[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, - local: false, - 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 -- @@ -1107,9 +1074,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"); @@ -1138,11 +1109,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"); @@ -1190,10 +1164,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"); @@ -1236,13 +1211,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"); @@ -1318,49 +1293,6 @@ ids = ["default"] .expect("single-id case dispatches cleanly"); } - #[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. - 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_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "cloudflare".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("cloudflare dry-run dispatches cleanly"); - } - - #[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. - 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); - - run_provision(&ProvisionArgs { - adapter: "fastly".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("fastly dry-run dispatches cleanly"); - } - // ---------- provision --local path containment ---------- #[test] From 1e1e20677ccb76c75d3b3cf15f8a909988fd92d3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:32:39 -0700 Subject: [PATCH 28/28] Fix three review findings: dry-run leak, baseline containment, deployed schema check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-1 — local dry-run was double-logging status lines. `run_local_dry_run` rendered a sanitized report (staged tempdir paths swapped back to project-relative) and logged it, then returned the original `outcome`. `run_provision`'s trailing status-line loop would then log the raw staged-tempdir paths again. Section 5's real adapter writers would surface `/var/folders/…` paths on stdout. Fix: return an outcome with `status_lines` cleared — the sanitized report already contains the rewritten status content, so the outer loop becomes a no-op. Full log-capture regression is still deferred to Task 13's ignored test (log::set_logger is one-shot per process). High-2 — `write_baseline_to_disk` bypassed path containment. The manifest-declared paths are gated by `assert_provision_paths _contained`, but the *baseline pairs* returned by an adapter's `synthesise_baseline_manifest` weren't. A synthesiser returning `/tmp/x.toml` or `../../etc/passwd` would escape via `root.join()` (absolute) or ordinary path resolution (parent-dir components). Fix: reject absolute paths + `Component::ParentDir` before joining. Two regression tests: `write_baseline_rejects_absolute_path`, `write_baseline_rejects_parent_traversal`. Medium-3 — `merge_deployed_into_manifest` trusted adapter output blindly. Existing manifests are gated by `validate_deployed_field_ownership` before dispatch, but the writeback itself had no schema check — a buggy adapter emitting an unknown key (e.g. `nonsense_key`) or a known key it doesn't own (Cloudflare emitting `service_id`) would persist into edgezero.toml and break future loads via `deny_unknown_fields` on `ManifestAdapterDeployed`. Fix: extend `merge_deployed_into_manifest` signature with `owned_fields: &[&str]`; check every key in `state.fields` / `state.sub_tables` against a hard-coded schema list + the owned fields. Two regression tests: `merge_deployed_rejects_adapter_emitted_unknown_field`, `merge_deployed_rejects_adapter_emitted_non_owned_field`. Call sites updated: `run_provision` passes `adapter.deployed_fields()`; the three pre-existing merge_deployed tests pass an appropriate superset. --- crates/edgezero-cli/src/provision.rs | 231 ++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 00de4797..4db4b0e8 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -9,7 +9,7 @@ //! here. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use similar::{ChangeTag, TextDiff}; use toml_edit::{table, value, DocumentMut}; @@ -224,6 +224,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { &args.manifest, canonical_adapter_key, deployed_writeback, + adapter.deployed_fields(), args.dry_run, )?; } @@ -249,10 +250,32 @@ 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, Task 10+) — the only difference -/// is which root is passed in. +/// 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; @@ -323,12 +346,57 @@ fn deployed_state_for( /// 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 @@ -676,7 +744,21 @@ fn run_local_dry_run( if !report.is_empty() { log::info!("{report}"); } - Ok(outcome) + // 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 @@ -2367,7 +2449,16 @@ manifest = "crates/cf/wrangler.toml" state.sub_tables.insert("kv_namespaces".to_owned(), kv); // Canonical key is "Cloudflare" (as written in the manifest). - merge_deployed_into_manifest(&manifest_path, "Cloudflare", &state, false).unwrap(); + // 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. @@ -2413,7 +2504,19 @@ manifest = "crates/cf/wrangler.toml" state .fields .insert("service_id".to_owned(), "SVC1".to_owned()); - merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, false).unwrap(); + // 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!( @@ -2443,9 +2546,123 @@ manifest = "crates/cf/wrangler.toml" 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, true).unwrap(); + 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" + ); + } }